This commit is contained in:
master
2025-10-21 18:54:26 +03:00
parent 48f3071e2a
commit 104d5813c2
50 changed files with 3027 additions and 596 deletions

View File

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

View File

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