This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.|
|
||||
|
||||
19
src/StellaOps.Concelier.Core/Unknown/IUnknownStateLedger.cs
Normal file
19
src/StellaOps.Concelier.Core/Unknown/IUnknownStateLedger.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
313
src/StellaOps.Concelier.Core/Unknown/UnknownStateLedger.cs
Normal file
313
src/StellaOps.Concelier.Core/Unknown/UnknownStateLedger.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
73
src/StellaOps.Concelier.Core/Unknown/UnknownStateSnapshot.cs
Normal file
73
src/StellaOps.Concelier.Core/Unknown/UnknownStateSnapshot.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
110
src/StellaOps.Excititor.Core/MirrorDistributionOptions.cs
Normal file
110
src/StellaOps.Excititor.Core/MirrorDistributionOptions.cs
Normal 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; }
|
||||
}
|
||||
47
src/StellaOps.Excititor.Core/MirrorExportPlanner.cs
Normal file
47
src/StellaOps.Excititor.Core/MirrorExportPlanner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
78
src/StellaOps.Excititor.Core/VexQuietProvenance.cs
Normal file
78
src/StellaOps.Excititor.Core/VexQuietProvenance.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.|
|
||||
|
||||
140
src/StellaOps.Excititor.Export/VexExportEnvelopeBuilder.cs
Normal file
140
src/StellaOps.Excititor.Export/VexExportEnvelopeBuilder.cs
Normal 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);
|
||||
716
src/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs
Normal file
716
src/StellaOps.Excititor.Export/VexMirrorBundlePublisher.cs
Normal 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);
|
||||
}
|
||||
@@ -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!;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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:00–16:05 UTC; 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`.|
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
49
src/StellaOps.Web/karma.conf.cjs
Normal file
49
src/StellaOps.Web/karma.conf.cjs
Normal 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
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user