update evidence bundle to include new evidence types and implement ProofSpine integration
Some checks failed
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Manifest Integrity / Validate Pack Fixtures (push) Has been cancelled
Manifest Integrity / Validate Schema Integrity (push) Has been cancelled
Manifest Integrity / Validate Contract Documents (push) Has been cancelled
Manifest Integrity / Audit SHA256SUMS Files (push) Has been cancelled
Manifest Integrity / Verify Merkle Roots (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
sm-remote-ci / build-and-test (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-15 09:15:30 +02:00
parent 8c8f0c632d
commit 505fe7a885
49 changed files with 4756 additions and 551 deletions

View File

@@ -0,0 +1,17 @@
using Microsoft.Extensions.Options;
using StellaOps.Scanner.ProofSpine.Options;
namespace StellaOps.Scanner.ProofSpine;
public sealed class DefaultCryptoProfile : ICryptoProfile
{
private readonly IOptions<ProofSpineDsseSigningOptions> _options;
public DefaultCryptoProfile(IOptions<ProofSpineDsseSigningOptions> options)
=> _options = options ?? throw new ArgumentNullException(nameof(options));
public string KeyId => _options.Value.KeyId;
public string Algorithm => _options.Value.Algorithm;
}

View File

@@ -0,0 +1,47 @@
using System.Text;
namespace StellaOps.Scanner.ProofSpine;
internal static class DssePreAuthEncoding
{
private const string Prefix = "DSSEv1";
private const byte Space = 0x20;
public static byte[] Build(string payloadType, ReadOnlySpan<byte> payload)
{
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
var typeLenBytes = Encoding.UTF8.GetBytes(typeBytes.Length.ToString());
var payloadLenBytes = Encoding.UTF8.GetBytes(payload.Length.ToString());
var totalLength = Prefix.Length
+ 1 + typeLenBytes.Length
+ 1 + typeBytes.Length
+ 1 + payloadLenBytes.Length
+ 1 + payload.Length;
var buffer = new byte[totalLength];
var offset = 0;
Encoding.UTF8.GetBytes(Prefix, buffer.AsSpan(offset));
offset += Prefix.Length;
buffer[offset++] = Space;
typeLenBytes.CopyTo(buffer.AsSpan(offset));
offset += typeLenBytes.Length;
buffer[offset++] = Space;
typeBytes.CopyTo(buffer.AsSpan(offset));
offset += typeBytes.Length;
buffer[offset++] = Space;
payloadLenBytes.CopyTo(buffer.AsSpan(offset));
offset += payloadLenBytes.Length;
buffer[offset++] = Space;
payload.CopyTo(buffer.AsSpan(offset));
return buffer;
}
}

View File

@@ -0,0 +1,144 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
using StellaOps.Scanner.ProofSpine.Options;
namespace StellaOps.Scanner.ProofSpine;
public sealed class HmacDsseSigningService : IDsseSigningService
{
private readonly IOptions<ProofSpineDsseSigningOptions> _options;
private readonly ICryptoHmac _cryptoHmac;
private readonly ICryptoHash _cryptoHash;
public HmacDsseSigningService(
IOptions<ProofSpineDsseSigningOptions> options,
ICryptoHmac cryptoHmac,
ICryptoHash cryptoHash)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_cryptoHmac = cryptoHmac ?? throw new ArgumentNullException(nameof(cryptoHmac));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
}
public Task<DsseEnvelope> SignAsync(
object payload,
string payloadType,
ICryptoProfile cryptoProfile,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(payload);
ArgumentNullException.ThrowIfNull(cryptoProfile);
cancellationToken.ThrowIfCancellationRequested();
var payloadBytes = CanonicalJson.SerializeToUtf8Bytes(payload);
var pae = DssePreAuthEncoding.Build(payloadType, payloadBytes);
var (signatureBytes, signatureKeyId) = ResolveSignature(pae, cryptoProfile.KeyId);
var envelope = new DsseEnvelope(
payloadType,
Convert.ToBase64String(payloadBytes),
new[] { new DsseSignature(signatureKeyId, Convert.ToBase64String(signatureBytes)) });
return Task.FromResult(envelope);
}
public Task<DsseVerificationOutcome> VerifyAsync(DsseEnvelope envelope, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
cancellationToken.ThrowIfCancellationRequested();
if (envelope.Signatures is null || envelope.Signatures.Count == 0)
{
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_missing_signatures"));
}
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
{
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_payload_not_base64"));
}
var pae = DssePreAuthEncoding.Build(envelope.PayloadType, payloadBytes);
var expected = ComputeExpectedSignature(pae);
var keyId = _options.Value.KeyId;
foreach (var signature in envelope.Signatures)
{
if (!string.Equals(signature.KeyId, keyId, StringComparison.Ordinal))
{
continue;
}
if (!TryDecodeBase64(signature.Sig, out var provided))
{
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_sig_not_base64"));
}
if (CryptographicOperations.FixedTimeEquals(expected.SignatureBytes, provided))
{
return Task.FromResult(new DsseVerificationOutcome(true, expected.IsTrusted, failureReason: null));
}
return Task.FromResult(new DsseVerificationOutcome(false, expected.IsTrusted, "dsse_sig_mismatch"));
}
return Task.FromResult(new DsseVerificationOutcome(false, false, "dsse_key_not_trusted"));
}
private (byte[] SignatureBytes, string KeyId) ResolveSignature(ReadOnlySpan<byte> pae, string keyId)
{
var options = _options.Value;
if (string.Equals(options.Mode, "hmac", StringComparison.OrdinalIgnoreCase)
&& TryDecodeBase64(options.SecretBase64, out var secret))
{
return (_cryptoHmac.ComputeHmacForPurpose(secret, pae, HmacPurpose.Signing), keyId);
}
if (options.AllowDeterministicFallback)
{
return (_cryptoHash.ComputeHashForPurpose(pae, HashPurpose.Attestation), keyId);
}
throw new InvalidOperationException(
"ProofSpine DSSE signing is not configured (mode=hmac requires secretBase64) and deterministic fallback is disabled.");
}
private (byte[] SignatureBytes, bool IsTrusted) ComputeExpectedSignature(ReadOnlySpan<byte> pae)
{
var options = _options.Value;
if (string.Equals(options.Mode, "hmac", StringComparison.OrdinalIgnoreCase)
&& TryDecodeBase64(options.SecretBase64, out var secret))
{
return (_cryptoHmac.ComputeHmacForPurpose(secret, pae, HmacPurpose.Signing), true);
}
if (options.AllowDeterministicFallback)
{
return (_cryptoHash.ComputeHashForPurpose(pae, HashPurpose.Attestation), false);
}
return (Array.Empty<byte>(), false);
}
private static bool TryDecodeBase64(string? value, out byte[] bytes)
{
if (string.IsNullOrWhiteSpace(value))
{
bytes = Array.Empty<byte>();
return false;
}
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
}

View File

@@ -0,0 +1,47 @@
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.ProofSpine;
/// <summary>
/// Service for DSSE (Dead Simple Signing Envelope) signing operations.
/// </summary>
public interface IDsseSigningService
{
/// <summary>
/// Signs a payload and returns a DSSE envelope.
/// </summary>
Task<DsseEnvelope> SignAsync(
object payload,
string payloadType,
ICryptoProfile cryptoProfile,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE envelope signature.
/// </summary>
Task<DsseVerificationOutcome> VerifyAsync(
DsseEnvelope envelope,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Cryptographic profile for signing operations.
/// </summary>
public interface ICryptoProfile
{
/// <summary>
/// Key identifier.
/// </summary>
string KeyId { get; }
/// <summary>
/// Signing algorithm identifier (e.g., "hs256", "ed25519").
/// </summary>
string Algorithm { get; }
}
public sealed record DsseVerificationOutcome(
bool IsValid,
bool IsTrusted,
string? FailureReason);

View File

@@ -0,0 +1,53 @@
namespace StellaOps.Scanner.ProofSpine;
/// <summary>
/// Repository for ProofSpine persistence and queries.
/// </summary>
public interface IProofSpineRepository
{
/// <summary>
/// Gets a ProofSpine by its ID.
/// </summary>
Task<ProofSpine?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a ProofSpine by its decision criteria.
/// </summary>
Task<ProofSpine?> GetByDecisionAsync(
string artifactId,
string vulnId,
string policyProfileId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all ProofSpines for a scan run.
/// </summary>
Task<IReadOnlyList<ProofSpine>> GetByScanRunAsync(
string scanRunId,
CancellationToken cancellationToken = default);
/// <summary>
/// Saves a ProofSpine.
/// </summary>
Task<ProofSpine> SaveAsync(ProofSpine spine, CancellationToken cancellationToken = default);
/// <summary>
/// Supersedes an old spine with a new one.
/// </summary>
Task SupersedeAsync(
string oldSpineId,
string newSpineId,
string reason,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all segments for a spine.
/// </summary>
Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(
string spineId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
string scanRunId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,25 @@
namespace StellaOps.Scanner.ProofSpine.Options;
public sealed class ProofSpineDsseSigningOptions
{
public const string SectionName = "scanner:proofSpine:dsse";
/// <summary>
/// Signing mode: "hmac" or "deterministic".
/// </summary>
public string Mode { get; set; } = "deterministic";
public string KeyId { get; set; } = "scanner-deterministic";
public string Algorithm { get; set; } = "hs256";
/// <summary>
/// Base64-encoded secret key used when <see cref="Mode"/> is "hmac".
/// </summary>
public string? SecretBase64 { get; set; }
= null;
public bool AllowDeterministicFallback { get; set; }
= true;
}

View File

@@ -0,0 +1,442 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.ProofSpine;
/// <summary>
/// Builds ProofSpine chains from evidence segments.
/// Ensures deterministic ordering and cryptographic chaining.
/// </summary>
public sealed class ProofSpineBuilder
{
public const string DefaultSegmentPayloadType = "application/vnd.stellaops.proofspine.segment+json";
private readonly List<ProofSegmentInput> _segments = new();
private readonly IDsseSigningService _signer;
private readonly ICryptoProfile _cryptoProfile;
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private string? _artifactId;
private string? _vulnerabilityId;
private string? _policyProfileId;
private string? _scanRunId;
private string _segmentPayloadType = DefaultSegmentPayloadType;
private string? _verdict;
private string? _verdictReason;
public ProofSpineBuilder(
IDsseSigningService signer,
ICryptoProfile cryptoProfile,
ICryptoHash cryptoHash,
TimeProvider timeProvider)
{
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
_cryptoProfile = cryptoProfile ?? throw new ArgumentNullException(nameof(cryptoProfile));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
public ProofSpineBuilder WithSegmentPayloadType(string payloadType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(payloadType);
_segmentPayloadType = payloadType.Trim();
return this;
}
public ProofSpineBuilder ForArtifact(string artifactId)
{
_artifactId = artifactId;
return this;
}
public ProofSpineBuilder ForVulnerability(string vulnId)
{
_vulnerabilityId = vulnId;
return this;
}
public ProofSpineBuilder WithPolicyProfile(string profileId)
{
_policyProfileId = profileId;
return this;
}
public ProofSpineBuilder WithScanRun(string scanRunId)
{
_scanRunId = scanRunId;
return this;
}
/// <summary>
/// Adds an SBOM slice segment showing component relevance.
/// </summary>
public ProofSpineBuilder AddSbomSlice(
string sbomDigest,
IReadOnlyList<string> relevantPurls,
string toolId,
string toolVersion)
{
var sortedPurls = relevantPurls
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.OrderBy(p => p, StringComparer.Ordinal)
.ToArray();
var input = new SbomSliceInput(sbomDigest, sortedPurls);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.SbomSlice,
ComputeCanonicalHash(input),
ComputeCanonicalHash(sortedPurls),
input,
toolId,
toolVersion));
return this;
}
/// <summary>
/// Adds a vulnerability match segment.
/// </summary>
public ProofSpineBuilder AddMatch(
string vulnId,
string purl,
string matchedVersion,
string matchReason,
string toolId,
string toolVersion)
{
var input = new MatchInput(vulnId, purl, matchedVersion);
var result = new MatchResult(matchReason);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.Match,
ComputeCanonicalHash(input),
ComputeCanonicalHash(result),
new MatchPayload(input, result),
toolId,
toolVersion));
return this;
}
/// <summary>
/// Adds a reachability analysis segment.
/// </summary>
public ProofSpineBuilder AddReachability(
string callgraphDigest,
string latticeState,
double confidence,
IReadOnlyList<string>? pathWitness,
string toolId,
string toolVersion)
{
var witness = pathWitness?.Where(s => !string.IsNullOrWhiteSpace(s)).Select(s => s.Trim()).ToArray();
var input = new ReachabilityInput(callgraphDigest);
var result = new ReachabilityResult(latticeState, confidence, witness);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.Reachability,
ComputeCanonicalHash(input),
ComputeCanonicalHash(result),
new ReachabilityPayload(input, result),
toolId,
toolVersion));
return this;
}
/// <summary>
/// Adds a guard analysis segment (feature flags, config gates).
/// </summary>
public ProofSpineBuilder AddGuardAnalysis(
IReadOnlyList<GuardCondition> guards,
bool allGuardsPassed,
string toolId,
string toolVersion)
{
var normalized = guards
.Where(g => g is not null)
.Select(g => new GuardCondition(g.Name.Trim(), g.Type.Trim(), g.Value.Trim(), g.Passed))
.OrderBy(g => g.Name, StringComparer.Ordinal)
.ThenBy(g => g.Type, StringComparer.Ordinal)
.ThenBy(g => g.Value, StringComparer.Ordinal)
.ToArray();
var input = new GuardAnalysisInput(normalized);
var result = new GuardAnalysisResult(allGuardsPassed);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.GuardAnalysis,
ComputeCanonicalHash(input),
ComputeCanonicalHash(result),
new GuardAnalysisPayload(input, result),
toolId,
toolVersion));
return this;
}
/// <summary>
/// Adds runtime observation evidence.
/// </summary>
public ProofSpineBuilder AddRuntimeObservation(
string runtimeFactsDigest,
bool wasObserved,
int hitCount,
string toolId,
string toolVersion)
{
var input = new RuntimeObservationInput(runtimeFactsDigest);
var result = new RuntimeObservationResult(wasObserved, hitCount);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.RuntimeObservation,
ComputeCanonicalHash(input),
ComputeCanonicalHash(result),
new RuntimeObservationPayload(input, result),
toolId,
toolVersion));
return this;
}
/// <summary>
/// Adds a policy evaluation segment (final verdict).
/// </summary>
public ProofSpineBuilder AddPolicyEval(
string policyDigest,
IReadOnlyDictionary<string, string> factors,
string verdict,
string verdictReason,
string toolId,
string toolVersion)
{
var normalizedFactors = factors
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key))
.OrderBy(pair => pair.Key.Trim(), StringComparer.Ordinal)
.Select(pair => new PolicyFactor(pair.Key.Trim(), pair.Value?.Trim() ?? string.Empty))
.ToArray();
var input = new PolicyEvalInput(policyDigest, normalizedFactors);
var result = new PolicyEvalResult(verdict, verdictReason);
_segments.Add(new ProofSegmentInput(
ProofSegmentType.PolicyEval,
ComputeCanonicalHash(input),
ComputeCanonicalHash(result),
new PolicyEvalPayload(input, result),
toolId,
toolVersion));
_verdict = verdict;
_verdictReason = verdictReason;
return this;
}
/// <summary>
/// Builds the final ProofSpine with chained, signed segments.
/// </summary>
public async Task<ProofSpine> BuildAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ValidateBuilder();
var ordered = _segments
.OrderBy(s => (int)s.Type)
.ThenBy(s => s.InputHash, StringComparer.Ordinal)
.ThenBy(s => s.ResultHash, StringComparer.Ordinal)
.ToList();
var builtSegments = new List<ProofSegment>(ordered.Count);
string? prevHash = null;
for (var i = 0; i < ordered.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var input = ordered[i];
var createdAt = _timeProvider.GetUtcNow();
var segmentId = ComputeSegmentId(input.Type, i, input.InputHash, input.ResultHash, prevHash);
var signedPayload = new ProofSegmentPayload(
SegmentType: input.Type.ToString(),
Index: i,
InputHash: input.InputHash,
ResultHash: input.ResultHash,
PrevSegmentHash: prevHash,
Payload: input.Payload,
ToolId: input.ToolId,
ToolVersion: input.ToolVersion,
CreatedAt: createdAt);
var envelope = await _signer.SignAsync(
signedPayload,
_segmentPayloadType,
_cryptoProfile,
cancellationToken).ConfigureAwait(false);
var segment = new ProofSegment(
segmentId,
input.Type,
i,
input.InputHash,
input.ResultHash,
prevHash,
envelope,
input.ToolId,
input.ToolVersion,
ProofSegmentStatus.Verified,
createdAt);
builtSegments.Add(segment);
prevHash = segment.ResultHash;
}
var rootHash = ComputeRootHash(builtSegments.Select(s => s.ResultHash));
var spineId = ComputeSpineId(_artifactId!, _vulnerabilityId!, _policyProfileId!, rootHash);
return new ProofSpine(
spineId,
_artifactId!,
_vulnerabilityId!,
_policyProfileId!,
builtSegments.ToImmutableArray(),
_verdict ?? "under_investigation",
_verdictReason ?? "No policy evaluation completed",
rootHash,
_scanRunId!,
_timeProvider.GetUtcNow(),
SupersededBySpineId: null);
}
private void ValidateBuilder()
{
if (string.IsNullOrWhiteSpace(_artifactId))
throw new InvalidOperationException("ArtifactId is required.");
if (string.IsNullOrWhiteSpace(_vulnerabilityId))
throw new InvalidOperationException("VulnerabilityId is required.");
if (string.IsNullOrWhiteSpace(_policyProfileId))
throw new InvalidOperationException("PolicyProfileId is required.");
if (string.IsNullOrWhiteSpace(_scanRunId))
throw new InvalidOperationException("ScanRunId is required.");
if (_segments.Count == 0)
throw new InvalidOperationException("At least one segment is required.");
}
private string ComputeCanonicalHash<T>(T value)
{
var bytes = CanonicalJson.SerializeToUtf8Bytes(value);
return _cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Content);
}
private string ComputeRootHash(IEnumerable<string> segmentResultHashes)
{
var concat = string.Join(":", segmentResultHashes);
return _cryptoHash.ComputePrefixedHashForPurpose(Encoding.UTF8.GetBytes(concat), HashPurpose.Content);
}
private string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
{
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private string ComputeSegmentId(ProofSegmentType type, int index, string inputHash, string resultHash, string? prevHash)
{
var data = $"{type}:{index}:{inputHash}:{resultHash}:{prevHash ?? "null"}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private sealed record ProofSegmentInput(
ProofSegmentType Type,
string InputHash,
string ResultHash,
object Payload,
string ToolId,
string ToolVersion);
private sealed record SbomSliceInput(
[property: JsonPropertyName("sbomDigest")] string SbomDigest,
[property: JsonPropertyName("relevantPurls")] IReadOnlyList<string> RelevantPurls);
private sealed record MatchInput(
[property: JsonPropertyName("vulnId")] string VulnId,
[property: JsonPropertyName("purl")] string Purl,
[property: JsonPropertyName("matchedVersion")] string MatchedVersion);
private sealed record MatchResult(
[property: JsonPropertyName("matchReason")] string MatchReason);
private sealed record MatchPayload(
[property: JsonPropertyName("input")] MatchInput Input,
[property: JsonPropertyName("result")] MatchResult Result);
private sealed record ReachabilityInput(
[property: JsonPropertyName("callgraphDigest")] string CallgraphDigest);
private sealed record ReachabilityResult(
[property: JsonPropertyName("latticeState")] string LatticeState,
[property: JsonPropertyName("confidence")] double Confidence,
[property: JsonPropertyName("pathWitness")] IReadOnlyList<string>? PathWitness);
private sealed record ReachabilityPayload(
[property: JsonPropertyName("input")] ReachabilityInput Input,
[property: JsonPropertyName("result")] ReachabilityResult Result);
private sealed record GuardAnalysisInput(
[property: JsonPropertyName("guards")] IReadOnlyList<GuardCondition> Guards);
private sealed record GuardAnalysisResult(
[property: JsonPropertyName("allGuardsPassed")] bool AllGuardsPassed);
private sealed record GuardAnalysisPayload(
[property: JsonPropertyName("input")] GuardAnalysisInput Input,
[property: JsonPropertyName("result")] GuardAnalysisResult Result);
private sealed record RuntimeObservationInput(
[property: JsonPropertyName("runtimeFactsDigest")] string RuntimeFactsDigest);
private sealed record RuntimeObservationResult(
[property: JsonPropertyName("wasObserved")] bool WasObserved,
[property: JsonPropertyName("hitCount")] int HitCount);
private sealed record RuntimeObservationPayload(
[property: JsonPropertyName("input")] RuntimeObservationInput Input,
[property: JsonPropertyName("result")] RuntimeObservationResult Result);
private sealed record PolicyFactor(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("value")] string Value);
private sealed record PolicyEvalInput(
[property: JsonPropertyName("policyDigest")] string PolicyDigest,
[property: JsonPropertyName("factors")] IReadOnlyList<PolicyFactor> Factors);
private sealed record PolicyEvalResult(
[property: JsonPropertyName("verdict")] string Verdict,
[property: JsonPropertyName("verdictReason")] string VerdictReason);
private sealed record PolicyEvalPayload(
[property: JsonPropertyName("input")] PolicyEvalInput Input,
[property: JsonPropertyName("result")] PolicyEvalResult Result);
private sealed record ProofSegmentPayload(
[property: JsonPropertyName("segmentType")] string SegmentType,
[property: JsonPropertyName("index")] int Index,
[property: JsonPropertyName("inputHash")] string InputHash,
[property: JsonPropertyName("resultHash")] string ResultHash,
[property: JsonPropertyName("prevSegmentHash")] string? PrevSegmentHash,
[property: JsonPropertyName("payload")] object Payload,
[property: JsonPropertyName("toolId")] string ToolId,
[property: JsonPropertyName("toolVersion")] string ToolVersion,
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Scanner.ProofSpine;
public sealed record ProofSpineSummary(
string SpineId,
string ArtifactId,
string VulnerabilityId,
string Verdict,
int SegmentCount,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,188 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
using StellaOps.Replay.Core;
namespace StellaOps.Scanner.ProofSpine;
public sealed class ProofSpineVerifier
{
private static readonly JsonSerializerOptions SignedPayloadOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly IDsseSigningService _signingService;
private readonly ICryptoHash _cryptoHash;
public ProofSpineVerifier(IDsseSigningService signingService, ICryptoHash cryptoHash)
{
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
}
public async Task<ProofSpineVerificationResult> VerifyAsync(ProofSpine spine, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(spine);
cancellationToken.ThrowIfCancellationRequested();
var spineErrors = new List<string>();
var segments = spine.Segments ?? Array.Empty<ProofSegment>();
var segmentResults = new ProofSegmentVerificationResult[segments.Count];
string? prevHash = null;
for (var i = 0; i < segments.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var segment = segments[i];
var errors = new List<string>();
if (segment.Index != i)
{
errors.Add($"segment_index_mismatch:{segment.Index}->{i}");
}
if (i == 0)
{
if (segment.PrevSegmentHash is not null)
{
errors.Add("prev_hash_expected_null");
}
}
else if (!string.Equals(segment.PrevSegmentHash, prevHash, StringComparison.Ordinal))
{
errors.Add("prev_hash_mismatch");
}
var expectedSegmentId = ComputeSegmentId(segment.SegmentType, i, segment.InputHash, segment.ResultHash, prevHash);
if (!string.Equals(segment.SegmentId, expectedSegmentId, StringComparison.Ordinal))
{
errors.Add("segment_id_mismatch");
}
var dsseOutcome = await _signingService.VerifyAsync(segment.Envelope, cancellationToken).ConfigureAwait(false);
var status = dsseOutcome switch
{
{ IsValid: true, IsTrusted: true } => ProofSegmentStatus.Verified,
{ IsValid: true, IsTrusted: false } => ProofSegmentStatus.Untrusted,
{ IsValid: false, FailureReason: "dsse_key_not_trusted" } => ProofSegmentStatus.Untrusted,
_ => ProofSegmentStatus.Invalid
};
if (!dsseOutcome.IsValid && !string.IsNullOrWhiteSpace(dsseOutcome.FailureReason))
{
errors.Add(dsseOutcome.FailureReason);
}
if (!TryReadSignedPayload(segment.Envelope, out var signed, out var payloadError))
{
errors.Add(payloadError ?? "signed_payload_invalid");
status = ProofSegmentStatus.Invalid;
}
else
{
if (!string.Equals(signed.SegmentType, segment.SegmentType.ToString(), StringComparison.OrdinalIgnoreCase))
{
errors.Add("signed_segment_type_mismatch");
status = ProofSegmentStatus.Invalid;
}
if (signed.Index != i)
{
errors.Add("signed_index_mismatch");
status = ProofSegmentStatus.Invalid;
}
if (!string.Equals(signed.InputHash, segment.InputHash, StringComparison.Ordinal)
|| !string.Equals(signed.ResultHash, segment.ResultHash, StringComparison.Ordinal)
|| !string.Equals(signed.PrevSegmentHash, segment.PrevSegmentHash, StringComparison.Ordinal))
{
errors.Add("signed_fields_mismatch");
status = ProofSegmentStatus.Invalid;
}
}
segmentResults[i] = new ProofSegmentVerificationResult(segment.SegmentId, status, errors.ToArray());
prevHash = segment.ResultHash;
}
var expectedRootHash = ComputeRootHash(segments.Select(s => s.ResultHash));
if (!string.Equals(spine.RootHash, expectedRootHash, StringComparison.Ordinal))
{
spineErrors.Add("root_hash_mismatch");
}
var expectedSpineId = ComputeSpineId(spine.ArtifactId, spine.VulnerabilityId, spine.PolicyProfileId, expectedRootHash);
if (!string.Equals(spine.SpineId, expectedSpineId, StringComparison.Ordinal))
{
spineErrors.Add("spine_id_mismatch");
}
var ok = spineErrors.Count == 0 && segmentResults.All(r => r.Status is ProofSegmentStatus.Verified or ProofSegmentStatus.Untrusted);
return new ProofSpineVerificationResult(ok, spineErrors.ToArray(), segmentResults);
}
private static bool TryReadSignedPayload(DsseEnvelope envelope, out SignedProofSegmentPayload payload, out string? error)
{
error = null;
payload = default!;
try
{
var bytes = Convert.FromBase64String(envelope.Payload);
payload = JsonSerializer.Deserialize<SignedProofSegmentPayload>(bytes, SignedPayloadOptions)
?? throw new InvalidOperationException("signed_payload_null");
return true;
}
catch (FormatException)
{
error = "dsse_payload_not_base64";
return false;
}
catch (Exception)
{
error = "signed_payload_json_invalid";
return false;
}
}
private string ComputeRootHash(IEnumerable<string> segmentResultHashes)
{
var concat = string.Join(":", segmentResultHashes);
return _cryptoHash.ComputePrefixedHashForPurpose(Encoding.UTF8.GetBytes(concat), HashPurpose.Content);
}
private string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
{
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private string ComputeSegmentId(ProofSegmentType type, int index, string inputHash, string resultHash, string? prevHash)
{
var data = $"{type}:{index}:{inputHash}:{resultHash}:{prevHash ?? "null"}";
var hex = _cryptoHash.ComputeHashHexForPurpose(Encoding.UTF8.GetBytes(data), HashPurpose.Content);
return hex[..32];
}
private sealed record SignedProofSegmentPayload(
[property: JsonPropertyName("segmentType")] string SegmentType,
[property: JsonPropertyName("index")] int Index,
[property: JsonPropertyName("inputHash")] string InputHash,
[property: JsonPropertyName("resultHash")] string ResultHash,
[property: JsonPropertyName("prevSegmentHash")] string? PrevSegmentHash);
}
public sealed record ProofSpineVerificationResult(
bool IsValid,
IReadOnlyList<string> Errors,
IReadOnlyList<ProofSegmentVerificationResult> Segments);
public sealed record ProofSegmentVerificationResult(
string SegmentId,
ProofSegmentStatus Status,
IReadOnlyList<string> Errors);