feat: Implement EvidenceBundleAttestationBuilder with unit tests for claims generation and tenant validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
master
2025-11-20 09:17:58 +02:00
parent 10212d67c0
commit f0e74d2ee8
4 changed files with 219 additions and 1 deletions

View File

@@ -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

View File

@@ -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<IAdvisoryObservationQueryService, AdvisoryObservat
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
builder.Services.AddSingleton<IAdvisoryChunkCache, AdvisoryChunkCache>();
builder.Services.AddSingleton<IAdvisoryAiTelemetry, AdvisoryAiTelemetry>();
builder.Services.AddSingleton<EvidenceBundleAttestationBuilder>();
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)

View File

@@ -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<string> PipelineInputs,
string Tenant,
string Scope,
string EvidenceBundlePath,
AttestationTransparency Transparency,
bool AocGuardrails,
IReadOnlyCollection<string> 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<AttestationClaims> 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<string>();
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<string>());
}
private static async Task<ManifestPayload> ReadManifestAsync(string path, CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
var manifest = await JsonSerializer.DeserializeAsync<ManifestPayload>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
return manifest ?? throw new InvalidOperationException("Manifest payload could not be read.");
}
private static async Task<TransparencyPayload?> 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<TransparencyPayload>(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<string> 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<string>? 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<string>? Details { get; init; }
}
private sealed record TransparencyPayload
{
[JsonPropertyName("rekor_uuid")] public string? RekorUuid { get; init; }
[JsonPropertyName("skip_reason")] public string? SkipReason { get; init; }
}
}

View File

@@ -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<InvalidOperationException>(() =>
builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None));
Assert.Contains("Tenant must be lowercase", ex.Message);
}
}