compose and authority fixes. finish sprints.
This commit is contained in:
@@ -63,7 +63,6 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStellaOpsCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ public class BundleManifestSerializationTests
|
||||
config.IncludeAttestations.Should().BeTrue();
|
||||
config.IncludePolicyVerdicts.Should().BeTrue();
|
||||
config.IncludeScanResults.Should().BeTrue();
|
||||
config.IncludeRuntimeWitnesses.Should().BeTrue();
|
||||
config.IncludeKeys.Should().BeTrue();
|
||||
config.IncludeVerifyScripts.Should().BeTrue();
|
||||
config.Compression.Should().Be("gzip");
|
||||
@@ -202,12 +203,13 @@ public class BundleManifestSerializationTests
|
||||
var allArtifacts = manifest.AllArtifacts.ToList();
|
||||
|
||||
// Assert
|
||||
allArtifacts.Should().HaveCount(5);
|
||||
allArtifacts.Should().HaveCount(6);
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("sbom");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("vex");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("attestation");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("policy");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("scan");
|
||||
allArtifacts.Select(a => a.Type).Should().Contain("runtime_witness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -217,7 +219,7 @@ public class BundleManifestSerializationTests
|
||||
var manifest = CreateTestManifest();
|
||||
|
||||
// Act & Assert
|
||||
manifest.TotalArtifacts.Should().Be(5);
|
||||
manifest.TotalArtifacts.Should().Be(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -264,6 +266,7 @@ public class BundleManifestSerializationTests
|
||||
BundlePaths.AttestationsDirectory.Should().Be("attestations");
|
||||
BundlePaths.PolicyDirectory.Should().Be("policy");
|
||||
BundlePaths.ScansDirectory.Should().Be("scans");
|
||||
BundlePaths.RuntimeWitnessesDirectory.Should().Be("runtime-witnesses");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -275,6 +278,8 @@ public class BundleManifestSerializationTests
|
||||
BundleMediaTypes.VexOpenVex.Should().Be("application/vnd.openvex+json");
|
||||
BundleMediaTypes.DsseEnvelope.Should().Be("application/vnd.dsse.envelope+json");
|
||||
BundleMediaTypes.PublicKeyPem.Should().Be("application/x-pem-file");
|
||||
BundleMediaTypes.RuntimeWitnessTrace.Should().Be("application/vnd.stellaops.witness.v1+json");
|
||||
BundleMediaTypes.SigstoreBundleV03.Should().Be("application/vnd.dev.sigstore.bundle.v0.3+json");
|
||||
}
|
||||
|
||||
private static BundleManifest CreateTestManifest()
|
||||
@@ -326,6 +331,28 @@ public class BundleManifestSerializationTests
|
||||
Size = 10000,
|
||||
Type = "scan"
|
||||
}),
|
||||
RuntimeWitnesses = ImmutableArray.Create(new ArtifactEntry
|
||||
{
|
||||
Path = "runtime-witnesses/wit-sha256-001/trace.sigstore.json",
|
||||
Digest = "sha256:wit123",
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
Size = 4096,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:abc",
|
||||
KernelRelease = "6.8.0",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-001"
|
||||
},
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-sha256-001/trace.json",
|
||||
"runtime-witnesses/wit-sha256-001/trace.dsse.json"
|
||||
]
|
||||
}),
|
||||
PublicKeys = ImmutableArray.Create(new KeyEntry
|
||||
{
|
||||
Path = "keys/signing.pub",
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using FluentAssertions;
|
||||
using StellaOps.EvidenceLocker.Export.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Export.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RuntimeWitnessOfflineVerifierTests
|
||||
{
|
||||
private readonly RuntimeWitnessOfflineVerifier _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithValidTriplet_ReturnsSuccess()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
|
||||
var result = _sut.Verify(fixture.Manifest, fixture.ArtifactsByPath);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMissingSigstoreArtifact_ReturnsFailure()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var manifest = fixture.Manifest with
|
||||
{
|
||||
RuntimeWitnesses = fixture.Manifest.RuntimeWitnesses
|
||||
.Where(artifact => artifact.WitnessRole != RuntimeWitnessArtifactRoles.SigstoreBundle)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
|
||||
var result = _sut.Verify(manifest, fixture.ArtifactsByPath);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("sigstore_bundle", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WithMismatchedDssePayload_ReturnsFailure()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var mismatchedDsseBytes = Encoding.UTF8.GetBytes("""
|
||||
{"payloadType":"application/vnd.stellaops.witness.v1+json","payload":"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpESUZGRVJFTlQifQ==","signatures":[{"keyid":"runtime-key","sig":"c2ln"}]}
|
||||
""");
|
||||
var artifacts = fixture.ArtifactsByPath
|
||||
.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
|
||||
artifacts["runtime-witnesses/wit-001/trace.dsse.json"] = mismatchedDsseBytes;
|
||||
|
||||
var manifest = fixture.Manifest with
|
||||
{
|
||||
RuntimeWitnesses = fixture.Manifest.RuntimeWitnesses
|
||||
.Select(artifact => artifact.Path == "runtime-witnesses/wit-001/trace.dsse.json"
|
||||
? artifact with { Digest = $"sha256:{ComputeSha256Hex(mismatchedDsseBytes)}" }
|
||||
: artifact)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
|
||||
var result = _sut.Verify(manifest, artifacts);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Errors.Should().Contain(error => error.Contains("do not match DSSE payload", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Regulatory")]
|
||||
public void ReplayFrames_WithFixedWitnessArtifacts_AreByteIdenticalAcrossKernelLibcMatrix()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var verification = _sut.Verify(fixture.Manifest, fixture.ArtifactsByPath);
|
||||
verification.Success.Should().BeTrue();
|
||||
|
||||
var matrix = CreateReplayMatrix();
|
||||
matrix.Select(row => row.KernelRelease)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count()
|
||||
.Should()
|
||||
.BeGreaterThanOrEqualTo(3);
|
||||
matrix.Select(row => row.LibcVariant)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Should()
|
||||
.Contain(["glibc", "musl"]);
|
||||
|
||||
var projections = matrix
|
||||
.Select(row => ProjectReplayFrames(fixture.Manifest, fixture.ArtifactsByPath, row))
|
||||
.ToList();
|
||||
|
||||
projections.Should().NotBeEmpty();
|
||||
projections.Select(projection => projection.FrameCount)
|
||||
.Should()
|
||||
.OnlyContain(static count => count > 0);
|
||||
|
||||
var baselineBytes = projections[0].FrameBytes;
|
||||
projections.Select(projection => projection.FrameBytes)
|
||||
.Should()
|
||||
.OnlyContain(bytes => bytes.SequenceEqual(baselineBytes));
|
||||
|
||||
projections.Select(projection => projection.FrameDigest)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void BuildReplayFrameBytes_WithReorderedObservations_ProducesIdenticalDigest()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var tracePath = fixture.Manifest.RuntimeWitnesses
|
||||
.Single(artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Trace)
|
||||
.Path;
|
||||
var baselineTraceBytes = fixture.ArtifactsByPath[tracePath];
|
||||
var reorderedTraceBytes = ReorderObservations(baselineTraceBytes);
|
||||
|
||||
var baselineFrames = BuildReplayFrameBytes(baselineTraceBytes);
|
||||
var reorderedFrames = BuildReplayFrameBytes(reorderedTraceBytes);
|
||||
|
||||
baselineFrames.Should().Equal(reorderedFrames);
|
||||
ComputeSha256Hex(baselineFrames).Should().Be(ComputeSha256Hex(reorderedFrames));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Intent", "Safety")]
|
||||
public void BuildReplayFrameBytes_WithMutatedObservation_ProducesDifferentDigest()
|
||||
{
|
||||
var fixture = CreateFixture();
|
||||
var tracePath = fixture.Manifest.RuntimeWitnesses
|
||||
.Single(artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Trace)
|
||||
.Path;
|
||||
var baselineTraceBytes = fixture.ArtifactsByPath[tracePath];
|
||||
var mutatedTraceBytes = MutateFirstObservationStackHash(baselineTraceBytes, "sha256:ccc");
|
||||
|
||||
var baselineFrames = BuildReplayFrameBytes(baselineTraceBytes);
|
||||
var mutatedFrames = BuildReplayFrameBytes(mutatedTraceBytes);
|
||||
|
||||
ComputeSha256Hex(baselineFrames).Should().NotBe(ComputeSha256Hex(mutatedFrames));
|
||||
}
|
||||
|
||||
private static (BundleManifest Manifest, IReadOnlyDictionary<string, byte[]> ArtifactsByPath) CreateFixture()
|
||||
{
|
||||
var tracePath = "runtime-witnesses/wit-001/trace.json";
|
||||
var dssePath = "runtime-witnesses/wit-001/trace.dsse.json";
|
||||
var sigstorePath = "runtime-witnesses/wit-001/trace.sigstore.json";
|
||||
|
||||
var traceBytes = Encoding.UTF8.GetBytes("""
|
||||
{
|
||||
"witness_schema":"stellaops.witness.v1",
|
||||
"witness_id":"wit:sha256:runtime-001",
|
||||
"claim_id":"claim:sha256:artifact123:pathabcdef123456",
|
||||
"observation_type":"runtime",
|
||||
"observations":[
|
||||
{
|
||||
"observed_at":"2026-02-17T11:59:01Z",
|
||||
"observation_count":1,
|
||||
"stack_sample_hash":"sha256:bbb",
|
||||
"process_id":4421,
|
||||
"container_id":"container-a",
|
||||
"pod_name":"api-0",
|
||||
"namespace":"prod",
|
||||
"source_type":"tetragon",
|
||||
"observation_id":"obs-b"
|
||||
},
|
||||
{
|
||||
"observed_at":"2026-02-17T11:59:00Z",
|
||||
"observation_count":2,
|
||||
"stack_sample_hash":"sha256:aaa",
|
||||
"process_id":4421,
|
||||
"container_id":"container-a",
|
||||
"pod_name":"api-0",
|
||||
"namespace":"prod",
|
||||
"source_type":"tetragon",
|
||||
"observation_id":"obs-a"
|
||||
}
|
||||
],
|
||||
"symbolization":{
|
||||
"build_id":"gnu-build-id:runtime-test",
|
||||
"debug_artifact_uri":"cas://symbols/runtime-test.debug",
|
||||
"symbolizer":{
|
||||
"name":"llvm-symbolizer",
|
||||
"version":"18.1.7",
|
||||
"digest":"sha256:symbolizer"
|
||||
},
|
||||
"libc_variant":"glibc",
|
||||
"sysroot_digest":"sha256:sysroot"
|
||||
}
|
||||
}
|
||||
""");
|
||||
var payloadBase64 = Convert.ToBase64String(traceBytes);
|
||||
|
||||
var dsseBytes = Encoding.UTF8.GetBytes(
|
||||
$"{{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"{payloadBase64}\",\"signatures\":[{{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}}]}}");
|
||||
|
||||
var sigstoreBytes = Encoding.UTF8.GetBytes(
|
||||
$"{{\"mediaType\":\"application/vnd.dev.sigstore.bundle.v0.3+json\",\"verificationMaterial\":{{\"publicKey\":{{\"rawBytes\":\"cHVibGlj\"}}}},\"dsseEnvelope\":{{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"{payloadBase64}\",\"signatures\":[{{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}}]}}}}");
|
||||
|
||||
var index = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:abc123",
|
||||
KernelRelease = "6.8.0-45-generic",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-42"
|
||||
};
|
||||
|
||||
var manifest = new BundleManifest
|
||||
{
|
||||
BundleId = "bundle-runtime-001",
|
||||
CreatedAt = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero),
|
||||
Metadata = new BundleMetadata
|
||||
{
|
||||
Subject = new BundleSubject
|
||||
{
|
||||
Type = SubjectTypes.ContainerImage,
|
||||
Digest = "sha256:subject"
|
||||
},
|
||||
Provenance = new BundleProvenance
|
||||
{
|
||||
Creator = new CreatorInfo
|
||||
{
|
||||
Name = "StellaOps",
|
||||
Version = "1.0.0"
|
||||
},
|
||||
ExportedAt = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero)
|
||||
},
|
||||
TimeWindow = new TimeWindow
|
||||
{
|
||||
Earliest = new DateTimeOffset(2026, 2, 17, 11, 0, 0, TimeSpan.Zero),
|
||||
Latest = new DateTimeOffset(2026, 2, 17, 12, 0, 0, TimeSpan.Zero)
|
||||
}
|
||||
},
|
||||
RuntimeWitnesses =
|
||||
[
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = tracePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(traceBytes)}",
|
||||
MediaType = BundleMediaTypes.RuntimeWitnessTrace,
|
||||
Size = traceBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Trace,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [dssePath, sigstorePath]
|
||||
},
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = dssePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(dsseBytes)}",
|
||||
MediaType = BundleMediaTypes.DsseEnvelope,
|
||||
Size = dsseBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Dsse,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [tracePath, sigstorePath]
|
||||
},
|
||||
new ArtifactEntry
|
||||
{
|
||||
Path = sigstorePath,
|
||||
Digest = $"sha256:{ComputeSha256Hex(sigstoreBytes)}",
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
Size = sigstoreBytes.Length,
|
||||
Type = "runtime_witness",
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts = [tracePath, dssePath]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var artifactsByPath = new Dictionary<string, byte[]>(StringComparer.Ordinal)
|
||||
{
|
||||
[tracePath] = traceBytes,
|
||||
[dssePath] = dsseBytes,
|
||||
[sigstorePath] = sigstoreBytes
|
||||
};
|
||||
|
||||
return (manifest, artifactsByPath);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ReplayEnvironment> CreateReplayMatrix()
|
||||
{
|
||||
return
|
||||
[
|
||||
new ReplayEnvironment("5.15.0-1068-azure", "glibc"),
|
||||
new ReplayEnvironment("6.1.0-21-amd64", "glibc"),
|
||||
new ReplayEnvironment("6.6.32-0-lts", "musl")
|
||||
];
|
||||
}
|
||||
|
||||
private static ReplayProjection ProjectReplayFrames(
|
||||
BundleManifest manifest,
|
||||
IReadOnlyDictionary<string, byte[]> artifactsByPath,
|
||||
ReplayEnvironment environment)
|
||||
{
|
||||
var dsseArtifact = manifest.RuntimeWitnesses.Single(
|
||||
artifact => artifact.WitnessRole == RuntimeWitnessArtifactRoles.Dsse);
|
||||
var dsseBytes = artifactsByPath[dsseArtifact.Path];
|
||||
|
||||
using var dsseDocument = JsonDocument.Parse(dsseBytes);
|
||||
var payload = ReadRequiredString(dsseDocument.RootElement, "payload");
|
||||
var traceBytes = Convert.FromBase64String(payload);
|
||||
var frameBytes = BuildReplayFrameBytes(traceBytes);
|
||||
|
||||
return new ReplayProjection(
|
||||
environment.KernelRelease,
|
||||
environment.LibcVariant,
|
||||
frameBytes,
|
||||
$"sha256:{ComputeSha256Hex(frameBytes)}",
|
||||
GetFrameCount(frameBytes));
|
||||
}
|
||||
|
||||
private static byte[] BuildReplayFrameBytes(byte[] traceBytes)
|
||||
{
|
||||
using var traceDocument = JsonDocument.Parse(traceBytes);
|
||||
var root = traceDocument.RootElement;
|
||||
var symbolization = root.GetProperty("symbolization");
|
||||
|
||||
var frames = root.GetProperty("observations")
|
||||
.EnumerateArray()
|
||||
.Select(observation => new ReplayFrame
|
||||
{
|
||||
ObservedAt = ReadRequiredString(observation, "observed_at"),
|
||||
ObservationId = ReadRequiredString(observation, "observation_id"),
|
||||
StackSampleHash = ReadRequiredString(observation, "stack_sample_hash"),
|
||||
ProcessId = ReadOptionalInt(observation, "process_id"),
|
||||
ContainerId = ReadOptionalString(observation, "container_id"),
|
||||
Namespace = ReadOptionalString(observation, "namespace"),
|
||||
PodName = ReadOptionalString(observation, "pod_name"),
|
||||
SourceType = ReadOptionalString(observation, "source_type"),
|
||||
ObservationCount = ReadOptionalInt(observation, "observation_count")
|
||||
})
|
||||
.OrderBy(static frame => frame.ObservedAt, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ObservationId, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.StackSampleHash, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ProcessId ?? int.MinValue)
|
||||
.ThenBy(static frame => frame.ContainerId ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.Namespace ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.PodName ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.SourceType ?? string.Empty, StringComparer.Ordinal)
|
||||
.ThenBy(static frame => frame.ObservationCount ?? int.MinValue)
|
||||
.ToList();
|
||||
|
||||
var replay = new ReplayFrameDocument
|
||||
{
|
||||
WitnessId = ReadRequiredString(root, "witness_id"),
|
||||
ClaimId = ReadRequiredString(root, "claim_id"),
|
||||
BuildId = ReadRequiredString(symbolization, "build_id"),
|
||||
SymbolizerName = ReadRequiredString(symbolization.GetProperty("symbolizer"), "name"),
|
||||
SymbolizerVersion = ReadRequiredString(symbolization.GetProperty("symbolizer"), "version"),
|
||||
SymbolizerDigest = ReadRequiredString(symbolization.GetProperty("symbolizer"), "digest"),
|
||||
LibcVariant = ReadRequiredString(symbolization, "libc_variant"),
|
||||
SysrootDigest = ReadRequiredString(symbolization, "sysroot_digest"),
|
||||
Frames = frames
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(replay, ReplayJsonOptions);
|
||||
}
|
||||
|
||||
private static int GetFrameCount(byte[] frameBytes)
|
||||
{
|
||||
using var frameDocument = JsonDocument.Parse(frameBytes);
|
||||
return frameDocument.RootElement
|
||||
.GetProperty("frames")
|
||||
.GetArrayLength();
|
||||
}
|
||||
|
||||
private static string ReadRequiredString(JsonElement element, string propertyName)
|
||||
{
|
||||
var value = ReadOptionalString(element, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException($"Required string '{propertyName}' missing from replay fixture.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.GetString(),
|
||||
JsonValueKind.Number => property.GetRawText(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static int? ReadOptionalInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (!element.TryGetProperty(propertyName, out var property))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] ReorderObservations(byte[] traceBytes)
|
||||
{
|
||||
var root = JsonNode.Parse(traceBytes)?.AsObject()
|
||||
?? throw new InvalidOperationException("Trace JSON must parse into an object.");
|
||||
var observations = root["observations"]?.AsArray()
|
||||
?? throw new InvalidOperationException("Trace JSON must contain observations.");
|
||||
|
||||
var reordered = new JsonArray();
|
||||
for (var i = observations.Count - 1; i >= 0; i--)
|
||||
{
|
||||
reordered.Add(observations[i]?.DeepClone());
|
||||
}
|
||||
|
||||
root["observations"] = reordered;
|
||||
return Encoding.UTF8.GetBytes(root.ToJsonString());
|
||||
}
|
||||
|
||||
private static byte[] MutateFirstObservationStackHash(byte[] traceBytes, string newHash)
|
||||
{
|
||||
var root = JsonNode.Parse(traceBytes)?.AsObject()
|
||||
?? throw new InvalidOperationException("Trace JSON must parse into an object.");
|
||||
var observations = root["observations"]?.AsArray()
|
||||
?? throw new InvalidOperationException("Trace JSON must contain observations.");
|
||||
if (observations.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Trace JSON observations array cannot be empty.");
|
||||
}
|
||||
|
||||
var first = observations[0]?.AsObject()
|
||||
?? throw new InvalidOperationException("Observation entry must be an object.");
|
||||
first["stack_sample_hash"] = newHash;
|
||||
|
||||
return Encoding.UTF8.GetBytes(root.ToJsonString());
|
||||
}
|
||||
|
||||
private static string ComputeSha256Hex(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(bytes, hash);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions ReplayJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private sealed record ReplayEnvironment(string KernelRelease, string LibcVariant);
|
||||
|
||||
private sealed record ReplayProjection(
|
||||
string KernelRelease,
|
||||
string LibcVariant,
|
||||
byte[] FrameBytes,
|
||||
string FrameDigest,
|
||||
int FrameCount);
|
||||
|
||||
private sealed record ReplayFrameDocument
|
||||
{
|
||||
public required string WitnessId { get; init; }
|
||||
public required string ClaimId { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
public required string SymbolizerName { get; init; }
|
||||
public required string SymbolizerVersion { get; init; }
|
||||
public required string SymbolizerDigest { get; init; }
|
||||
public required string LibcVariant { get; init; }
|
||||
public required string SysrootDigest { get; init; }
|
||||
public required IReadOnlyList<ReplayFrame> Frames { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ReplayFrame
|
||||
{
|
||||
public required string ObservedAt { get; init; }
|
||||
public required string ObservationId { get; init; }
|
||||
public required string StackSampleHash { get; init; }
|
||||
public int? ProcessId { get; init; }
|
||||
public string? ContainerId { get; init; }
|
||||
public string? Namespace { get; init; }
|
||||
public string? PodName { get; init; }
|
||||
public string? SourceType { get; init; }
|
||||
public int? ObservationCount { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,74 @@ public class TarGzBundleExporterTests
|
||||
manifest.TotalArtifacts.Should().Be(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToStreamAsync_IncludesRuntimeWitnessTriplet_WhenConfigured()
|
||||
{
|
||||
// Arrange
|
||||
var bundleData = CreateTestBundleData() with
|
||||
{
|
||||
RuntimeWitnesses = CreateRuntimeWitnessArtifacts()
|
||||
};
|
||||
_dataProviderMock
|
||||
.Setup(x => x.LoadBundleDataAsync("test-bundle", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundleData);
|
||||
|
||||
var request = new ExportRequest
|
||||
{
|
||||
BundleId = "test-bundle",
|
||||
Configuration = new ExportConfiguration { IncludeRuntimeWitnesses = true }
|
||||
};
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportToStreamAsync(request, stream, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.RuntimeWitnesses.Should().HaveCount(3);
|
||||
result.Manifest.RuntimeWitnesses.Select(a => a.WitnessRole).Should().BeEquivalentTo(
|
||||
[
|
||||
RuntimeWitnessArtifactRoles.Trace,
|
||||
RuntimeWitnessArtifactRoles.Dsse,
|
||||
RuntimeWitnessArtifactRoles.SigstoreBundle
|
||||
]);
|
||||
result.Manifest.RuntimeWitnesses.Should().OnlyContain(a => a.WitnessId == "wit:sha256:runtime-001");
|
||||
result.Manifest.RuntimeWitnesses.Should().OnlyContain(a => a.WitnessIndex != null);
|
||||
|
||||
stream.Position = 0;
|
||||
var entries = await ExtractTarGzEntries(stream);
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.json");
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.dsse.json");
|
||||
entries.Should().Contain("runtime-witnesses/wit-001/trace.sigstore.json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToStreamAsync_ExcludesRuntimeWitnessTriplet_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var bundleData = CreateTestBundleData() with
|
||||
{
|
||||
RuntimeWitnesses = CreateRuntimeWitnessArtifacts()
|
||||
};
|
||||
_dataProviderMock
|
||||
.Setup(x => x.LoadBundleDataAsync("test-bundle", null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(bundleData);
|
||||
|
||||
var request = new ExportRequest
|
||||
{
|
||||
BundleId = "test-bundle",
|
||||
Configuration = new ExportConfiguration { IncludeRuntimeWitnesses = false }
|
||||
};
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportToStreamAsync(request, stream, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.RuntimeWitnesses.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportRequest_RequiresBundleId()
|
||||
{
|
||||
@@ -388,4 +456,61 @@ public class TarGzBundleExporterTests
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BundleArtifact> CreateRuntimeWitnessArtifacts()
|
||||
{
|
||||
var index = new RuntimeWitnessIndexKey
|
||||
{
|
||||
BuildId = "gnu-build-id:runtime-test",
|
||||
KernelRelease = "6.8.0-45-generic",
|
||||
ProbeId = "probe-runtime-core",
|
||||
PolicyRunId = "policy-run-42"
|
||||
};
|
||||
|
||||
return
|
||||
[
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"witness_id\":\"wit:sha256:runtime-001\"}"),
|
||||
MediaType = BundleMediaTypes.RuntimeWitnessTrace,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Trace,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.dsse.json",
|
||||
"runtime-witnesses/wit-001/trace.sigstore.json"
|
||||
]
|
||||
},
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.dsse.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpydW50aW1lLTAwMSJ9\",\"signatures\":[{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}]}"),
|
||||
MediaType = BundleMediaTypes.DsseEnvelope,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.Dsse,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.json",
|
||||
"runtime-witnesses/wit-001/trace.sigstore.json"
|
||||
]
|
||||
},
|
||||
new BundleArtifact
|
||||
{
|
||||
FileName = "wit-001/trace.sigstore.json",
|
||||
Content = Encoding.UTF8.GetBytes("{\"mediaType\":\"application/vnd.dev.sigstore.bundle.v0.3+json\",\"verificationMaterial\":{\"publicKey\":{\"rawBytes\":\"cHVibGlj\"}},\"dsseEnvelope\":{\"payloadType\":\"application/vnd.stellaops.witness.v1+json\",\"payload\":\"eyJ3aXRuZXNzX2lkIjoid2l0OnNoYTI1NjpydW50aW1lLTAwMSJ9\",\"signatures\":[{\"keyid\":\"runtime-key\",\"sig\":\"c2ln\"}]}}"),
|
||||
MediaType = BundleMediaTypes.SigstoreBundleV03,
|
||||
WitnessId = "wit:sha256:runtime-001",
|
||||
WitnessRole = RuntimeWitnessArtifactRoles.SigstoreBundle,
|
||||
WitnessIndex = index,
|
||||
LinkedArtifacts =
|
||||
[
|
||||
"runtime-witnesses/wit-001/trace.json",
|
||||
"runtime-witnesses/wit-001/trace.dsse.json"
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ public class VerifyScriptGeneratorTests
|
||||
readme.Should().Contain("SBOMs");
|
||||
readme.Should().Contain("VEX Statements");
|
||||
readme.Should().Contain("Attestations");
|
||||
readme.Should().Contain("Runtime Witness Artifacts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -228,6 +229,7 @@ public class VerifyScriptGeneratorTests
|
||||
readme.Should().Contain("sboms/");
|
||||
readme.Should().Contain("vex/");
|
||||
readme.Should().Contain("attestations/");
|
||||
readme.Should().Contain("runtime-witnesses/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user