consolidation of some of the modules, localization fixes, product advisories work, qa work

This commit is contained in:
master
2026-03-05 03:54:22 +02:00
parent 7bafcc3eef
commit 8e1cb9448d
3878 changed files with 72600 additions and 46861 deletions

View File

@@ -0,0 +1,138 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using static StellaOps.Localization.T;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.RiskEngine.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
namespace StellaOps.RiskEngine.WebService.Endpoints;
/// <summary>
/// Minimal API endpoints for exploit maturity assessment.
/// </summary>
public static class ExploitMaturityEndpoints
{
/// <summary>
/// Maps exploit maturity endpoints to the application.
/// </summary>
public static IEndpointRouteBuilder MapExploitMaturityEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/exploit-maturity")
.WithTags("ExploitMaturity")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
// GET /exploit-maturity/{cveId} - Assess exploit maturity for a CVE
group.MapGet("/{cveId}", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var result = await service.AssessMaturityAsync(cveId, ct).ConfigureAwait(false);
return Results.Ok(result);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturity")
.WithSummary("Assess exploit maturity for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.assess_description"))
.Produces<ExploitMaturityResult>()
.ProducesProblem(400);
// GET /exploit-maturity/{cveId}/level - Get just the maturity level
group.MapGet("/{cveId}/level", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var level = await service.GetMaturityLevelAsync(cveId, ct).ConfigureAwait(false);
return level.HasValue
? Results.Ok(new { cveId, level = level.Value.ToString() })
: Results.NotFound(new { cveId, error = _t("riskengine.error.maturity_level_undetermined") });
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturityLevel")
.WithSummary("Get exploit maturity level for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.get_level_description"));
// GET /exploit-maturity/{cveId}/history - Get maturity history
group.MapGet("/{cveId}/history", async (
string cveId,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
try
{
var history = await service.GetMaturityHistoryAsync(cveId, ct).ConfigureAwait(false);
return Results.Ok(new { cveId, entries = history });
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.WithName("GetExploitMaturityHistory")
.WithSummary("Get exploit maturity history for a CVE")
.WithDescription(_t("riskengine.exploit_maturity.get_history_description"));
// POST /exploit-maturity/batch - Batch assess multiple CVEs
group.MapPost("/batch", async (
BatchMaturityRequest request,
[FromServices] IExploitMaturityService service,
CancellationToken ct) =>
{
if (request.CveIds is null || request.CveIds.Count == 0)
{
return Results.BadRequest(new { error = _t("riskengine.error.cve_ids_required") });
}
var results = new List<ExploitMaturityResult>();
var errors = new List<BatchError>();
foreach (var cveId in request.CveIds.Distinct())
{
try
{
var result = await service.AssessMaturityAsync(cveId, ct).ConfigureAwait(false);
results.Add(result);
}
catch (ArgumentException ex)
{
errors.Add(new BatchError(cveId, ex.Message));
}
}
return Results.Ok(new { results, errors });
})
.WithName("BatchAssessExploitMaturity")
.WithSummary("Batch assess exploit maturity for multiple CVEs")
.WithDescription(_t("riskengine.exploit_maturity.batch_assess_description"))
.RequireAuthorization(RiskEnginePolicies.Operate);
return app;
}
}
/// <summary>
/// Request for batch maturity assessment.
/// </summary>
public sealed record BatchMaturityRequest(IReadOnlyList<string>? CveIds);
/// <summary>
/// Error entry in batch response.
/// </summary>
public sealed record BatchError(string CveId, string Error);

View File

@@ -0,0 +1,263 @@
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using StellaOps.Localization;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.RiskEngine.Core.Contracts;
using StellaOps.RiskEngine.Core.Providers;
using StellaOps.RiskEngine.Core.Services;
using StellaOps.RiskEngine.Infrastructure.Stores;
using StellaOps.RiskEngine.WebService.Endpoints;
using StellaOps.RiskEngine.WebService.Security;
using StellaOps.Router.AspNet;
using System.Linq;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<RiskScoreQueue>();
var storageDriver = ResolveStorageDriver(builder.Configuration, "RiskEngine");
RegisterResultStore(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver);
builder.Services.AddSingleton<IRiskScoreProviderRegistry>(_ =>
new RiskScoreProviderRegistry(new IRiskScoreProvider[]
{
new DefaultTransformsProvider(),
new CvssKevProvider(new NullCvssSource(), new NullKevSource()),
new EpssProvider(new NullEpssSource()),
new CvssKevEpssProvider(new NullCvssSource(), new NullKevSource(), new NullEpssSource()),
new VexGateProvider(),
new FixExposureProvider()
}));
// Exploit Maturity Service registration
builder.Services.AddSingleton<IEpssSource, NullEpssSource>();
builder.Services.AddSingleton<IKevSource, NullKevSource>();
builder.Services.AddSingleton<IExploitMaturityService, ExploitMaturityService>();
// Authentication and authorization
builder.Services.AddStellaOpsResourceServerAuthentication(builder.Configuration);
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddAuthorization(options =>
{
options.AddStellaOpsScopePolicy(RiskEnginePolicies.Read, StellaOpsScopes.RiskEngineRead);
options.AddStellaOpsScopePolicy(RiskEnginePolicies.Operate, StellaOpsScopes.RiskEngineOperate);
});
builder.Services.AddStellaOpsLocalization(builder.Configuration);
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
// Stella Router integration
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "riskengine",
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.TryAddStellaOpsLocalBinding("riskengine");
var app = builder.Build();
app.LogStellaOpsLocalHostname("riskengine");
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseStellaOpsCors();
app.UseStellaOpsLocalization();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaOpsTenantMiddleware();
app.TryUseStellaRouter(routerEnabled);
await app.LoadTranslationsAsync();
// Map exploit maturity endpoints
app.MapExploitMaturityEndpoints();
app.MapGet("/risk-scores/providers", (IRiskScoreProviderRegistry registry) =>
Results.Ok(new { providers = registry.ProviderNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase) }))
.WithName("ListRiskScoreProviders")
.WithDescription("Returns the sorted list of registered risk score provider names. Use this to discover which scoring strategies are available before submitting job or simulation requests.")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
app.MapPost("/risk-scores/jobs", async (
ScoreRequest request,
[FromServices] RiskScoreQueue queue,
[FromServices] IRiskScoreProviderRegistry registry,
[FromServices] IRiskScoreResultStore store,
CancellationToken ct) =>
{
var normalized = new ScoreRequest(
request.Provider,
request.Subject,
request.Signals ?? new Dictionary<string, double>());
var jobId = await queue.EnqueueWithIdAsync(normalized, ct).ConfigureAwait(false);
var worker = new RiskScoreWorker(queue, registry, store, TimeProvider.System);
var result = await worker.ProcessNextAsync(ct).ConfigureAwait(false);
return Results.Accepted($"/risk-scores/jobs/{jobId}", new { jobId, result });
})
.WithName("CreateRiskScoreJob")
.WithDescription("Enqueues a risk scoring job for the specified subject and provider, immediately executes it synchronously, and returns a 202 Accepted response with the job ID and computed result. The provider must be registered or the job will fail with an error in the result payload.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
app.MapGet("/risk-scores/jobs/{jobId:guid}", (
Guid jobId,
[FromServices] IRiskScoreResultStore store) =>
store.TryGet(jobId, out var result)
? Results.Ok(result)
: Results.NotFound())
.WithName("GetRiskScoreJob")
.WithDescription("Returns the stored risk score result for the specified job ID. Returns 404 if the job ID is not found in the result store, which may occur if the store has been cleared or the ID is invalid.")
.RequireAuthorization(RiskEnginePolicies.Read)
.RequireTenant();
app.MapPost("/risk-scores/simulations", async (
IReadOnlyCollection<ScoreRequest> requests,
[FromServices] IRiskScoreProviderRegistry registry,
CancellationToken ct) =>
{
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
return Results.Ok(new { results });
})
.WithName("RunRiskScoreSimulation")
.WithDescription("Evaluates a collection of risk score requests against the registered providers and returns the full result list. Unlike the job endpoint, simulations do not persist results. Requests for unregistered providers are returned with a failure flag and error message.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
app.MapPost("/risk-scores/simulations/summary", async (
IReadOnlyCollection<ScoreRequest> requests,
[FromServices] IRiskScoreProviderRegistry registry,
CancellationToken ct) =>
{
var results = await EvaluateAsync(requests, registry, ct).ConfigureAwait(false);
var scores = results.Select(r => r.Score).ToArray();
var summary = new
{
averageScore = scores.Length == 0 ? 0d : scores.Average(),
minScore = scores.Length == 0 ? 0d : scores.Min(),
maxScore = scores.Length == 0 ? 0d : scores.Max(),
topMovers = results
.OrderByDescending(r => r.Score)
.ThenBy(r => r.Subject, StringComparer.Ordinal)
.Take(3)
.ToArray()
};
return Results.Ok(new { summary, results });
})
.WithName("GetRiskScoreSimulationSummary")
.WithDescription("Evaluates a collection of risk score requests and returns both the full result list and an aggregate summary including average, minimum, and maximum scores plus the top-three highest-scoring subjects. Use this variant when a dashboard-style overview is required alongside per-subject detail.")
.RequireAuthorization(RiskEnginePolicies.Operate)
.RequireTenant();
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerEnabled);
await app.RunAsync().ConfigureAwait(false);
static void RegisterResultStore(IServiceCollection services, IConfiguration configuration, bool isDevelopment, string storageDriver)
{
if (string.Equals(storageDriver, "postgres", StringComparison.OrdinalIgnoreCase))
{
var connectionString = ResolvePostgresConnectionString(configuration, "RiskEngine");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!isDevelopment)
{
throw new InvalidOperationException(
"RiskEngine requires PostgreSQL connection settings in non-development mode. " +
"Set ConnectionStrings:Default or RiskEngine:Storage:Postgres:ConnectionString.");
}
services.AddSingleton<IRiskScoreResultStore, InMemoryRiskScoreResultStore>();
return;
}
services.AddSingleton<IRiskScoreResultStore>(_ => new PostgresRiskScoreResultStore(connectionString));
return;
}
if (string.Equals(storageDriver, "inmemory", StringComparison.OrdinalIgnoreCase))
{
services.AddSingleton<IRiskScoreResultStore, InMemoryRiskScoreResultStore>();
return;
}
throw new InvalidOperationException(
$"Unsupported RiskEngine storage driver '{storageDriver}'. Allowed values: postgres, inmemory.");
}
static string ResolveStorageDriver(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration["Storage:Driver"],
configuration[$"{serviceName}:Storage:Driver"])
?? "postgres";
}
static string? ResolvePostgresConnectionString(IConfiguration configuration, string serviceName)
{
return FirstNonEmpty(
configuration[$"{serviceName}:Storage:Postgres:ConnectionString"],
configuration["Storage:Postgres:ConnectionString"],
configuration[$"Postgres:{serviceName}:ConnectionString"],
configuration[$"ConnectionStrings:{serviceName}"],
configuration["ConnectionStrings:Default"]);
}
static string? FirstNonEmpty(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
static async Task<List<RiskScoreResult>> EvaluateAsync(
IReadOnlyCollection<ScoreRequest> requests,
IRiskScoreProviderRegistry registry,
CancellationToken ct)
{
var results = new List<RiskScoreResult>(requests.Count);
foreach (var req in requests)
{
var normalized = new ScoreRequest(
req.Provider,
req.Subject,
req.Signals ?? new Dictionary<string, double>());
if (!registry.TryGet(normalized.Provider, out var provider))
{
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, "Provider not registered", normalized.Signals, TimeProvider.System.GetUtcNow()));
continue;
}
try
{
var score = await provider.ScoreAsync(normalized, ct).ConfigureAwait(false);
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, score, true, null, normalized.Signals, TimeProvider.System.GetUtcNow()));
}
catch (Exception ex)
{
results.Add(new RiskScoreResult(Guid.NewGuid(), normalized.Provider, normalized.Subject, 0d, false, ex.Message, normalized.Signals, TimeProvider.System.GetUtcNow()));
}
}
return results;
}

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:10161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:10160;http://localhost:10161",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"STELLAOPS_WEBSERVICES_CORS": "true",
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000"
}
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
namespace StellaOps.RiskEngine.WebService.Security;
/// <summary>
/// Named authorization policy constants for the Risk Engine service.
/// Policies are registered via AddStellaOpsScopePolicy in Program.cs.
/// </summary>
internal static class RiskEnginePolicies
{
/// <summary>Policy for querying risk score providers and job results. Requires risk-engine:read scope.</summary>
public const string Read = "RiskEngine.Read";
/// <summary>Policy for submitting risk score jobs and simulations. Requires risk-engine:operate scope.</summary>
public const string Operate = "RiskEngine.Operate";
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.RiskEngine.Core\StellaOps.RiskEngine.Core.csproj"/>
<ProjectReference Include="..\__Libraries\StellaOps.RiskEngine.Infrastructure\StellaOps.RiskEngine.Infrastructure.csproj"/>
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj"/>
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj"/>
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Translations\*.json" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@StellaOps.RiskEngine.WebService_HostAddress = http://localhost:5115
GET {{StellaOps.RiskEngine.WebService_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,9 @@
# StellaOps.RiskEngine.WebService Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_solid_review.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/RiskEngine/StellaOps.RiskEngine/StellaOps.RiskEngine.WebService/StellaOps.RiskEngine.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-005 | DONE | Production result-store wiring switched from in-memory to Postgres by storage-driver contract, with explicit in-memory fallback. |

View File

@@ -0,0 +1,11 @@
{
"_meta": { "locale": "en-US", "namespace": "riskengine", "version": "1.0" },
"riskengine.exploit_maturity.assess_description": "Returns a unified exploit maturity assessment for the specified CVE by aggregating EPSS probability, KEV catalog membership, and in-the-wild exploitation signals. The result includes the overall maturity level, per-provider signal breakdown, and a composite confidence score.",
"riskengine.exploit_maturity.get_level_description": "Returns only the resolved maturity level enum value for the specified CVE without the full per-provider signal breakdown. Use this lightweight variant when only the top-level classification is needed. Returns 404 if the maturity level could not be determined.",
"riskengine.exploit_maturity.get_history_description": "Returns the chronological history of maturity level assessments for the specified CVE, ordered from oldest to newest. Each entry records the maturity level, the contributing signals, and the timestamp of assessment. Useful for tracking escalation from theoretical to active exploitation.",
"riskengine.exploit_maturity.batch_assess_description": "Submits a list of CVE IDs for bulk exploit maturity assessment and returns results for all successfully evaluated CVEs plus a separate errors array for any that could not be resolved. Duplicate CVE IDs are deduplicated before evaluation.",
"riskengine.error.maturity_level_undetermined": "Maturity level could not be determined",
"riskengine.error.cve_ids_required": "CveIds list is required"
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}