up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-07 23:07:09 +02:00
parent 4b124fb056
commit 68bc53a07b
42 changed files with 3460 additions and 1132 deletions

View File

@@ -117,6 +117,24 @@ public sealed class OfflineKitDistributor
CreatedAt: _timeProvider.GetUtcNow()));
}
// Check for risk bundle
var riskBundlePath = Path.Combine(targetPath, "risk-bundles", "export-risk-bundle-v1.tgz");
if (File.Exists(riskBundlePath))
{
var bundleBytes = File.ReadAllBytes(riskBundlePath);
var bundleHash = _cryptoHash.ComputeHashHexForPurpose(bundleBytes, HashPurpose.Content);
entries.Add(new OfflineKitManifestEntry(
Kind: "risk-bundle",
KitVersion: kitVersion,
Artifact: "risk-bundles/export-risk-bundle-v1.tgz",
Checksum: "checksums/risk-bundles/export-risk-bundle-v1.tgz.sha256",
CliExample: "stella risk-bundle verify --file risk-bundles/export-risk-bundle-v1.tgz",
ImportExample: "stella risk-bundle import --file risk-bundles/export-risk-bundle-v1.tgz --offline",
RootHash: $"sha256:{bundleHash}",
CreatedAt: _timeProvider.GetUtcNow()));
}
// Write manifest-offline.json
var manifest = new OfflineKitOfflineManifest(
Version: "offline-kit/v1",

View File

@@ -63,6 +63,32 @@ public sealed record OfflineKitPortableEvidenceEntry(
public const string KindValue = "portable-evidence";
}
/// <summary>
/// Manifest entry for a risk bundle in an offline kit.
/// </summary>
public sealed record OfflineKitRiskBundleEntry(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("bundleId")] string BundleId,
[property: JsonPropertyName("inputsHash")] string InputsHash,
[property: JsonPropertyName("providers")] IReadOnlyList<OfflineKitRiskProviderInfo> Providers,
[property: JsonPropertyName("rootHash")] string RootHash,
[property: JsonPropertyName("artifact")] string Artifact,
[property: JsonPropertyName("checksum")] string Checksum,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
{
public const string KindValue = "risk-bundle";
}
/// <summary>
/// Provider information for a risk bundle entry.
/// </summary>
public sealed record OfflineKitRiskProviderInfo(
[property: JsonPropertyName("providerId")] string ProviderId,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("snapshotDate")] string? SnapshotDate,
[property: JsonPropertyName("optional")] bool Optional);
/// <summary>
/// Root manifest for an offline kit.
/// </summary>
@@ -109,6 +135,19 @@ public sealed record OfflineKitBootstrapRequest(
byte[] BundleBytes,
DateTimeOffset CreatedAt);
/// <summary>
/// Request to add a risk bundle to an offline kit.
/// </summary>
public sealed record OfflineKitRiskBundleRequest(
string KitId,
string ExportId,
string BundleId,
string InputsHash,
IReadOnlyList<OfflineKitRiskProviderInfo> Providers,
string RootHash,
byte[] BundleBytes,
DateTimeOffset CreatedAt);
/// <summary>
/// Result of adding an entry to an offline kit.
/// </summary>

View File

@@ -15,6 +15,7 @@ public sealed class OfflineKitPackager
private const string MirrorsDir = "mirrors";
private const string BootstrapDir = "bootstrap";
private const string EvidenceDir = "evidence";
private const string RiskBundlesDir = "risk-bundles";
private const string ChecksumsDir = "checksums";
private const string ManifestFileName = "manifest.json";
@@ -22,6 +23,7 @@ public sealed class OfflineKitPackager
private const string MirrorBundleFileName = "export-mirror-bundle-v1.tgz";
private const string BootstrapBundleFileName = "export-bootstrap-pack-v1.tgz";
private const string EvidenceBundleFileName = "export-portable-bundle-v1.tgz";
private const string RiskBundleFileName = "export-risk-bundle-v1.tgz";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
@@ -123,6 +125,34 @@ public sealed class OfflineKitPackager
BootstrapBundleFileName);
}
/// <summary>
/// Adds a risk bundle to the offline kit.
/// </summary>
public OfflineKitAddResult AddRiskBundle(
string outputDirectory,
OfflineKitRiskBundleRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (string.IsNullOrWhiteSpace(outputDirectory))
{
throw new ArgumentException("Output directory must be provided.", nameof(outputDirectory));
}
cancellationToken.ThrowIfCancellationRequested();
var artifactRelativePath = Path.Combine(RiskBundlesDir, RiskBundleFileName);
var checksumRelativePath = Path.Combine(ChecksumsDir, RiskBundlesDir, $"{RiskBundleFileName}.sha256");
return WriteBundle(
outputDirectory,
request.BundleBytes,
artifactRelativePath,
checksumRelativePath,
RiskBundleFileName);
}
/// <summary>
/// Creates a manifest entry for an attestation bundle.
/// </summary>
@@ -169,6 +199,23 @@ public sealed class OfflineKitPackager
CreatedAt: request.CreatedAt);
}
/// <summary>
/// Creates a manifest entry for a risk bundle.
/// </summary>
public OfflineKitRiskBundleEntry CreateRiskBundleEntry(OfflineKitRiskBundleRequest request, string sha256Hash)
{
return new OfflineKitRiskBundleEntry(
Kind: OfflineKitRiskBundleEntry.KindValue,
ExportId: request.ExportId,
BundleId: request.BundleId,
InputsHash: request.InputsHash,
Providers: request.Providers,
RootHash: $"sha256:{request.RootHash}",
Artifact: Path.Combine(RiskBundlesDir, RiskBundleFileName).Replace('\\', '/'),
Checksum: Path.Combine(ChecksumsDir, RiskBundlesDir, $"{RiskBundleFileName}.sha256").Replace('\\', '/'),
CreatedAt: request.CreatedAt);
}
/// <summary>
/// Writes or updates the offline kit manifest.
/// </summary>

View File

