compose and authority fixes. finish sprints.

This commit is contained in:
master
2026-02-17 21:59:47 +02:00
parent fb46a927ad
commit 49cdebe2f1
187 changed files with 23189 additions and 1439 deletions

View File

@@ -59,6 +59,11 @@ public sealed record BundleData
/// </summary>
public IReadOnlyList<BundleArtifact> ScanResults { get; init; } = [];
/// <summary>
/// Runtime witness triplet artifacts (trace, DSSE, Sigstore bundle).
/// </summary>
public IReadOnlyList<BundleArtifact> RuntimeWitnesses { get; init; } = [];
/// <summary>
/// Public keys for verification.
/// </summary>
@@ -94,6 +99,26 @@ public sealed record BundleArtifact
/// Subject of the artifact.
/// </summary>
public string? Subject { get; init; }
/// <summary>
/// Runtime witness identity this artifact belongs to.
/// </summary>
public string? WitnessId { get; init; }
/// <summary>
/// Runtime witness artifact role (trace, dsse, sigstore_bundle).
/// </summary>
public string? WitnessRole { get; init; }
/// <summary>
/// Deterministic runtime witness lookup keys.
/// </summary>
public RuntimeWitnessIndexKey? WitnessIndex { get; init; }
/// <summary>
/// Related artifact paths for witness-level linkage.
/// </summary>
public IReadOnlyList<string>? LinkedArtifacts { get; init; }
}
/// <summary>

View File

