Update
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
2025-10-21 18:54:26 +03:00
committed by Vladimir Moushkov
parent 791e12baab
commit cfaea5efd9
50 changed files with 3027 additions and 596 deletions

View File

@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Unknown;
using StellaOps.Concelier.Models;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Unknown;
public sealed class UnknownStateLedgerTests
{
[Fact]
public async Task RecordAsync_DetectsMarkersAndPersistsLedger()
{
var repository = new FakeUnknownStateRepository();
var observedAt = DateTimeOffset.Parse("2025-10-20T08:00:00Z");
var recordedAt = DateTimeOffset.Parse("2025-10-21T00:00:00Z");
var ledger = new UnknownStateLedger(repository, new FixedTimeProvider(recordedAt));
var advisory = BuildAdvisory(
provenance: Array.Empty<AdvisoryProvenance>(),
packages: new[]
{
BuildPackage(
statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.KnownAffected, source: "Vendor") },
versionRanges: Array.Empty<AffectedVersionRange>()),
BuildPackage(
statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.Fixed) },
versionRanges: new[]
{
new AffectedVersionRange(
rangeKind: "semver",
introducedVersion: null,
fixedVersion: null,
lastAffectedVersion: null,
rangeExpression: null,
provenance: new AdvisoryProvenance("unknown", "range", string.Empty, recordedAt, fieldMask: null)),
}),
});
var request = new UnknownStateLedgerRequest("CVE-2025-1111", advisory, observedAt);
var result = await ledger.RecordAsync(request, CancellationToken.None);
Assert.True(advisory.Provenance.IsDefaultOrEmpty);
Assert.Equal("cve-2025-1111", result.VulnerabilityKey);
Assert.Equal(observedAt.ToUniversalTime(), result.AsOf);
var markerNames = result.Markers.Select(marker => marker.Marker).OrderBy(name => name, StringComparer.Ordinal).ToArray();
Assert.True(markerNames.Length == 3, "Markers: " + string.Join(",", markerNames));
Assert.Single(repository.Upserts);
Assert.Equal("cve-2025-1111", repository.Upserts.Single().VulnerabilityKey);
Assert.Equal(3, repository.Upserts.Single().Snapshots.Count);
var markers = result.Markers.ToDictionary(marker => marker.Marker, marker => marker, StringComparer.Ordinal);
Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.UnknownVulnerabilityRange));
Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.UnknownOrigin));
Assert.True(markers.ContainsKey(UnknownStateMarkerKinds.AmbiguousFix));
var fixMarker = markers[UnknownStateMarkerKinds.AmbiguousFix];
Assert.Equal(0.45, fixMarker.Confidence, 3);
Assert.Equal("medium", fixMarker.ConfidenceBand);
Assert.Contains("explicit fixed version", fixMarker.Evidence, StringComparison.OrdinalIgnoreCase);
var repositoryMarkers = await repository.GetByVulnerabilityAsync("cve-2025-1111", CancellationToken.None);
Assert.Equal(3, repositoryMarkers.Count);
}
[Fact]
public async Task RecordAsync_NoUnknownSignals_ClearsLedger()
{
var repository = new FakeUnknownStateRepository();
var observedAt = DateTimeOffset.Parse("2025-10-19T09:00:00Z");
var recordedAt = DateTimeOffset.Parse("2025-10-21T03:00:00Z");
var ledger = new UnknownStateLedger(repository, new FixedTimeProvider(recordedAt));
var advisory = BuildAdvisory(
provenance: new[] { new AdvisoryProvenance("NVD", "merge", "nvd-source", recordedAt, fieldMask: null) },
packages: new[]
{
BuildPackage(
statuses: new[] { BuildStatus(AffectedPackageStatusCatalog.KnownAffected, source: "Vendor") },
versionRanges: new[]
{
new AffectedVersionRange("semver", "1.0.0", "1.0.5", null, ">=1.0.0,<1.0.5", new AdvisoryProvenance("NVD", "range", string.Empty, recordedAt, fieldMask: null)),
},
provenance: new[]
{
new AdvisoryProvenance("Vendor", "advisory", "vendor", recordedAt, fieldMask: null),
}),
});
var result = await ledger.RecordAsync(new UnknownStateLedgerRequest("GHSA-1234", advisory, observedAt), CancellationToken.None);
Assert.Equal("ghsa-1234", result.VulnerabilityKey);
Assert.Empty(result.Markers);
Assert.Single(repository.Upserts);
var stored = repository.Upserts.Single();
Assert.Empty(stored.Snapshots);
}
[Fact]
public async Task GetByVulnerabilityAsync_NormalizesKey()
{
var repository = new FakeUnknownStateRepository();
var snapshot = new UnknownStateSnapshot(
UnknownStateMarkerKinds.UnknownOrigin,
0.6,
"medium",
DateTimeOffset.Parse("2025-10-19T00:00:00Z"),
DateTimeOffset.Parse("2025-10-19T01:00:00Z"),
"evidence");
repository.Stored["cve-2025-0001"] = new List<UnknownStateSnapshot> { snapshot };
var ledger = new UnknownStateLedger(repository);
var markers = await ledger.GetByVulnerabilityAsync("CVE-2025-0001", CancellationToken.None);
Assert.Single(markers);
Assert.Equal(snapshot, markers[0]);
}
[Fact]
public void CanonicalSerialization_IsDeterministic()
{
var snapshot = new UnknownStateSnapshot(
UnknownStateMarkerKinds.UnknownOrigin,
0.6,
"medium",
DateTimeOffset.Parse("2025-10-19T00:00:00Z"),
DateTimeOffset.Parse("2025-10-21T12:00:00Z"),
"Provenance missing");
var json = CanonicalJsonSerializer.Serialize(snapshot);
const string expected = "{\"confidence\":0.6,\"confidenceBand\":\"medium\",\"evidence\":\"Provenance missing\",\"marker\":\"unknown_origin\",\"observedAt\":\"2025-10-19T00:00:00+00:00\",\"recordedAt\":\"2025-10-21T12:00:00+00:00\"}";
Assert.Equal(expected, json);
}
private static Advisory BuildAdvisory(
IEnumerable<AdvisoryProvenance> provenance,
IEnumerable<AffectedPackage> packages)
=> new(
advisoryKey: "ADV-1",
title: "Sample advisory",
summary: null,
language: "en",
published: null,
modified: null,
severity: "High",
exploitKnown: false,
aliases: new[] { "CVE-2025-1111" },
references: Array.Empty<AdvisoryReference>(),
affectedPackages: packages,
cvssMetrics: Array.Empty<CvssMetric>(),
provenance: provenance,
description: null,
cwes: Array.Empty<AdvisoryWeakness>(),
canonicalMetricId: null);
private static AffectedPackage BuildPackage(
IEnumerable<AffectedPackageStatus> statuses,
IEnumerable<AffectedVersionRange> versionRanges,
IEnumerable<AdvisoryProvenance>? provenance = null)
=> new(
type: AffectedPackageTypes.SemVer,
identifier: "pkg/example",
platform: null,
versionRanges: versionRanges,
statuses: statuses,
provenance: provenance,
normalizedVersions: Array.Empty<NormalizedVersionRule>());
private static AffectedPackageStatus BuildStatus(string status, string source = "unknown")
=> new(status, new AdvisoryProvenance(source, "status", string.Empty, DateTimeOffset.Parse("2025-10-15T00:00:00Z"), fieldMask: null));
private sealed class FakeUnknownStateRepository : IUnknownStateRepository
{
public List<(string VulnerabilityKey, IReadOnlyCollection<UnknownStateSnapshot> Snapshots)> Upserts { get; } = new();
public Dictionary<string, List<UnknownStateSnapshot>> Stored { get; } = new(StringComparer.Ordinal);
public ValueTask UpsertAsync(string vulnerabilityKey, IReadOnlyCollection<UnknownStateSnapshot> snapshots, CancellationToken cancellationToken)
{
Upserts.Add((vulnerabilityKey, snapshots));
Stored[vulnerabilityKey] = snapshots?.ToList() ?? new List<UnknownStateSnapshot>();
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<UnknownStateSnapshot>> GetByVulnerabilityAsync(string vulnerabilityKey, CancellationToken cancellationToken)
{
Stored.TryGetValue(vulnerabilityKey, out var snapshots);
return ValueTask.FromResult<IReadOnlyList<UnknownStateSnapshot>>(snapshots ?? new List<UnknownStateSnapshot>());
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now.ToUniversalTime();
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -18,4 +18,4 @@
|Reference normalization & freshness instrumentation cleanup|BE-Core, QA|Models|DONE (2025-10-15) reference keys normalized, freshness overrides applied to union fields, and new tests assert decision logging.|
|FEEDCORE-ENGINE-07-001 Advisory event log & asOf queries|Team Core Engine & Storage Analytics|FEEDSTORAGE-DATA-07-001|**DONE (2025-10-19)** Implemented `AdvisoryEventLog` service plus repository contracts, canonical hashing, and lower-cased key normalization with replay support; documented determinism guarantees. Tests: `dotnet test src/StellaOps.Concelier.Core.Tests/StellaOps.Concelier.Core.Tests.csproj`.|
|FEEDCORE-ENGINE-07-002 Noise prior computation service|Team Core Engine & Data Science|FEEDCORE-ENGINE-07-001|**DONE (2025-10-21)** Build rule-based learner capturing false-positive priors per package/env, persist summaries, and expose APIs for Excititor/scan suppressors with reproducible statistics.|
|FEEDCORE-ENGINE-07-003 Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|TODO Persist `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with initial confidence bands, expose query surface for Policy, and add fixtures validating canonical serialization.|
|FEEDCORE-ENGINE-07-003 Unknown state ledger & confidence seeding|Team Core Engine & Storage Analytics|FEEDCORE-ENGINE-07-001|DONE (2025-10-21) Persisted `unknown_vuln_range/unknown_origin/ambiguous_fix` markers with seeded confidence bands, exposed query surface for Policy, and added canonical serialization fixtures + regression tests.|

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Surface for recording and querying unknown-state markers.
/// </summary>
public interface IUnknownStateLedger
{
ValueTask<UnknownStateLedgerResult> RecordAsync(
UnknownStateLedgerRequest request,
CancellationToken cancellationToken);
ValueTask<IReadOnlyList<UnknownStateSnapshot>> GetByVulnerabilityAsync(
string vulnerabilityKey,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Persistence abstraction for unknown-state ledger entries.
/// </summary>
public interface IUnknownStateRepository
{
ValueTask UpsertAsync(
string vulnerabilityKey,
IReadOnlyCollection<UnknownStateSnapshot> snapshots,
CancellationToken cancellationToken);
ValueTask<IReadOnlyList<UnknownStateSnapshot>> GetByVulnerabilityAsync(
string vulnerabilityKey,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,313 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Default implementation that derives unknown-state markers from canonical advisories.
/// </summary>
public sealed class UnknownStateLedger : IUnknownStateLedger
{
private static readonly ImmutableHashSet<string> ImpactStatuses = ImmutableHashSet.Create(
StringComparer.Ordinal,
AffectedPackageStatusCatalog.KnownAffected,
AffectedPackageStatusCatalog.Affected,
AffectedPackageStatusCatalog.UnderInvestigation,
AffectedPackageStatusCatalog.Pending,
AffectedPackageStatusCatalog.Unknown);
private static readonly ImmutableHashSet<string> FixStatuses = ImmutableHashSet.Create(
StringComparer.Ordinal,
AffectedPackageStatusCatalog.Fixed,
AffectedPackageStatusCatalog.FirstFixed,
AffectedPackageStatusCatalog.Mitigated);
private static readonly ImmutableDictionary<string, UnknownMarkerSeed> MarkerSeeds = new Dictionary<string, UnknownMarkerSeed>(StringComparer.Ordinal)
{
[UnknownStateMarkerKinds.UnknownVulnerabilityRange] = new UnknownMarkerSeed(0.8, "high"),
[UnknownStateMarkerKinds.UnknownOrigin] = new UnknownMarkerSeed(0.6, "medium"),
[UnknownStateMarkerKinds.AmbiguousFix] = new UnknownMarkerSeed(0.45, "medium"),
}.ToImmutableDictionary(StringComparer.Ordinal);
private readonly IUnknownStateRepository _repository;
private readonly TimeProvider _timeProvider;
public UnknownStateLedger(IUnknownStateRepository repository, TimeProvider? timeProvider = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<UnknownStateLedgerResult> RecordAsync(
UnknownStateLedgerRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var recordedAt = _timeProvider.GetUtcNow();
var markers = EvaluateMarkers(request.Advisory, request.AsOf, recordedAt);
await _repository.UpsertAsync(request.VulnerabilityKey, markers, cancellationToken).ConfigureAwait(false);
return new UnknownStateLedgerResult(request.VulnerabilityKey, request.AsOf, markers);
}
public ValueTask<IReadOnlyList<UnknownStateSnapshot>> GetByVulnerabilityAsync(
string vulnerabilityKey,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(vulnerabilityKey);
var normalizedKey = vulnerabilityKey.Trim().ToLowerInvariant();
return _repository.GetByVulnerabilityAsync(normalizedKey, cancellationToken);
}
private static ImmutableArray<UnknownStateSnapshot> EvaluateMarkers(
Advisory advisory,
DateTimeOffset observedAt,
DateTimeOffset recordedAt)
{
var builder = ImmutableArray.CreateBuilder<UnknownStateSnapshot>(initialCapacity: 3);
if (advisory is not null)
{
if (TryCreateUnknownVulnerabilityRangeMarker(advisory, observedAt, recordedAt, out var unknownRange))
{
builder.Add(unknownRange);
}
if (TryCreateUnknownOriginMarker(advisory, observedAt, recordedAt, out var unknownOrigin))
{
builder.Add(unknownOrigin);
}
if (TryCreateAmbiguousFixMarker(advisory, observedAt, recordedAt, out var ambiguousFix))
{
builder.Add(ambiguousFix);
}
}
if (builder.Count == 0)
{
return ImmutableArray<UnknownStateSnapshot>.Empty;
}
builder.Sort(static (left, right) => StringComparer.Ordinal.Compare(left.Marker, right.Marker));
return builder.ToImmutable();
}
private static bool TryCreateUnknownVulnerabilityRangeMarker(
Advisory advisory,
DateTimeOffset observedAt,
DateTimeOffset recordedAt,
out UnknownStateSnapshot snapshot)
{
snapshot = null!;
if (advisory.AffectedPackages.IsDefaultOrEmpty)
{
return false;
}
var lackingPackages = 0;
foreach (var package in advisory.AffectedPackages)
{
if (package is null)
{
continue;
}
if (!HasImpactStatus(package))
{
continue;
}
if (!HasConcreteRange(package))
{
lackingPackages++;
}
}
if (lackingPackages == 0)
{
return false;
}
var seed = MarkerSeeds[UnknownStateMarkerKinds.UnknownVulnerabilityRange];
var evidence = lackingPackages == 1
? "1 affected package lacks explicit version ranges."
: $"{lackingPackages} affected packages lack explicit version ranges.";
snapshot = new UnknownStateSnapshot(
UnknownStateMarkerKinds.UnknownVulnerabilityRange,
seed.Confidence,
seed.Band,
observedAt,
recordedAt,
evidence);
return true;
}
private static bool TryCreateUnknownOriginMarker(
Advisory advisory,
DateTimeOffset observedAt,
DateTimeOffset recordedAt,
out UnknownStateSnapshot snapshot)
{
snapshot = null!;
if (ContainsKnownProvenance(advisory.Provenance))
{
return false;
}
var seed = MarkerSeeds[UnknownStateMarkerKinds.UnknownOrigin];
var evidence = advisory.Provenance.IsDefaultOrEmpty
? "Advisory provenance is missing; falling back to inferred sources."
: "All advisory provenance sources resolve to 'unknown'.";
snapshot = new UnknownStateSnapshot(
UnknownStateMarkerKinds.UnknownOrigin,
seed.Confidence,
seed.Band,
observedAt,
recordedAt,
evidence);
return true;
}
private static bool TryCreateAmbiguousFixMarker(
Advisory advisory,
DateTimeOffset observedAt,
DateTimeOffset recordedAt,
out UnknownStateSnapshot snapshot)
{
snapshot = null!;
if (advisory.AffectedPackages.IsDefaultOrEmpty)
{
return false;
}
var ambiguousPackages = 0;
foreach (var package in advisory.AffectedPackages)
{
if (package is null)
{
continue;
}
if (!package.Statuses.IsDefaultOrEmpty && package.Statuses.Any(status => FixStatuses.Contains(status.Status)))
{
var hasFixedRange = package.VersionRanges.Any(static range => !string.IsNullOrWhiteSpace(range.FixedVersion));
if (!hasFixedRange)
{
ambiguousPackages++;
}
}
}
if (ambiguousPackages == 0)
{
return false;
}
var seed = MarkerSeeds[UnknownStateMarkerKinds.AmbiguousFix];
var evidence = ambiguousPackages == 1
? "Fix status published without explicit fixed version details."
: $"Fix status published without explicit fixed versions for {ambiguousPackages} packages.";
snapshot = new UnknownStateSnapshot(
UnknownStateMarkerKinds.AmbiguousFix,
seed.Confidence,
seed.Band,
observedAt,
recordedAt,
evidence);
return true;
}
private static bool HasImpactStatus(AffectedPackage package)
{
if (package.Statuses.IsDefaultOrEmpty)
{
return false;
}
foreach (var status in package.Statuses)
{
if (status is null)
{
continue;
}
if (ImpactStatuses.Contains(status.Status))
{
return true;
}
}
return false;
}
private static bool HasConcreteRange(AffectedPackage package)
{
if (package.VersionRanges.IsDefaultOrEmpty)
{
return false;
}
foreach (var range in package.VersionRanges)
{
if (range is null)
{
continue;
}
if (!string.IsNullOrWhiteSpace(range.IntroducedVersion) ||
!string.IsNullOrWhiteSpace(range.FixedVersion) ||
!string.IsNullOrWhiteSpace(range.LastAffectedVersion) ||
!string.IsNullOrWhiteSpace(range.RangeExpression))
{
return true;
}
}
return false;
}
private static bool ContainsKnownProvenance(ImmutableArray<AdvisoryProvenance> provenance)
{
if (provenance.IsDefaultOrEmpty)
{
return false;
}
foreach (var entry in provenance)
{
if (entry is null)
{
continue;
}
if (IsKnownSource(entry.Source))
{
return true;
}
}
return false;
}
private static bool IsKnownSource(string? source)
=> !string.IsNullOrWhiteSpace(source) &&
!string.Equals(source, "unknown", StringComparison.OrdinalIgnoreCase);
private readonly record struct UnknownMarkerSeed(double Confidence, string Band);
}

View File

@@ -0,0 +1,29 @@
using System;
using StellaOps.Concelier.Models;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Input payload describing the advisory snapshot used to derive unknown-state markers.
/// </summary>
public sealed record UnknownStateLedgerRequest
{
public UnknownStateLedgerRequest(string vulnerabilityKey, Advisory advisory, DateTimeOffset asOf)
{
VulnerabilityKey = NormalizeKey(vulnerabilityKey);
Advisory = advisory ?? throw new ArgumentNullException(nameof(advisory));
AsOf = asOf.ToUniversalTime();
}
public string VulnerabilityKey { get; init; }
public Advisory Advisory { get; init; }
public DateTimeOffset AsOf { get; init; }
private static string NormalizeKey(string key)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
return key.Trim().ToLowerInvariant();
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Result emitted after unknown-state markers are derived and persisted.
/// </summary>
public sealed record UnknownStateLedgerResult
{
public UnknownStateLedgerResult(string vulnerabilityKey, DateTimeOffset asOf, ImmutableArray<UnknownStateSnapshot> markers)
{
if (string.IsNullOrWhiteSpace(vulnerabilityKey))
{
throw new ArgumentException("Vulnerability key must be provided.", nameof(vulnerabilityKey));
}
VulnerabilityKey = vulnerabilityKey.Trim().ToLowerInvariant();
AsOf = asOf.ToUniversalTime();
Markers = markers.IsDefault ? ImmutableArray<UnknownStateSnapshot>.Empty : markers;
}
public string VulnerabilityKey { get; init; }
public DateTimeOffset AsOf { get; init; }
public ImmutableArray<UnknownStateSnapshot> Markers { get; init; }
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Known unknown-state markers emitted from advisory analysis.
/// </summary>
public static class UnknownStateMarkerKinds
{
public const string UnknownVulnerabilityRange = "unknown_vuln_range";
public const string UnknownOrigin = "unknown_origin";
public const string AmbiguousFix = "ambiguous_fix";
public static IReadOnlyList<string> All { get; } = new[]
{
UnknownVulnerabilityRange,
UnknownOrigin,
AmbiguousFix,
};
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Globalization;
namespace StellaOps.Concelier.Core.Unknown;
/// <summary>
/// Describes a persisted unknown-state marker for a vulnerability.
/// </summary>
public sealed record UnknownStateSnapshot
{
public UnknownStateSnapshot(
string marker,
double confidence,
string confidenceBand,
DateTimeOffset observedAt,
DateTimeOffset recordedAt,
string evidence)
{
Marker = NormalizeMarker(marker);
Confidence = NormalizeConfidence(confidence);
ConfidenceBand = NormalizeBand(confidenceBand);
ObservedAt = observedAt.ToUniversalTime();
RecordedAt = recordedAt.ToUniversalTime();
Evidence = NormalizeEvidence(evidence);
}
public string Marker { get; init; }
public double Confidence { get; init; }
public string ConfidenceBand { get; init; }
public DateTimeOffset ObservedAt { get; init; }
public DateTimeOffset RecordedAt { get; init; }
public string Evidence { get; init; }
public override string ToString()
=> string.Create(CultureInfo.InvariantCulture, $"{Marker}:{Confidence:0.###}@{ObservedAt:O}");
private static string NormalizeMarker(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return value.Trim().ToLowerInvariant();
}
private static double NormalizeConfidence(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 0d;
}
return Math.Clamp(value, 0d, 1d);
}
private static string NormalizeBand(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
return value.Trim().ToLowerInvariant();
}
private static string NormalizeEvidence(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim();
}
}

View File

@@ -2,6 +2,6 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| EXCITITOR-CONN-STELLA-07-001 | TODO | Excititor Connectors Stella | EXCITITOR-EXPORT-01-007 | Implement mirror fetch client consuming `https://<domain>.stella-ops.org/excititor/exports/index.json`, validating signatures/digests, storing raw consensus bundles with provenance. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. |
| EXCITITOR-CONN-STELLA-07-001 | DONE (2025-10-21) | Excititor Connectors Stella | EXCITITOR-EXPORT-01-007 | **DONE (2025-10-21)** Implemented `StellaOpsMirrorConnector` with `MirrorManifestClient` + `MirrorSignatureVerifier`, digest validation, signature enforcement, raw document + DTO persistence, and resume cursor updates. Added fixture-backed tests covering happy path and tampered manifest rejection. | Fetch job downloads mirror manifest, verifies DSSE/signature, stores raw documents + provenance; unit tests cover happy path and tampered manifest failure. |
| EXCITITOR-CONN-STELLA-07-002 | TODO | Excititor Connectors Stella | EXCITITOR-CONN-STELLA-07-001 | Normalize mirror bundles into VexClaim sets referencing original provider metadata and mirror provenance. | Normalizer emits VexClaims with mirror provenance + policy metadata, fixtures assert deterministic output parity vs local exports. |
| EXCITITOR-CONN-STELLA-07-003 | TODO | Excititor Connectors Stella | EXCITITOR-CONN-STELLA-07-002 | Implement incremental cursor handling per-export digest, support resume, and document configuration for downstream Excititor mirrors. | Connector resumes from last export digest, handles delta/export rotation, docs show configuration; integration test covers resume + new export ingest. |

View File

@@ -0,0 +1,110 @@
using System.Collections.Generic;
namespace StellaOps.Excititor.Core;
public sealed class MirrorDistributionOptions
{
public const string SectionName = "Excititor:Mirror";
/// <summary>
/// Global enable flag for mirror distribution surfaces and bundle generation.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Optional absolute or relative path for mirror artifacts. When unset, publishers
/// may fall back to artifact-store specific defaults.
/// </summary>
public string? OutputRoot { get; set; }
/// <summary>
/// Directory name created under <see cref="OutputRoot"/> that holds mirror artifacts.
/// Defaults to <c>mirror</c> to align with offline kit layouts.
/// </summary>
public string DirectoryName { get; set; } = "mirror";
/// <summary>
/// Optional human-readable hint describing where downstream mirrors should publish
/// bundles (e.g., s3://mirror/excititor). Propagated to manifests and index payloads.
/// </summary>
public string? TargetRepository { get; set; }
/// <summary>
/// Signing configuration applied to generated bundle payloads.
/// </summary>
public MirrorSigningOptions Signing { get; } = new();
/// <summary>
/// Domains exposed for mirror consumption. Each domain groups a set of export plans.
/// </summary>
public List<MirrorDomainOptions> Domains { get; } = new();
}
public sealed class MirrorDomainOptions
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool RequireAuthentication { get; set; } = false;
/// <summary>
/// Maximum index requests allowed per rolling window.
/// </summary>
public int MaxIndexRequestsPerHour { get; set; } = 120;
/// <summary>
/// Maximum export downloads allowed per rolling window.
/// </summary>
public int MaxDownloadRequestsPerHour { get; set; } = 600;
public List<MirrorExportOptions> Exports { get; } = new();
}
public sealed class MirrorExportOptions
{
public string Key { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public Dictionary<string, string> Filters { get; } = new();
public Dictionary<string, bool> Sort { get; } = new();
public int? Limit { get; set; } = null;
public int? Offset { get; set; } = null;
public string? View { get; set; } = null;
}
public sealed class MirrorSigningOptions
{
/// <summary>
/// Enables signing of mirror bundle payloads when true. When false the publisher
/// omits detached JWS artifacts.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Signing algorithm requested (for example, ES256). The publisher validates that
/// the selected provider can satisfy the requested algorithm.
/// </summary>
public string? Algorithm { get; set; }
/// <summary>
/// Optional key identifier resolved against the configured crypto provider registry.
/// </summary>
public string? KeyId { get; set; }
/// <summary>
/// Optional provider hint used to resolve signing providers when multiple are registered.
/// </summary>
public string? Provider { get; set; }
/// <summary>
/// Optional file path to a signing key (PEM). Used when the requested provider does
/// not already have the key loaded into its key store.
/// </summary>
public string? KeyPath { get; set; }
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record MirrorExportPlan(
string Key,
VexExportFormat Format,
VexQuery Query,
VexQuerySignature Signature);
public static class MirrorExportPlanner
{
public static bool TryBuild(MirrorExportOptions exportOptions, out MirrorExportPlan plan, out string? error)
{
if (exportOptions is null)
{
plan = null!;
error = "invalid_export_configuration";
return false;
}
if (string.IsNullOrWhiteSpace(exportOptions.Key))
{
plan = null!;
error = "missing_export_key";
return false;
}
if (string.IsNullOrWhiteSpace(exportOptions.Format) ||
!Enum.TryParse(exportOptions.Format, ignoreCase: true, out VexExportFormat format))
{
plan = null!;
error = "unsupported_export_format";
return false;
}
var filters = exportOptions.Filters.Select(pair => new VexQueryFilter(pair.Key, pair.Value));
var sorts = exportOptions.Sort.Select(pair => new VexQuerySort(pair.Key, pair.Value));
var query = VexQuery.Create(filters, sorts, exportOptions.Limit, exportOptions.Offset, exportOptions.View);
var signature = VexQuerySignature.FromQuery(query);
plan = new MirrorExportPlan(exportOptions.Key.Trim(), format, query, signature);
error = null;
return true;
}
}

View File

@@ -230,13 +230,33 @@ public static class VexCanonicalJsonSerializer
"sourceProviders",
"consensusRevision",
"policyRevisionId",
"policyDigest",
"consensusDigest",
"scoreDigest",
"attestation",
"sizeBytes",
}
},
"policyDigest",
"consensusDigest",
"scoreDigest",
"quietProvenance",
"attestation",
"sizeBytes",
}
},
{
typeof(VexQuietProvenance),
new[]
{
"vulnerabilityId",
"productKey",
"statements",
}
},
{
typeof(VexQuietStatement),
new[]
{
"providerId",
"statementId",
"justification",
"signature",
}
},
{
typeof(VexScoreEnvelope),
new[]

View File

@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Runtime.Serialization;
using System.Text;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
namespace StellaOps.Excititor.Core;
@@ -19,9 +20,10 @@ public sealed record VexExportManifest
string? policyRevisionId = null,
string? policyDigest = null,
VexContentAddress? consensusDigest = null,
VexContentAddress? scoreDigest = null,
VexAttestationMetadata? attestation = null,
long sizeBytes = 0)
VexContentAddress? scoreDigest = null,
IEnumerable<VexQuietProvenance>? quietProvenance = null,
VexAttestationMetadata? attestation = null,
long sizeBytes = 0)
{
if (string.IsNullOrWhiteSpace(exportId))
{
@@ -48,11 +50,12 @@ public sealed record VexExportManifest
SourceProviders = NormalizeProviders(sourceProviders);
ConsensusRevision = string.IsNullOrWhiteSpace(consensusRevision) ? null : consensusRevision.Trim();
PolicyRevisionId = string.IsNullOrWhiteSpace(policyRevisionId) ? null : policyRevisionId.Trim();
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
ConsensusDigest = consensusDigest;
ScoreDigest = scoreDigest;
Attestation = attestation;
SizeBytes = sizeBytes;
PolicyDigest = string.IsNullOrWhiteSpace(policyDigest) ? null : policyDigest.Trim();
ConsensusDigest = consensusDigest;
ScoreDigest = scoreDigest;
QuietProvenance = NormalizeQuietProvenance(quietProvenance);
Attestation = attestation;
SizeBytes = sizeBytes;
}
public string ExportId { get; }
@@ -79,13 +82,15 @@ public sealed record VexExportManifest
public VexContentAddress? ConsensusDigest { get; }
public VexContentAddress? ScoreDigest { get; }
public VexAttestationMetadata? Attestation { get; }
public VexContentAddress? ScoreDigest { get; }
public ImmutableArray<VexQuietProvenance> QuietProvenance { get; }
public VexAttestationMetadata? Attestation { get; }
public long SizeBytes { get; }
private static ImmutableArray<string> NormalizeProviders(IEnumerable<string> providers)
private static ImmutableArray<string> NormalizeProviders(IEnumerable<string> providers)
{
if (providers is null)
{
@@ -103,11 +108,24 @@ public sealed record VexExportManifest
set.Add(provider.Trim());
}
return set.Count == 0
? ImmutableArray<string>.Empty
: set.ToImmutableArray();
}
}
return set.Count == 0
? ImmutableArray<string>.Empty
: set.ToImmutableArray();
}
private static ImmutableArray<VexQuietProvenance> NormalizeQuietProvenance(IEnumerable<VexQuietProvenance>? quietProvenance)
{
if (quietProvenance is null)
{
return ImmutableArray<VexQuietProvenance>.Empty;
}
return quietProvenance
.OrderBy(static entry => entry.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static entry => entry.ProductKey, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexContentAddress
{

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Excititor.Core;
public sealed record VexQuietProvenance
{
public VexQuietProvenance(string vulnerabilityId, string productKey, IEnumerable<VexQuietStatement> statements)
{
if (string.IsNullOrWhiteSpace(vulnerabilityId))
{
throw new ArgumentException("Vulnerability id must be provided.", nameof(vulnerabilityId));
}
if (string.IsNullOrWhiteSpace(productKey))
{
throw new ArgumentException("Product key must be provided.", nameof(productKey));
}
VulnerabilityId = vulnerabilityId.Trim();
ProductKey = productKey.Trim();
Statements = NormalizeStatements(statements);
}
public string VulnerabilityId { get; }
public string ProductKey { get; }
public ImmutableArray<VexQuietStatement> Statements { get; }
private static ImmutableArray<VexQuietStatement> NormalizeStatements(IEnumerable<VexQuietStatement> statements)
{
if (statements is null)
{
throw new ArgumentNullException(nameof(statements));
}
return statements
.OrderBy(static s => s.ProviderId, StringComparer.Ordinal)
.ThenBy(static s => s.StatementId, StringComparer.Ordinal)
.ToImmutableArray();
}
}
public sealed record VexQuietStatement
{
public VexQuietStatement(
string providerId,
string statementId,
VexJustification? justification,
VexSignatureMetadata? signature)
{
if (string.IsNullOrWhiteSpace(providerId))
{
throw new ArgumentException("Provider id must be provided.", nameof(providerId));
}
if (string.IsNullOrWhiteSpace(statementId))
{
throw new ArgumentException("Statement id must be provided.", nameof(statementId));
}
ProviderId = providerId.Trim();
StatementId = statementId.Trim();
Justification = justification;
Signature = signature;
}
public string ProviderId { get; }
public string StatementId { get; }
public VexJustification? Justification { get; }
public VexSignatureMetadata? Signature { get; }
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Immutable;
using System.IO;
using System.Text;
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Excititor.Core;
@@ -32,6 +33,18 @@ public sealed class ExportEngineTests
Assert.Equal(VexExportFormat.Json, manifest.Format);
Assert.Equal("baseline/v1", manifest.ConsensusRevision);
Assert.Equal(1, manifest.ClaimCount);
Assert.NotNull(dataSource.LastDataSet);
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
dataSource.LastDataSet!,
VexPolicySnapshot.Default,
context.RequestedAt);
Assert.NotNull(manifest.ConsensusDigest);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Algorithm, manifest.ConsensusDigest!.Algorithm);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest.Digest);
Assert.NotNull(manifest.ScoreDigest);
Assert.Equal(expectedEnvelopes.ScoreDigest.Algorithm, manifest.ScoreDigest!.Algorithm);
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest.Digest);
Assert.Empty(manifest.QuietProvenance);
// second call hits cache
var cached = await engine.ExportAsync(context, CancellationToken.None);
@@ -114,13 +127,82 @@ public sealed class ExportEngineTests
var manifest = await engine.ExportAsync(context, CancellationToken.None);
Assert.NotNull(attestation.LastRequest);
Assert.NotNull(dataSource.LastDataSet);
var expectedEnvelopes = VexExportEnvelopeBuilder.Build(
dataSource.LastDataSet!,
VexPolicySnapshot.Default,
requestedAt);
Assert.Equal(manifest.ExportId, attestation.LastRequest!.ExportId);
var metadata = attestation.LastRequest.Metadata;
Assert.True(metadata.ContainsKey("consensusDigest"), "Consensus digest metadata missing");
Assert.Equal(expectedEnvelopes.ConsensusDigest.ToUri(), metadata["consensusDigest"]);
Assert.True(metadata.ContainsKey("scoreDigest"), "Score digest metadata missing");
Assert.Equal(expectedEnvelopes.ScoreDigest.ToUri(), metadata["scoreDigest"]);
Assert.Equal(expectedEnvelopes.Consensus.Length.ToString(CultureInfo.InvariantCulture), metadata["consensusEntryCount"]);
Assert.Equal(expectedEnvelopes.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture), metadata["scoreEntryCount"]);
Assert.Equal(VexPolicySnapshot.Default.RevisionId, metadata["policyRevisionId"]);
Assert.Equal(VexPolicySnapshot.Default.Version, metadata["policyVersion"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreAlpha"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreBeta"]);
Assert.Equal(VexPolicySnapshot.Default.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture), metadata["scoreWeightCeiling"]);
Assert.NotNull(manifest.Attestation);
Assert.Equal(attestation.Response.Attestation.EnvelopeDigest, manifest.Attestation!.EnvelopeDigest);
Assert.Equal(attestation.Response.Attestation.PredicateType, manifest.Attestation.PredicateType);
Assert.NotNull(manifest.ConsensusDigest);
Assert.Equal(expectedEnvelopes.ConsensusDigest.Digest, manifest.ConsensusDigest!.Digest);
Assert.NotNull(manifest.ScoreDigest);
Assert.Equal(expectedEnvelopes.ScoreDigest.Digest, manifest.ScoreDigest!.Digest);
Assert.Empty(manifest.QuietProvenance);
Assert.NotNull(store.LastSavedManifest);
Assert.Equal(manifest.Attestation, store.LastSavedManifest!.Attestation);
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
}
[Fact]
public async Task ExportAsync_IncludesQuietProvenanceMetadata()
{
var store = new InMemoryExportStore();
var evaluator = new StaticPolicyEvaluator("baseline/v1");
var dataSource = new QuietExportDataSource();
var exporter = new DummyExporter(VexExportFormat.Json);
var attestation = new RecordingAttestationClient();
var engine = new VexExportEngine(
store,
evaluator,
dataSource,
new[] { exporter },
NullLogger<VexExportEngine>.Instance,
cacheIndex: null,
artifactStores: null,
attestationClient: attestation);
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0002") });
var requestedAt = DateTimeOffset.UtcNow;
var context = new VexExportRequestContext(query, VexExportFormat.Json, requestedAt);
var manifest = await engine.ExportAsync(context, CancellationToken.None);
var quiet = Assert.Single(manifest.QuietProvenance);
Assert.Equal("CVE-2025-0002", quiet.VulnerabilityId);
Assert.Equal("pkg:demo/app", quiet.ProductKey);
var statement = Assert.Single(quiet.Statements);
Assert.Equal("vendor", statement.ProviderId);
Assert.Equal("sha256:quiet", statement.StatementId);
Assert.Equal(VexJustification.ComponentNotPresent, statement.Justification);
Assert.NotNull(statement.Signature);
Assert.Equal("quiet-signer", statement.Signature!.Subject);
Assert.Equal("quiet-key", statement.Signature.KeyId);
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(manifest.QuietProvenance);
Assert.NotNull(attestation.LastRequest);
Assert.True(attestation.LastRequest!.Metadata.TryGetValue("quietedBy", out var quietJson));
Assert.Equal(expectedQuietJson, quietJson);
Assert.True(attestation.LastRequest.Metadata.TryGetValue("quietedByStatementCount", out var quietCount));
Assert.Equal("1", quietCount);
Assert.NotNull(store.LastSavedManifest);
Assert.Equal(manifest.QuietProvenance, store.LastSavedManifest!.QuietProvenance);
}
private sealed class InMemoryExportStore : IVexExportStore
@@ -148,6 +230,48 @@ public sealed class ExportEngineTests
=> FormattableString.Invariant($"{signature}|{format}");
}
private sealed class QuietExportDataSource : IVexExportDataSource
{
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
var signature = new VexSignatureMetadata(
type: "pgp",
subject: "quiet-signer",
issuer: "quiet-ca",
keyId: "quiet-key",
verifiedAt: DateTimeOffset.UnixEpoch,
transparencyLogReference: "rekor://quiet");
var claim = new VexClaim(
"CVE-2025-0002",
"vendor",
new VexProduct("pkg:demo/app", "Demo"),
VexClaimStatus.NotAffected,
new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/quiet"), signature: signature),
DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow,
justification: VexJustification.ComponentNotPresent);
var consensus = new VexConsensus(
"CVE-2025-0002",
claim.Product,
VexConsensusStatus.NotAffected,
DateTimeOffset.UtcNow,
new[]
{
new VexConsensusSource("vendor", VexClaimStatus.NotAffected, "sha256:quiet", 1.0, claim.Justification),
},
conflicts: null,
policyVersion: "baseline/v1",
summary: "not_affected");
return ValueTask.FromResult(new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor")));
}
}
private sealed class RecordingAttestationClient : IVexAttestationClient
{
public VexAttestationRequest? LastRequest { get; private set; }
@@ -226,6 +350,8 @@ public sealed class ExportEngineTests
private sealed class InMemoryExportDataSource : IVexExportDataSource
{
public VexExportDataSet? LastDataSet { get; private set; }
public ValueTask<VexExportDataSet> FetchAsync(VexQuery query, CancellationToken cancellationToken)
{
var claim = new VexClaim(
@@ -247,10 +373,13 @@ public sealed class ExportEngineTests
policyVersion: "baseline/v1",
summary: "affected");
return ValueTask.FromResult(new VexExportDataSet(
var dataSet = new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor")));
ImmutableArray.Create("vendor"));
LastDataSet = dataSet;
return ValueTask.FromResult(dataSet);
}
}

View File

@@ -0,0 +1,324 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using System.Collections.Immutable;
using System.IO.Abstractions.TestingHelpers;
using Xunit;
namespace StellaOps.Excititor.Export.Tests;
public sealed class MirrorBundlePublisherTests
{
[Fact]
public async Task PublishAsync_WritesMirrorArtifacts()
{
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(generatedAt);
var fileSystem = new MockFileSystem();
var options = new MirrorDistributionOptions
{
OutputRoot = @"C:\exports",
DirectoryName = "mirror",
TargetRepository = "s3://mirror/excititor",
};
var domain = new MirrorDomainOptions
{
Id = "primary",
DisplayName = "Primary Mirror",
};
var exportOptions = new MirrorExportOptions
{
Key = "consensus-json",
Format = "json",
};
exportOptions.Filters["vulnId"] = "CVE-2025-0001";
domain.Exports.Add(exportOptions);
options.Domains.Add(domain);
var publisher = new VexMirrorBundlePublisher(
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
NullLogger<VexMirrorBundlePublisher>.Instance,
timeProvider,
fileSystem,
cryptoRegistry: null,
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
var sample = CreateSampleExport(generatedAt);
var manifest = sample.Manifest;
var envelope = sample.Envelope;
var dataSet = sample.DataSet;
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
await publisher.PublishAsync(manifest, envelope, dataSet, CancellationToken.None);
var mirrorRoot = @"C:\exports\mirror";
var domainRoot = Path.Combine(mirrorRoot, "primary");
var bundlePath = Path.Combine(domainRoot, "bundle.json");
var manifestPath = Path.Combine(domainRoot, "manifest.json");
var indexPath = Path.Combine(mirrorRoot, "index.json");
var signaturePath = Path.Combine(domainRoot, "bundle.json.jws");
Assert.True(fileSystem.File.Exists(bundlePath));
Assert.True(fileSystem.File.Exists(manifestPath));
Assert.True(fileSystem.File.Exists(indexPath));
Assert.False(fileSystem.File.Exists(signaturePath));
var bundleBytes = fileSystem.File.ReadAllBytes(bundlePath);
var manifestBytes = fileSystem.File.ReadAllBytes(manifestPath);
var indexBytes = fileSystem.File.ReadAllBytes(indexPath);
var expectedBundleDigest = ComputeSha256(bundleBytes);
var expectedManifestDigest = ComputeSha256(manifestBytes);
var expectedConsensusJson = envelope.ConsensusCanonicalJson;
var expectedScoreJson = envelope.ScoreCanonicalJson;
var expectedClaimsJson = SerializeClaims(dataSet.Claims);
var expectedQuietJson = VexCanonicalJsonSerializer.Serialize(envelope.QuietProvenance);
using (var bundleDocument = JsonDocument.Parse(bundleBytes))
{
var root = bundleDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("primary", root.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
Assert.Single(exports);
var export = exports[0];
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
Assert.Equal("json", export.GetProperty("format").GetString());
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
Assert.Equal(expectedConsensusJson, export.GetProperty("consensusDocument").GetString());
Assert.Equal(expectedScoreJson, export.GetProperty("scoreDocument").GetString());
Assert.Equal(expectedClaimsJson, export.GetProperty("claimsDocument").GetString());
Assert.Equal(expectedQuietJson, export.GetProperty("quietDocument").GetString());
var providers = export.GetProperty("sourceProviders").EnumerateArray().Select(p => p.GetString()).ToArray();
Assert.Single(providers);
Assert.Equal("vendor", providers[0]);
}
using (var manifestDocument = JsonDocument.Parse(manifestBytes))
{
var root = manifestDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("primary", root.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", root.GetProperty("displayName").GetString());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var bundleDescriptor = root.GetProperty("bundle");
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
Assert.False(bundleDescriptor.TryGetProperty("signature", out _));
var exports = root.GetProperty("exports").EnumerateArray().ToArray();
Assert.Single(exports);
var export = exports[0];
Assert.Equal("consensus-json", export.GetProperty("key").GetString());
Assert.Equal("json", export.GetProperty("format").GetString());
Assert.Equal(manifest.ExportId, export.GetProperty("exportId").GetString());
Assert.Equal(manifest.QuerySignature.Value, export.GetProperty("querySignature").GetString());
Assert.Equal(manifest.Artifact.ToUri(), export.GetProperty("artifactDigest").GetString());
Assert.Equal(manifest.SizeBytes, export.GetProperty("artifactSizeBytes").GetInt64());
Assert.Equal(manifest.ConsensusRevision, export.GetProperty("consensusRevision").GetString());
Assert.Equal(manifest.PolicyRevisionId, export.GetProperty("policyRevisionId").GetString());
Assert.Equal(manifest.PolicyDigest, export.GetProperty("policyDigest").GetString());
Assert.False(export.TryGetProperty("attestation", out _));
}
using (var indexDocument = JsonDocument.Parse(indexBytes))
{
var root = indexDocument.RootElement;
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
Assert.Equal("s3://mirror/excititor", root.GetProperty("targetRepository").GetString());
var domains = root.GetProperty("domains").EnumerateArray().ToArray();
Assert.Single(domains);
var entry = domains[0];
Assert.Equal("primary", entry.GetProperty("domainId").GetString());
Assert.Equal("Primary Mirror", entry.GetProperty("displayName").GetString());
Assert.Equal(generatedAt, entry.GetProperty("generatedAt").GetDateTimeOffset());
Assert.Equal(1, entry.GetProperty("exportCount").GetInt32());
var manifestDescriptor = entry.GetProperty("manifest");
Assert.Equal("primary/manifest.json", manifestDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedManifestDigest, manifestDescriptor.GetProperty("digest").GetString());
Assert.Equal(manifestBytes.LongLength, manifestDescriptor.GetProperty("sizeBytes").GetInt64());
var bundleDescriptor = entry.GetProperty("bundle");
Assert.Equal("primary/bundle.json", bundleDescriptor.GetProperty("path").GetString());
Assert.Equal(expectedBundleDigest, bundleDescriptor.GetProperty("digest").GetString());
Assert.Equal(bundleBytes.LongLength, bundleDescriptor.GetProperty("sizeBytes").GetInt64());
var exportKeys = entry.GetProperty("exportKeys").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Single(exportKeys);
Assert.Equal("consensus-json", exportKeys[0]);
}
}
[Fact]
public async Task PublishAsync_NoMatchingDomain_DoesNotWriteArtifacts()
{
var generatedAt = DateTimeOffset.Parse("2025-10-21T12:00:00Z");
var timeProvider = new FixedTimeProvider(generatedAt);
var fileSystem = new MockFileSystem();
var options = new MirrorDistributionOptions
{
OutputRoot = @"C:\exports",
DirectoryName = "mirror",
};
var domain = new MirrorDomainOptions
{
Id = "primary",
DisplayName = "Primary Mirror",
};
var exportOptions = new MirrorExportOptions
{
Key = "consensus-json",
Format = "json",
};
exportOptions.Filters["vulnId"] = "CVE-2099-9999";
domain.Exports.Add(exportOptions);
options.Domains.Add(domain);
var publisher = new VexMirrorBundlePublisher(
new StaticOptionsMonitor<MirrorDistributionOptions>(options),
NullLogger<VexMirrorBundlePublisher>.Instance,
timeProvider,
fileSystem,
cryptoRegistry: null,
Options.Create(new FileSystemArtifactStoreOptions { RootPath = @"C:\exports" }));
var sample = CreateSampleExport(generatedAt);
await publisher.PublishAsync(sample.Manifest, sample.Envelope, sample.DataSet, CancellationToken.None);
Assert.False(fileSystem.Directory.Exists(@"C:\exports\mirror"));
}
private static SampleExport CreateSampleExport(DateTimeOffset generatedAt)
{
var query = VexQuery.Create(new[] { new VexQueryFilter("vulnId", "CVE-2025-0001") });
var signature = VexQuerySignature.FromQuery(query);
var product = new VexProduct("pkg:demo/app", "Demo");
var document = new VexClaimDocument(VexDocumentFormat.OpenVex, "sha256:quiet", new Uri("https://example.org/vex.json"));
var claim = new VexClaim(
"CVE-2025-0001",
"vendor",
product,
VexClaimStatus.NotAffected,
document,
generatedAt.AddDays(-1),
generatedAt,
justification: VexJustification.ComponentNotPresent);
var consensus = new VexConsensus(
"CVE-2025-0001",
product,
VexConsensusStatus.NotAffected,
generatedAt,
new[] { new VexConsensusSource("vendor", VexClaimStatus.NotAffected, document.Digest, 1.0, claim.Justification) },
conflicts: null,
signals: null,
policyVersion: "baseline/v1",
summary: "not_affected",
policyRevisionId: "policy/v1",
policyDigest: "sha256:policy");
var dataSet = new VexExportDataSet(
ImmutableArray.Create(consensus),
ImmutableArray.Create(claim),
ImmutableArray.Create("vendor"));
var envelope = VexExportEnvelopeBuilder.Build(dataSet, VexPolicySnapshot.Default, generatedAt);
var manifest = new VexExportManifest(
"exports/20251021T120000000Z/abcdef",
signature,
VexExportFormat.Json,
generatedAt,
new VexContentAddress("sha256", "deadbeef"),
dataSet.Claims.Length,
dataSet.SourceProviders,
consensusRevision: "baseline/v1",
policyRevisionId: "policy/v1",
policyDigest: "sha256:policy",
consensusDigest: envelope.ConsensusDigest,
scoreDigest: envelope.ScoreDigest,
quietProvenance: envelope.QuietProvenance,
attestation: null,
sizeBytes: 1024);
return new SampleExport(manifest, envelope, dataSet);
}
private static string SerializeClaims(ImmutableArray<VexClaim> claims)
=> VexCanonicalJsonSerializer.Serialize(
claims
.OrderBy(claim => claim.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(claim => claim.Product.Key, StringComparer.Ordinal)
.ThenBy(claim => claim.ProviderId, StringComparer.Ordinal)
.ToImmutableArray());
private static string ComputeSha256(byte[] bytes)
{
using var sha = SHA256.Create();
var digest = sha.ComputeHash(bytes);
return "sha256:" + Convert.ToHexString(digest).ToLowerInvariant();
}
private sealed record SampleExport(
VexExportManifest Manifest,
VexExportEnvelopeContext Envelope,
VexExportDataSet DataSet);
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _value;
public FixedTimeProvider(DateTimeOffset value) => _value = value;
public override DateTimeOffset GetUtcNow() => _value;
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
{
public StaticOptionsMonitor(T value) => CurrentValue = value;
public T CurrentValue { get; private set; }
public T Get(string? name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,8 +1,9 @@
using System;
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Globalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Excititor.Core;
@@ -42,6 +43,7 @@ public sealed class VexExportEngine : IExportEngine
private readonly IVexCacheIndex? _cacheIndex;
private readonly IReadOnlyList<IVexArtifactStore> _artifactStores;
private readonly IVexAttestationClient? _attestationClient;
private readonly IVexMirrorBundlePublisher? _mirrorPublisher;
public VexExportEngine(
IVexExportStore exportStore,
@@ -51,7 +53,8 @@ public sealed class VexExportEngine : IExportEngine
ILogger<VexExportEngine> logger,
IVexCacheIndex? cacheIndex = null,
IEnumerable<IVexArtifactStore>? artifactStores = null,
IVexAttestationClient? attestationClient = null)
IVexAttestationClient? attestationClient = null,
IVexMirrorBundlePublisher? mirrorPublisher = null)
{
_exportStore = exportStore ?? throw new ArgumentNullException(nameof(exportStore));
_policyEvaluator = policyEvaluator ?? throw new ArgumentNullException(nameof(policyEvaluator));
@@ -60,6 +63,7 @@ public sealed class VexExportEngine : IExportEngine
_cacheIndex = cacheIndex;
_artifactStores = artifactStores?.ToArray() ?? Array.Empty<IVexArtifactStore>();
_attestationClient = attestationClient;
_mirrorPublisher = mirrorPublisher;
if (exporters is null)
{
@@ -105,12 +109,13 @@ public sealed class VexExportEngine : IExportEngine
}
var dataset = await _dataSource.FetchAsync(context.Query, cancellationToken).ConfigureAwait(false);
var exporter = ResolveExporter(context.Format);
var policySnapshot = _policyEvaluator.Snapshot;
var envelopeContext = VexExportEnvelopeBuilder.Build(dataset, policySnapshot, context.RequestedAt);
var exporter = ResolveExporter(context.Format);
var exportRequest = new VexExportRequest(
context.Query,
dataset.Consensus,
envelopeContext.Consensus,
dataset.Claims,
context.RequestedAt);
@@ -120,6 +125,37 @@ public sealed class VexExportEngine : IExportEngine
await using var buffer = new MemoryStream();
var result = await exporter.SerializeAsync(exportRequest, buffer, cancellationToken).ConfigureAwait(false);
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
foreach (var kvp in result.Metadata)
{
metadataBuilder[kvp.Key] = kvp.Value;
}
metadataBuilder["consensusDigest"] = envelopeContext.ConsensusDigest.ToUri();
metadataBuilder["consensusEntryCount"] = envelopeContext.Consensus.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["scoreDigest"] = envelopeContext.ScoreDigest.ToUri();
metadataBuilder["scoreEntryCount"] = envelopeContext.ScoreEnvelope.Entries.Length.ToString(CultureInfo.InvariantCulture);
metadataBuilder["policyRevisionId"] = policySnapshot.RevisionId;
metadataBuilder["policyVersion"] = policySnapshot.Version;
if (!string.IsNullOrWhiteSpace(policySnapshot.Digest))
{
metadataBuilder["policyDigest"] = policySnapshot.Digest;
}
metadataBuilder["scoreAlpha"] = policySnapshot.ConsensusOptions.Alpha.ToString("G17", CultureInfo.InvariantCulture);
metadataBuilder["scoreBeta"] = policySnapshot.ConsensusOptions.Beta.ToString("G17", CultureInfo.InvariantCulture);
metadataBuilder["scoreWeightCeiling"] = policySnapshot.ConsensusOptions.WeightCeiling.ToString("G17", CultureInfo.InvariantCulture);
if (!envelopeContext.QuietProvenance.IsDefaultOrEmpty && envelopeContext.QuietProvenance.Length > 0)
{
metadataBuilder["quietedBy"] = VexCanonicalJsonSerializer.Serialize(envelopeContext.QuietProvenance);
var quietStatementCount = envelopeContext.QuietProvenance.Sum(static entry => entry.Statements.Length);
metadataBuilder["quietedByStatementCount"] = quietStatementCount.ToString(CultureInfo.InvariantCulture);
}
var exportMetadata = metadataBuilder.ToImmutable();
if (_artifactStores.Count > 0)
{
var writtenBytes = buffer.ToArray();
@@ -129,7 +165,7 @@ public sealed class VexExportEngine : IExportEngine
result.Digest,
context.Format,
writtenBytes,
result.Metadata);
exportMetadata);
foreach (var store in _artifactStores)
{
@@ -155,7 +191,7 @@ public sealed class VexExportEngine : IExportEngine
context.Format,
context.RequestedAt,
dataset.SourceProviders,
result.Metadata);
exportMetadata);
var response = await _attestationClient.SignAsync(attestationRequest, cancellationToken).ConfigureAwait(false);
attestationMetadata = response.Attestation;
@@ -175,8 +211,8 @@ public sealed class VexExportEngine : IExportEngine
_logger.LogInformation("Attestation generated for export {ExportId}", exportId);
}
var consensusDigestAddress = TryGetContentAddress(result.Metadata, "consensusDigest");
var scoreDigestAddress = TryGetContentAddress(result.Metadata, "scoreDigest");
var consensusDigestAddress = TryGetContentAddress(exportMetadata, "consensusDigest");
var scoreDigestAddress = TryGetContentAddress(exportMetadata, "scoreDigest");
var manifest = new VexExportManifest(
exportId,
@@ -192,11 +228,24 @@ public sealed class VexExportEngine : IExportEngine
policyDigest: policySnapshot.Digest,
consensusDigest: consensusDigestAddress,
scoreDigest: scoreDigestAddress,
quietProvenance: envelopeContext.QuietProvenance,
attestation: attestationMetadata,
sizeBytes: result.BytesWritten);
await _exportStore.SaveAsync(manifest, cancellationToken).ConfigureAwait(false);
if (_mirrorPublisher is not null)
{
try
{
await _mirrorPublisher.PublishAsync(manifest, envelopeContext, dataset, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Mirror bundle publishing failed for export {ExportId}", manifest.ExportId);
}
}
_logger.LogInformation(
"Export generated for {Signature} ({Format}) size={SizeBytes} bytes",
signature.Value,
@@ -237,6 +286,7 @@ public static class VexExportServiceCollectionExtensions
{
public static IServiceCollection AddVexExportEngine(this IServiceCollection services)
{
services.AddSingleton<IVexMirrorBundlePublisher, VexMirrorBundlePublisher>();
services.AddSingleton<IExportEngine, VexExportEngine>();
services.AddVexExportCacheServices();
return services;

View File

@@ -15,5 +15,6 @@
<ProjectReference Include="..\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Policy\StellaOps.Excititor.Policy.csproj" />
<ProjectReference Include="..\StellaOps.Excititor.Storage.Mongo\StellaOps.Excititor.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,6 +6,6 @@ If you are working on this file you need to read docs/ARCHITECTURE_EXCITITOR.md
|EXCITITOR-EXPORT-01-002 Cache index & eviction hooks|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-STORAGE-01-003|**DONE (2025-10-16)** Export engine now invalidates cache entries on force refresh, cache services expose prune/invalidate APIs, and storage maintenance trims expired/dangling records with Mongo2Go coverage.|
|EXCITITOR-EXPORT-01-003 Artifact store adapters|Team Excititor Export|EXCITITOR-EXPORT-01-001|**DONE (2025-10-16)** Implemented multi-store pipeline with filesystem, S3-compatible, and offline bundle adapters (hash verification + manifest/zip output) plus unit coverage and DI hooks.|
|EXCITITOR-EXPORT-01-004 Attestation handoff integration|Team Excititor Export|EXCITITOR-EXPORT-01-001, EXCITITOR-ATTEST-01-001|**DONE (2025-10-17)** Export engine now invokes attestation client, logs diagnostics, and persists Rekor/envelope metadata on manifests; regression coverage added in `ExportEngineTests.ExportAsync_AttachesAttestationMetadata`.|
|EXCITITOR-EXPORT-01-005 Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|**DOING (2025-10-19)** Prereqs EXCITITOR-EXPORT-01-004 and EXCITITOR-CORE-02-001 confirmed DONE; planning export updates to emit consensus+score envelopes, include policy/scoring digests, and extend offline bundle/ORAS layouts for signed VEX responses.|
|EXCITITOR-EXPORT-01-006 Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|TODO Attach `quietedBy` statement IDs, signers, and justification codes to exports/offline bundles, mirror metadata into attested manifest, and add regression fixtures.|
|EXCITITOR-EXPORT-01-007 Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|TODO Create per-domain mirror bundles with consensus/score artifacts, publish signed index for downstream Excititor sync, and ensure deterministic digests + fixtures.|
|EXCITITOR-EXPORT-01-005 Score & resolve envelope surfaces|Team Excititor Export|EXCITITOR-EXPORT-01-004, EXCITITOR-CORE-02-001|**DONE (2025-10-21)** Export engine now canonicalizes consensus/score envelopes, persists their SHA-256 digests into manifests/attestation metadata, and regression tests validate metadata wiring via `ExportEngineTests`.|
|EXCITITOR-EXPORT-01-006 Quiet provenance packaging|Team Excititor Export|EXCITITOR-EXPORT-01-005, POLICY-CORE-09-005|**DONE (2025-10-21)** Export manifests now carry quiet-provenance entries (statement digests, signers, justification codes); metadata flows into offline bundles & attestations with regression coverage in `ExportEngineTests`.|
|EXCITITOR-EXPORT-01-007 Mirror bundle + domain manifest|Team Excititor Export|EXCITITOR-EXPORT-01-006|**DONE (2025-10-21)** Created per-domain mirror bundles with consensus/score artefacts, published signed-ready manifests/index for downstream Excititor sync, and added regression coverage.|

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Linq;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Policy;
namespace StellaOps.Excititor.Export;
internal static class VexExportEnvelopeBuilder
{
public static VexExportEnvelopeContext Build(
VexExportDataSet dataSet,
VexPolicySnapshot policySnapshot,
DateTimeOffset generatedAt)
{
ArgumentNullException.ThrowIfNull(dataSet);
ArgumentNullException.ThrowIfNull(policySnapshot);
var orderedConsensus = dataSet.Consensus
.OrderBy(static consensus => consensus.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(static consensus => consensus.Product.Key, StringComparer.Ordinal)
.ToImmutableArray();
var claimsByKey = dataSet.Claims
.GroupBy(static claim => (claim.VulnerabilityId, claim.Product.Key))
.ToDictionary(
static group => group.Key,
static group => group.ToImmutableArray());
var quietEntries = ImmutableArray.CreateBuilder<VexQuietProvenance>();
foreach (var consensus in orderedConsensus)
{
if (consensus.Status != VexConsensusStatus.NotAffected)
{
continue;
}
if (!claimsByKey.TryGetValue((consensus.VulnerabilityId, consensus.Product.Key), out var claimsForKey) ||
claimsForKey.IsDefaultOrEmpty)
{
continue;
}
var statementsBuilder = ImmutableArray.CreateBuilder<VexQuietStatement>();
foreach (var source in consensus.Sources)
{
if (source.Status != VexClaimStatus.NotAffected)
{
continue;
}
var matchingClaim = claimsForKey.FirstOrDefault(claim =>
string.Equals(claim.ProviderId, source.ProviderId, StringComparison.Ordinal) &&
string.Equals(claim.Document.Digest, source.DocumentDigest, StringComparison.Ordinal));
if (matchingClaim is null)
{
continue;
}
var justification = matchingClaim.Justification ?? source.Justification;
statementsBuilder.Add(new VexQuietStatement(
matchingClaim.ProviderId,
matchingClaim.Document.Digest,
justification,
matchingClaim.Document.Signature));
}
if (statementsBuilder.Count == 0)
{
continue;
}
quietEntries.Add(new VexQuietProvenance(
consensus.VulnerabilityId,
consensus.Product.Key,
statementsBuilder.ToImmutable()));
}
var consensusJson = VexCanonicalJsonSerializer.Serialize(orderedConsensus);
var consensusDigest = ComputeAddress(consensusJson);
var scoreEntries = orderedConsensus
.Select(static consensus => new VexScoreEntry(
consensus.VulnerabilityId,
consensus.Product.Key,
consensus.Status,
consensus.CalculatedAt,
consensus.Signals,
score: null))
.ToImmutableArray();
var options = policySnapshot.ConsensusOptions;
var scoreEnvelope = new VexScoreEnvelope(
generatedAt.ToUniversalTime(),
policySnapshot.RevisionId,
NormalizeDigest(policySnapshot.Digest),
options.Alpha,
options.Beta,
options.WeightCeiling,
scoreEntries);
var scoreJson = VexCanonicalJsonSerializer.Serialize(scoreEnvelope);
var scoreDigest = ComputeAddress(scoreJson);
return new VexExportEnvelopeContext(
orderedConsensus,
consensusJson,
consensusDigest,
scoreEnvelope,
scoreJson,
scoreDigest,
quietEntries.ToImmutable());
}
private static string? NormalizeDigest(string? digest)
=> string.IsNullOrWhiteSpace(digest) ? null : digest.Trim();
private static VexContentAddress ComputeAddress(string canonicalJson)
{
var bytes = Encoding.UTF8.GetBytes(canonicalJson);
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(bytes, hash);
var digest = Convert.ToHexString(hash).ToLowerInvariant();
return new VexContentAddress("sha256", digest);
}
}
internal sealed record VexExportEnvelopeContext(
ImmutableArray<VexConsensus> Consensus,
string ConsensusCanonicalJson,
VexContentAddress ConsensusDigest,
VexScoreEnvelope ScoreEnvelope,
string ScoreCanonicalJson,
VexContentAddress ScoreDigest,
ImmutableArray<VexQuietProvenance> QuietProvenance);

View File

@@ -0,0 +1,716 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Excititor.Core;
namespace StellaOps.Excititor.Export;
public interface IVexMirrorBundlePublisher
{
ValueTask PublishAsync(
VexExportManifest manifest,
VexExportEnvelopeContext envelope,
VexExportDataSet dataSet,
CancellationToken cancellationToken);
}
public sealed class VexMirrorBundlePublisher : IVexMirrorBundlePublisher
{
private const int SchemaVersion = 1;
private const string BundleFileName = "bundle.json";
private const string BundleSignatureFileName = "bundle.json.jws";
private const string ManifestFileName = "manifest.json";
private const string IndexFileName = "index.json";
private const string SignatureMediaType = "application/vnd.stellaops.excititor.mirror-bundle+jws";
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
};
private readonly IOptionsMonitor<MirrorDistributionOptions> _optionsMonitor;
private readonly ILogger<VexMirrorBundlePublisher> _logger;
private readonly TimeProvider _timeProvider;
private readonly IFileSystem _fileSystem;
private readonly ICryptoProviderRegistry? _cryptoRegistry;
private readonly IOptions<FileSystemArtifactStoreOptions>? _fileSystemOptions;
private readonly SemaphoreSlim _mutex = new(1, 1);
public VexMirrorBundlePublisher(
IOptionsMonitor<MirrorDistributionOptions> optionsMonitor,
ILogger<VexMirrorBundlePublisher> logger,
TimeProvider timeProvider,
IFileSystem? fileSystem = null,
ICryptoProviderRegistry? cryptoRegistry = null,
IOptions<FileSystemArtifactStoreOptions>? fileSystemOptions = null)
{
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_fileSystem = fileSystem ?? new FileSystem();
_cryptoRegistry = cryptoRegistry;
_fileSystemOptions = fileSystemOptions;
}
public async ValueTask PublishAsync(
VexExportManifest manifest,
VexExportEnvelopeContext envelope,
VexExportDataSet dataSet,
CancellationToken cancellationToken)
{
if (manifest is null)
{
throw new ArgumentNullException(nameof(manifest));
}
if (envelope is null)
{
throw new ArgumentNullException(nameof(envelope));
}
if (dataSet is null)
{
throw new ArgumentNullException(nameof(dataSet));
}
var options = _optionsMonitor.CurrentValue;
if (!options.Enabled || options.Domains.Count == 0)
{
return;
}
var matches = ResolveDomainMatches(options, manifest);
if (matches.Count == 0)
{
return;
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var outputRoot = ResolveOutputRoot(options);
var mirrorRoot = _fileSystem.Path.Combine(outputRoot, options.DirectoryName ?? "mirror");
_fileSystem.Directory.CreateDirectory(mirrorRoot);
foreach (var match in matches)
{
await UpdateDomainAsync(
options,
match.Domain,
match.Plan,
manifest,
envelope,
dataSet,
mirrorRoot,
cancellationToken).ConfigureAwait(false);
}
await WriteIndexAsync(options, mirrorRoot, cancellationToken).ConfigureAwait(false);
}
finally
{
_mutex.Release();
}
}
private string ResolveOutputRoot(MirrorDistributionOptions options)
{
if (!string.IsNullOrWhiteSpace(options.OutputRoot))
{
return _fileSystem.Path.GetFullPath(options.OutputRoot);
}
if (_fileSystemOptions?.Value is { RootPath: { Length: > 0 } root })
{
return _fileSystem.Path.GetFullPath(root);
}
return _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(AppContext.BaseDirectory, "mirror"));
}
private static List<DomainMatch> ResolveDomainMatches(MirrorDistributionOptions options, VexExportManifest manifest)
{
var matches = new List<DomainMatch>();
foreach (var domain in options.Domains)
{
foreach (var export in domain.Exports)
{
if (!MirrorExportPlanner.TryBuild(export, out var plan, out _))
{
continue;
}
if (!string.Equals(plan.Signature.Value, manifest.QuerySignature.Value, StringComparison.Ordinal))
{
continue;
}
if (plan.Format != manifest.Format)
{
continue;
}
matches.Add(new DomainMatch(domain, plan));
}
}
return matches;
}
private async Task UpdateDomainAsync(
MirrorDistributionOptions options,
MirrorDomainOptions domain,
MirrorExportPlan plan,
VexExportManifest manifest,
VexExportEnvelopeContext envelope,
VexExportDataSet dataSet,
string mirrorRoot,
CancellationToken cancellationToken)
{
var domainDirectory = _fileSystem.Path.Combine(mirrorRoot, domain.Id);
_fileSystem.Directory.CreateDirectory(domainDirectory);
var bundlePath = _fileSystem.Path.Combine(domainDirectory, BundleFileName);
var existingBundle = await ReadDocumentAsync<MirrorBundleDocument>(bundlePath, cancellationToken).ConfigureAwait(false);
var exports = existingBundle?.Exports.ToDictionary(entry => entry.Key, StringComparer.Ordinal)
?? new Dictionary<string, MirrorBundleExportEntry>(StringComparer.Ordinal);
var exportEntry = CreateExportEntry(plan, manifest, envelope, dataSet);
exports[exportEntry.Key] = exportEntry;
var orderedExports = exports.Values.OrderBy(entry => entry.Key, StringComparer.Ordinal).ToArray();
var generatedAt = _timeProvider.GetUtcNow();
var bundleDocument = new MirrorBundleDocument(
SchemaVersion,
generatedAt,
options.TargetRepository,
domain.Id,
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
orderedExports);
var bundleBytes = Serialize(bundleDocument);
var bundleDigest = ComputeDigest(bundleBytes);
await WriteFileAsync(bundlePath, bundleBytes, cancellationToken).ConfigureAwait(false);
MirrorSignatureDescriptor? signatureDescriptor = null;
if (options.Signing.Enabled)
{
signatureDescriptor = await WriteSignatureAsync(
options.Signing,
mirrorRoot,
domainDirectory,
bundleBytes,
cancellationToken).ConfigureAwait(false);
}
else
{
var signaturePath = _fileSystem.Path.Combine(domainDirectory, BundleSignatureFileName);
if (_fileSystem.File.Exists(signaturePath))
{
_fileSystem.File.Delete(signaturePath);
}
}
var manifestDocument = new MirrorDomainManifestDocument(
SchemaVersion,
generatedAt,
domain.Id,
string.IsNullOrWhiteSpace(domain.DisplayName) ? domain.Id : domain.DisplayName,
options.TargetRepository,
new MirrorFileDescriptor(
ToRelativePath(mirrorRoot, bundlePath),
bundleBytes.LongLength,
bundleDigest,
signatureDescriptor),
orderedExports.Select(CreateManifestExportEntry).ToArray());
var manifestBytes = Serialize(manifestDocument);
await WriteFileAsync(_fileSystem.Path.Combine(domainDirectory, ManifestFileName), manifestBytes, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Updated mirror bundle for domain {DomainId} export {ExportKey} (digest {Digest}).",
domain.Id,
plan.Key,
bundleDigest);
}
private async Task WriteIndexAsync(MirrorDistributionOptions options, string mirrorRoot, CancellationToken cancellationToken)
{
var entries = new List<MirrorIndexDomainEntry>();
foreach (var domain in options.Domains.OrderBy(d => d.Id, StringComparer.Ordinal))
{
var domainDirectory = _fileSystem.Path.Combine(mirrorRoot, domain.Id);
var manifestPath = _fileSystem.Path.Combine(domainDirectory, ManifestFileName);
var bundlePath = _fileSystem.Path.Combine(domainDirectory, BundleFileName);
var manifestBytes = await ReadAllBytesAsync(manifestPath, cancellationToken).ConfigureAwait(false);
var bundleBytes = await ReadAllBytesAsync(bundlePath, cancellationToken).ConfigureAwait(false);
if (manifestBytes is null || bundleBytes is null)
{
continue;
}
var manifestDocument = JsonSerializer.Deserialize<MirrorDomainManifestDocument>(manifestBytes, SerializerOptions);
if (manifestDocument is null)
{
continue;
}
var manifestDescriptor = new MirrorFileDescriptor(
ToRelativePath(mirrorRoot, manifestPath),
manifestBytes.LongLength,
ComputeDigest(manifestBytes),
signature: null);
var bundleDescriptor = manifestDocument.Bundle with
{
Path = ToRelativePath(mirrorRoot, bundlePath),
SizeBytes = bundleBytes.LongLength,
Digest = ComputeDigest(bundleBytes),
};
var exportKeys = manifestDocument.Exports
.Select(export => export.Key)
.OrderBy(key => key, StringComparer.Ordinal)
.ToArray();
entries.Add(new MirrorIndexDomainEntry(
manifestDocument.DomainId,
manifestDocument.DisplayName,
manifestDocument.GeneratedAt,
manifestDocument.Exports.Length,
manifestDescriptor,
bundleDescriptor,
exportKeys));
}
var indexDocument = new MirrorIndexDocument(
SchemaVersion,
_timeProvider.GetUtcNow(),
options.TargetRepository,
entries.OrderBy(entry => entry.DomainId, StringComparer.Ordinal).ToArray());
var indexBytes = Serialize(indexDocument);
var indexPath = _fileSystem.Path.Combine(mirrorRoot, IndexFileName);
await WriteFileAsync(indexPath, indexBytes, cancellationToken).ConfigureAwait(false);
}
private MirrorBundleExportEntry CreateExportEntry(
MirrorExportPlan plan,
VexExportManifest manifest,
VexExportEnvelopeContext envelope,
VexExportDataSet dataSet)
{
var consensusJson = envelope.ConsensusCanonicalJson;
var scoreJson = envelope.ScoreCanonicalJson;
var claimsJson = SerializeClaims(dataSet.Claims);
var quietJson = SerializeQuiet(envelope.QuietProvenance);
return new MirrorBundleExportEntry(
plan.Key,
plan.Format.ToString().ToLowerInvariant(),
manifest.ExportId,
manifest.QuerySignature.Value,
manifest.CreatedAt,
manifest.SizeBytes,
manifest.Artifact.ToUri(),
manifest.ConsensusRevision,
manifest.PolicyRevisionId,
manifest.PolicyDigest,
manifest.ConsensusDigest?.ToUri(),
manifest.ScoreDigest?.ToUri(),
manifest.SourceProviders.ToArray(),
consensusJson,
scoreJson,
claimsJson,
quietJson,
manifest.Attestation is null
? null
: new MirrorExportAttestationDescriptor(
manifest.Attestation.PredicateType,
manifest.Attestation.Rekor?.Location,
manifest.Attestation.EnvelopeDigest,
manifest.Attestation.SignedAt));
}
private static MirrorManifestExportEntry CreateManifestExportEntry(MirrorBundleExportEntry entry)
=> new(
entry.Key,
entry.Format,
entry.ExportId,
entry.QuerySignature,
entry.CreatedAt,
entry.ArtifactDigest,
entry.ArtifactSizeBytes,
entry.ConsensusRevision,
entry.PolicyRevisionId,
entry.PolicyDigest,
entry.ConsensusDigest,
entry.ScoreDigest,
entry.SourceProviders,
entry.Attestation);
private static string? SerializeClaims(ImmutableArray<VexClaim> claims)
{
if (claims.IsDefaultOrEmpty || claims.Length == 0)
{
return null;
}
var ordered = claims
.OrderBy(c => c.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(c => c.Product.Key, StringComparer.Ordinal)
.ThenBy(c => c.ProviderId, StringComparer.Ordinal)
.ToImmutableArray();
return VexCanonicalJsonSerializer.Serialize(ordered);
}
private static string? SerializeQuiet(ImmutableArray<VexQuietProvenance> quiet)
{
if (quiet.IsDefaultOrEmpty || quiet.Length == 0)
{
return null;
}
return VexCanonicalJsonSerializer.Serialize(quiet);
}
private static byte[] Serialize<T>(T document)
=> JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
private async Task<T?> ReadDocumentAsync<T>(string path, CancellationToken cancellationToken)
{
if (!_fileSystem.File.Exists(path))
{
return default;
}
await using var stream = _fileSystem.File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<T>(stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
}
private async Task<byte[]?> ReadAllBytesAsync(string path, CancellationToken cancellationToken)
{
if (!_fileSystem.File.Exists(path))
{
return null;
}
await using var stream = _fileSystem.File.OpenRead(path);
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return buffer.ToArray();
}
private async Task WriteFileAsync(string path, byte[] content, CancellationToken cancellationToken)
{
var directory = _fileSystem.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
_fileSystem.Directory.CreateDirectory(directory);
}
await using var stream = _fileSystem.File.Create(path);
await stream.WriteAsync(content, 0, content.Length, cancellationToken).ConfigureAwait(false);
}
private static string ComputeDigest(ReadOnlySpan<byte> content)
{
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(content, hash);
return FormattableString.Invariant($"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}");
}
private async Task<MirrorSignatureDescriptor?> WriteSignatureAsync(
MirrorSigningOptions signingOptions,
string mirrorRoot,
string domainDirectory,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
{
if (!signingOptions.Enabled)
{
return null;
}
if (_cryptoRegistry is null)
{
throw new InvalidOperationException("Mirror signing requires ICryptoProviderRegistry to be registered.");
}
var context = PrepareSigningContext(signingOptions);
var (signature, signedAt) = await CreateSignatureAsync(context, payload, cancellationToken).ConfigureAwait(false);
var signaturePath = _fileSystem.Path.Combine(domainDirectory, BundleSignatureFileName);
await WriteFileAsync(signaturePath, Utf8NoBom.GetBytes(signature), cancellationToken).ConfigureAwait(false);
return new MirrorSignatureDescriptor(
ToRelativePath(mirrorRoot, signaturePath),
context.Algorithm,
context.Signer.KeyId,
context.Provider,
signedAt);
}
private JsonMirrorSigningContext PrepareSigningContext(MirrorSigningOptions signingOptions)
{
var algorithm = string.IsNullOrWhiteSpace(signingOptions.Algorithm)
? SignatureAlgorithms.Es256
: signingOptions.Algorithm.Trim();
var keyId = signingOptions.KeyId?.Trim();
if (string.IsNullOrEmpty(keyId))
{
throw new InvalidOperationException("Mirror signing requires Excititor:Mirror:Signing:KeyId to be configured.");
}
var providerHint = signingOptions.Provider?.Trim();
CryptoSignerResolution resolved;
try
{
resolved = _cryptoRegistry!.ResolveSigner(CryptoCapability.Signing, algorithm, new CryptoKeyReference(keyId, providerHint), providerHint);
}
catch (KeyNotFoundException)
{
var provider = ResolveProvider(algorithm, providerHint);
var signingKey = LoadSigningKey(signingOptions, provider, algorithm);
provider.UpsertSigningKey(signingKey);
resolved = _cryptoRegistry.ResolveSigner(CryptoCapability.Signing, algorithm, new CryptoKeyReference(keyId, provider.Name), provider.Name);
}
return new JsonMirrorSigningContext(resolved.Signer, algorithm, resolved.ProviderName, _timeProvider);
}
private ICryptoProvider ResolveProvider(string algorithm, string? providerHint)
{
if (!string.IsNullOrWhiteSpace(providerHint) && _cryptoRegistry!.TryResolve(providerHint, out var hinted))
{
if (!hinted.Supports(CryptoCapability.Signing, algorithm))
{
throw new InvalidOperationException(FormattableString.Invariant(
$"Crypto provider '{providerHint}' does not support signing algorithm '{algorithm}'."));
}
return hinted;
}
return _cryptoRegistry!.ResolveOrThrow(CryptoCapability.Signing, algorithm);
}
private CryptoSigningKey LoadSigningKey(MirrorSigningOptions signingOptions, ICryptoProvider provider, string algorithm)
{
var keyPath = signingOptions.KeyPath?.Trim();
if (string.IsNullOrEmpty(keyPath))
{
throw new InvalidOperationException("Mirror signing requires Excititor:Mirror:Signing:KeyPath when the key is not already loaded.");
}
var resolvedPath = _fileSystem.Path.IsPathRooted(keyPath)
? keyPath
: _fileSystem.Path.GetFullPath(_fileSystem.Path.Combine(AppContext.BaseDirectory, keyPath));
if (!_fileSystem.File.Exists(resolvedPath))
{
throw new FileNotFoundException($"Mirror signing key '{resolvedPath}' not found.", resolvedPath);
}
var pem = _fileSystem.File.ReadAllText(resolvedPath);
using var ecdsa = ECDsa.Create();
try
{
ecdsa.ImportFromPem(pem);
}
catch (CryptographicException ex)
{
throw new InvalidOperationException("Failed to import mirror signing key. Ensure the PEM contains an EC private key.", ex);
}
var parameters = ecdsa.ExportParameters(includePrivateParameters: true);
return new CryptoSigningKey(
new CryptoKeyReference(signingOptions.KeyId!, provider.Name),
algorithm,
in parameters,
_timeProvider.GetUtcNow());
}
private static async Task<(string Signature, DateTimeOffset SignedAt)> CreateSignatureAsync(
JsonMirrorSigningContext context,
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken)
{
var header = new Dictionary<string, object>
{
["alg"] = context.Algorithm,
["kid"] = context.Signer.KeyId,
["typ"] = SignatureMediaType,
["b64"] = false,
["crit"] = new[] { "b64" },
};
if (!string.IsNullOrWhiteSpace(context.Provider))
{
header["provider"] = context.Provider;
}
var headerJson = JsonSerializer.Serialize(header, SerializerOptions);
var protectedHeader = Base64UrlEncode(Utf8NoBom.GetBytes(headerJson));
var signingInputLength = protectedHeader.Length + 1 + payload.Length;
var buffer = ArrayPool<byte>.Shared.Rent(signingInputLength);
try
{
var headerBytes = Encoding.ASCII.GetBytes(protectedHeader);
Buffer.BlockCopy(headerBytes, 0, buffer, 0, headerBytes.Length);
buffer[headerBytes.Length] = (byte)'.';
payload.Span.CopyTo(new Span<byte>(buffer, headerBytes.Length + 1, payload.Length));
var signingInput = new ReadOnlyMemory<byte>(buffer, 0, signingInputLength);
var signatureBytes = await context.Signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false);
var encodedSignature = Base64UrlEncode(signatureBytes);
var signedAt = context.TimeProvider.GetUtcNow();
return (string.Concat(protectedHeader, "..", encodedSignature), signedAt);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static string Base64UrlEncode(ReadOnlySpan<byte> value)
=> Convert.ToBase64String(value)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
private string ToRelativePath(string root, string path)
{
if (string.IsNullOrEmpty(path))
{
return string.Empty;
}
var fullRoot = _fileSystem.Path.GetFullPath(root);
var fullPath = _fileSystem.Path.GetFullPath(path);
if (!fullPath.StartsWith(fullRoot, StringComparison.OrdinalIgnoreCase))
{
return fullPath.Replace('\\', '/');
}
var relative = fullPath[fullRoot.Length..]
.TrimStart(_fileSystem.Path.DirectorySeparatorChar, _fileSystem.Path.AltDirectorySeparatorChar);
return relative.Replace('\\', '/');
}
private readonly record struct DomainMatch(MirrorDomainOptions Domain, MirrorExportPlan Plan);
private sealed record JsonMirrorSigningContext(ICryptoSigner Signer, string Algorithm, string Provider, TimeProvider TimeProvider);
private sealed record MirrorBundleDocument(
int SchemaVersion,
DateTimeOffset GeneratedAt,
string? TargetRepository,
string DomainId,
string DisplayName,
IReadOnlyList<MirrorBundleExportEntry> Exports);
private sealed record MirrorBundleExportEntry(
string Key,
string Format,
string ExportId,
string QuerySignature,
DateTimeOffset CreatedAt,
long ArtifactSizeBytes,
string ArtifactDigest,
string? ConsensusRevision,
string? PolicyRevisionId,
string? PolicyDigest,
string? ConsensusDigest,
string? ScoreDigest,
IReadOnlyList<string> SourceProviders,
string? ConsensusDocument,
string? ScoreDocument,
string? ClaimsDocument,
string? QuietDocument,
MirrorExportAttestationDescriptor? Attestation);
private sealed record MirrorExportAttestationDescriptor(
string PredicateType,
string? RekorLocation,
string? EnvelopeDigest,
DateTimeOffset? SignedAt);
private sealed record MirrorDomainManifestDocument(
int SchemaVersion,
DateTimeOffset GeneratedAt,
string DomainId,
string DisplayName,
string? TargetRepository,
MirrorFileDescriptor Bundle,
IReadOnlyList<MirrorManifestExportEntry> Exports);
private sealed record MirrorManifestExportEntry(
string Key,
string Format,
string ExportId,
string QuerySignature,
DateTimeOffset CreatedAt,
string ArtifactDigest,
long ArtifactSizeBytes,
string? ConsensusRevision,
string? PolicyRevisionId,
string? PolicyDigest,
string? ConsensusDigest,
string? ScoreDigest,
IReadOnlyList<string> SourceProviders,
MirrorExportAttestationDescriptor? Attestation);
private sealed record MirrorFileDescriptor(
string Path,
long SizeBytes,
string Digest,
MirrorSignatureDescriptor? Signature);
private sealed record MirrorSignatureDescriptor(
string Path,
string Algorithm,
string KeyId,
string? Provider,
DateTimeOffset SignedAt);
private sealed record MirrorIndexDocument(
int SchemaVersion,
DateTimeOffset GeneratedAt,
string? TargetRepository,
IReadOnlyList<MirrorIndexDomainEntry> Domains);
private sealed record MirrorIndexDomainEntry(
string DomainId,
string DisplayName,
DateTimeOffset GeneratedAt,
int ExportCount,
MirrorFileDescriptor Manifest,
MirrorFileDescriptor Bundle,
IReadOnlyList<string> ExportKeys);
}

View File

@@ -105,11 +105,13 @@ internal sealed class VexExportManifestRecord
public string? ScoreDigestAlgorithm { get; set; }
= null;
public string? ScoreDigestValue { get; set; }
= null;
public string? PredicateType { get; set; }
= null;
public string? ScoreDigestValue { get; set; }
= null;
public List<VexQuietProvenanceRecord> QuietProvenance { get; set; } = new();
public string? PredicateType { get; set; }
= null;
public string? RekorApiVersion { get; set; }
= null;
@@ -150,10 +152,11 @@ internal sealed class VexExportManifestRecord
ConsensusDigestAlgorithm = manifest.ConsensusDigest?.Algorithm,
ConsensusDigestValue = manifest.ConsensusDigest?.Digest,
ScoreDigestAlgorithm = manifest.ScoreDigest?.Algorithm,
ScoreDigestValue = manifest.ScoreDigest?.Digest,
PredicateType = manifest.Attestation?.PredicateType,
RekorApiVersion = manifest.Attestation?.Rekor?.ApiVersion,
RekorLocation = manifest.Attestation?.Rekor?.Location,
ScoreDigestValue = manifest.ScoreDigest?.Digest,
QuietProvenance = manifest.QuietProvenance.Select(VexQuietProvenanceRecord.FromDomain).ToList(),
PredicateType = manifest.Attestation?.PredicateType,
RekorApiVersion = manifest.Attestation?.Rekor?.ApiVersion,
RekorLocation = manifest.Attestation?.Rekor?.Location,
RekorLogIndex = manifest.Attestation?.Rekor?.LogIndex,
RekorInclusionProofUri = manifest.Attestation?.Rekor?.InclusionProofUri?.ToString(),
EnvelopeDigest = manifest.Attestation?.EnvelopeDigest,
@@ -201,19 +204,81 @@ internal sealed class VexExportManifestRecord
ConsensusRevision,
PolicyRevisionId,
PolicyDigest,
consensusDigest,
scoreDigest,
attestation,
SizeBytes);
consensusDigest,
scoreDigest,
quietProvenance: QuietProvenance.Count == 0
? ImmutableArray<VexQuietProvenance>.Empty
: QuietProvenance
.Select(static record => record.ToDomain())
.ToImmutableArray(),
attestation,
SizeBytes);
}
public static string CreateId(VexQuerySignature signature, VexExportFormat format)
=> string.Format(CultureInfo.InvariantCulture, "{0}|{1}", signature.Value, format.ToString().ToLowerInvariant());
}
[BsonIgnoreExtraElements]
internal sealed class VexProviderRecord
{
}
[BsonIgnoreExtraElements]
internal sealed class VexQuietProvenanceRecord
{
public string VulnerabilityId { get; set; } = default!;
public string ProductKey { get; set; } = default!;
public List<VexQuietStatementRecord> Statements { get; set; } = new();
public static VexQuietProvenanceRecord FromDomain(VexQuietProvenance provenance)
=> new()
{
VulnerabilityId = provenance.VulnerabilityId,
ProductKey = provenance.ProductKey,
Statements = provenance.Statements.Select(VexQuietStatementRecord.FromDomain).ToList(),
};
public VexQuietProvenance ToDomain()
=> new(VulnerabilityId, ProductKey, Statements.Select(static statement => statement.ToDomain()));
}
[BsonIgnoreExtraElements]
internal sealed class VexQuietStatementRecord
{
public string ProviderId { get; set; } = default!;
public string StatementId { get; set; } = default!;
public string? Justification { get; set; }
= null;
public VexSignatureMetadataDocument? Signature { get; set; }
= null;
public static VexQuietStatementRecord FromDomain(VexQuietStatement statement)
=> new()
{
ProviderId = statement.ProviderId,
StatementId = statement.StatementId,
Justification = statement.Justification?.ToString().ToLowerInvariant(),
Signature = VexSignatureMetadataDocument.FromDomain(statement.Signature),
};
public VexQuietStatement ToDomain()
{
var justification = string.IsNullOrWhiteSpace(Justification)
? (VexJustification?)null
: Enum.Parse<VexJustification>(Justification, ignoreCase: true);
return new VexQuietStatement(
ProviderId,
StatementId,
justification,
Signature?.ToDomain());
}
}
[BsonIgnoreExtraElements]
internal sealed class VexProviderRecord
{
[BsonId]
public string Id { get; set; } = default!;

View File

@@ -15,7 +15,6 @@ using StellaOps.Excititor.Connectors.Abstractions;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Options;
namespace StellaOps.Excititor.WebService.Tests;

View File

@@ -8,9 +8,8 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Export;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Services;
namespace StellaOps.Excititor.WebService.Endpoints;
@@ -99,13 +98,13 @@ internal static class MirrorEndpoints
}
var resolvedExports = new List<MirrorExportIndexEntry>();
foreach (var exportOption in domain.Exports)
{
if (!TryBuildExportPlan(exportOption, out var plan, out var error))
{
resolvedExports.Add(new MirrorExportIndexEntry(
exportOption.Key,
null,
foreach (var exportOption in domain.Exports)
{
if (!MirrorExportPlanner.TryBuild(exportOption, out var plan, out var error))
{
resolvedExports.Add(new MirrorExportIndexEntry(
exportOption.Key,
null,
null,
exportOption.Format,
null,
@@ -117,7 +116,7 @@ internal static class MirrorEndpoints
continue;
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
{
@@ -178,16 +177,16 @@ internal static class MirrorEndpoints
return Results.Unauthorized();
}
if (!TryFindExport(domain, exportKey, out var exportOptions))
{
return Results.NotFound();
}
if (!TryBuildExportPlan(exportOptions, out var plan, out var error))
{
await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
if (!TryFindExport(domain, exportKey, out var exportOptions))
{
return Results.NotFound();
}
if (!MirrorExportPlanner.TryBuild(exportOptions, out var plan, out var error))
{
await WritePlainTextAsync(httpContext, error ?? "invalid_export_configuration", StatusCodes.Status503ServiceUnavailable, cancellationToken).ConfigureAwait(false);
return Results.Empty;
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
@@ -242,10 +241,10 @@ internal static class MirrorEndpoints
return Results.Empty;
}
if (!TryFindExport(domain, exportKey, out var exportOptions) || !TryBuildExportPlan(exportOptions, out var plan, out _))
{
return Results.NotFound();
}
if (!TryFindExport(domain, exportKey, out var exportOptions) || !MirrorExportPlanner.TryBuild(exportOptions, out var plan, out _))
{
return Results.NotFound();
}
var manifest = await exportStore.FindAsync(plan.Signature, plan.Format, cancellationToken).ConfigureAwait(false);
if (manifest is null)
@@ -287,37 +286,11 @@ internal static class MirrorEndpoints
return domain is not null;
}
private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export)
{
export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!;
return export is not null;
}
private static bool TryBuildExportPlan(MirrorExportOptions exportOptions, out MirrorExportPlan plan, out string? error)
{
plan = null!;
error = null;
if (string.IsNullOrWhiteSpace(exportOptions.Key))
{
error = "missing_export_key";
return false;
}
if (string.IsNullOrWhiteSpace(exportOptions.Format) || !Enum.TryParse<VexExportFormat>(exportOptions.Format, ignoreCase: true, out var format))
{
error = "unsupported_export_format";
return false;
}
var filters = exportOptions.Filters.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value)).ToArray();
var sorts = exportOptions.Sort.Select(pair => new VexQuerySort(pair.Key, pair.Value)).ToArray();
var query = VexQuery.Create(filters.Select(kv => new VexQueryFilter(kv.Key, kv.Value)), sorts, exportOptions.Limit, exportOptions.Offset, exportOptions.View);
var signature = VexQuerySignature.FromQuery(query);
plan = new MirrorExportPlan(format, query, signature);
return true;
}
private static bool TryFindExport(MirrorDomainOptions domain, string exportKey, out MirrorExportOptions export)
{
export = domain.Exports.FirstOrDefault(e => string.Equals(e.Key, exportKey, StringComparison.OrdinalIgnoreCase))!;
return export is not null;
}
private static string ResolveContentType(VexExportFormat format)
=> format switch
@@ -351,19 +324,15 @@ internal static class MirrorEndpoints
await context.Response.WriteAsync(message, cancellationToken);
}
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var json = VexCanonicalJsonSerializer.Serialize(payload);
await context.Response.WriteAsync(json, cancellationToken);
private static async Task WriteJsonAsync<T>(HttpContext context, T payload, int statusCode, CancellationToken cancellationToken)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/json";
var json = VexCanonicalJsonSerializer.Serialize(payload);
await context.Response.WriteAsync(json, cancellationToken);
}
private sealed record MirrorExportPlan(
VexExportFormat Format,
VexQuery Query,
VexQuerySignature Signature);
}
}
internal sealed record MirrorDomainListResponse(IReadOnlyList<MirrorDomainSummary> Domains);

View File

@@ -1,52 +0,0 @@
using System.Collections.Generic;
namespace StellaOps.Excititor.WebService.Options;
public sealed class MirrorDistributionOptions
{
public const string SectionName = "Excititor:Mirror";
public List<MirrorDomainOptions> Domains { get; } = new();
}
public sealed class MirrorDomainOptions
{
public string Id { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public bool RequireAuthentication { get; set; }
= false;
/// <summary>
/// Maximum index requests allowed per rolling window.
/// </summary>
public int MaxIndexRequestsPerHour { get; set; } = 120;
/// <summary>
/// Maximum export downloads allowed per rolling window.
/// </summary>
public int MaxDownloadRequestsPerHour { get; set; } = 600;
public List<MirrorExportOptions> Exports { get; } = new();
}
public sealed class MirrorExportOptions
{
public string Key { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public Dictionary<string, string> Filters { get; } = new();
public Dictionary<string, bool> Sort { get; } = new();
public int? Limit { get; set; }
= null;
public int? Offset { get; set; }
= null;
public string? View { get; set; }
= null;
}

View File

@@ -13,11 +13,11 @@ using StellaOps.Excititor.Export;
using StellaOps.Excititor.Formats.CSAF;
using StellaOps.Excititor.Formats.CycloneDX;
using StellaOps.Excititor.Formats.OpenVEX;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Options;
using StellaOps.Excititor.WebService.Services;
using StellaOps.Excititor.Policy;
using StellaOps.Excititor.Storage.Mongo;
using StellaOps.Excititor.WebService.Endpoints;
using StellaOps.Excititor.WebService.Services;
using StellaOps.Excititor.Core;
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NJsonSchema;
using Xunit;
using System.Threading.Tasks;
namespace StellaOps.Notify.Models.Tests;
public sealed class PlatformEventSchemaValidationTests
{
public static IEnumerable<object[]> SampleFiles() => new[]
{
new object[] { "scanner.report.ready@1.sample.json", "scanner.report.ready@1.json" },
new object[] { "scanner.scan.completed@1.sample.json", "scanner.scan.completed@1.json" },
new object[] { "scheduler.rescan.delta@1.sample.json", "scheduler.rescan.delta@1.json" },
new object[] { "attestor.logged@1.sample.json", "attestor.logged@1.json" }
};
[Theory]
[MemberData(nameof(SampleFiles))]
public async Task EventSamplesConformToPublishedSchemas(string sampleFile, string schemaFile)
{
var baseDirectory = AppContext.BaseDirectory;
var samplePath = Path.Combine(baseDirectory, sampleFile);
var schemaPath = Path.Combine(baseDirectory, schemaFile);
Assert.True(File.Exists(samplePath), $"Sample '{sampleFile}' not found at '{samplePath}'.");
Assert.True(File.Exists(schemaPath), $"Schema '{schemaFile}' not found at '{schemaPath}'.");
var schema = await JsonSchema.FromJsonAsync(File.ReadAllText(schemaPath));
var errors = schema.Validate(File.ReadAllText(samplePath));
if (errors.Count > 0)
{
var formatted = string.Join(
Environment.NewLine,
errors.Select(error => $"{error.Path}: {error.Kind} ({error})"));
Assert.True(errors.Count == 0, $"Schema validation failed for '{sampleFile}':{Environment.NewLine}{formatted}");
}
}
}

View File

@@ -5,16 +5,20 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="../../docs/events/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="../../docs/notify/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="NJsonSchema" Version="10.9.0" />
<None Include="../../docs/events/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="../../docs/events/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Include="../../docs/notify/samples/*.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using StellaOps.Plugin.Hosting;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginDependencyInjectionExtensionsTests
{
[Fact]
public void RegisterPluginRoutines_RegistersServiceBindingsAndHonoursLifetimes()
{
const string source = """
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace SamplePlugin;
public interface IScopedExample {}
public interface ISingletonExample {}
[ServiceBinding(typeof(IScopedExample), ServiceLifetime.Scoped, RegisterAsSelf = true)]
public sealed class ScopedExample : IScopedExample {}
[ServiceBinding(typeof(ISingletonExample), ServiceLifetime.Singleton)]
public sealed class SingletonExample : ISingletonExample {}
""";
using var plugin = TestPluginAssembly.Create(source);
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.IScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedDescriptor.Lifetime);
Assert.Equal("SamplePlugin.ScopedExample", scopedDescriptor.ImplementationType?.FullName);
var scopedSelfDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(ServiceLifetime.Scoped, scopedSelfDescriptor.Lifetime);
var singletonDescriptor = Assert.Single(
services,
static d => d.ServiceType.FullName == "SamplePlugin.ISingletonExample");
Assert.Equal(ServiceLifetime.Singleton, singletonDescriptor.Lifetime);
using var provider = services.BuildServiceProvider();
object firstScopeInstance;
using (var scope = provider.CreateScope())
{
var resolvedFirst = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
var resolvedSecond = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.Same(resolvedFirst, resolvedSecond);
firstScopeInstance = resolvedFirst;
}
using (var scope = provider.CreateScope())
{
var resolved = ServiceProviderServiceExtensions.GetRequiredService(scope.ServiceProvider, scopedDescriptor.ServiceType);
Assert.NotSame(firstScopeInstance, resolved);
}
var singletonFirst = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
var singletonSecond = ServiceProviderServiceExtensions.GetRequiredService(provider, singletonDescriptor.ServiceType);
Assert.Same(singletonFirst, singletonSecond);
services.RegisterPluginRoutines(configuration, plugin.Options, NullLogger.Instance);
var scopedRegistrations = services.Count(d =>
d.ServiceType.FullName == "SamplePlugin.IScopedExample" &&
d.ImplementationType?.FullName == "SamplePlugin.ScopedExample");
Assert.Equal(1, scopedRegistrations);
}
private sealed class TestPluginAssembly : IDisposable
{
private TestPluginAssembly(string directoryPath, string assemblyPath)
{
DirectoryPath = directoryPath;
AssemblyPath = assemblyPath;
Options = new PluginHostOptions
{
PluginsDirectory = directoryPath,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
Options.SearchPatterns.Add(Path.GetFileName(assemblyPath));
}
public string DirectoryPath { get; }
public string AssemblyPath { get; }
public PluginHostOptions Options { get; }
public static TestPluginAssembly Create(string source)
{
var directoryPath = Path.Combine(Path.GetTempPath(), "stellaops-plugin-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directoryPath);
var assemblyName = "SamplePlugin" + Guid.NewGuid().ToString("N");
var assemblyPath = Path.Combine(directoryPath, assemblyName + ".dll");
var syntaxTree = CSharpSyntaxTree.ParseText(source);
var references = CollectMetadataReferences();
var compilation = CSharpCompilation.Create(
assemblyName,
new[] { syntaxTree },
references,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release));
var emitResult = compilation.Emit(assemblyPath);
if (!emitResult.Success)
{
var diagnostics = string.Join(Environment.NewLine, emitResult.Diagnostics);
throw new InvalidOperationException("Failed to compile plugin assembly:" + Environment.NewLine + diagnostics);
}
return new TestPluginAssembly(directoryPath, assemblyPath);
}
public void Dispose()
{
try
{
if (Directory.Exists(DirectoryPath))
{
Directory.Delete(DirectoryPath, recursive: true);
}
}
catch
{
// Ignore cleanup failures plugin load contexts may keep files locked on Windows.
}
}
private static IReadOnlyCollection<MetadataReference> CollectMetadataReferences()
{
var referencePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa)
{
foreach (var path in tpa.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
referencePaths.Add(path);
}
}
referencePaths.Add(typeof(object).Assembly.Location);
referencePaths.Add(typeof(ServiceBindingAttribute).Assembly.Location);
referencePaths.Add(typeof(IDependencyInjectionRoutine).Assembly.Location);
referencePaths.Add(typeof(ServiceLifetime).Assembly.Location);
return referencePaths
.Select(path => MetadataReference.CreateFromFile(path))
.ToArray();
}
}
}

View File

@@ -9,10 +9,13 @@
<ProjectReference Include="..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" />
<ProjectReference Include="..\StellaOps.Plugin\StellaOps.Plugin.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
</Project>

View File

@@ -1,7 +1,7 @@
# TASKS
| Task | Owner(s) | Depends on | Notes |
|---|---|---|---|
|PLUGIN-DI-08-001 Scoped service support in plugin bootstrap|Plugin Platform Guild (DONE 2025-10-19)|StellaOps.DependencyInjection|Introduced `ServiceBindingAttribute` metadata for scoped DI, taught plugin/job loaders to consume it with duplicate-safe registration, added coverage, and refreshed the plug-in SDK guide.|
|PLUGIN-DI-08-001 Scoped service support in plugin bootstrap|Plugin Platform Guild (DONE 2025-10-21)|StellaOps.DependencyInjection|Scoped DI metadata primitives landed; dynamic plugin integration tests now verify `RegisterPluginRoutines` honours `[ServiceBinding]` lifetimes and remains idempotent.|
|PLUGIN-DI-08-002.COORD Authority scoped-service handshake|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001|Workshop held 2025-10-20 15:0016:05UTC; outcomes/notes captured in `docs/dev/authority-plugin-di-coordination.md`, follow-up action items assigned for PLUGIN-DI-08-002 implementation plan.|
|PLUGIN-DI-08-002 Authority plugin integration updates|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-001, PLUGIN-DI-08-002.COORD|Standard registrar now registers scoped credential/provisioning stores + identity-provider plugins, registry Acquire scopes instances, and regression suites (`dotnet test src/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj`, `dotnet test src/StellaOps.Authority/StellaOps.Authority.Tests/StellaOps.Authority.Tests.csproj`) cover scoped lifetimes + handles.|
|PLUGIN-DI-08-003 Authority registry scoped resolution|Plugin Platform Guild, Authority Core (DONE 2025-10-20)|PLUGIN-DI-08-002.COORD|Reworked `IAuthorityIdentityProviderRegistry` to expose metadata + scoped handles, updated OpenIddict flows/Program health endpoints, and added coverage via `AuthorityIdentityProviderRegistryTests`.|

View File

@@ -14,9 +14,14 @@ Run `ng generate component component-name` to generate a new component. You can
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running unit tests
The suite runs headlessly through Karma. Install dependencies with `npm install`, then execute `npm test` (alias for `ng test --watch=false`). The run expects a Chromium-compatible browser:
- On developer machines ensure Google Chrome or Chromium is available on `PATH`; otherwise set `CHROME_BIN` to the browser executable.
- In CI you can rely on Puppeteer by exporting `PUPPETEER_EXECUTABLE_PATH` to the downloaded Chromium binary; the test harness automatically adopts it.
For interactive development, use `npm run test:watch` (invokes `ng test --watch`) and optionally pass `--browsers=Chrome` to open a full browser.
## Running end-to-end tests

View File

@@ -3,4 +3,5 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| WEB1.TRIVY-SETTINGS | DONE (2025-10-21) | UX Specialist, Angular Eng | Backend `/exporters/trivy-db` contract | Implement Trivy DB exporter settings panel with `publishFull`, `publishDelta`, `includeFull`, `includeDelta` toggles and “Run export now” action using future `/exporters/trivy-db/settings` API. | ✅ Angular route `/concelier/trivy-db-settings` backed by `TrivyDbSettingsPageComponent` with reactive form; ✅ Overrides persisted via `ConcelierExporterClient` (`settings`/`run` endpoints); ✅ Manual run button saves current overrides then triggers export and surfaces run metadata. |
| WEB1.TRIVY-SETTINGS-TESTS | BLOCKED (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | Add headless UI test run (`ng test --watch=false`) and document steps once Angular CLI tooling is available in CI/local environment. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. |
| WEB1.TRIVY-SETTINGS-TESTS | DONE (2025-10-21) | UX Specialist, Angular Eng | WEB1.TRIVY-SETTINGS | **DONE (2025-10-21)** Added headless Karma harness (`ng test --watch=false`) wired to ChromeHeadless/CI launcher, created `karma.conf.cjs`, updated npm scripts + docs with Chromium prerequisites so CI/offline runners can execute specs deterministically. | Angular CLI available (npm scripts chained), Karma suite for Trivy DB components passing locally and in CI, docs note required prerequisites. |
| WEB1.DEPS-13-001 | TODO | UX Specialist, Angular Eng, DevEx | WEB1.TRIVY-SETTINGS-TESTS | Stabilise Angular workspace dependencies for CI/offline nodes: refresh `package-lock.json`, ensure Puppeteer/Chromium binaries optional, document deterministic install workflow. | `npm install` completes without manual intervention on air-gapped nodes, `npm test` headless run succeeds from clean checkout, README updated with lockfile + cache steps. |

View File

@@ -76,19 +76,20 @@
"buildTarget": "stellaops-web:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.cjs",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],

View File

@@ -0,0 +1,49 @@
const { join } = require('path');
const { env } = process;
if (!env.CHROME_BIN && env.PUPPETEER_EXECUTABLE_PATH) {
env.CHROME_BIN = env.PUPPETEER_EXECUTABLE_PATH;
}
const isCI = env.CI === 'true' || env.CI === '1';
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false
},
jasmineHtmlReporter: {
suppressAll: true
},
coverageReporter: {
dir: join(__dirname, './coverage/stellaops-web'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
browsers: [isCI ? 'ChromeHeadlessCI' : 'ChromeHeadless'],
customLaunchers: {
ChromeHeadlessCI: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage']
}
},
restartOnFileChange: false
});
};

View File

@@ -1,13 +1,14 @@
{
"name": "stellaops-web",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
"test:watch": "ng test --watch"
},
"private": true,
"dependencies": {
"@angular/animations": "^17.3.0",

View File

@@ -7,8 +7,7 @@ import {
signal,
} from '@angular/core';
import {
FormBuilder,
FormGroup,
NonNullableFormBuilder,
ReactiveFormsModule,
} from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -28,19 +27,21 @@ type StatusKind = 'idle' | 'loading' | 'saving' | 'running' | 'success' | 'error
styleUrls: ['./trivy-db-settings-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
type TrivyDbSettingsFormValue = TrivyDbSettingsDto;
export class TrivyDbSettingsPageComponent implements OnInit {
private readonly client = inject(ConcelierExporterClient);
private readonly formBuilder = inject(FormBuilder);
private readonly formBuilder = inject(NonNullableFormBuilder);
readonly status = signal<StatusKind>('idle');
readonly message = signal<string | null>(null);
readonly lastRun = signal<TrivyDbRunResponseDto | null>(null);
readonly form: FormGroup = this.formBuilder.group({
publishFull: [true],
publishDelta: [true],
includeFull: [true],
includeDelta: [true],
readonly form = this.formBuilder.group<TrivyDbSettingsFormValue>({
publishFull: true,
publishDelta: true,
includeFull: true,
includeDelta: true,
});
ngOnInit(): void {
@@ -52,7 +53,7 @@ export class TrivyDbSettingsPageComponent implements OnInit {
this.message.set(null);
try {
const settings = await firstValueFrom(
const settings: TrivyDbSettingsDto = await firstValueFrom(
this.client.getTrivyDbSettings()
);
this.form.patchValue(settings);
@@ -73,7 +74,7 @@ export class TrivyDbSettingsPageComponent implements OnInit {
try {
const payload = this.buildPayload();
const updated = await firstValueFrom(
const updated: TrivyDbSettingsDto = await firstValueFrom(
this.client.updateTrivyDbSettings(payload)
);
this.form.patchValue(updated);
@@ -98,7 +99,7 @@ export class TrivyDbSettingsPageComponent implements OnInit {
// Persist overrides before triggering a run, ensuring parity.
await firstValueFrom(this.client.updateTrivyDbSettings(payload));
const response = await firstValueFrom(
const response: TrivyDbRunResponseDto = await firstValueFrom(
this.client.runTrivyDbExport(payload)
);
@@ -124,7 +125,7 @@ export class TrivyDbSettingsPageComponent implements OnInit {
}
private buildPayload(): TrivyDbSettingsDto {
const raw = this.form.getRawValue() as TrivyDbSettingsDto;
const raw = this.form.getRawValue();
return {
publishFull: !!raw.publishFull,
publishDelta: !!raw.publishDelta,