feat: Implement EvidenceBundleAttestationBuilder with unit tests for claims generation and tenant validation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user