@@ -79,18 +79,25 @@ public sealed record BundleManifest
[JsonPropertyOrder(8)]
public ImmutableArray<ArtifactEntry> ScanResults { get; init; } = ImmutableArray<ArtifactEntry>.Empty;
/// <summary>
/// Runtime witness artifacts (trace.json/trace.dsse.json/trace.sigstore.json) included in the bundle.
/// </summary>
[JsonPropertyName("runtimeWitnesses")]
[JsonPropertyOrder(9)]
public ImmutableArray<ArtifactEntry> RuntimeWitnesses { get; init; } = ImmutableArray<ArtifactEntry>.Empty;
/// <summary>
/// Public keys for verification.
/// </summary>
[JsonPropertyName("publicKeys")]
[JsonPropertyOrder(9)]
[JsonPropertyOrder(10)]
public ImmutableArray<KeyEntry> PublicKeys { get; init; } = ImmutableArray<KeyEntry>.Empty;
/// <summary>
/// Merkle root hash of all artifacts for integrity verification.
/// </summary>
[JsonPropertyName("merkleRoot")]
[JsonPropertyOrder(10)]
[JsonPropertyOrder(11)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MerkleRoot { get; init; }
@@ -99,15 +106,20 @@ public sealed record BundleManifest
/// </summary>
[JsonIgnore]
public IEnumerable<ArtifactEntry> AllArtifacts =>
Sboms.Concat(VexStatements).Concat(Attestations).Concat(PolicyVerdicts).Concat(ScanResults);
Sboms
.Concat(VexStatements)
.Concat(Attestations)
.Concat(PolicyVerdicts)
.Concat(ScanResults)
.Concat(RuntimeWitnesses);
/// <summary>
/// Total count of artifacts in the bundle.
/// </summary>
[JsonPropertyName("totalArtifacts")]
[JsonPropertyOrder(11)]
[JsonPropertyOrder(12)]
public int TotalArtifacts => Sboms.Length + VexStatements.Length + Attestations.Length +
PolicyVerdicts.Length + ScanResults.Length;
PolicyVerdicts.Length + ScanResults.Length + RuntimeWitnesses.Length;
}
/// <summary>
@@ -165,6 +177,82 @@ public sealed record ArtifactEntry
[JsonPropertyOrder(6)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Subject { get; init; }
/// <summary>
/// Runtime witness identity this artifact belongs to.
/// </summary>
[JsonPropertyName("witnessId")]
[JsonPropertyOrder(7)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? WitnessId { get; init; }
/// <summary>
/// Runtime witness artifact role (trace, dsse, sigstore_bundle).
/// </summary>
[JsonPropertyName("witnessRole")]
[JsonPropertyOrder(8)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? WitnessRole { get; init; }
/// <summary>
/// Runtime witness lookup keys for deterministic replay.
/// </summary>
[JsonPropertyName("witnessIndex")]
[JsonPropertyOrder(9)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public RuntimeWitnessIndexKey? WitnessIndex { get; init; }
/// <summary>
/// Related artifact paths for this witness artifact.
/// </summary>
[JsonPropertyName("linkedArtifacts")]
[JsonPropertyOrder(10)]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ImmutableArray<string>? LinkedArtifacts { get; init; }
}
/// <summary>
/// Deterministic lookup keys for runtime witness artifacts.
/// </summary>
public sealed record RuntimeWitnessIndexKey
{
/// <summary>
/// Build ID of the observed userspace binary.
/// </summary>
[JsonPropertyName("buildId")]
[JsonPropertyOrder(0)]
public required string BuildId { get; init; }
/// <summary>
/// Kernel release used during runtime collection.
/// </summary>
[JsonPropertyName("kernelRelease")]
[JsonPropertyOrder(1)]
public required string KernelRelease { get; init; }
/// <summary>
/// Probe identifier that produced this runtime witness.
/// </summary>
[JsonPropertyName("probeId")]
[JsonPropertyOrder(2)]
public required string ProbeId { get; init; }
/// <summary>
/// Policy run identifier associated with the runtime evidence.
/// </summary>
[JsonPropertyName("policyRunId")]
[JsonPropertyOrder(3)]
public required string PolicyRunId { get; init; }
}
/// <summary>
/// Runtime witness artifact role values.
/// </summary>
public static class RuntimeWitnessArtifactRoles
{
public const string Trace = "trace";
public const string Dsse = "dsse";
public const string SigstoreBundle = "sigstore_bundle";
}
/// <summary>
@@ -234,6 +322,7 @@ public static class BundlePaths
public const string AttestationsDirectory = "attestations";
public const string PolicyDirectory = "policy";
public const string ScansDirectory = "scans";
public const string RuntimeWitnessesDirectory = "runtime-witnesses";
}
/// <summary>
@@ -249,4 +338,6 @@ public static class BundleMediaTypes
public const string PolicyVerdict = "application/json";
public const string ScanResult = "application/json";
public const string PublicKeyPem = "application/x-pem-file";
public const string RuntimeWitnessTrace = "application/vnd.stellaops.witness.v1+json";
public const string SigstoreBundleV03 = "application/vnd.dev.sigstore.bundle.v0.3+json";
}

View File

@@ -329,32 +329,39 @@ public sealed record ExportConfiguration
[JsonPropertyOrder(4)]
public bool IncludeScanResults { get; init; } = true;
/// <summary>
/// Include runtime witness triplets (trace, DSSE, Sigstore bundle) in export.
/// </summary>
[JsonPropertyName("includeRuntimeWitnesses")]
[JsonPropertyOrder(5)]
public bool IncludeRuntimeWitnesses { get; init; } = true;
/// <summary>
/// Include public keys for offline verification.
/// </summary>
[JsonPropertyName("includeKeys")]
[JsonPropertyOrder(5)]
[JsonPropertyOrder(6)]
public bool IncludeKeys { get; init; } = true;
/// <summary>
/// Include verification scripts.
/// </summary>
[JsonPropertyName("includeVerifyScripts")]
[JsonPropertyOrder(6)]
[JsonPropertyOrder(7)]
public bool IncludeVerifyScripts { get; init; } = true;
/// <summary>
/// Compression algorithm (gzip, brotli, none).
/// </summary>
[JsonPropertyName("compression")]
[JsonPropertyOrder(7)]
[JsonPropertyOrder(8)]
public string Compression { get; init; } = "gzip";
/// <summary>
/// Compression level (1-9).
/// </summary>
[JsonPropertyName("compressionLevel")]
[JsonPropertyOrder(8)]
[JsonPropertyOrder(9)]
public int CompressionLevel { get; init; } = 6;
}

View File

