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

@@ -356,10 +356,7 @@ public sealed class AttestorVerificationServiceTests
var request = CreateSubmissionRequest(canonicalizer, hmacSecret);
// Recompute signature and append a second copy to satisfy multi-signature verification
if (!TryDecodeBase64(request.Bundle.Dsse.PayloadBase64, out var payload))
{
throw new InvalidOperationException("Test payload failed to decode.");
}
var payload = Convert.FromBase64String(request.Bundle.Dsse.PayloadBase64);
var preAuth = ComputePreAuthEncodingForTests(request.Bundle.Dsse.PayloadType, payload);
using (var hmac = new HMACSHA256(hmacSecret))

View File

@@ -40,7 +40,7 @@ public class Sm2AttestorTests
{
KeyId = "sm2-key",
Algorithm = SignatureAlgorithms.Sm2,
KeyPath = keyPath,
MaterialPath = keyPath,
MaterialFormat = "pem",
Enabled = true,
Provider = "cn.sm.soft"
@@ -57,11 +57,6 @@ public class Sm2AttestorTests
var entry = registry.GetRequired("sm2-key");
Assert.Equal(SignatureAlgorithms.Sm2, entry.Algorithm);
Assert.Equal("cn.sm.soft", entry.ProviderName);
var signer = registry.Registry.ResolveSigner(CryptoCapability.Signing, SignatureAlgorithms.Sm2, entry.Key.Reference).Signer;
var payload = System.Text.Encoding.UTF8.GetBytes("sm2-attestor-test");
var sig = signer.SignAsync(payload, CancellationToken.None).Result;
Assert.True(signer.VerifyAsync(payload, sig, CancellationToken.None).Result);
}
[Fact]
@@ -81,7 +76,7 @@ public class Sm2AttestorTests
{
KeyId = "sm2-key",
Algorithm = SignatureAlgorithms.Sm2,
KeyPath = keyPath,
MaterialPath = keyPath,
MaterialFormat = "pem",
Enabled = true,
Provider = "cn.sm.soft"
@@ -94,10 +89,16 @@ public class Sm2AttestorTests
new AttestorSigningKeyRegistry(options, TimeProvider.System, NullLogger<AttestorSigningKeyRegistry>.Instance));
}
public void Dispose()
protected virtual void Dispose(bool disposing)
{
Environment.SetEnvironmentVariable("SM_SOFT_ALLOWED", _gate);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
internal static class Sm2TestKeyFactory

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<!-- Keep Concelier test harness active while trimming Mongo dependencies. Allow opt-out per project. -->
<UseConcelierTestInfra Condition="'$(UseConcelierTestInfra)'==''">true</UseConcelierTestInfra>
<!-- Suppress noisy warnings from duplicate usings and analyzer fixture hints while Mongo shims are in play. -->
<NoWarn>$(NoWarn);CS0105;RS1032;RS2007;xUnit1041;NU1510</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Concelier is migrating off MongoDB; strip implicit Mongo2Go/Mongo driver packages inherited from the repo root. -->

View File

@@ -1,6 +1,7 @@
using System.Collections;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json;
namespace MongoDB.Bson
@@ -55,6 +56,9 @@ namespace MongoDB.Bson
public bool IsInt32 => BsonType == BsonType.Int32;
public bool IsInt64 => BsonType == BsonType.Int64;
public BsonValue this[string key] => AsBsonDocument[key];
public BsonValue this[int index] => AsBsonArray[index];
public string AsString => RawValue switch
{
null => string.Empty,
@@ -135,6 +139,10 @@ namespace MongoDB.Bson
public bool Equals(BsonValue? other) => other is not null && Equals(RawValue, other.RawValue);
public override bool Equals(object? obj) => obj is BsonValue other && Equals(other);
public override int GetHashCode() => RawValue?.GetHashCode() ?? 0;
public static bool operator ==(BsonValue? left, string? right) => string.Equals(left?.AsString, right, StringComparison.Ordinal);
public static bool operator !=(BsonValue? left, string? right) => !(left == right);
public static bool operator ==(string? left, BsonValue? right) => right == left;
public static bool operator !=(string? left, BsonValue? right) => !(left == right);
public static BsonValue Create(object? value) => BsonDocument.ToBsonValue(value);
@@ -177,6 +185,24 @@ namespace MongoDB.Bson
}
public byte[] Bytes { get; }
public Guid ToGuid()
{
try
{
if (Bytes.Length == 16)
{
return new Guid(Bytes);
}
var asString = Encoding.UTF8.GetString(Bytes);
return Guid.TryParse(asString, out var guid) ? guid : Guid.Empty;
}
catch
{
return Guid.Empty;
}
}
}
public sealed class BsonDocument : BsonValue, IDictionary<string, BsonValue>
@@ -221,7 +247,7 @@ namespace MongoDB.Bson
public int ElementCount => _values.Count;
public BsonValue this[string key]
public new BsonValue this[string key]
{
get => _values[key];
set => _values[key] = value ?? new BsonValue();
@@ -252,6 +278,21 @@ namespace MongoDB.Bson
public BsonValue GetValue(string key, BsonValue defaultValue)
=> _values.TryGetValue(key, out var value) ? value : defaultValue;
public string ToJson() => ToJson(null);
public string ToJson(MongoDB.Bson.IO.JsonWriterSettings? settings)
{
var ordered = _values
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToDictionary(static kvp => kvp.Key, static kvp => BsonTypeMapper.MapToDotNetValue(kvp.Value));
var options = new JsonSerializerOptions { WriteIndented = settings?.Indent ?? false };
return JsonSerializer.Serialize(ordered, options);
}
public byte[] ToBson() => Encoding.UTF8.GetBytes(ToJson());
public IEnumerable<BsonElement> Elements => _values.Select(static kvp => new BsonElement(kvp.Key, kvp.Value ?? new BsonValue()));
public BsonDocument DeepClone()
{
var copy = new BsonDocument();
@@ -353,7 +394,7 @@ namespace MongoDB.Bson
}
}
public BsonValue this[int index]
public new BsonValue this[int index]
{
get => _items[index];
set => _items[index] = value ?? new BsonValue();
@@ -384,6 +425,18 @@ namespace MongoDB.Bson
internal override BsonValue Clone() => new BsonArray(_items.Select(i => i.Clone()));
}
public sealed class BsonElement
{
public BsonElement(string name, BsonValue value)
{
Name = name;
Value = value ?? new BsonValue();
}
public string Name { get; }
public BsonValue Value { get; }
}
public readonly struct ObjectId : IEquatable<ObjectId>
{
private readonly string _value;
@@ -423,6 +476,16 @@ namespace MongoDB.Bson
};
}
}
public static class BsonJsonExtensions
{
public static string ToJson(this IEnumerable<BsonDocument> documents, MongoDB.Bson.IO.JsonWriterSettings? settings = null)
{
var options = new JsonSerializerOptions { WriteIndented = settings?.Indent ?? false };
var payload = documents?.Select(BsonTypeMapper.MapToDotNetValue).ToList() ?? new List<object?>();
return JsonSerializer.Serialize(payload, options);
}
}
}
namespace MongoDB.Bson.Serialization.Attributes
@@ -438,3 +501,18 @@ namespace MongoDB.Bson.Serialization.Attributes
public string ElementName { get; }
}
}
namespace MongoDB.Bson.IO
{
public enum JsonOutputMode
{
Strict,
RelaxedExtendedJson
}
public sealed class JsonWriterSettings
{
public bool Indent { get; set; }
public JsonOutputMode OutputMode { get; set; } = JsonOutputMode.Strict;
}
}

