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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
58
src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts
Normal file
58
src/Web/StellaOps.Web/src/app/core/api/cvss.client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/Web/StellaOps.Web/src/app/core/api/cvss.models.ts
Normal file
38
src/Web/StellaOps.Web/src/app/core/api/cvss.models.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user