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
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
namespace StellaOps.Scanner.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Service for DSSE (Dead Simple Signing Envelope) signing operations.
|
||||
@@ -13,13 +12,14 @@ public interface IDsseSigningService
|
||||
/// </summary>
|
||||
Task<DsseEnvelope> SignAsync(
|
||||
object payload,
|
||||
string payloadType,
|
||||
ICryptoProfile cryptoProfile,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a DSSE envelope signature.
|
||||
/// </summary>
|
||||
Task<bool> VerifyAsync(
|
||||
Task<DsseVerificationOutcome> VerifyAsync(
|
||||
DsseEnvelope envelope,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -35,7 +35,13 @@ public interface ICryptoProfile
|
||||
string KeyId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signing algorithm (e.g., "ed25519", "ecdsa-p256").
|
||||
/// Signing algorithm identifier (e.g., "hs256", "ed25519").
|
||||
/// </summary>
|
||||
string Algorithm { get; }
|
||||
}
|
||||
|
||||
public sealed record DsseVerificationOutcome(
|
||||
bool IsValid,
|
||||
bool IsTrusted,
|
||||
string? FailureReason);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
namespace StellaOps.Scanner.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for ProofSpine persistence and queries.
|
||||
@@ -50,4 +46,8 @@ public interface IProofSpineRepository
|
||||
Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(
|
||||
string spineId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
|
||||
string scanRunId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Scanner.ProofSpine;
|
||||
|
||||
public sealed record ProofSpineSummary(
|
||||
string SpineId,
|
||||
string ArtifactId,
|
||||
string VulnerabilityId,
|
||||
string Verdict,
|
||||
int SegmentCount,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Builds ProofSpine chains from evidence segments.
|
||||
/// Ensures deterministic ordering and cryptographic chaining.
|
||||
/// </summary>
|
||||
public sealed class ProofSpineBuilder
|
||||
{
|
||||
private readonly List<ProofSegmentInput> _segments = new();
|
||||
private readonly IDsseSigningService _signer;
|
||||
private readonly ICryptoProfile _cryptoProfile;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private string? _artifactId;
|
||||
private string? _vulnerabilityId;
|
||||
private string? _policyProfileId;
|
||||
private string? _scanRunId;
|
||||
|
||||
public ProofSpineBuilder(
|
||||
IDsseSigningService signer,
|
||||
ICryptoProfile cryptoProfile,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_signer = signer;
|
||||
_cryptoProfile = cryptoProfile;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
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 input = new SbomSliceInput(sbomDigest, relevantPurls);
|
||||
var inputHash = ComputeCanonicalHash(input);
|
||||
var resultHash = ComputeCanonicalHash(relevantPurls);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.SbomSlice,
|
||||
inputHash,
|
||||
resultHash,
|
||||
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 { Input = input, Result = 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 input = new ReachabilityInput(callgraphDigest);
|
||||
var result = new ReachabilityResult(latticeState, confidence, pathWitness);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.Reachability,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = 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 input = new GuardAnalysisInput(guards);
|
||||
var result = new GuardAnalysisResult(allGuardsPassed);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.GuardAnalysis,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = 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 { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds policy evaluation segment with final verdict.
|
||||
/// </summary>
|
||||
public ProofSpineBuilder AddPolicyEval(
|
||||
string policyDigest,
|
||||
string verdict,
|
||||
string verdictReason,
|
||||
IReadOnlyDictionary<string, object> factors,
|
||||
string toolId,
|
||||
string toolVersion)
|
||||
{
|
||||
var input = new PolicyEvalInput(policyDigest, factors);
|
||||
var result = new PolicyEvalResult(verdict, verdictReason);
|
||||
|
||||
_segments.Add(new ProofSegmentInput(
|
||||
ProofSegmentType.PolicyEval,
|
||||
ComputeCanonicalHash(input),
|
||||
ComputeCanonicalHash(result),
|
||||
new { Input = input, Result = result },
|
||||
toolId,
|
||||
toolVersion));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the final ProofSpine with chained, signed segments.
|
||||
/// </summary>
|
||||
public async Task<ProofSpine> BuildAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ValidateBuilder();
|
||||
|
||||
// Sort segments by type (predetermined order)
|
||||
var orderedSegments = _segments
|
||||
.OrderBy(s => (int)s.Type)
|
||||
.ToList();
|
||||
|
||||
var builtSegments = new List<ProofSegment>();
|
||||
string? prevHash = null;
|
||||
|
||||
for (var i = 0; i < orderedSegments.Count; i++)
|
||||
{
|
||||
var input = orderedSegments[i];
|
||||
var createdAt = _timeProvider.GetUtcNow();
|
||||
|
||||
// Build payload for signing
|
||||
var payload = new ProofSegmentPayload(
|
||||
input.Type.ToString(),
|
||||
i,
|
||||
input.InputHash,
|
||||
input.ResultHash,
|
||||
prevHash,
|
||||
input.Payload,
|
||||
input.ToolId,
|
||||
input.ToolVersion,
|
||||
createdAt);
|
||||
|
||||
// Sign with DSSE
|
||||
var envelope = await _signer.SignAsync(
|
||||
payload,
|
||||
_cryptoProfile,
|
||||
cancellationToken);
|
||||
|
||||
var segmentId = ComputeSegmentId(input, i, prevHash);
|
||||
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;
|
||||
}
|
||||
|
||||
// Compute root hash = hash(concat of all segment result hashes)
|
||||
var rootHash = ComputeRootHash(builtSegments);
|
||||
|
||||
// Compute deterministic spine ID
|
||||
var spineId = ComputeSpineId(_artifactId!, _vulnerabilityId!, _policyProfileId!, rootHash);
|
||||
|
||||
// Extract verdict from policy eval segment
|
||||
var (verdict, verdictReason) = ExtractVerdict(builtSegments);
|
||||
|
||||
return new ProofSpine(
|
||||
spineId,
|
||||
_artifactId!,
|
||||
_vulnerabilityId!,
|
||||
_policyProfileId!,
|
||||
builtSegments.ToImmutableArray(),
|
||||
verdict,
|
||||
verdictReason,
|
||||
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 static string ComputeCanonicalHash(object input)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(input, CanonicalJsonOptions);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSegmentId(ProofSegmentInput input, int index, string? prevHash)
|
||||
{
|
||||
var data = $"{input.Type}:{index}:{input.InputHash}:{input.ResultHash}:{prevHash ?? "null"}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
||||
}
|
||||
|
||||
private static string ComputeRootHash(IEnumerable<ProofSegment> segments)
|
||||
{
|
||||
var concat = string.Join(":", segments.Select(s => s.ResultHash));
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(concat));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeSpineId(string artifactId, string vulnId, string profileId, string rootHash)
|
||||
{
|
||||
var data = $"{artifactId}:{vulnId}:{profileId}:{rootHash}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(data));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant()[..32];
|
||||
}
|
||||
|
||||
private static (string Verdict, string VerdictReason) ExtractVerdict(List<ProofSegment> segments)
|
||||
{
|
||||
// Default verdict if no policy eval segment
|
||||
return ("under_investigation", "No policy evaluation completed");
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
// Supporting input types
|
||||
internal sealed record ProofSegmentInput(
|
||||
ProofSegmentType Type,
|
||||
string InputHash,
|
||||
string ResultHash,
|
||||
object Payload,
|
||||
string ToolId,
|
||||
string ToolVersion);
|
||||
|
||||
internal sealed record SbomSliceInput(string SbomDigest, IReadOnlyList<string> RelevantPurls);
|
||||
internal sealed record MatchInput(string VulnId, string Purl, string MatchedVersion);
|
||||
internal sealed record MatchResult(string MatchReason);
|
||||
internal sealed record ReachabilityInput(string CallgraphDigest);
|
||||
internal sealed record ReachabilityResult(string LatticeState, double Confidence, IReadOnlyList<string>? PathWitness);
|
||||
internal sealed record GuardAnalysisInput(IReadOnlyList<GuardCondition> Guards);
|
||||
internal sealed record GuardAnalysisResult(bool AllGuardsPassed);
|
||||
internal sealed record RuntimeObservationInput(string RuntimeFactsDigest);
|
||||
internal sealed record RuntimeObservationResult(bool WasObserved, int HitCount);
|
||||
internal sealed record PolicyEvalInput(string PolicyDigest, IReadOnlyDictionary<string, object> Factors);
|
||||
internal sealed record PolicyEvalResult(string Verdict, string VerdictReason);
|
||||
internal sealed record ProofSegmentPayload(
|
||||
string SegmentType, int Index, string InputHash, string ResultHash,
|
||||
string? PrevSegmentHash, object Payload, string ToolId, string ToolVersion,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -1,86 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.ProofSpine;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a complete verifiable decision chain from SBOM to VEX verdict.
|
||||
/// </summary>
|
||||
public sealed record ProofSpine(
|
||||
string SpineId,
|
||||
string ArtifactId,
|
||||
string VulnerabilityId,
|
||||
string PolicyProfileId,
|
||||
IReadOnlyList<ProofSegment> Segments,
|
||||
string Verdict,
|
||||
string VerdictReason,
|
||||
string RootHash,
|
||||
string ScanRunId,
|
||||
DateTimeOffset CreatedAt,
|
||||
string? SupersededBySpineId);
|
||||
|
||||
/// <summary>
|
||||
/// A single evidence segment in the proof chain.
|
||||
/// </summary>
|
||||
public sealed record ProofSegment(
|
||||
string SegmentId,
|
||||
ProofSegmentType SegmentType,
|
||||
int Index,
|
||||
string InputHash,
|
||||
string ResultHash,
|
||||
string? PrevSegmentHash,
|
||||
DsseEnvelope Envelope,
|
||||
string ToolId,
|
||||
string ToolVersion,
|
||||
ProofSegmentStatus Status,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Segment types in execution order.
|
||||
/// </summary>
|
||||
public enum ProofSegmentType
|
||||
{
|
||||
SbomSlice = 1, // Component relevance extraction
|
||||
Match = 2, // SBOM-to-vulnerability mapping
|
||||
Reachability = 3, // Symbol reachability analysis
|
||||
GuardAnalysis = 4, // Config/feature flag gates
|
||||
RuntimeObservation = 5, // Runtime evidence correlation
|
||||
PolicyEval = 6 // Lattice decision computation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verification status of a segment.
|
||||
/// </summary>
|
||||
public enum ProofSegmentStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Verified = 1,
|
||||
Partial = 2, // Some evidence missing but chain valid
|
||||
Invalid = 3, // Signature verification failed
|
||||
Untrusted = 4 // Key not in trust store
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope wrapper for signed content.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope(
|
||||
string PayloadType,
|
||||
byte[] Payload,
|
||||
IReadOnlyList<DsseSignature> Signatures);
|
||||
|
||||
/// <summary>
|
||||
/// A signature in a DSSE envelope.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature(
|
||||
string KeyId,
|
||||
byte[] Sig);
|
||||
|
||||
/// <summary>
|
||||
/// Guard condition for feature flag or config gate analysis.
|
||||
/// </summary>
|
||||
public sealed record GuardCondition(
|
||||
string Name,
|
||||
string Type,
|
||||
string Value,
|
||||
bool Passed);
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Scanner.Core.Contracts;
|
||||
using StellaOps.Scanner.EntryTrace;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
@@ -73,6 +74,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<EntryTraceRepository>();
|
||||
services.AddScoped<RubyPackageInventoryRepository>();
|
||||
services.AddScoped<BunPackageInventoryRepository>();
|
||||
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- proof spine storage schema (startup migration)
|
||||
-- schema: created externally via search_path; tables unqualified for scanner schema compatibility
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proof_spines (
|
||||
spine_id TEXT PRIMARY KEY,
|
||||
artifact_id TEXT NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
policy_profile_id TEXT NOT NULL,
|
||||
verdict TEXT NOT NULL,
|
||||
verdict_reason TEXT,
|
||||
root_hash TEXT NOT NULL,
|
||||
scan_run_id TEXT NOT NULL,
|
||||
segment_count INT NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
superseded_by_spine_id TEXT REFERENCES proof_spines(spine_id),
|
||||
|
||||
CONSTRAINT proof_spines_unique_decision UNIQUE (artifact_id, vuln_id, policy_profile_id, root_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spines_lookup
|
||||
ON proof_spines(artifact_id, vuln_id, policy_profile_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spines_scan_run
|
||||
ON proof_spines(scan_run_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spines_created_at
|
||||
ON proof_spines(created_at_utc DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proof_segments (
|
||||
segment_id TEXT PRIMARY KEY,
|
||||
spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||
idx INT NOT NULL,
|
||||
segment_type TEXT NOT NULL,
|
||||
input_hash TEXT NOT NULL,
|
||||
result_hash TEXT NOT NULL,
|
||||
prev_segment_hash TEXT,
|
||||
envelope_json TEXT NOT NULL,
|
||||
tool_id TEXT NOT NULL,
|
||||
tool_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT proof_segments_unique_index UNIQUE (spine_id, idx)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_segments_spine_idx
|
||||
ON proof_segments(spine_id, idx);
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_segments_type
|
||||
ON proof_segments(segment_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS proof_spine_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
old_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||
new_spine_id TEXT NOT NULL REFERENCES proof_spines(spine_id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_old
|
||||
ON proof_spine_history(old_spine_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_new
|
||||
ON proof_spine_history(new_spine_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_proof_spine_history_created_at
|
||||
ON proof_spine_history(created_at_utc DESC);
|
||||
|
||||
@@ -3,4 +3,5 @@ namespace StellaOps.Scanner.Storage.Postgres.Migrations;
|
||||
internal static class MigrationIds
|
||||
{
|
||||
public const string CreateTables = "001_create_tables.sql";
|
||||
public const string ProofSpineTables = "002_proof_spine_tables.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using ProofSpineModel = StellaOps.Scanner.ProofSpine.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public sealed class PostgresProofSpineRepository : RepositoryBase<ScannerDataSource>, IProofSpineRepository
|
||||
{
|
||||
private const string Tenant = "";
|
||||
|
||||
private string SchemaName => DataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
|
||||
private string SpinesTable => $"{SchemaName}.proof_spines";
|
||||
|
||||
private string SegmentsTable => $"{SchemaName}.proof_segments";
|
||||
|
||||
private string HistoryTable => $"{SchemaName}.proof_spine_history";
|
||||
|
||||
private static readonly JsonSerializerOptions LenientJson = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresProofSpineRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresProofSpineRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ProofSpineModel?> GetByIdAsync(string spineId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||
verdict, verdict_reason, root_hash, scan_run_id,
|
||||
created_at_utc, superseded_by_spine_id
|
||||
FROM {SpinesTable}
|
||||
WHERE spine_id = @spine_id
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
|
||||
MapSpine,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<ProofSpineModel?> GetByDecisionAsync(
|
||||
string artifactId,
|
||||
string vulnId,
|
||||
string policyProfileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(vulnId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyProfileId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||
verdict, verdict_reason, root_hash, scan_run_id,
|
||||
created_at_utc, superseded_by_spine_id
|
||||
FROM {SpinesTable}
|
||||
WHERE artifact_id = @artifact_id
|
||||
AND vuln_id = @vuln_id
|
||||
AND policy_profile_id = @policy_profile_id
|
||||
ORDER BY created_at_utc DESC, spine_id DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "artifact_id", artifactId.Trim());
|
||||
AddParameter(cmd, "vuln_id", vulnId.Trim());
|
||||
AddParameter(cmd, "policy_profile_id", policyProfileId.Trim());
|
||||
},
|
||||
MapSpine,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProofSpineModel>> GetByScanRunAsync(
|
||||
string scanRunId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||
verdict, verdict_reason, root_hash, scan_run_id,
|
||||
created_at_utc, superseded_by_spine_id
|
||||
FROM {SpinesTable}
|
||||
WHERE scan_run_id = @scan_run_id
|
||||
ORDER BY created_at_utc DESC, spine_id DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
|
||||
MapSpine,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ProofSpineModel> SaveAsync(ProofSpineModel spine, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(spine);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (spine.Segments is null || spine.Segments.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("ProofSpine requires at least one segment.");
|
||||
}
|
||||
|
||||
var createdAt = spine.CreatedAt == default ? _timeProvider.GetUtcNow() : spine.CreatedAt;
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var insertSpine = $"""
|
||||
INSERT INTO {SpinesTable} (
|
||||
spine_id, artifact_id, vuln_id, policy_profile_id,
|
||||
verdict, verdict_reason, root_hash, scan_run_id,
|
||||
segment_count, created_at_utc, superseded_by_spine_id
|
||||
)
|
||||
VALUES (
|
||||
@spine_id, @artifact_id, @vuln_id, @policy_profile_id,
|
||||
@verdict, @verdict_reason, @root_hash, @scan_run_id,
|
||||
@segment_count, @created_at_utc, @superseded_by_spine_id
|
||||
)
|
||||
ON CONFLICT (spine_id) DO NOTHING
|
||||
""";
|
||||
|
||||
await using (var command = CreateCommand(insertSpine, connection))
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "spine_id", spine.SpineId);
|
||||
AddParameter(command, "artifact_id", spine.ArtifactId);
|
||||
AddParameter(command, "vuln_id", spine.VulnerabilityId);
|
||||
AddParameter(command, "policy_profile_id", spine.PolicyProfileId);
|
||||
AddParameter(command, "verdict", spine.Verdict);
|
||||
AddParameter(command, "verdict_reason", spine.VerdictReason);
|
||||
AddParameter(command, "root_hash", spine.RootHash);
|
||||
AddParameter(command, "scan_run_id", spine.ScanRunId);
|
||||
AddParameter(command, "segment_count", spine.Segments.Count);
|
||||
AddParameter(command, "created_at_utc", createdAt);
|
||||
AddParameter(command, "superseded_by_spine_id", spine.SupersededBySpineId);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var insertSegment = $"""
|
||||
INSERT INTO {SegmentsTable} (
|
||||
segment_id, spine_id, idx, segment_type, input_hash, result_hash, prev_segment_hash,
|
||||
envelope_json, tool_id, tool_version, status, created_at_utc
|
||||
)
|
||||
VALUES (
|
||||
@segment_id, @spine_id, @idx, @segment_type, @input_hash, @result_hash, @prev_segment_hash,
|
||||
@envelope_json, @tool_id, @tool_version, @status, @created_at_utc
|
||||
)
|
||||
ON CONFLICT (segment_id) DO NOTHING
|
||||
""";
|
||||
|
||||
foreach (var segment in spine.Segments.OrderBy(s => s.Index))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await using var command = CreateCommand(insertSegment, connection);
|
||||
command.Transaction = transaction;
|
||||
|
||||
AddParameter(command, "segment_id", segment.SegmentId);
|
||||
AddParameter(command, "spine_id", spine.SpineId);
|
||||
AddParameter(command, "idx", segment.Index);
|
||||
AddParameter(command, "segment_type", segment.SegmentType.ToString());
|
||||
AddParameter(command, "input_hash", segment.InputHash);
|
||||
AddParameter(command, "result_hash", segment.ResultHash);
|
||||
AddParameter(command, "prev_segment_hash", segment.PrevSegmentHash);
|
||||
AddParameter(command, "envelope_json", SerializeEnvelope(segment.Envelope));
|
||||
AddParameter(command, "tool_id", segment.ToolId);
|
||||
AddParameter(command, "tool_version", segment.ToolVersion);
|
||||
AddParameter(command, "status", segment.Status.ToString());
|
||||
AddParameter(command, "created_at_utc", segment.CreatedAt == default ? createdAt : segment.CreatedAt);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
return spine with { CreatedAt = createdAt };
|
||||
}
|
||||
|
||||
public async Task SupersedeAsync(
|
||||
string oldSpineId,
|
||||
string newSpineId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(oldSpineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newSpineId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(Tenant, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var update = $"""
|
||||
UPDATE {SpinesTable}
|
||||
SET superseded_by_spine_id = @new_spine_id
|
||||
WHERE spine_id = @old_spine_id
|
||||
""";
|
||||
|
||||
await using (var command = CreateCommand(update, connection))
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var insertHistory = $"""
|
||||
INSERT INTO {HistoryTable} (id, old_spine_id, new_spine_id, reason, created_at_utc)
|
||||
VALUES (@id, @old_spine_id, @new_spine_id, @reason, @created_at_utc)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""";
|
||||
|
||||
await using (var command = CreateCommand(insertHistory, connection))
|
||||
{
|
||||
command.Transaction = transaction;
|
||||
AddParameter(command, "id", Guid.NewGuid().ToString("N"));
|
||||
AddParameter(command, "old_spine_id", oldSpineId.Trim());
|
||||
AddParameter(command, "new_spine_id", newSpineId.Trim());
|
||||
AddParameter(command, "reason", reason);
|
||||
AddParameter(command, "created_at_utc", _timeProvider.GetUtcNow());
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProofSegment>> GetSegmentsAsync(string spineId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(spineId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT segment_id, segment_type, idx, input_hash, result_hash, prev_segment_hash,
|
||||
envelope_json, tool_id, tool_version, status, created_at_utc
|
||||
FROM {SegmentsTable}
|
||||
WHERE spine_id = @spine_id
|
||||
ORDER BY idx
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "spine_id", spineId.Trim()),
|
||||
MapSegment,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ProofSpineSummary>> GetSummariesByScanRunAsync(
|
||||
string scanRunId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanRunId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT spine_id, artifact_id, vuln_id, verdict, segment_count, created_at_utc
|
||||
FROM {SpinesTable}
|
||||
WHERE scan_run_id = @scan_run_id
|
||||
ORDER BY created_at_utc DESC, spine_id DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd => AddParameter(cmd, "scan_run_id", scanRunId.Trim()),
|
||||
MapSummary,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static ProofSpineModel MapSpine(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ProofSpineModel(
|
||||
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
|
||||
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
PolicyProfileId: reader.GetString(reader.GetOrdinal("policy_profile_id")),
|
||||
Segments: Array.Empty<ProofSegment>(),
|
||||
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
||||
VerdictReason: reader.IsDBNull(reader.GetOrdinal("verdict_reason"))
|
||||
? string.Empty
|
||||
: reader.GetString(reader.GetOrdinal("verdict_reason")),
|
||||
RootHash: reader.GetString(reader.GetOrdinal("root_hash")),
|
||||
ScanRunId: reader.GetString(reader.GetOrdinal("scan_run_id")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")),
|
||||
SupersededBySpineId: GetNullableString(reader, reader.GetOrdinal("superseded_by_spine_id")));
|
||||
}
|
||||
|
||||
private static ProofSegment MapSegment(NpgsqlDataReader reader)
|
||||
{
|
||||
var segmentTypeString = reader.GetString(reader.GetOrdinal("segment_type"));
|
||||
if (!Enum.TryParse<ProofSegmentType>(segmentTypeString, ignoreCase: true, out var segmentType))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported proof segment type '{segmentTypeString}'.");
|
||||
}
|
||||
|
||||
var statusString = reader.GetString(reader.GetOrdinal("status"));
|
||||
if (!Enum.TryParse<ProofSegmentStatus>(statusString, ignoreCase: true, out var status))
|
||||
{
|
||||
status = ProofSegmentStatus.Pending;
|
||||
}
|
||||
|
||||
var envelopeJson = reader.GetString(reader.GetOrdinal("envelope_json"));
|
||||
|
||||
return new ProofSegment(
|
||||
SegmentId: reader.GetString(reader.GetOrdinal("segment_id")),
|
||||
SegmentType: segmentType,
|
||||
Index: reader.GetInt32(reader.GetOrdinal("idx")),
|
||||
InputHash: reader.GetString(reader.GetOrdinal("input_hash")),
|
||||
ResultHash: reader.GetString(reader.GetOrdinal("result_hash")),
|
||||
PrevSegmentHash: GetNullableString(reader, reader.GetOrdinal("prev_segment_hash")),
|
||||
Envelope: DeserializeEnvelope(envelopeJson),
|
||||
ToolId: reader.GetString(reader.GetOrdinal("tool_id")),
|
||||
ToolVersion: reader.GetString(reader.GetOrdinal("tool_version")),
|
||||
Status: status,
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
|
||||
}
|
||||
|
||||
private static string SerializeEnvelope(DsseEnvelope envelope)
|
||||
{
|
||||
var doc = new DsseEnvelopeDocument(
|
||||
envelope.PayloadType,
|
||||
envelope.Payload,
|
||||
envelope.Signatures.Select(s => new DsseSignatureDocument(s.KeyId, s.Sig)).ToArray());
|
||||
|
||||
return CanonicalJson.Serialize(doc);
|
||||
}
|
||||
|
||||
private static DsseEnvelope DeserializeEnvelope(string json)
|
||||
{
|
||||
var doc = JsonSerializer.Deserialize<DsseEnvelopeDocument>(json, LenientJson)
|
||||
?? throw new InvalidOperationException("DSSE envelope deserialized to null.");
|
||||
|
||||
var signatures = doc.Signatures is null
|
||||
? Array.Empty<DsseSignature>()
|
||||
: doc.Signatures.Select(s => new DsseSignature(s.KeyId, s.Sig)).ToArray();
|
||||
|
||||
return new DsseEnvelope(doc.PayloadType, doc.Payload, signatures);
|
||||
}
|
||||
|
||||
private sealed record DsseEnvelopeDocument(
|
||||
[property: JsonPropertyName("payloadType")] string PayloadType,
|
||||
[property: JsonPropertyName("payload")] string Payload,
|
||||
[property: JsonPropertyName("signatures")] IReadOnlyList<DsseSignatureDocument> Signatures);
|
||||
|
||||
private sealed record DsseSignatureDocument(
|
||||
[property: JsonPropertyName("keyid")] string KeyId,
|
||||
[property: JsonPropertyName("sig")] string Sig);
|
||||
|
||||
private static ProofSpineSummary MapSummary(NpgsqlDataReader reader)
|
||||
=> new(
|
||||
SpineId: reader.GetString(reader.GetOrdinal("spine_id")),
|
||||
ArtifactId: reader.GetString(reader.GetOrdinal("artifact_id")),
|
||||
VulnerabilityId: reader.GetString(reader.GetOrdinal("vuln_id")),
|
||||
Verdict: reader.GetString(reader.GetOrdinal("verdict")),
|
||||
SegmentCount: reader.GetInt32(reader.GetOrdinal("segment_count")),
|
||||
CreatedAt: reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at_utc")));
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user