@@ -112,6 +112,56 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
public void AddRiskBundle_CreatesArtifactAndChecksum()
{
var request = CreateTestRiskBundleRequest();
var result = _packager.AddRiskBundle(_tempDir, request);
Assert.True(result.Success);
Assert.True(File.Exists(Path.Combine(_tempDir, result.ArtifactPath)));
Assert.True(File.Exists(Path.Combine(_tempDir, result.ChecksumPath)));
}
[Fact]
public void AddRiskBundle_PreservesBytesExactly()
{
var originalBytes = Encoding.UTF8.GetBytes("test-risk-bundle-content");
var request = new OfflineKitRiskBundleRequest(
KitId: "kit-001",
ExportId: Guid.NewGuid().ToString(),
BundleId: Guid.NewGuid().ToString(),
InputsHash: "inputs-hash-001",
Providers: new List<OfflineKitRiskProviderInfo>
{
new("cisa-kev", "https://cisa.gov/kev", "2025-01-15", Optional: false)
},
RootHash: "abc123",
BundleBytes: originalBytes,
CreatedAt: _timeProvider.GetUtcNow());
var result = _packager.AddRiskBundle(_tempDir, request);
var writtenBytes = File.ReadAllBytes(Path.Combine(_tempDir, result.ArtifactPath));
Assert.Equal(originalBytes, writtenBytes);
}
[Fact]
public void AddRiskBundle_RejectsOverwrite()
{
var request = CreateTestRiskBundleRequest();
// First write succeeds
var result1 = _packager.AddRiskBundle(_tempDir, request);
Assert.True(result1.Success);
// Second write fails (immutability)
var result2 = _packager.AddRiskBundle(_tempDir, request);
Assert.False(result2.Success);
Assert.Contains("immutable", result2.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CreateAttestationEntry_HasCorrectKind()
{
@@ -169,6 +219,54 @@ public sealed class OfflineKitPackagerTests : IDisposable
Assert.Equal("bootstrap-pack", entry.Kind);
}
[Fact]
public void CreateRiskBundleEntry_HasCorrectKind()
{
var request = CreateTestRiskBundleRequest();
var entry = _packager.CreateRiskBundleEntry(request, "sha256hash");
Assert.Equal("risk-bundle", entry.Kind);
}
[Fact]
public void CreateRiskBundleEntry_HasCorrectPaths()
{
var request = CreateTestRiskBundleRequest();
var entry = _packager.CreateRiskBundleEntry(request, "sha256hash");
Assert.Equal("risk-bundles/export-risk-bundle-v1.tgz", entry.Artifact);
Assert.Equal("checksums/risk-bundles/export-risk-bundle-v1.tgz.sha256", entry.Checksum);
}
[Fact]
public void CreateRiskBundleEntry_IncludesProviderInfo()
{
var providers = new List<OfflineKitRiskProviderInfo>
{
new("cisa-kev", "https://cisa.gov/kev", "2025-01-15", Optional: false),
new("nvd", "https://nvd.nist.gov", "2025-01-15", Optional: true)
};
var request = new OfflineKitRiskBundleRequest(
KitId: "kit-001",
ExportId: Guid.NewGuid().ToString(),
BundleId: Guid.NewGuid().ToString(),
InputsHash: "inputs-hash-001",
Providers: providers,
RootHash: "test-root-hash",
BundleBytes: Encoding.UTF8.GetBytes("test-risk-bundle"),
CreatedAt: _timeProvider.GetUtcNow());
var entry = _packager.CreateRiskBundleEntry(request, "sha256hash");
Assert.Equal(2, entry.Providers.Count);
Assert.Equal("cisa-kev", entry.Providers[0].ProviderId);
Assert.False(entry.Providers[0].Optional);
Assert.Equal("nvd", entry.Providers[1].ProviderId);
Assert.True(entry.Providers[1].Optional);
}
[Fact]
public void WriteManifest_CreatesManifestFile()
{
@@ -276,18 +374,22 @@ public sealed class OfflineKitPackagerTests : IDisposable
var attestationRequest = CreateTestAttestationRequest();
var mirrorRequest = CreateTestMirrorRequest();
var bootstrapRequest = CreateTestBootstrapRequest();
var riskBundleRequest = CreateTestRiskBundleRequest();
var attestResult = _packager.AddAttestationBundle(_tempDir, attestationRequest);
var mirrorResult = _packager.AddMirrorBundle(_tempDir, mirrorRequest);
var bootstrapResult = _packager.AddBootstrapPack(_tempDir, bootstrapRequest);
var riskResult = _packager.AddRiskBundle(_tempDir, riskBundleRequest);
// Verify directory structure
Assert.True(Directory.Exists(Path.Combine(_tempDir, "attestations")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "mirrors")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "bootstrap")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "risk-bundles")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "attestations")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "mirrors")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "bootstrap")));
Assert.True(Directory.Exists(Path.Combine(_tempDir, "checksums", "risk-bundles")));
}
private OfflineKitAttestationRequest CreateTestAttestationRequest()
@@ -323,4 +425,21 @@ public sealed class OfflineKitPackagerTests : IDisposable
BundleBytes: Encoding.UTF8.GetBytes("test-bootstrap-pack"),
CreatedAt: _timeProvider.GetUtcNow());
}
private OfflineKitRiskBundleRequest CreateTestRiskBundleRequest()
{
return new OfflineKitRiskBundleRequest(
KitId: "kit-001",
ExportId: Guid.NewGuid().ToString(),
BundleId: Guid.NewGuid().ToString(),
InputsHash: "test-inputs-hash",
Providers: new List<OfflineKitRiskProviderInfo>
{
new("cisa-kev", "https://cisa.gov/kev", "2025-01-15", Optional: false),
new("nvd", "https://nvd.nist.gov", "2025-01-15", Optional: true)
},
RootHash: "test-root-hash",
BundleBytes: Encoding.UTF8.GetBytes("test-risk-bundle"),
CreatedAt: _timeProvider.GetUtcNow());
}
}

View File

@@ -10,6 +10,7 @@ using StellaOps.ExportCenter.WebService.EvidenceLocker;
using StellaOps.ExportCenter.WebService.Attestation;
using StellaOps.ExportCenter.WebService.Incident;
using StellaOps.ExportCenter.WebService.RiskBundle;
using StellaOps.ExportCenter.WebService.SimulationExport;
var builder = WebApplication.CreateBuilder(args);
@@ -67,6 +68,9 @@ builder.Services.AddExportIncidentManagement();
// Risk bundle job handler
builder.Services.AddRiskBundleJobHandler();
// Simulation export services
builder.Services.AddSimulationExport();
builder.Services.AddOpenApi();
var app = builder.Build();
@@ -95,6 +99,9 @@ app.MapIncidentEndpoints();
// Risk bundle endpoints
app.MapRiskBundleEndpoints();
// Simulation export endpoints
app.MapSimulationExportEndpoints();
// Legacy exports endpoints (deprecated, use /v1/exports/* instead)
app.MapGet("/exports", () => Results.Ok(Array.Empty<object>()))
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer)

View File

