From f0e74d2ee8d75b79ecfec494f192ee21370ce23e Mon Sep 17 00:00:00 2001 From: master <> Date: Thu, 20 Nov 2025 09:17:58 +0200 Subject: [PATCH] feat: Implement EvidenceBundleAttestationBuilder with unit tests for claims generation and tenant validation --- ...PRINT_0110_0001_0001_ingestion_evidence.md | 4 +- .../StellaOps.Concelier.WebService/Program.cs | 5 + .../EvidenceBundleAttestationBuilder.cs | 144 ++++++++++++++++++ .../EvidenceBundleAttestationBuilderTests.cs | 67 ++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/Concelier/__Libraries/StellaOps.Concelier.Core/Attestation/EvidenceBundleAttestationBuilder.cs create mode 100644 src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs diff --git a/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md b/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md index dc774ab55..d823f13af 100644 --- a/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md +++ b/docs/implplan/SPRINT_0110_0001_0001_ingestion_evidence.md @@ -43,7 +43,7 @@ | 7 | CONCELIER-AIAI-31-003 | DONE (2025-11-12) | — | Concelier Observability Guild | Telemetry counters/histograms live for Advisory AI dashboards. | | 8 | CONCELIER-AIRGAP-56-001..58-001 | BLOCKED | PREP-ART-56-001; PREP-EVIDENCE-BDL-01 | Concelier Core · AirGap Guilds | Mirror/offline provenance chain; proceed against frozen contracts. | | 9 | CONCELIER-CONSOLE-23-001..003 | BLOCKED | PREP-CONSOLE-FIXTURES-29; PREP-EVIDENCE-BDL-01 | Concelier Console Guild | Console advisory aggregation/search helpers; proceed on frozen schema. | -| 10 | CONCELIER-ATTEST-73-001/002 | TODO | PREP-ATTEST-SCOPE-73; PREP-EVIDENCE-BDL-01 | Concelier Core · Evidence Locker Guild | Attestation inputs + transparency metadata; implement using frozen Evidence Bundle v1 and scope note (`docs/modules/evidence-locker/attestation-scope-note.md`). | +| 10 | CONCELIER-ATTEST-73-001/002 | DOING | PREP-ATTEST-SCOPE-73; PREP-EVIDENCE-BDL-01 | Concelier Core · Evidence Locker Guild | Attestation inputs + transparency metadata; implement using frozen Evidence Bundle v1 and scope note (`docs/modules/evidence-locker/attestation-scope-note.md`). | | 11 | FEEDCONN-ICSCISA-02-012 / KISA-02-008 | BLOCKED | PREP-FEEDCONN-ICS-KISA-PLAN | Concelier Feed Owners | Overdue provenance refreshes. | | 12 | EXCITITOR-AIAI-31-001 | DONE (2025-11-09) | — | Excititor Web/Core Guilds | Normalised VEX justification projections shipped. | | 13 | EXCITITOR-AIAI-31-002 | BLOCKED (2025-11-19) | Contract/doc updates landed; tests cannot execute locally (vstest harness missing DLL); needs CI runner. | Excititor Web/Core Guilds | Chunk API for Advisory AI feeds; limits/headers/logging implemented; awaiting CI test run. | @@ -116,6 +116,8 @@ | 2025-11-18 | Assessed air-gap/console/attestation tracks; all still blocked pending Mirror thin-bundle dates, published console schemas, and Evidence Locker attestation scope. Updated Delivery Tracker statuses accordingly. | Implementer | | 2025-11-19 | Updated SBOM-AIAI-31-003 dependency list: SBOM-AIAI-31-001 is now DONE, remaining blocker is CLI-VULN-29-001/CLI-VEX-30-001. | Project Mgmt | | 2025-11-19 | Published stub thin bundle sample + hash, CLI-VULN/CLI-VEX guardrail artefacts, and attestation scope note; tasks remain blocked only on remaining upstream contracts. | Project Mgmt | +| 2025-11-20 | Added EvidenceBundleAttestationBuilder + DI registration and unit tests (builder harness) for CONCELIER-ATTEST-73-001/002; vstest harness still failing locally (invalid test source). WebService endpoint wired for future attestation metadata once bundle paths are plumbed. | Implementer | +| 2025-11-20 | Moved CONCELIER-ATTEST-73-001/002 to DOING; starting implementation against frozen Evidence Bundle v1 and attestation scope note. Next: wire attestation payload/claims into Concelier ingestion, add verification tests, and record bundle/claim hashes. | Implementer | ## Decisions & Risks ### Decisions in flight diff --git a/src/Concelier/StellaOps.Concelier.WebService/Program.cs b/src/Concelier/StellaOps.Concelier.WebService/Program.cs index 67f17576b..c8921d1c7 100644 --- a/src/Concelier/StellaOps.Concelier.WebService/Program.cs +++ b/src/Concelier/StellaOps.Concelier.WebService/Program.cs @@ -54,6 +54,7 @@ using StellaOps.Concelier.Storage.Mongo; using StellaOps.Concelier.Storage.Mongo.Advisories; using StellaOps.Concelier.Storage.Mongo.Aliases; using StellaOps.Provenance.Mongo; +using StellaOps.Concelier.Core.Attestation; var builder = WebApplication.CreateBuilder(args); @@ -112,6 +113,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions(); @@ -762,6 +764,7 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe string advisoryKey, HttpContext context, [FromServices] IAdvisoryRawService rawService, + [FromServices] EvidenceBundleAttestationBuilder attestationBuilder, CancellationToken cancellationToken) => { ApplyNoCache(context.Response); @@ -807,6 +810,8 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey; var response = new AdvisoryEvidenceResponse(responseKey, recordResponses); + // TODO: Attach attestation metadata when Evidence Bundle tarball is available per tenant/advisory. + // The builder is registered for future use once bundle paths are discoverable from evidence storage. return JsonResult(response); }); if (authorityConfigured) diff --git a/src/Concelier/__Libraries/StellaOps.Concelier.Core/Attestation/EvidenceBundleAttestationBuilder.cs b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Attestation/EvidenceBundleAttestationBuilder.cs new file mode 100644 index 000000000..5fd42a63c --- /dev/null +++ b/src/Concelier/__Libraries/StellaOps.Concelier.Core/Attestation/EvidenceBundleAttestationBuilder.cs @@ -0,0 +1,144 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace StellaOps.Concelier.Core.Attestation; + +public sealed record AttestationTransparency(string? RekorUuid, string? SkipReason); + +public sealed record AttestationClaims( + string SubjectName, + string SubjectDigest, + string BundleId, + string BundleVersion, + DateTimeOffset BundleCreated, + string PipelineVersion, + IReadOnlyCollection PipelineInputs, + string Tenant, + string Scope, + string EvidenceBundlePath, + AttestationTransparency Transparency, + bool AocGuardrails, + IReadOnlyCollection AocDetails); + +public sealed record EvidenceBundleAttestationRequest( + string BundlePath, + string ManifestPath, + string? TransparencyPath, + string PipelineVersion); + +public sealed class EvidenceBundleAttestationBuilder +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public async Task BuildAsync(EvidenceBundleAttestationRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.BundlePath)) + { + throw new ArgumentException("Bundle path is required", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.ManifestPath)) + { + throw new ArgumentException("Manifest path is required", nameof(request)); + } + + var manifest = await ReadManifestAsync(request.ManifestPath, cancellationToken).ConfigureAwait(false); + ValidateTenant(manifest.Tenant); + + var bundleDigest = await ComputeSha256Async(request.BundlePath, cancellationToken).ConfigureAwait(false); + var pipelineInputs = manifest.Inputs ?? Array.Empty(); + + var transparency = await ReadTransparencyAsync(request.TransparencyPath, cancellationToken).ConfigureAwait(false) + ?? new TransparencyPayload { SkipReason = "offline" }; + + return new AttestationClaims( + SubjectName: manifest.BundleId ?? string.Empty, + SubjectDigest: $"sha256:{bundleDigest}", + BundleId: manifest.BundleId ?? string.Empty, + BundleVersion: manifest.Version ?? string.Empty, + BundleCreated: manifest.Created, + PipelineVersion: request.PipelineVersion, + PipelineInputs: pipelineInputs, + Tenant: manifest.Tenant ?? string.Empty, + Scope: manifest.Scope ?? string.Empty, + EvidenceBundlePath: request.BundlePath, + Transparency: new AttestationTransparency(transparency.RekorUuid, transparency.SkipReason ?? "offline"), + AocGuardrails: manifest.Aoc?.Guardrails ?? false, + AocDetails: manifest.Aoc?.Details ?? Array.Empty()); + } + + private static async Task ReadManifestAsync(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + var manifest = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + return manifest ?? throw new InvalidOperationException("Manifest payload could not be read."); + } + + private static async Task ReadTransparencyAsync(string? path, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + await using var stream = File.OpenRead(path); + return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); + } + + private static void ValidateTenant(string? tenant) + { + if (string.IsNullOrWhiteSpace(tenant)) + { + throw new InvalidOperationException("Tenant must be present in evidence bundle manifest."); + } + + if (!string.Equals(tenant, tenant.ToLowerInvariant(), StringComparison.Ordinal)) + { + throw new InvalidOperationException("Tenant must be lowercase to satisfy attestation scope requirements."); + } + } + + private static async Task ComputeSha256Async(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + using var sha256 = SHA256.Create(); + var hash = await sha256.ComputeHashAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private sealed record ManifestPayload + { + [JsonPropertyName("bundle_id")] public string? BundleId { get; init; } + + [JsonPropertyName("version")] public string? Version { get; init; } + + [JsonPropertyName("created")] public DateTimeOffset Created { get; init; } + + [JsonPropertyName("tenant")] public string? Tenant { get; init; } + + [JsonPropertyName("scope")] public string? Scope { get; init; } + + [JsonPropertyName("inputs")] public IReadOnlyCollection? Inputs { get; init; } + + [JsonPropertyName("aoc")] public AocPayload? Aoc { get; init; } + } + + private sealed record AocPayload + { + [JsonPropertyName("guardrails")] public bool Guardrails { get; init; } + + [JsonPropertyName("details")] public IReadOnlyCollection? Details { get; init; } + } + + private sealed record TransparencyPayload + { + [JsonPropertyName("rekor_uuid")] public string? RekorUuid { get; init; } + + [JsonPropertyName("skip_reason")] public string? SkipReason { get; init; } + } +} diff --git a/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs new file mode 100644 index 000000000..ce5f96276 --- /dev/null +++ b/src/Concelier/__Tests/StellaOps.Concelier.Core.Tests/Attestation/EvidenceBundleAttestationBuilderTests.cs @@ -0,0 +1,67 @@ +using StellaOps.Concelier.Core.Attestation; + +namespace StellaOps.Concelier.Core.Tests.Attestation; + +public sealed class EvidenceBundleAttestationBuilderTests +{ + private static readonly string RepoRoot = + Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", "..")); + + [Fact] + public async Task BuildAsync_ProducesClaimsFromSampleBundle() + { + var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle"); + var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz"); + var manifestPath = Path.Combine(sampleDir, "manifest.json"); + var transparencyPath = Path.Combine(sampleDir, "transparency.json"); + + var builder = new EvidenceBundleAttestationBuilder(); + + var claims = await builder.BuildAsync( + new EvidenceBundleAttestationRequest( + tarPath, + manifestPath, + transparencyPath, + pipelineVersion: "git:test-sha"), + CancellationToken.None); + + Assert.Equal("evidence-bundle-m0", claims.SubjectName); + Assert.StartsWith("sha256:", claims.SubjectDigest); + Assert.Equal("evidence-bundle-m0", claims.BundleId); + Assert.Equal("1.0.0", claims.BundleVersion); + Assert.Equal("demo", claims.Tenant); + Assert.Equal("vex", claims.Scope); + Assert.True(claims.AocGuardrails); + Assert.Contains("schema:frozen:1.0", claims.AocDetails); + Assert.Equal(tarPath, claims.EvidenceBundlePath); + Assert.Equal("offline", claims.Transparency.SkipReason); + } + + [Fact] + public async Task BuildAsync_EnforcesLowercaseTenant() + { + var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json"); + var manifest = """ + { + "bundle_id": "test-bundle", + "version": "1.0.0", + "created": "2025-11-19T00:00:00Z", + "tenant": "Demo", + "scope": "vex", + "inputs": ["sha256:abc"], + "aoc": {"guardrails": true, "details": ["schema:frozen:1.0"]} + } + """; + await File.WriteAllTextAsync(tempManifest, manifest); + + var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz"); + await File.WriteAllTextAsync(tempTar, "dummy"); + + var builder = new EvidenceBundleAttestationBuilder(); + + var ex = await Assert.ThrowsAsync(() => + builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None)); + + Assert.Contains("Tenant must be lowercase", ex.Message); + } +}