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
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user