@@ -0,0 +1,300 @@
using StellaOps.EvidenceLocker.Export.Models;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Export;
/// <summary>
/// Validates runtime witness triplets for offline replay verification.
/// </summary>
public sealed class RuntimeWitnessOfflineVerifier
{
private static readonly HashSet<string> RequiredRoles = new(StringComparer.Ordinal)
{
RuntimeWitnessArtifactRoles.Trace,
RuntimeWitnessArtifactRoles.Dsse,
RuntimeWitnessArtifactRoles.SigstoreBundle
};
/// <summary>
/// Verifies runtime witness triplets using only bundle-contained artifacts.
/// </summary>
public RuntimeWitnessOfflineVerificationResult Verify(
BundleManifest manifest,
IReadOnlyDictionary<string, byte[]> artifactsByPath)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentNullException.ThrowIfNull(artifactsByPath);
var errors = new List<string>();
var witnessArtifacts = manifest.RuntimeWitnesses
.OrderBy(static artifact => artifact.WitnessId, StringComparer.Ordinal)
.ThenBy(static artifact => artifact.Path, StringComparer.Ordinal)
.ToList();
foreach (var artifact in witnessArtifacts)
{
if (string.IsNullOrWhiteSpace(artifact.WitnessId))
{
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessId.");
}
if (string.IsNullOrWhiteSpace(artifact.WitnessRole))
{
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessRole.");
}
else if (!RequiredRoles.Contains(artifact.WitnessRole))
{
errors.Add($"runtime witness artifact '{artifact.Path}' has unsupported witnessRole '{artifact.WitnessRole}'.");
}
if (artifact.WitnessIndex is null)
{
errors.Add($"runtime witness artifact '{artifact.Path}' is missing witnessIndex.");
}
}
foreach (var group in witnessArtifacts.GroupBy(static artifact => artifact.WitnessId, StringComparer.Ordinal))
{
if (string.IsNullOrWhiteSpace(group.Key))
{
continue;
}
VerifyWitnessTriplet(group.Key!, group.ToList(), artifactsByPath, errors);
}
return errors.Count == 0
? RuntimeWitnessOfflineVerificationResult.Passed()
: RuntimeWitnessOfflineVerificationResult.Failure(errors);
}
private static void VerifyWitnessTriplet(
string witnessId,
IReadOnlyList<ArtifactEntry> artifacts,
IReadOnlyDictionary<string, byte[]> artifactsByPath,
ICollection<string> errors)
{
var errorCountBefore = errors.Count;
var roleMap = artifacts
.Where(static artifact => !string.IsNullOrWhiteSpace(artifact.WitnessRole))
.GroupBy(static artifact => artifact.WitnessRole!, StringComparer.Ordinal)
.ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal);
foreach (var requiredRole in RequiredRoles)
{
if (!roleMap.ContainsKey(requiredRole))
{
errors.Add($"runtime witness '{witnessId}' is missing '{requiredRole}' artifact.");
}
}
if (errors.Count > errorCountBefore && !roleMap.ContainsKey(RuntimeWitnessArtifactRoles.Trace))
{
return;
}
if (!roleMap.TryGetValue(RuntimeWitnessArtifactRoles.Trace, out var traceArtifact)
|| !roleMap.TryGetValue(RuntimeWitnessArtifactRoles.Dsse, out var dsseArtifact)
|| !roleMap.TryGetValue(RuntimeWitnessArtifactRoles.SigstoreBundle, out var sigstoreArtifact))
{
return;
}
if (!TryGetArtifactBytes(traceArtifact, artifactsByPath, errors, out var traceBytes)
|| !TryGetArtifactBytes(dsseArtifact, artifactsByPath, errors, out var dsseBytes)
|| !TryGetArtifactBytes(sigstoreArtifact, artifactsByPath, errors, out var sigstoreBytes))
{
return;
}
if (!TryGetDssePayload(dsseBytes, dsseArtifact.Path, errors, out var dssePayloadType, out var dssePayloadBase64))
{
return;
}
byte[] dssePayloadBytes;
try
{
dssePayloadBytes = Convert.FromBase64String(dssePayloadBase64!);
}
catch (FormatException)
{
errors.Add($"runtime witness '{witnessId}' DSSE payload is not valid base64 in '{dsseArtifact.Path}'.");
return;
}
if (!traceBytes.SequenceEqual(dssePayloadBytes))
{
errors.Add($"runtime witness '{witnessId}' trace payload bytes do not match DSSE payload.");
}
VerifySigstoreBundle(sigstoreBytes, sigstoreArtifact.Path, witnessId, dssePayloadType!, dssePayloadBase64!, errors);
}
private static bool TryGetArtifactBytes(
ArtifactEntry artifact,
IReadOnlyDictionary<string, byte[]> artifactsByPath,
ICollection<string> errors,
out byte[] bytes)
{
if (!artifactsByPath.TryGetValue(artifact.Path, out bytes!))
{
errors.Add($"runtime witness artifact '{artifact.Path}' is missing from offline artifact set.");
return false;
}
var computedDigest = ComputeSha256Hex(bytes);
var expectedDigest = NormalizeDigest(artifact.Digest);
if (!string.Equals(expectedDigest, computedDigest, StringComparison.Ordinal))
{
errors.Add($"runtime witness artifact '{artifact.Path}' digest mismatch.");
return false;
}
return true;
}
private static bool TryGetDssePayload(
byte[] dsseBytes,
string path,
ICollection<string> errors,
out string? payloadType,
out string? payloadBase64)
{
payloadType = null;
payloadBase64 = null;
try
{
using var document = JsonDocument.Parse(dsseBytes);
var root = document.RootElement;
if (!root.TryGetProperty("payloadType", out var payloadTypeElement)
|| string.IsNullOrWhiteSpace(payloadTypeElement.GetString()))
{
errors.Add($"DSSE envelope '{path}' is missing payloadType.");
return false;
}
if (!root.TryGetProperty("payload", out var payloadElement)
|| string.IsNullOrWhiteSpace(payloadElement.GetString()))
{
errors.Add($"DSSE envelope '{path}' is missing payload.");
return false;
}
payloadType = payloadTypeElement.GetString();
payloadBase64 = payloadElement.GetString();
return true;
}
catch (JsonException)
{
errors.Add($"DSSE envelope '{path}' is not valid JSON.");
return false;
}
}
private static void VerifySigstoreBundle(
byte[] sigstoreBytes,
string path,
string witnessId,
string expectedPayloadType,
string expectedPayloadBase64,
ICollection<string> errors)
{
try
{
using var document = JsonDocument.Parse(sigstoreBytes);
var root = document.RootElement;
var mediaType = root.TryGetProperty("mediaType", out var mediaTypeElement)
? mediaTypeElement.GetString()
: null;
if (!string.Equals(mediaType, BundleMediaTypes.SigstoreBundleV03, StringComparison.Ordinal))
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' has unsupported mediaType '{mediaType ?? "<missing>"}'.");
}
if (!root.TryGetProperty("dsseEnvelope", out var dsseEnvelope))
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' is missing dsseEnvelope.");
return;
}
var bundlePayloadType = dsseEnvelope.TryGetProperty("payloadType", out var payloadTypeElement)
? payloadTypeElement.GetString()
: null;
var bundlePayload = dsseEnvelope.TryGetProperty("payload", out var payloadElement)
? payloadElement.GetString()
: null;
if (!string.Equals(bundlePayloadType, expectedPayloadType, StringComparison.Ordinal))
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle payloadType does not match trace DSSE envelope.");
}
if (!string.Equals(bundlePayload, expectedPayloadBase64, StringComparison.Ordinal))
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle payload does not match trace DSSE envelope.");
}
if (!dsseEnvelope.TryGetProperty("signatures", out var signatures)
|| signatures.ValueKind != JsonValueKind.Array
|| signatures.GetArrayLength() == 0)
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' has no DSSE signatures.");
}
}
catch (JsonException)
{
errors.Add($"runtime witness '{witnessId}' sigstore bundle '{path}' is not valid JSON.");
}
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(bytes, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string NormalizeDigest(string digest)
{
const string prefix = "sha256:";
return digest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? digest[prefix.Length..].ToLowerInvariant()
: digest.ToLowerInvariant();
}
}
/// <summary>
/// Runtime witness offline verification outcome.
/// </summary>
public sealed record RuntimeWitnessOfflineVerificationResult
{
/// <summary>
/// Whether verification succeeded.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Verification errors, if any.
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
public static RuntimeWitnessOfflineVerificationResult Passed()
=> new()
{
Success = true
};
public static RuntimeWitnessOfflineVerificationResult Failure(IReadOnlyList<string> errors)
=> new()
{
Success = false,
Errors = errors
};
}