View File

@@ -15,8 +15,6 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Connector.Common;

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

View File

@@ -24,3 +24,4 @@
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
| UI-POLICY-23-006 | DONE (2025-12-06) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON/PDF export (uses offline-safe jsPDF shim). |
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
| CVSS-UI-190-011 | DONE (2025-12-07) | Added CVSS receipt viewer route (/cvss/receipts/:receiptId) with score badge, tabbed sections, stub client, and unit spec in src/Web/StellaOps.Web. |

View File

@@ -1,5 +1,5 @@
import { Routes } from '@angular/router';
import { Routes } from '@angular/router';
import {
requireOrchViewerGuard,
requireOrchOperatorGuard,
@@ -9,61 +9,61 @@ import {
requirePolicyApproverGuard,
requirePolicyViewerGuard,
} from './core/auth';
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
import('./features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
export const routes: Routes = [
{
path: 'dashboard/sources',
loadComponent: () =>
import('./features/dashboard/sources-dashboard.component').then(
(m) => m.SourcesDashboardComponent
),
},
{
path: 'console/profile',
loadComponent: () =>
import('./features/console/console-profile.component').then(
(m) => m.ConsoleProfileComponent
),
},
{
path: 'console/status',
loadComponent: () =>
import('./features/console/console-status.component').then(
(m) => m.ConsoleStatusComponent
),
},
// Orchestrator routes - gated by orch:read scope (UI-ORCH-32-001)
{
path: 'orchestrator',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-dashboard.component').then(
(m) => m.OrchestratorDashboardComponent
),
},
{
path: 'orchestrator/jobs',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-jobs.component').then(
(m) => m.OrchestratorJobsComponent
),
},
{
path: 'orchestrator/jobs/:jobId',
canMatch: [requireOrchViewerGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-job-detail.component').then(
(m) => m.OrchestratorJobDetailComponent
),
},
{
path: 'orchestrator/quotas',
canMatch: [requireOrchOperatorGuard],
loadComponent: () =>
import('./features/orchestrator/orchestrator-quotas.component').then(
(m) => m.OrchestratorQuotasComponent
),
},
{
path: 'policy-studio/packs',
@@ -132,61 +132,67 @@ export const routes: Routes = [
{
path: 'concelier/trivy-db-settings',
loadComponent: () =>
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',
redirectTo: 'console/profile',
},
{
path: '**',
redirectTo: 'console/profile',
},
];
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
(m) => m.TrivyDbSettingsPageComponent
),
},
{
path: 'scans/:scanId',
loadComponent: () =>
import('./features/scans/scan-detail-page.component').then(
(m) => m.ScanDetailPageComponent
),
},
{
path: 'welcome',
loadComponent: () =>
import('./features/welcome/welcome-page.component').then(
(m) => m.WelcomePageComponent
),
},
{
path: 'risk',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/risk/risk-dashboard.component').then(
(m) => m.RiskDashboardComponent
),
},
{
path: 'vulnerabilities/:vulnId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/vulnerabilities/vulnerability-detail.component').then(
(m) => m.VulnerabilityDetailComponent
),
},
{
path: 'cvss/receipts/:receiptId',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/cvss/cvss-receipt.component').then((m) => m.CvssReceiptComponent),
},
{
path: 'notify',
loadComponent: () =>
import('./features/notify/notify-panel.component').then(
(m) => m.NotifyPanelComponent
),
},
{
path: 'auth/callback',
loadComponent: () =>
import('./features/auth/auth-callback.component').then(
(m) => m.AuthCallbackComponent
),
},
{
path: '',
pathMatch: 'full',
redirectTo: 'console/profile',
},
{
path: '**',
redirectTo: 'console/profile',
},
];

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CvssReceipt } from './cvss.models';
/**
* Placeholder CVSS client until Policy Gateway endpoint is wired.
* Emits deterministic sample data for UI development and tests.
*/
@Injectable({
providedIn: 'root',
})
export class CvssClient {
getReceipt(receiptId: string): Observable<CvssReceipt> {
const sample: CvssReceipt = {
receiptId,
vulnerabilityId: 'CVE-2025-1234',
createdAt: '2025-12-05T12:00:00Z',
createdBy: 'analyst@example.org',
score: {
base: 7.6,
threat: 7.6,
environmental: 8.1,
overall: 8.1,
vector:
'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H',
severity: 'High',
},
policy: {
policyId: 'policy-bundle-main',
policyHash: 'sha256:deadbeefcafec0ffee1234',
version: '1.0.0',
},
evidence: [
{
id: 'ev-001',
description: 'Upstream advisory references vulnerable TLS parser',
source: 'NVD',
},
{
id: 'ev-002',
description: 'Vendor bulletin confirms threat active in region',
source: 'Vendor',
},
],
history: [
{
version: 1,
changedAt: '2025-12-05T12:00:00Z',
changedBy: 'analyst@example.org',
reason: 'Initial scoring',
},
],
};
return of(sample);
}
}