@@ -0,0 +1,59 @@
namespace StellaOps.ExportCenter.WebService.SimulationExport;
/// <summary>
/// Interface for exporting simulation reports.
/// </summary>
public interface ISimulationReportExporter
{
/// <summary>
/// Gets available simulations for export.
/// </summary>
/// <param name="tenantId">Optional tenant ID filter.</param>
/// <param name="limit">Maximum number of simulations to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Available simulations response.</returns>
Task<AvailableSimulationsResponse> GetAvailableSimulationsAsync(
string? tenantId,
int limit = 50,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports a simulation report.
/// </summary>
/// <param name="request">Export request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export result.</returns>
Task<SimulationExportResult> ExportAsync(
SimulationExportRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets an export document by ID.
/// </summary>
/// <param name="exportId">Export identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Export document or null if not found.</returns>
Task<SimulationExportDocument?> GetExportDocumentAsync(
string exportId,
CancellationToken cancellationToken = default);
/// <summary>
/// Streams an export in NDJSON format.
/// </summary>
/// <param name="request">Export request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of export lines.</returns>
IAsyncEnumerable<SimulationExportLine> StreamExportAsync(
SimulationExportRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the CSV export for a simulation.
/// </summary>
/// <param name="simulationId">Simulation ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>CSV content as a string.</returns>
Task<string?> GetCsvExportAsync(
string simulationId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,167 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration;
namespace StellaOps.ExportCenter.WebService.SimulationExport;
/// <summary>
/// Extension methods for mapping simulation export endpoints.
/// </summary>
public static class SimulationExportEndpoints
{
private static readonly JsonSerializerOptions NdjsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Maps simulation export endpoints to the application.
/// </summary>
public static WebApplication MapSimulationExportEndpoints(this WebApplication app)
{
var group = app.MapGroup("/v1/exports/simulations")
.WithTags("Simulation Exports")
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportViewer);
// GET /v1/exports/simulations - List available simulations
group.MapGet("", GetAvailableSimulationsAsync)
.WithName("GetAvailableSimulations")
.WithSummary("List available simulations for export")
.WithDescription("Returns simulations that can be exported, optionally filtered by tenant.")
.Produces<AvailableSimulationsResponse>(StatusCodes.Status200OK);
// POST /v1/exports/simulations - Export a simulation
group.MapPost("", ExportSimulationAsync)
.RequireAuthorization(StellaOpsResourceServerPolicies.ExportOperator)
.WithName("ExportSimulation")
.WithSummary("Export a simulation report")
.WithDescription("Exports a simulation report with scored data and explainability snapshots.")
.Produces<SimulationExportResult>(StatusCodes.Status202Accepted)
.Produces<SimulationExportResult>(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound);
// GET /v1/exports/simulations/{exportId} - Get export document
group.MapGet("/{exportId}", GetExportDocumentAsync)
.WithName("GetSimulationExportDocument")
.WithSummary("Get exported simulation document")
.WithDescription("Returns the exported simulation document in JSON format.")
.Produces<SimulationExportDocument>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound);
// GET /v1/exports/simulations/{simulationId}/stream - Stream export as NDJSON
group.MapGet("/{simulationId}/stream", StreamExportAsync)
.WithName("StreamSimulationExport")
.WithSummary("Stream simulation export as NDJSON")
.WithDescription("Streams the simulation export in NDJSON format for large datasets.")
.Produces(StatusCodes.Status200OK, contentType: "application/x-ndjson")
.Produces(StatusCodes.Status404NotFound);
// GET /v1/exports/simulations/{simulationId}/csv - Get CSV export
group.MapGet("/{simulationId}/csv", GetCsvExportAsync)
.WithName("GetSimulationCsvExport")
.WithSummary("Get simulation export as CSV")
.WithDescription("Returns the simulation finding scores in CSV format.")
.Produces(StatusCodes.Status200OK, contentType: "text/csv")
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<Ok<AvailableSimulationsResponse>> GetAvailableSimulationsAsync(
[FromQuery] string? tenantId,
[FromQuery] int? limit,
[FromServices] ISimulationReportExporter exporter,
CancellationToken cancellationToken)
{
var simulations = await exporter.GetAvailableSimulationsAsync(tenantId, limit ?? 50, cancellationToken);
return TypedResults.Ok(simulations);
}
private static async Task<Results<Accepted<SimulationExportResult>, BadRequest<SimulationExportResult>, NotFound>> ExportSimulationAsync(
[FromBody] SimulationExportRequest request,
[FromServices] ISimulationReportExporter exporter,
CancellationToken cancellationToken)
{
var result = await exporter.ExportAsync(request, cancellationToken);
if (!result.Success && result.ErrorMessage?.Contains("not found") == true)
{
return TypedResults.NotFound();
}
if (!result.Success)
{
return TypedResults.BadRequest(result);
}
return TypedResults.Accepted($"/v1/exports/simulations/{result.ExportId}", result);
}
private static async Task<Results<Ok<SimulationExportDocument>, NotFound>> GetExportDocumentAsync(
string exportId,
[FromServices] ISimulationReportExporter exporter,
CancellationToken cancellationToken)
{
var document = await exporter.GetExportDocumentAsync(exportId, cancellationToken);
if (document is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(document);
}
private static async Task<IResult> StreamExportAsync(
string simulationId,
[FromQuery] bool? includeScoredData,
[FromQuery] bool? includeExplainability,
[FromQuery] bool? includeDistribution,
[FromServices] ISimulationReportExporter exporter,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var request = new SimulationExportRequest
{
SimulationId = simulationId,
Format = SimulationExportFormat.Ndjson,
IncludeScoredData = includeScoredData ?? true,
IncludeExplainability = includeExplainability ?? true,
IncludeDistribution = includeDistribution ?? true
};
httpContext.Response.ContentType = "application/x-ndjson";
httpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"simulation-{simulationId}.ndjson\"";
await foreach (var line in exporter.StreamExportAsync(request, cancellationToken))
{
var json = JsonSerializer.Serialize(line, NdjsonOptions);
await httpContext.Response.WriteAsync(json + "\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
}
return Results.Empty;
}
private static async Task<IResult> GetCsvExportAsync(
string simulationId,
[FromServices] ISimulationReportExporter exporter,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var csv = await exporter.GetCsvExportAsync(simulationId, cancellationToken);
if (csv is null)
{
return TypedResults.NotFound();
}
httpContext.Response.ContentType = "text/csv";
httpContext.Response.Headers.ContentDisposition = $"attachment; filename=\"simulation-{simulationId}.csv\"";
await httpContext.Response.WriteAsync(csv, cancellationToken);
return Results.Empty;
}
}

View File

@@ -0,0 +1,544 @@
using System.Text.Json.Serialization;
namespace StellaOps.ExportCenter.WebService.SimulationExport;
/// <summary>
/// Request to export a simulation report.
/// </summary>
public sealed record SimulationExportRequest
{
/// <summary>
/// Simulation ID to export.
/// </summary>
public required string SimulationId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Export format.
/// </summary>
public SimulationExportFormat Format { get; init; } = SimulationExportFormat.Json;
/// <summary>
/// Include scored data (finding scores, aggregate metrics).
/// </summary>
public bool IncludeScoredData { get; init; } = true;
/// <summary>
/// Include explainability snapshots (signal analysis, override analysis).
/// </summary>
public bool IncludeExplainability { get; init; } = true;
/// <summary>
/// Include distribution analysis.
/// </summary>
public bool IncludeDistribution { get; init; } = true;
/// <summary>
/// Include component breakdown.
/// </summary>
public bool IncludeComponentBreakdown { get; init; } = false;
/// <summary>
/// Include trend analysis (if available).
/// </summary>
public bool IncludeTrends { get; init; } = false;
/// <summary>
/// Maximum number of top movers to include.
/// </summary>
public int TopMoversLimit { get; init; } = 10;
/// <summary>
/// Maximum number of top signal contributors to include.
/// </summary>
public int TopContributorsLimit { get; init; } = 10;
}
/// <summary>
/// Export format for simulation reports.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum SimulationExportFormat
{
/// <summary>JSON format (single document).</summary>
Json = 0,
/// <summary>NDJSON format (streaming).</summary>
Ndjson = 1,
/// <summary>CSV format (tabular findings data).</summary>
Csv = 2
}
/// <summary>
/// Result of a simulation export request.
/// </summary>
public sealed record SimulationExportResult
{
/// <summary>
/// Whether the export was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Export identifier.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// Simulation ID that was exported.
/// </summary>
public required string SimulationId { get; init; }
/// <summary>
/// Export format.
/// </summary>
public required SimulationExportFormat Format { get; init; }
/// <summary>
/// Timestamp when the export was created.
/// </summary>
public required DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Storage key for the exported file.
/// </summary>
public string? StorageKey { get; init; }
/// <summary>
/// Content type of the exported file.
/// </summary>
public string? ContentType { get; init; }
/// <summary>
/// Size of the exported file in bytes.
/// </summary>
public long? SizeBytes { get; init; }
/// <summary>
/// Error message if export failed.
/// </summary>
public string? ErrorMessage { get; init; }
/// <summary>
/// Export summary.
/// </summary>
public SimulationExportSummary? Summary { get; init; }
}
/// <summary>
/// Summary of exported simulation data.
/// </summary>
public sealed record SimulationExportSummary
{
/// <summary>
/// Profile ID used in the simulation.
/// </summary>
public required string ProfileId { get; init; }
/// <summary>
/// Profile version.
/// </summary>
public required string ProfileVersion { get; init; }
/// <summary>
/// Total number of findings scored.
/// </summary>
public required int TotalFindings { get; init; }
/// <summary>
/// Severity breakdown.
/// </summary>
public required SeverityBreakdown SeverityBreakdown { get; init; }
/// <summary>
/// Aggregate risk metrics.
/// </summary>
public required AggregateMetricsSummary AggregateMetrics { get; init; }
/// <summary>
/// Whether explainability data was included.
/// </summary>
public required bool HasExplainability { get; init; }
/// <summary>
/// Simulation timestamp.
/// </summary>
public required DateTimeOffset SimulationTimestamp { get; init; }
/// <summary>
/// Determinism hash for reproducibility.
/// </summary>
public string? DeterminismHash { get; init; }
}
/// <summary>
/// Breakdown by severity level.
/// </summary>
public sealed record SeverityBreakdown
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Informational { get; init; }
}
/// <summary>
/// Summary of aggregate metrics.
/// </summary>
public sealed record AggregateMetricsSummary
{
public double MeanScore { get; init; }
public double MedianScore { get; init; }
public double MaxScore { get; init; }
public double MinScore { get; init; }
}
/// <summary>
/// Exported simulation report document.
/// </summary>
public sealed record SimulationExportDocument
{
/// <summary>
/// Export metadata.
/// </summary>
public required SimulationExportMetadata Metadata { get; init; }
/// <summary>
/// Scored data section.
/// </summary>
public ScoredDataSection? ScoredData { get; init; }
/// <summary>
/// Explainability section.
/// </summary>
public ExplainabilitySection? Explainability { get; init; }
/// <summary>
/// Distribution section.
/// </summary>
public DistributionSection? Distribution { get; init; }
/// <summary>
/// Component breakdown section.
/// </summary>
public ComponentSection? Components { get; init; }
/// <summary>
/// Trend analysis section.
/// </summary>
public TrendSection? Trends { get; init; }
}
/// <summary>
/// Export metadata.
/// </summary>
public sealed record SimulationExportMetadata
{
public required string ExportId { get; init; }
public required string SimulationId { get; init; }
public required string ProfileId { get; init; }
public required string ProfileVersion { get; init; }
public required string ProfileHash { get; init; }
public required DateTimeOffset SimulationTimestamp { get; init; }
public required DateTimeOffset ExportTimestamp { get; init; }
public required string ExportFormat { get; init; }
public required string SchemaVersion { get; init; }
public string? TenantId { get; init; }
public string? CorrelationId { get; init; }
public string? DeterminismHash { get; init; }
}
/// <summary>
/// Scored data section of the export.
/// </summary>
public sealed record ScoredDataSection
{
/// <summary>
/// Individual finding scores.
/// </summary>
public required IReadOnlyList<ExportedFindingScore> FindingScores { get; init; }
/// <summary>
/// Aggregate metrics.
/// </summary>
public required ExportedAggregateMetrics AggregateMetrics { get; init; }
/// <summary>
/// Top movers (highest risk findings).
/// </summary>
public IReadOnlyList<ExportedTopMover>? TopMovers { get; init; }
}
/// <summary>
/// Exported finding score.
/// </summary>
public sealed record ExportedFindingScore
{
public required string FindingId { get; init; }
public required double RawScore { get; init; }
public required double NormalizedScore { get; init; }
public required string Severity { get; init; }
public required string RecommendedAction { get; init; }
public string? ComponentPurl { get; init; }
public string? AdvisoryId { get; init; }
public IReadOnlyList<ExportedContribution>? Contributions { get; init; }
public IReadOnlyList<ExportedOverride>? OverridesApplied { get; init; }
}
/// <summary>
/// Exported signal contribution.
/// </summary>
public sealed record ExportedContribution
{
public required string SignalName { get; init; }
public object? SignalValue { get; init; }
public required double Weight { get; init; }
public required double Contribution { get; init; }
public required double ContributionPercentage { get; init; }
}
/// <summary>
/// Exported applied override.
/// </summary>
public sealed record ExportedOverride
{
public required string OverrideType { get; init; }
public object? OriginalValue { get; init; }
public object? AppliedValue { get; init; }
public string? Reason { get; init; }
}
/// <summary>
/// Exported aggregate metrics.
/// </summary>
public sealed record ExportedAggregateMetrics
{
public required int TotalFindings { get; init; }
public required double MeanScore { get; init; }
public required double MedianScore { get; init; }
public required double StdDeviation { get; init; }
public required double MaxScore { get; init; }
public required double MinScore { get; init; }
public required int CriticalCount { get; init; }
public required int HighCount { get; init; }
public required int MediumCount { get; init; }
public required int LowCount { get; init; }
public required int InformationalCount { get; init; }
}
/// <summary>
/// Exported top mover.
/// </summary>
public sealed record ExportedTopMover
{
public required string FindingId { get; init; }
public string? ComponentPurl { get; init; }
public required double Score { get; init; }
public required string Severity { get; init; }
public required string PrimaryDriver { get; init; }
public required double DriverContribution { get; init; }
}
/// <summary>
/// Explainability section of the export.
/// </summary>
public sealed record ExplainabilitySection
{
/// <summary>
/// Signal analysis.
/// </summary>
public required ExportedSignalAnalysis SignalAnalysis { get; init; }
/// <summary>
/// Override analysis.
/// </summary>
public required ExportedOverrideAnalysis OverrideAnalysis { get; init; }
}
/// <summary>
/// Exported signal analysis.
/// </summary>
public sealed record ExportedSignalAnalysis
{
public required int TotalSignals { get; init; }
public required int SignalsUsed { get; init; }
public required int SignalsMissing { get; init; }
public required double SignalCoverage { get; init; }
public IReadOnlyList<ExportedSignalContributor>? TopContributors { get; init; }
public IReadOnlyList<string>? MostImpactfulMissing { get; init; }
}
/// <summary>
/// Exported signal contributor.
/// </summary>
public sealed record ExportedSignalContributor
{
public required string SignalName { get; init; }
public required double TotalContribution { get; init; }
public required double ContributionPercentage { get; init; }
public required double AvgValue { get; init; }
public required double Weight { get; init; }
public required string ImpactDirection { get; init; }
}
/// <summary>
/// Exported override analysis.
/// </summary>
public sealed record ExportedOverrideAnalysis
{
public required int TotalOverridesEvaluated { get; init; }
public required int SeverityOverridesApplied { get; init; }
public required int DecisionOverridesApplied { get; init; }
public required double OverrideApplicationRate { get; init; }
public int? OverrideConflictsCount { get; init; }
}
/// <summary>
/// Distribution section of the export.
/// </summary>
public sealed record DistributionSection
{
/// <summary>
/// Score buckets.
/// </summary>
public required IReadOnlyList<ExportedScoreBucket> ScoreBuckets { get; init; }
/// <summary>
/// Percentiles.
/// </summary>
public required IReadOnlyDictionary<string, double> Percentiles { get; init; }
/// <summary>
/// Severity breakdown.
/// </summary>
public required IReadOnlyDictionary<string, int> SeverityBreakdown { get; init; }
/// <summary>
/// Action breakdown.
/// </summary>
public IReadOnlyDictionary<string, int>? ActionBreakdown { get; init; }
}
/// <summary>
/// Exported score bucket.
/// </summary>
public sealed record ExportedScoreBucket
{
public required double RangeMin { get; init; }
public required double RangeMax { get; init; }
public required string Label { get; init; }
public required int Count { get; init; }
public required double Percentage { get; init; }
}
/// <summary>
/// Component section of the export.
/// </summary>
public sealed record ComponentSection
{
public required int TotalComponents { get; init; }
public required int ComponentsWithFindings { get; init; }
public IReadOnlyList<ExportedComponentRisk>? TopRiskComponents { get; init; }
public IReadOnlyDictionary<string, ExportedEcosystemSummary>? EcosystemBreakdown { get; init; }
}
/// <summary>
/// Exported component risk.
/// </summary>
public sealed record ExportedComponentRisk
{
public required string ComponentPurl { get; init; }
public required int FindingCount { get; init; }
public required double MaxScore { get; init; }
public required double AvgScore { get; init; }
public required string HighestSeverity { get; init; }
public required string RecommendedAction { get; init; }
}
/// <summary>
/// Exported ecosystem summary.
/// </summary>
public sealed record ExportedEcosystemSummary
{
public required string Ecosystem { get; init; }
public required int ComponentCount { get; init; }
public required int FindingCount { get; init; }
public required double AvgScore { get; init; }
public required int CriticalCount { get; init; }
public required int HighCount { get; init; }
}
/// <summary>
/// Trend section of the export.
/// </summary>
public sealed record TrendSection
{
public required string ComparisonType { get; init; }
public required ExportedTrendMetric ScoreTrend { get; init; }
public required ExportedTrendMetric SeverityTrend { get; init; }
public required ExportedTrendMetric ActionTrend { get; init; }
public required int FindingsImproved { get; init; }
public required int FindingsWorsened { get; init; }
public required int FindingsUnchanged { get; init; }
}
/// <summary>
/// Exported trend metric.
/// </summary>
public sealed record ExportedTrendMetric
{
public required string Direction { get; init; }
public required double Magnitude { get; init; }
public required double PercentageChange { get; init; }
public required bool IsSignificant { get; init; }
}
/// <summary>
/// NDJSON line for streaming export.
/// </summary>
public sealed record SimulationExportLine
{
/// <summary>
/// Line type.
/// </summary>
public required string Type { get; init; }
/// <summary>
/// Line data.
/// </summary>
public required object Data { get; init; }
}
/// <summary>
/// Available simulation for export listing.
/// </summary>
public sealed record AvailableSimulation
{
public required string SimulationId { get; init; }
public required string ProfileId { get; init; }
public required string ProfileVersion { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public required int TotalFindings { get; init; }
public required string Status { get; init; }
public string? TenantId { get; init; }
}
/// <summary>
/// Response listing available simulations for export.
/// </summary>
public sealed record AvailableSimulationsResponse
{
public required IReadOnlyList<AvailableSimulation> Simulations { get; init; }
public required int TotalCount { get; init; }
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.ExportCenter.WebService.SimulationExport;
/// <summary>
/// Extension methods for registering simulation export services.
/// </summary>
public static class SimulationExportServiceCollectionExtensions
{
/// <summary>
/// Adds simulation report export services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSimulationExport(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Register TimeProvider if not already registered
services.TryAddSingleton(TimeProvider.System);
// Register the exporter
services.TryAddSingleton<ISimulationReportExporter, SimulationReportExporter>();
return services;
}
}

View File

@@ -0,0 +1,655 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.ExportCenter.WebService.Telemetry;
namespace StellaOps.ExportCenter.WebService.SimulationExport;
/// <summary>
/// Implementation of simulation report exporter.
/// </summary>
public sealed class SimulationReportExporter : ISimulationReportExporter
{
private const string SchemaVersion = "1.0.0";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
private static readonly JsonSerializerOptions CompactOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
private readonly TimeProvider _timeProvider;
private readonly ILogger<SimulationReportExporter> _logger;
// In-memory stores (would be replaced with persistent storage in production)
private readonly ConcurrentDictionary<string, SimulationExportDocument> _exports = new();
private readonly ConcurrentDictionary<string, SimulatedSimulationResult> _simulations = new();
public SimulationReportExporter(
TimeProvider timeProvider,
ILogger<SimulationReportExporter> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Initialize with sample simulations for demonstration
InitializeSampleSimulations();
}
public Task<AvailableSimulationsResponse> GetAvailableSimulationsAsync(
string? tenantId,
int limit = 50,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var query = _simulations.Values.AsEnumerable();
if (!string.IsNullOrWhiteSpace(tenantId))
{
query = query.Where(s => string.Equals(s.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
}
var simulations = query
.OrderByDescending(s => s.Timestamp)
.Take(Math.Min(limit, 100))
.Select(s => new AvailableSimulation
{
SimulationId = s.SimulationId,
ProfileId = s.ProfileId,
ProfileVersion = s.ProfileVersion,
Timestamp = s.Timestamp,
TotalFindings = s.TotalFindings,
Status = "completed",
TenantId = s.TenantId
})
.ToList();
return Task.FromResult(new AvailableSimulationsResponse
{
Simulations = simulations,
TotalCount = _simulations.Count
});
}
public async Task<SimulationExportResult> ExportAsync(
SimulationExportRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var now = _timeProvider.GetUtcNow();
var exportId = $"exp-{Guid.NewGuid():N}";
if (!_simulations.TryGetValue(request.SimulationId, out var simulation))
{
_logger.LogWarning("Simulation {SimulationId} not found for export", request.SimulationId);
return new SimulationExportResult
{
Success = false,
ExportId = exportId,
SimulationId = request.SimulationId,
Format = request.Format,
CreatedAt = now,
ErrorMessage = $"Simulation '{request.SimulationId}' not found"
};
}
try
{
var document = BuildExportDocument(request, simulation, exportId, now);
_exports[exportId] = document;
var contentType = request.Format switch
{
SimulationExportFormat.Json => "application/json",
SimulationExportFormat.Ndjson => "application/x-ndjson",
SimulationExportFormat.Csv => "text/csv",
_ => "application/json"
};
var sizeBytes = EstimateSize(document, request.Format);
ExportTelemetry.SimulationExportsTotal.Add(1,
new KeyValuePair<string, object?>("format", request.Format.ToString().ToLowerInvariant()),
new KeyValuePair<string, object?>("tenant_id", request.TenantId ?? "unknown"));
_logger.LogInformation(
"Exported simulation {SimulationId} as {ExportId} in {Format} format ({SizeBytes} bytes)",
request.SimulationId, exportId, request.Format, sizeBytes);
return new SimulationExportResult
{
Success = true,
ExportId = exportId,
SimulationId = request.SimulationId,
Format = request.Format,
CreatedAt = now,
StorageKey = $"exports/simulations/{exportId}",
ContentType = contentType,
SizeBytes = sizeBytes,
Summary = new SimulationExportSummary
{
ProfileId = simulation.ProfileId,
ProfileVersion = simulation.ProfileVersion,
TotalFindings = simulation.TotalFindings,
SeverityBreakdown = new SeverityBreakdown
{
Critical = simulation.CriticalCount,
High = simulation.HighCount,
Medium = simulation.MediumCount,
Low = simulation.LowCount,
Informational = simulation.InformationalCount
},
AggregateMetrics = new AggregateMetricsSummary
{
MeanScore = simulation.MeanScore,
MedianScore = simulation.MedianScore,
MaxScore = simulation.MaxScore,
MinScore = simulation.MinScore
},
HasExplainability = request.IncludeExplainability,
SimulationTimestamp = simulation.Timestamp,
DeterminismHash = simulation.DeterminismHash
}
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export simulation {SimulationId}", request.SimulationId);
return new SimulationExportResult
{
Success = false,
ExportId = exportId,
SimulationId = request.SimulationId,
Format = request.Format,
CreatedAt = now,
ErrorMessage = ex.Message
};
}
}
public Task<SimulationExportDocument?> GetExportDocumentAsync(
string exportId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
_exports.TryGetValue(exportId, out var document);
return Task.FromResult(document);
}
public async IAsyncEnumerable<SimulationExportLine> StreamExportAsync(
SimulationExportRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (!_simulations.TryGetValue(request.SimulationId, out var simulation))
{
yield break;
}
var now = _timeProvider.GetUtcNow();
var exportId = $"exp-{Guid.NewGuid():N}";
// Emit metadata first
yield return new SimulationExportLine
{
Type = "metadata",
Data = new SimulationExportMetadata
{
ExportId = exportId,
SimulationId = request.SimulationId,
ProfileId = simulation.ProfileId,
ProfileVersion = simulation.ProfileVersion,
ProfileHash = simulation.ProfileHash,
SimulationTimestamp = simulation.Timestamp,
ExportTimestamp = now,
ExportFormat = "ndjson",
SchemaVersion = SchemaVersion,
TenantId = request.TenantId,
CorrelationId = request.CorrelationId,
DeterminismHash = simulation.DeterminismHash
}
};
await Task.Yield();
// Emit aggregate metrics
if (request.IncludeScoredData)
{
yield return new SimulationExportLine
{
Type = "aggregate_metrics",
Data = new ExportedAggregateMetrics
{
TotalFindings = simulation.TotalFindings,
MeanScore = simulation.MeanScore,
MedianScore = simulation.MedianScore,
StdDeviation = simulation.StdDeviation,
MaxScore = simulation.MaxScore,
MinScore = simulation.MinScore,
CriticalCount = simulation.CriticalCount,
HighCount = simulation.HighCount,
MediumCount = simulation.MediumCount,
LowCount = simulation.LowCount,
InformationalCount = simulation.InformationalCount
}
};
// Emit individual finding scores
foreach (var finding in simulation.FindingScores.Take(100))
{
cancellationToken.ThrowIfCancellationRequested();
yield return new SimulationExportLine
{
Type = "finding_score",
Data = finding
};
}
// Emit top movers
foreach (var mover in simulation.TopMovers.Take(request.TopMoversLimit))
{
yield return new SimulationExportLine
{
Type = "top_mover",
Data = mover
};
}
}
// Emit explainability data
if (request.IncludeExplainability && simulation.SignalAnalysis is not null)
{
yield return new SimulationExportLine
{
Type = "signal_analysis",
Data = simulation.SignalAnalysis
};
yield return new SimulationExportLine
{
Type = "override_analysis",
Data = simulation.OverrideAnalysis
};
}
// Emit distribution
if (request.IncludeDistribution && simulation.Distribution is not null)
{
yield return new SimulationExportLine
{
Type = "distribution",
Data = simulation.Distribution
};
}
// Emit completion marker
yield return new SimulationExportLine
{
Type = "complete",
Data = new { exported_at = now.ToString("O") }
};
}
public Task<string?> GetCsvExportAsync(
string simulationId,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_simulations.TryGetValue(simulationId, out var simulation))
{
return Task.FromResult<string?>(null);
}
var csv = new StringBuilder();
// Header
csv.AppendLine("finding_id,raw_score,normalized_score,severity,recommended_action,component_purl,advisory_id");
// Data rows
foreach (var finding in simulation.FindingScores)
{
csv.AppendLine(
$"\"{finding.FindingId}\"," +
$"{finding.RawScore:F4}," +
$"{finding.NormalizedScore:F4}," +
$"\"{finding.Severity}\"," +
$"\"{finding.RecommendedAction}\"," +
$"\"{finding.ComponentPurl ?? ""}\"," +
$"\"{finding.AdvisoryId ?? ""}\"");
}
return Task.FromResult<string?>(csv.ToString());
}
private SimulationExportDocument BuildExportDocument(
SimulationExportRequest request,
SimulatedSimulationResult simulation,
string exportId,
DateTimeOffset now)
{
var metadata = new SimulationExportMetadata
{
ExportId = exportId,
SimulationId = request.SimulationId,
ProfileId = simulation.ProfileId,
ProfileVersion = simulation.ProfileVersion,
ProfileHash = simulation.ProfileHash,
SimulationTimestamp = simulation.Timestamp,
ExportTimestamp = now,
ExportFormat = request.Format.ToString().ToLowerInvariant(),
SchemaVersion = SchemaVersion,
TenantId = request.TenantId,
CorrelationId = request.CorrelationId,
DeterminismHash = simulation.DeterminismHash
};
ScoredDataSection? scoredData = null;
if (request.IncludeScoredData)
{
scoredData = new ScoredDataSection
{
FindingScores = simulation.FindingScores,
AggregateMetrics = new ExportedAggregateMetrics
{
TotalFindings = simulation.TotalFindings,
MeanScore = simulation.MeanScore,
MedianScore = simulation.MedianScore,
StdDeviation = simulation.StdDeviation,
MaxScore = simulation.MaxScore,
MinScore = simulation.MinScore,
CriticalCount = simulation.CriticalCount,
HighCount = simulation.HighCount,
MediumCount = simulation.MediumCount,
LowCount = simulation.LowCount,
InformationalCount = simulation.InformationalCount
},
TopMovers = simulation.TopMovers.Take(request.TopMoversLimit).ToList()
};
}
ExplainabilitySection? explainability = null;
if (request.IncludeExplainability && simulation.SignalAnalysis is not null)
{
explainability = new ExplainabilitySection
{
SignalAnalysis = simulation.SignalAnalysis,
OverrideAnalysis = simulation.OverrideAnalysis!
};
}
DistributionSection? distribution = null;
if (request.IncludeDistribution && simulation.Distribution is not null)
{
distribution = simulation.Distribution;
}
ComponentSection? components = null;
if (request.IncludeComponentBreakdown && simulation.ComponentBreakdown is not null)
{
components = simulation.ComponentBreakdown;
}
TrendSection? trends = null;
if (request.IncludeTrends && simulation.Trends is not null)
{
trends = simulation.Trends;
}
return new SimulationExportDocument
{
Metadata = metadata,
ScoredData = scoredData,
Explainability = explainability,
Distribution = distribution,
Components = components,
Trends = trends
};
}
private static long EstimateSize(SimulationExportDocument document, SimulationExportFormat format)
{
// Rough estimation
var json = JsonSerializer.Serialize(document, CompactOptions);
return format switch
{
SimulationExportFormat.Json => json.Length * 2, // UTF-8 with indentation
SimulationExportFormat.Ndjson => json.Length,
SimulationExportFormat.Csv => json.Length / 2,
_ => json.Length
};
}
private void InitializeSampleSimulations()
{
var now = _timeProvider.GetUtcNow();
// Sample simulation 1
var sim1Id = "sim-001-" + Guid.NewGuid().ToString("N")[..8];
_simulations[sim1Id] = CreateSampleSimulation(sim1Id, "baseline-risk-v1", "1.0.0", now.AddHours(-2), 150);
// Sample simulation 2
var sim2Id = "sim-002-" + Guid.NewGuid().ToString("N")[..8];
_simulations[sim2Id] = CreateSampleSimulation(sim2Id, "strict-risk-v2", "2.1.0", now.AddHours(-1), 85);
}
private SimulatedSimulationResult CreateSampleSimulation(
string simulationId,
string profileId,
string profileVersion,
DateTimeOffset timestamp,
int findingCount)
{
var random = new Random(simulationId.GetHashCode());
var findings = new List<ExportedFindingScore>();
var severities = new[] { "critical", "high", "medium", "low", "informational" };
var actions = new[] { "upgrade", "patch", "monitor", "accept", "investigate" };
int critical = 0, high = 0, medium = 0, low = 0, info = 0;
var scores = new List<double>();
for (int i = 0; i < findingCount; i++)
{
var rawScore = random.NextDouble() * 100;
var normalizedScore = rawScore / 10.0;
var severity = severities[Math.Min((int)(rawScore / 20), 4)];
var action = actions[random.Next(actions.Length)];
scores.Add(normalizedScore);
switch (severity)
{
case "critical": critical++; break;
case "high": high++; break;
case "medium": medium++; break;
case "low": low++; break;
default: info++; break;
}
findings.Add(new ExportedFindingScore
{
FindingId = $"FIND-{i + 1:D5}",
RawScore = rawScore,
NormalizedScore = normalizedScore,
Severity = severity,
RecommendedAction = action,
ComponentPurl = $"pkg:npm/example-package-{i % 20}@1.{i % 10}.0",
AdvisoryId = $"CVE-2024-{10000 + i}",
Contributions = i < 10 ? new List<ExportedContribution>
{
new() { SignalName = "cvss_base", SignalValue = rawScore / 10.0, Weight = 0.3, Contribution = rawScore * 0.3, ContributionPercentage = 30 },
new() { SignalName = "epss_score", SignalValue = random.NextDouble(), Weight = 0.2, Contribution = rawScore * 0.2, ContributionPercentage = 20 },
new() { SignalName = "kev_listed", SignalValue = random.Next(2) == 1, Weight = 0.25, Contribution = rawScore * 0.25, ContributionPercentage = 25 }
} : null
});
}
scores.Sort();
var mean = scores.Average();
var median = scores.Count % 2 == 0
? (scores[scores.Count / 2 - 1] + scores[scores.Count / 2]) / 2
: scores[scores.Count / 2];
var stdDev = Math.Sqrt(scores.Sum(x => Math.Pow(x - mean, 2)) / scores.Count);
return new SimulatedSimulationResult
{
SimulationId = simulationId,
ProfileId = profileId,
ProfileVersion = profileVersion,
ProfileHash = $"sha256:{Guid.NewGuid():N}",
Timestamp = timestamp,
TenantId = "default",
TotalFindings = findingCount,
MeanScore = mean,
MedianScore = median,
StdDeviation = stdDev,
MaxScore = scores.Max(),
MinScore = scores.Min(),
CriticalCount = critical,
HighCount = high,
MediumCount = medium,
LowCount = low,
InformationalCount = info,
DeterminismHash = $"det-{Guid.NewGuid():N}",
FindingScores = findings,
TopMovers = findings
.OrderByDescending(f => f.NormalizedScore)
.Take(10)
.Select(f => new ExportedTopMover
{
FindingId = f.FindingId,
ComponentPurl = f.ComponentPurl,
Score = f.NormalizedScore,
Severity = f.Severity,
PrimaryDriver = "cvss_base",
DriverContribution = f.NormalizedScore * 0.3
})
.ToList(),
SignalAnalysis = new ExportedSignalAnalysis
{
TotalSignals = 8,
SignalsUsed = 6,
SignalsMissing = 2,
SignalCoverage = 0.75,
TopContributors = new List<ExportedSignalContributor>
{
new() { SignalName = "cvss_base", TotalContribution = 450.5, ContributionPercentage = 30, AvgValue = 6.5, Weight = 0.3, ImpactDirection = "increase" },
new() { SignalName = "kev_listed", TotalContribution = 375.2, ContributionPercentage = 25, AvgValue = 0.15, Weight = 0.25, ImpactDirection = "increase" },
new() { SignalName = "epss_score", TotalContribution = 300.8, ContributionPercentage = 20, AvgValue = 0.3, Weight = 0.2, ImpactDirection = "increase" }
},
MostImpactfulMissing = new List<string> { "reachability", "exploit_maturity" }
},
OverrideAnalysis = new ExportedOverrideAnalysis
{
TotalOverridesEvaluated = 25,
SeverityOverridesApplied = 8,
DecisionOverridesApplied = 5,
OverrideApplicationRate = 0.52,
OverrideConflictsCount = 1
},
Distribution = new DistributionSection
{
ScoreBuckets = new List<ExportedScoreBucket>
{
new() { RangeMin = 0, RangeMax = 2, Label = "Low", Count = (int)(findingCount * 0.3), Percentage = 30 },
new() { RangeMin = 2, RangeMax = 5, Label = "Medium", Count = (int)(findingCount * 0.4), Percentage = 40 },
new() { RangeMin = 5, RangeMax = 8, Label = "High", Count = (int)(findingCount * 0.2), Percentage = 20 },
new() { RangeMin = 8, RangeMax = 10, Label = "Critical", Count = (int)(findingCount * 0.1), Percentage = 10 }
},
Percentiles = new Dictionary<string, double>
{
["p50"] = median,
["p75"] = scores[(int)(scores.Count * 0.75)],
["p90"] = scores[(int)(scores.Count * 0.90)],
["p95"] = scores[(int)(scores.Count * 0.95)],
["p99"] = scores[(int)(scores.Count * 0.99)]
},
SeverityBreakdown = new Dictionary<string, int>
{
["critical"] = critical,
["high"] = high,
["medium"] = medium,
["low"] = low,
["informational"] = info
},
ActionBreakdown = new Dictionary<string, int>
{
["upgrade"] = (int)(findingCount * 0.3),
["patch"] = (int)(findingCount * 0.25),
["monitor"] = (int)(findingCount * 0.2),
["accept"] = (int)(findingCount * 0.15),
["investigate"] = (int)(findingCount * 0.1)
}
},
ComponentBreakdown = new ComponentSection
{
TotalComponents = 20,
ComponentsWithFindings = 18,
TopRiskComponents = Enumerable.Range(0, 5)
.Select(i => new ExportedComponentRisk
{
ComponentPurl = $"pkg:npm/example-package-{i}@1.{i}.0",
FindingCount = random.Next(3, 10),
MaxScore = 7.5 + random.NextDouble() * 2.5,
AvgScore = 5.0 + random.NextDouble() * 3.0,
HighestSeverity = i < 2 ? "critical" : "high",
RecommendedAction = i < 3 ? "upgrade" : "patch"
})
.ToList(),
EcosystemBreakdown = new Dictionary<string, ExportedEcosystemSummary>
{
["npm"] = new() { Ecosystem = "npm", ComponentCount = 12, FindingCount = 80, AvgScore = 5.2, CriticalCount = 5, HighCount = 15 },
["pypi"] = new() { Ecosystem = "pypi", ComponentCount = 5, FindingCount = 45, AvgScore = 4.8, CriticalCount = 2, HighCount = 8 },
["maven"] = new() { Ecosystem = "maven", ComponentCount = 3, FindingCount = 25, AvgScore = 6.1, CriticalCount = 3, HighCount = 7 }
}
}
};
}
private sealed class SimulatedSimulationResult
{
public required string SimulationId { get; init; }
public required string ProfileId { get; init; }
public required string ProfileVersion { get; init; }
public required string ProfileHash { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public string? TenantId { get; init; }
public required int TotalFindings { get; init; }
public required double MeanScore { get; init; }
public required double MedianScore { get; init; }
public required double StdDeviation { get; init; }
public required double MaxScore { get; init; }
public required double MinScore { get; init; }
public required int CriticalCount { get; init; }
public required int HighCount { get; init; }
public required int MediumCount { get; init; }
public required int LowCount { get; init; }
public required int InformationalCount { get; init; }
public required string DeterminismHash { get; init; }
public required IReadOnlyList<ExportedFindingScore> FindingScores { get; init; }
public required IReadOnlyList<ExportedTopMover> TopMovers { get; init; }
public ExportedSignalAnalysis? SignalAnalysis { get; init; }
public ExportedOverrideAnalysis? OverrideAnalysis { get; init; }
public DistributionSection? Distribution { get; init; }
public ComponentSection? ComponentBreakdown { get; init; }
public TrendSection? Trends { get; init; }
}
}

View File

@@ -166,6 +166,15 @@ public static class ExportTelemetry
"jobs",
"Total number of risk bundle jobs completed");
/// <summary>
/// Total number of simulation exports.
/// Tags: format (json|ndjson|csv), tenant_id
/// </summary>
public static readonly Counter<long> SimulationExportsTotal = Meter.CreateCounter<long>(
"export_simulation_exports_total",
"exports",
"Total number of simulation exports");
#endregion
#region Histograms