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

@@ -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);
}
}