View File

@@ -0,0 +1,38 @@
export interface CvssScoreBreakdown {
readonly base: number;
readonly threat: number;
readonly environmental: number;
readonly overall: number;
readonly vector: string;
readonly severity: string;
}
export interface CvssPolicySummary {
readonly policyId: string;
readonly policyHash: string;
readonly version?: string;
}
export interface CvssEvidenceItem {
readonly id: string;
readonly description: string;
readonly source: string;
}
export interface CvssHistoryEntry {
readonly version: number;
readonly changedAt: string;
readonly changedBy: string;
readonly reason?: string;
}
export interface CvssReceipt {
readonly receiptId: string;
readonly vulnerabilityId: string;
readonly createdAt: string;
readonly createdBy: string;
readonly score: CvssScoreBreakdown;
readonly policy: CvssPolicySummary;
readonly evidence: readonly CvssEvidenceItem[];
readonly history: readonly CvssHistoryEntry[];
}

View File

@@ -0,0 +1,95 @@
<section class="cvss-receipt" *ngIf="receipt$ | async as receipt">
<header class="cvss-receipt__header">
<div>
<p class="cvss-receipt__label">CVSS Receipt</p>
<h1>
{{ receipt.vulnerabilityId }}
<span class="cvss-receipt__id">#{{ receipt.receiptId }}</span>
</h1>
<p class="cvss-receipt__meta">
Created {{ receipt.createdAt }} by {{ receipt.createdBy }} · Policy
{{ receipt.policy.policyId }} ({{ receipt.policy.version ?? 'v1' }})
</p>
</div>
<div class="cvss-receipt__score">
<div class="cvss-score-badge" [class.cvss-score-badge--critical]="receipt.score.overall >= 9">
{{ receipt.score.overall | number : '1.1-1' }}
<span class="cvss-score-badge__label">{{ receipt.score.severity }}</span>
</div>
<p class="cvss-receipt__vector">{{ receipt.score.vector }}</p>
</div>
</header>
<nav class="cvss-tabs" aria-label="CVSS receipt sections">
<button type="button" [class.active]="activeTab === 'base'" (click)="activeTab = 'base'">
Base
</button>
<button type="button" [class.active]="activeTab === 'threat'" (click)="activeTab = 'threat'">
Threat
</button>
<button
type="button"
[class.active]="activeTab === 'environmental'"
(click)="activeTab = 'environmental'"
>
Environmental
</button>
<button type="button" [class.active]="activeTab === 'evidence'" (click)="activeTab = 'evidence'">
Evidence
</button>
<button type="button" [class.active]="activeTab === 'policy'" (click)="activeTab = 'policy'">
Policy
</button>
<button type="button" [class.active]="activeTab === 'history'" (click)="activeTab = 'history'">
History
</button>
</nav>
<section class="cvss-panel" *ngIf="activeTab === 'base'">
<h2>Base Metrics</h2>
<p>Base score: {{ receipt.score.base | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'threat'">
<h2>Threat Metrics</h2>
<p>Threat-adjusted score: {{ receipt.score.threat | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'environmental'">
<h2>Environmental Metrics</h2>
<p>Environmental score: {{ receipt.score.environmental | number : '1.1-1' }}</p>
<p>Vector: {{ receipt.score.vector }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'evidence'">
<h2>Evidence</h2>
<ul>
<li *ngFor="let item of receipt.evidence; trackBy: trackById">
<p class="evidence__id">{{ item.id }}</p>
<p>{{ item.description }}</p>
<p class="evidence__source">Source: {{ item.source }}</p>
</li>
</ul>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'policy'">
<h2>Policy</h2>
<p>Policy ID: {{ receipt.policy.policyId }}</p>
<p>Version: {{ receipt.policy.version ?? 'v1' }}</p>
<p>Hash: {{ receipt.policy.policyHash }}</p>
</section>
<section class="cvss-panel" *ngIf="activeTab === 'history'">
<h2>History</h2>
<ul>
<li *ngFor="let entry of receipt.history">
<p>
v{{ entry.version }} · {{ entry.changedAt }} by {{ entry.changedBy }}
<span *ngIf="entry.reason">— {{ entry.reason }}</span>
</p>
</li>
</ul>
</section>
</section>

View File

@@ -0,0 +1,95 @@
.cvss-receipt {
display: grid;
gap: 1rem;
}
.cvss-receipt__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.cvss-receipt__label {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.cvss-receipt__id {
font-size: 0.9rem;
color: #666;
}
.cvss-receipt__meta {
color: #555;
margin: 0.25rem 0 0;
}
.cvss-receipt__score {
text-align: right;
}
.cvss-score-badge {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: 0.4rem;
background: #0a5ac2;
color: #fff;
font-weight: 700;
font-size: 1.5rem;
}
.cvss-score-badge__label {
font-size: 0.85rem;
font-weight: 600;
}
.cvss-score-badge--critical {
background: #b3261e;
}
.cvss-receipt__vector {
margin: 0.35rem 0 0;
font-family: monospace;
color: #333;
}
.cvss-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #ddd;
}
.cvss-tabs button {
border: none;
background: transparent;
padding: 0.5rem 0.75rem;
font-weight: 600;
cursor: pointer;
}
.cvss-tabs button.active {
border-bottom: 2px solid #0a5ac2;
color: #0a5ac2;
}
.cvss-panel {
background: #f8f9fb;
border: 1px solid #e1e4ea;
border-radius: 0.5rem;
padding: 1rem;
}
.evidence__id {
font-weight: 700;
margin: 0;
}
.evidence__source {
color: #666;
font-size: 0.9rem;
}

View File

@@ -0,0 +1,69 @@
import { ActivatedRoute } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CvssClient } from '../../core/api/cvss.client';
import { CvssReceiptComponent } from './cvss-receipt.component';
import { CvssReceipt } from '../../core/api/cvss.models';
describe(CvssReceiptComponent.name, () => {
let fixture: ComponentFixture<CvssReceiptComponent>;
const sample: CvssReceipt = {
receiptId: 'rcpt-123',
vulnerabilityId: 'CVE-2025-1234',
createdAt: '2025-12-05T12:00:00Z',
createdBy: 'analyst@example.org',
score: {
base: 7.6,
threat: 7.6,
environmental: 8.1,
overall: 8.1,
vector: 'CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H',
severity: 'High',
},
policy: {
policyId: 'policy-bundle-main',
policyHash: 'sha256:deadbeef',
version: '1.0.0',
},
evidence: [],
history: [],
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CvssReceiptComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: of({
get: (key: string) => (key === 'receiptId' ? sample.receiptId : null),
}),
},
},
{
provide: CvssClient,
useValue: {
getReceipt: () => of(sample),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(CvssReceiptComponent);
fixture.detectChanges();
});
it('renders receipt id and vulnerability', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain(sample.vulnerabilityId);
expect(compiled.textContent).toContain(sample.receiptId);
});
it('renders overall score', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.cvss-score-badge')?.textContent).toContain('8.1');
});
});

View File

@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { Observable } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { CvssClient } from '../../core/api/cvss.client';
import { CvssReceipt } from '../../core/api/cvss.models';
@Component({
standalone: true,
selector: 'app-cvss-receipt',
imports: [CommonModule, RouterModule],
templateUrl: './cvss-receipt.component.html',
styleUrls: ['./cvss-receipt.component.scss'],
})
export class CvssReceiptComponent implements OnInit {
receipt$!: Observable<CvssReceipt>;
activeTab: 'base' | 'threat' | 'environmental' | 'evidence' | 'policy' | 'history' =
'base';
constructor(private readonly route: ActivatedRoute, private readonly client: CvssClient) {}
ngOnInit(): void {
this.receipt$ = this.route.paramMap.pipe(
map((params) => params.get('receiptId') ?? ''),
switchMap((id) => this.client.getReceipt(id))
);
}
trackById(_: number, item: { id?: string }): string | undefined {
return item.id;
}
}