View File

@@ -165,6 +165,22 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
}
}
// Add runtime witness artifacts (trace / trace.dsse / trace.sigstore)
if (config.IncludeRuntimeWitnesses)
{
foreach (var runtimeWitnessArtifact in bundleData.RuntimeWitnesses)
{
var entry = await AddArtifactAsync(
tarWriter,
runtimeWitnessArtifact,
BundlePaths.RuntimeWitnessesDirectory,
"runtime_witness",
cancellationToken);
manifestBuilder.AddRuntimeWitness(entry);
checksumEntries.Add((entry.Path, entry.Digest));
}
}
// Add public keys
if (config.IncludeKeys)
{
@@ -261,7 +277,13 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
Size = content.Length,
Type = type,
Format = artifact.Format,
Subject = artifact.Subject
Subject = artifact.Subject,
WitnessId = artifact.WitnessId,
WitnessRole = artifact.WitnessRole,
WitnessIndex = artifact.WitnessIndex,
LinkedArtifacts = artifact.LinkedArtifacts is null
? null
: [.. artifact.LinkedArtifacts.Order(StringComparer.Ordinal)]
};
}
@@ -450,6 +472,7 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
- Attestations: {manifest.Attestations.Length}
- Policy Verdicts: {manifest.PolicyVerdicts.Length}
- Scan Results: {manifest.ScanResults.Length}
- Runtime Witness Artifacts: {manifest.RuntimeWitnesses.Length}
- Public Keys: {manifest.PublicKeys.Length}
Total Artifacts: {manifest.TotalArtifacts}
@@ -469,6 +492,7 @@ public sealed class TarGzBundleExporter : IEvidenceBundleExporter
+-- attestations/ # DSSE attestation envelopes
+-- policy/ # Policy verdicts
+-- scans/ # Scan results
+-- runtime-witnesses/ # Runtime witness triplets and index metadata
+-- keys/ # Public keys for verification
```
@@ -515,6 +539,7 @@ internal sealed class BundleManifestBuilder
private readonly List<ArtifactEntry> _attestations = [];
private readonly List<ArtifactEntry> _policyVerdicts = [];
private readonly List<ArtifactEntry> _scanResults = [];
private readonly List<ArtifactEntry> _runtimeWitnesses = [];
private readonly List<KeyEntry> _publicKeys = [];
public BundleManifestBuilder(string bundleId, DateTimeOffset createdAt)
@@ -529,6 +554,7 @@ internal sealed class BundleManifestBuilder
public void AddAttestation(ArtifactEntry entry) => _attestations.Add(entry);
public void AddPolicyVerdict(ArtifactEntry entry) => _policyVerdicts.Add(entry);
public void AddScanResult(ArtifactEntry entry) => _scanResults.Add(entry);
public void AddRuntimeWitness(ArtifactEntry entry) => _runtimeWitnesses.Add(entry);
public void AddPublicKey(KeyEntry entry) => _publicKeys.Add(entry);
public BundleManifest Build() => new()
@@ -541,6 +567,7 @@ internal sealed class BundleManifestBuilder
Attestations = [.. _attestations],
PolicyVerdicts = [.. _policyVerdicts],
ScanResults = [.. _scanResults],
RuntimeWitnesses = [.. _runtimeWitnesses],
PublicKeys = [.. _publicKeys]
};
}

View File

@@ -343,6 +343,7 @@ if __name__ == ""__main__"":
| Attestations | {manifest.Attestations.Length} |
| Policy Verdicts | {manifest.PolicyVerdicts.Length} |
| Scan Results | {manifest.ScanResults.Length} |
| Runtime Witness Artifacts | {manifest.RuntimeWitnesses.Length} |
| Public Keys | {manifest.PublicKeys.Length} |
| **Total Artifacts** | **{manifest.TotalArtifacts}** |
@@ -362,6 +363,7 @@ if __name__ == ""__main__"":
+-- attestations/ # DSSE attestation envelopes
+-- policy/ # Policy verdicts
+-- scans/ # Scan results
+-- runtime-witnesses/ # Runtime witness triplets (trace + DSSE + Sigstore bundle)
+-- keys/ # Public keys for verification
```