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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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-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 | 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-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 & Risks
|
||||||
### Decisions in flight
|
### Decisions in flight
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ using StellaOps.Concelier.Storage.Mongo;
|
|||||||
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
using StellaOps.Concelier.Storage.Mongo.Advisories;
|
||||||
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
using StellaOps.Concelier.Storage.Mongo.Aliases;
|
||||||
using StellaOps.Provenance.Mongo;
|
using StellaOps.Provenance.Mongo;
|
||||||
|
using StellaOps.Concelier.Core.Attestation;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ builder.Services.AddSingleton<IAdvisoryObservationQueryService, AdvisoryObservat
|
|||||||
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
|
builder.Services.AddSingleton<AdvisoryChunkBuilder>();
|
||||||
builder.Services.AddSingleton<IAdvisoryChunkCache, AdvisoryChunkCache>();
|
builder.Services.AddSingleton<IAdvisoryChunkCache, AdvisoryChunkCache>();
|
||||||
builder.Services.AddSingleton<IAdvisoryAiTelemetry, AdvisoryAiTelemetry>();
|
builder.Services.AddSingleton<IAdvisoryAiTelemetry, AdvisoryAiTelemetry>();
|
||||||
|
builder.Services.AddSingleton<EvidenceBundleAttestationBuilder>();
|
||||||
|
|
||||||
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();
|
var features = concelierOptions.Features ?? new ConcelierOptions.FeaturesOptions();
|
||||||
|
|
||||||
@@ -762,6 +764,7 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
|||||||
string advisoryKey,
|
string advisoryKey,
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
[FromServices] IAdvisoryRawService rawService,
|
[FromServices] IAdvisoryRawService rawService,
|
||||||
|
[FromServices] EvidenceBundleAttestationBuilder attestationBuilder,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
ApplyNoCache(context.Response);
|
ApplyNoCache(context.Response);
|
||||||
@@ -807,6 +810,8 @@ var advisoryEvidenceEndpoint = app.MapGet("/vuln/evidence/advisories/{advisoryKe
|
|||||||
|
|
||||||
var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey;
|
var responseKey = recordResponses[0].Document.AdvisoryKey ?? canonicalKey;
|
||||||
var response = new AdvisoryEvidenceResponse(responseKey, recordResponses);
|
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);
|
return JsonResult(response);
|
||||||
});
|
});
|
||||||
if (authorityConfigured)
|
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