feat: add Attestation Chain and Triage Evidence API clients and models

- Implemented Attestation Chain API client with methods for verifying, fetching, and managing attestation chains.
- Created models for Attestation Chain, including DSSE envelope structures and verification results.
- Developed Triage Evidence API client for fetching finding evidence, including methods for evidence retrieval by CVE and component.
- Added models for Triage Evidence, encapsulating evidence responses, entry points, boundary proofs, and VEX evidence.
- Introduced mock implementations for both API clients to facilitate testing and development.
This commit is contained in:
master
2025-12-18 13:15:13 +02:00
parent 7d5250238c
commit 00d2c99af9
118 changed files with 13463 additions and 151 deletions

View File

@@ -0,0 +1,44 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Result of publishing a reachability witness.
/// </summary>
/// <param name="StatementHash">Hash of the in-toto statement.</param>
/// <param name="GraphHash">Hash of the rich graph.</param>
/// <param name="CasUri">CAS URI where graph is stored (if applicable).</param>
/// <param name="RekorLogIndex">Rekor transparency log index (if published).</param>
/// <param name="RekorLogId">Rekor log ID (if published).</param>
/// <param name="DsseEnvelopeBytes">Serialized DSSE envelope.</param>
public sealed record ReachabilityWitnessPublishResult(
string StatementHash,
string GraphHash,
string? CasUri,
long? RekorLogIndex,
string? RekorLogId,
byte[] DsseEnvelopeBytes);
/// <summary>
/// Interface for publishing reachability witness attestations.
/// </summary>
public interface IReachabilityWitnessPublisher
{
/// <summary>
/// Publishes a reachability witness attestation for the given graph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphBytes">Canonical JSON bytes of the graph.</param>
/// <param name="graphHash">Hash of the graph bytes.</param>
/// <param name="subjectDigest">Subject artifact digest.</param>
/// <param name="policyHash">Optional policy hash.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Publication result with CAS URI and optional Rekor proof.</returns>
Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,207 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Builds DSSE envelopes for reachability witness attestations.
/// Follows in-toto attestation framework with stellaops.reachabilityWitness predicate.
/// </summary>
public sealed class ReachabilityWitnessDsseBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <summary>
/// Creates a new DSSE builder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for content addressing.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
public ReachabilityWitnessDsseBuilder(ICryptoHash cryptoHash, TimeProvider? timeProvider = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Builds an in-toto statement from a RichGraph.
/// </summary>
/// <param name="graph">The rich graph to attest.</param>
/// <param name="graphHash">The computed hash of the canonical graph JSON.</param>
/// <param name="subjectDigest">The subject artifact digest (e.g., image digest).</param>
/// <param name="graphCasUri">Optional CAS URI where graph is stored.</param>
/// <param name="policyHash">Optional policy hash that was applied.</param>
/// <param name="sourceCommit">Optional source commit.</param>
/// <returns>An in-toto statement ready for DSSE signing.</returns>
public InTotoStatement BuildStatement(
RichGraph graph,
string graphHash,
string subjectDigest,
string? graphCasUri = null,
string? policyHash = null,
string? sourceCommit = null)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
var generatedAt = _timeProvider.GetUtcNow();
var predicate = new ReachabilityWitnessStatement
{
GraphHash = graphHash,
GraphCasUri = graphCasUri,
GeneratedAt = generatedAt,
Language = graph.Nodes.FirstOrDefault()?.Lang ?? "unknown",
NodeCount = graph.Nodes.Count,
EdgeCount = graph.Edges.Count,
EntrypointCount = graph.Roots?.Count ?? 0,
SinkCount = CountSinks(graph),
ReachableSinkCount = CountReachableSinks(graph),
PolicyHash = policyHash,
AnalyzerVersion = graph.Analyzer.Version ?? "unknown",
SourceCommit = sourceCommit,
SubjectDigest = subjectDigest
};
return new InTotoStatement
{
Type = "https://in-toto.io/Statement/v1",
Subject = new[]
{
new InTotoSubject
{
Name = ExtractSubjectName(subjectDigest),
Digest = new Dictionary<string, string>
{
[ExtractDigestAlgorithm(subjectDigest)] = ExtractDigestValue(subjectDigest)
}
}
},
PredicateType = "https://stella.ops/reachabilityWitness/v1",
Predicate = predicate
};
}
/// <summary>
/// Serializes an in-toto statement to canonical JSON.
/// </summary>
public byte[] SerializeStatement(InTotoStatement statement)
{
ArgumentNullException.ThrowIfNull(statement);
return JsonSerializer.SerializeToUtf8Bytes(statement, CanonicalJsonOptions);
}
/// <summary>
/// Computes the hash of a serialized statement.
/// </summary>
public string ComputeStatementHash(byte[] statementBytes)
{
ArgumentNullException.ThrowIfNull(statementBytes);
return _cryptoHash.ComputePrefixedHashForPurpose(statementBytes, HashPurpose.Graph);
}
private static int CountSinks(RichGraph graph)
{
// Count nodes with sink-related kinds (sql, crypto, deserialize, etc.)
return graph.Nodes.Count(n => IsSinkKind(n.Kind));
}
private static int CountReachableSinks(RichGraph graph)
{
// A sink is reachable if it has incoming edges
var nodesWithIncoming = new HashSet<string>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (!string.IsNullOrEmpty(edge.To))
{
nodesWithIncoming.Add(edge.To);
}
}
return graph.Nodes.Count(n =>
IsSinkKind(n.Kind) &&
nodesWithIncoming.Contains(n.Id));
}
private static bool IsSinkKind(string? kind)
{
// Recognize common sink kinds from the taxonomy
return kind?.ToLowerInvariant() switch
{
"sink" => true,
"sql" => true,
"crypto" => true,
"deserialize" => true,
"file" => true,
"network" => true,
"command" => true,
"reflection" => true,
_ => false
};
}
private static string ExtractSubjectName(string subjectDigest)
{
// For image digests like "sha256:abc123", return the full string
// For other formats, try to extract a meaningful name
return subjectDigest;
}
private static string ExtractDigestAlgorithm(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[..colonIndex] : "sha256";
}
private static string ExtractDigestValue(string subjectDigest)
{
var colonIndex = subjectDigest.IndexOf(':');
return colonIndex > 0 ? subjectDigest[(colonIndex + 1)..] : subjectDigest;
}
}
/// <summary>
/// In-toto Statement structure per https://github.com/in-toto/attestation.
/// </summary>
public sealed record InTotoStatement
{
/// <summary>Statement type (always "https://in-toto.io/Statement/v1")</summary>
[JsonPropertyName("_type")]
public required string Type { get; init; }
/// <summary>Array of subjects this attestation refers to</summary>
[JsonPropertyName("subject")]
public required InTotoSubject[] Subject { get; init; }
/// <summary>URI identifying the predicate type</summary>
[JsonPropertyName("predicateType")]
public required string PredicateType { get; init; }
/// <summary>The predicate object (type varies by predicateType)</summary>
[JsonPropertyName("predicate")]
public required object Predicate { get; init; }
}
/// <summary>
/// In-toto Subject structure.
/// </summary>
public sealed record InTotoSubject
{
/// <summary>Subject name (e.g., artifact path or identifier)</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Map of digest algorithm to digest value</summary>
[JsonPropertyName("digest")]
public required Dictionary<string, string> Digest { get; init; }
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Configuration for reachability witness attestation.
/// </summary>
public sealed class ReachabilityWitnessOptions
{
public const string SectionName = "Scanner:ReachabilityWitness";
/// <summary>Whether to generate DSSE attestations</summary>
public bool Enabled { get; set; } = true;
/// <summary>Attestation tier (standard, regulated, air-gapped, dev)</summary>
public AttestationTier Tier { get; set; } = AttestationTier.Standard;
/// <summary>Whether to publish to Rekor transparency log</summary>
public bool PublishToRekor { get; set; } = true;
/// <summary>Whether to store graph in CAS</summary>
public bool StoreInCas { get; set; } = true;
/// <summary>Maximum number of edge bundles to attest (for tier=standard)</summary>
public int MaxEdgeBundles { get; set; } = 5;
/// <summary>Key ID for signing (uses default if not specified)</summary>
public string? SigningKeyId { get; set; }
}
/// <summary>
/// Attestation tiers per hybrid-attestation.md.
/// </summary>
public enum AttestationTier
{
/// <summary>Standard: Graph DSSE + Rekor, optional edge bundles</summary>
Standard,
/// <summary>Regulated: Full attestation with strict signing</summary>
Regulated,
/// <summary>Air-gapped: Local-only, no Rekor</summary>
AirGapped,
/// <summary>Development: Minimal attestation for testing</summary>
Dev
}

View File

@@ -0,0 +1,147 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Publishes reachability witness attestations to CAS and Rekor.
/// </summary>
public sealed class ReachabilityWitnessPublisher : IReachabilityWitnessPublisher
{
private readonly ReachabilityWitnessOptions _options;
private readonly ReachabilityWitnessDsseBuilder _dsseBuilder;
private readonly ICryptoHash _cryptoHash;
private readonly ILogger<ReachabilityWitnessPublisher> _logger;
/// <summary>
/// Creates a new reachability witness publisher.
/// </summary>
public ReachabilityWitnessPublisher(
IOptions<ReachabilityWitnessOptions> options,
ICryptoHash cryptoHash,
ILogger<ReachabilityWitnessPublisher> logger,
TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(cryptoHash);
ArgumentNullException.ThrowIfNull(logger);
_options = options.Value;
_cryptoHash = cryptoHash;
_logger = logger;
_dsseBuilder = new ReachabilityWitnessDsseBuilder(cryptoHash, timeProvider);
}
/// <inheritdoc />
public async Task<ReachabilityWitnessPublishResult> PublishAsync(
RichGraph graph,
byte[] graphBytes,
string graphHash,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(graphBytes);
ArgumentException.ThrowIfNullOrWhiteSpace(graphHash);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
if (!_options.Enabled)
{
_logger.LogDebug("Reachability witness attestation is disabled");
return new ReachabilityWitnessPublishResult(
StatementHash: string.Empty,
GraphHash: graphHash,
CasUri: null,
RekorLogIndex: null,
RekorLogId: null,
DsseEnvelopeBytes: Array.Empty<byte>());
}
string? casUri = null;
// Step 1: Store graph in CAS (if enabled)
if (_options.StoreInCas)
{
casUri = await StoreInCasAsync(graphBytes, graphHash, cancellationToken).ConfigureAwait(false);
}
// Step 2: Build in-toto statement
var statement = _dsseBuilder.BuildStatement(
graph,
graphHash,
subjectDigest,
casUri,
policyHash,
sourceCommit);
var statementBytes = _dsseBuilder.SerializeStatement(statement);
var statementHash = _dsseBuilder.ComputeStatementHash(statementBytes);
_logger.LogInformation(
"Built reachability witness statement: hash={StatementHash}, nodes={NodeCount}, edges={EdgeCount}",
statementHash,
graph.Nodes.Count,
graph.Edges.Count);
// Step 3: Create DSSE envelope (placeholder - actual signing via Attestor service)
var dsseEnvelope = CreateDsseEnvelope(statementBytes);
// Step 4: Submit to Rekor (if enabled and not air-gapped)
long? rekorLogIndex = null;
string? rekorLogId = null;
if (_options.PublishToRekor && _options.Tier != AttestationTier.AirGapped)
{
(rekorLogIndex, rekorLogId) = await SubmitToRekorAsync(dsseEnvelope, cancellationToken).ConfigureAwait(false);
}
else if (_options.Tier == AttestationTier.AirGapped)
{
_logger.LogDebug("Skipping Rekor submission (air-gapped tier)");
}
return new ReachabilityWitnessPublishResult(
StatementHash: statementHash,
GraphHash: graphHash,
CasUri: casUri,
RekorLogIndex: rekorLogIndex,
RekorLogId: rekorLogId,
DsseEnvelopeBytes: dsseEnvelope);
}
private Task<string?> StoreInCasAsync(byte[] graphBytes, string graphHash, CancellationToken cancellationToken)
{
// TODO: Integrate with actual CAS storage (BID-007)
// For now, return a placeholder CAS URI based on hash
var casUri = $"cas://local/{graphHash}";
_logger.LogDebug("Stored graph in CAS: {CasUri}", casUri);
return Task.FromResult<string?>(casUri);
}
private byte[] CreateDsseEnvelope(byte[] statementBytes)
{
// TODO: Integrate with Attestor DSSE signing service (RWD-008)
// For now, return unsigned envelope structure
// In production, this would call the Attestor service to sign the statement
// Minimal DSSE envelope structure (unsigned)
var envelope = new
{
payloadType = "application/vnd.in-toto+json",
payload = Convert.ToBase64String(statementBytes),
signatures = Array.Empty<object>() // Will be populated by Attestor
};
return System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(envelope);
}
private Task<(long? logIndex, string? logId)> SubmitToRekorAsync(byte[] dsseEnvelope, CancellationToken cancellationToken)
{
// TODO: Integrate with Rekor backend (RWD-008)
// For now, return placeholder values
_logger.LogDebug("Rekor submission placeholder - actual integration pending");
return Task.FromResult<(long?, string?)>((null, null));
}
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Reachability witness statement for DSSE predicate.
/// Conforms to stella.ops/reachabilityWitness@v1 schema.
/// </summary>
public sealed record ReachabilityWitnessStatement
{
/// <summary>Schema identifier</summary>
[JsonPropertyName("schema")]
public string Schema { get; init; } = "stella.ops/reachabilityWitness@v1";
/// <summary>BLAKE3 hash of the canonical RichGraph JSON</summary>
[JsonPropertyName("graphHash")]
public required string GraphHash { get; init; }
/// <summary>CAS URI where graph is stored</summary>
[JsonPropertyName("graphCasUri")]
public string? GraphCasUri { get; init; }
/// <summary>When the analysis was performed (ISO-8601)</summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>Primary language of the analyzed code</summary>
[JsonPropertyName("language")]
public required string Language { get; init; }
/// <summary>Number of nodes in the graph</summary>
[JsonPropertyName("nodeCount")]
public required int NodeCount { get; init; }
/// <summary>Number of edges in the graph</summary>
[JsonPropertyName("edgeCount")]
public required int EdgeCount { get; init; }
/// <summary>Number of entrypoints identified</summary>
[JsonPropertyName("entrypointCount")]
public required int EntrypointCount { get; init; }
/// <summary>Total number of sinks in taxonomy</summary>
[JsonPropertyName("sinkCount")]
public required int SinkCount { get; init; }
/// <summary>Number of reachable sinks</summary>
[JsonPropertyName("reachableSinkCount")]
public required int ReachableSinkCount { get; init; }
/// <summary>Policy hash that was applied (if any)</summary>
[JsonPropertyName("policyHash")]
public string? PolicyHash { get; init; }
/// <summary>Analyzer version used</summary>
[JsonPropertyName("analyzerVersion")]
public required string AnalyzerVersion { get; init; }
/// <summary>Git commit of the analyzed code</summary>
[JsonPropertyName("sourceCommit")]
public string? SourceCommit { get; init; }
/// <summary>Subject artifact (image digest or file hash)</summary>
[JsonPropertyName("subjectDigest")]
public required string SubjectDigest { get; init; }
}

View File

@@ -0,0 +1,175 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds path witnesses from reachability analysis results.
/// </summary>
public interface IPathWitnessBuilder
{
/// <summary>
/// Creates a path witness for a reachable vulnerability.
/// </summary>
/// <param name="request">The witness creation request containing all necessary context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A signed path witness or null if the path is not reachable.</returns>
Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple path witnesses for all reachable paths to a vulnerability.
/// </summary>
/// <param name="request">The batch witness request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All generated witnesses.</returns>
IAsyncEnumerable<PathWitness> BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to build a single path witness.
/// </summary>
public sealed record PathWitnessRequest
{
/// <summary>
/// The SBOM digest for artifact context.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability ID (e.g., "CVE-2024-12345").
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD").
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Entrypoint symbol ID.
/// </summary>
public required string EntrypointSymbolId { get; init; }
/// <summary>
/// Entrypoint kind (http, grpc, cli, etc.).
/// </summary>
public required string EntrypointKind { get; init; }
/// <summary>
/// Human-readable entrypoint name.
/// </summary>
public required string EntrypointName { get; init; }
/// <summary>
/// Sink symbol ID.
/// </summary>
public required string SinkSymbolId { get; init; }
/// <summary>
/// Sink taxonomy type.
/// </summary>
public required string SinkType { get; init; }
/// <summary>
/// The call graph to use for path finding.
/// </summary>
public required RichGraph CallGraph { get; init; }
/// <summary>
/// BLAKE3 digest of the call graph.
/// </summary>
public required string CallgraphDigest { get; init; }
/// <summary>
/// Optional attack surface digest.
/// </summary>
public string? SurfaceDigest { get; init; }
/// <summary>
/// Optional analysis config digest.
/// </summary>
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Optional build ID.
/// </summary>
public string? BuildId { get; init; }
}
/// <summary>
/// Request to build witnesses for all paths to a vulnerability.
/// </summary>
public sealed record BatchWitnessRequest
{
/// <summary>
/// The SBOM digest for artifact context.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability ID.
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source.
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Sink symbol ID to find paths to.
/// </summary>
public required string SinkSymbolId { get; init; }
/// <summary>
/// Sink taxonomy type.
/// </summary>
public required string SinkType { get; init; }
/// <summary>
/// The call graph to use for path finding.
/// </summary>
public required RichGraph CallGraph { get; init; }
/// <summary>
/// BLAKE3 digest of the call graph.
/// </summary>
public required string CallgraphDigest { get; init; }
/// <summary>
/// Maximum number of witnesses to generate.
/// </summary>
public int MaxWitnesses { get; init; } = 10;
/// <summary>
/// Optional attack surface digest.
/// </summary>
public string? SurfaceDigest { get; init; }
/// <summary>
/// Optional analysis config digest.
/// </summary>
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Optional build ID.
/// </summary>
public string? BuildId { get; init; }
}

View File

@@ -0,0 +1,256 @@
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// A DSSE-signable path witness documenting the call path from entrypoint to vulnerable sink.
/// Conforms to stellaops.witness.v1 schema.
/// </summary>
public sealed record PathWitness
{
/// <summary>
/// Schema version identifier.
/// </summary>
[JsonPropertyName("witness_schema")]
public string WitnessSchema { get; init; } = Witnesses.WitnessSchema.Version;
/// <summary>
/// Content-addressed witness ID (e.g., "wit:sha256:...").
/// </summary>
[JsonPropertyName("witness_id")]
public required string WitnessId { get; init; }
/// <summary>
/// The artifact (SBOM, component) this witness relates to.
/// </summary>
[JsonPropertyName("artifact")]
public required WitnessArtifact Artifact { get; init; }
/// <summary>
/// The vulnerability this witness concerns.
/// </summary>
[JsonPropertyName("vuln")]
public required WitnessVuln Vuln { get; init; }
/// <summary>
/// The entrypoint from which the path originates.
/// </summary>
[JsonPropertyName("entrypoint")]
public required WitnessEntrypoint Entrypoint { get; init; }
/// <summary>
/// The call path from entrypoint to sink, ordered from caller to callee.
/// </summary>
[JsonPropertyName("path")]
public required IReadOnlyList<PathStep> Path { get; init; }
/// <summary>
/// The vulnerable sink reached at the end of the path.
/// </summary>
[JsonPropertyName("sink")]
public required WitnessSink Sink { get; init; }
/// <summary>
/// Detected gates (guards, authentication, validation) along the path.
/// </summary>
[JsonPropertyName("gates")]
public IReadOnlyList<DetectedGate>? Gates { get; init; }
/// <summary>
/// Evidence digests and build context for reproducibility.
/// </summary>
[JsonPropertyName("evidence")]
public required WitnessEvidence Evidence { get; init; }
/// <summary>
/// When this witness was generated (UTC ISO-8601).
/// </summary>
[JsonPropertyName("observed_at")]
public required DateTimeOffset ObservedAt { get; init; }
}
/// <summary>
/// Artifact context for a witness.
/// </summary>
public sealed record WitnessArtifact
{
/// <summary>
/// SHA-256 digest of the SBOM.
/// </summary>
[JsonPropertyName("sbom_digest")]
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
[JsonPropertyName("component_purl")]
public required string ComponentPurl { get; init; }
}
/// <summary>
/// Vulnerability information for a witness.
/// </summary>
public sealed record WitnessVuln
{
/// <summary>
/// Vulnerability identifier (e.g., "CVE-2024-12345").
/// </summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD", "OSV", "GHSA").
/// </summary>
[JsonPropertyName("source")]
public required string Source { get; init; }
/// <summary>
/// Affected version range expression.
/// </summary>
[JsonPropertyName("affected_range")]
public required string AffectedRange { get; init; }
}
/// <summary>
/// Entrypoint that starts the reachability path.
/// </summary>
public sealed record WitnessEntrypoint
{
/// <summary>
/// Kind of entrypoint (http, grpc, cli, job, event).
/// </summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }
/// <summary>
/// Human-readable name (e.g., "GET /api/users/{id}").
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Canonical symbol ID for the entrypoint.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
}
/// <summary>
/// A single step in the call path from entrypoint to sink.
/// </summary>
public sealed record PathStep
{
/// <summary>
/// Human-readable symbol name.
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Canonical symbol ID.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
/// <summary>
/// Source file path (null for external/binary symbols).
/// </summary>
[JsonPropertyName("file")]
public string? File { get; init; }
/// <summary>
/// Line number in source file (1-based).
/// </summary>
[JsonPropertyName("line")]
public int? Line { get; init; }
/// <summary>
/// Column number in source file (1-based).
/// </summary>
[JsonPropertyName("column")]
public int? Column { get; init; }
}
/// <summary>
/// The vulnerable sink at the end of the reachability path.
/// </summary>
public sealed record WitnessSink
{
/// <summary>
/// Human-readable symbol name.
/// </summary>
[JsonPropertyName("symbol")]
public required string Symbol { get; init; }
/// <summary>
/// Canonical symbol ID.
/// </summary>
[JsonPropertyName("symbol_id")]
public required string SymbolId { get; init; }
/// <summary>
/// Sink taxonomy type (e.g., "deserialization", "sql_injection", "path_traversal").
/// </summary>
[JsonPropertyName("sink_type")]
public required string SinkType { get; init; }
}
/// <summary>
/// A detected gate (guard/mitigating control) along the path.
/// </summary>
public sealed record DetectedGate
{
/// <summary>
/// Gate type (authRequired, inputValidation, rateLimited, etc.).
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Symbol that implements the gate.
/// </summary>
[JsonPropertyName("guard_symbol")]
public required string GuardSymbol { get; init; }
/// <summary>
/// Confidence level (0.0 - 1.0).
/// </summary>
[JsonPropertyName("confidence")]
public required double Confidence { get; init; }
/// <summary>
/// Human-readable detail about the gate.
/// </summary>
[JsonPropertyName("detail")]
public string? Detail { get; init; }
}
/// <summary>
/// Evidence digests for reproducibility and audit trail.
/// </summary>
public sealed record WitnessEvidence
{
/// <summary>
/// BLAKE3 digest of the call graph used.
/// </summary>
[JsonPropertyName("callgraph_digest")]
public required string CallgraphDigest { get; init; }
/// <summary>
/// SHA-256 digest of the attack surface manifest.
/// </summary>
[JsonPropertyName("surface_digest")]
public string? SurfaceDigest { get; init; }
/// <summary>
/// SHA-256 digest of the analysis configuration.
/// </summary>
[JsonPropertyName("analysis_config_digest")]
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Build identifier for the analyzed artifact.
/// </summary>
[JsonPropertyName("build_id")]
public string? BuildId { get; init; }
}

View File

@@ -0,0 +1,378 @@
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Gates;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Builds path witnesses from reachability analysis results.
/// </summary>
public sealed class PathWitnessBuilder : IPathWitnessBuilder
{
private readonly ICryptoHash _cryptoHash;
private readonly CompositeGateDetector? _gateDetector;
private readonly TimeProvider _timeProvider;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
/// <summary>
/// Creates a new PathWitnessBuilder.
/// </summary>
/// <param name="cryptoHash">Crypto hash service for witness ID generation.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="gateDetector">Optional gate detector for identifying guards along paths.</param>
public PathWitnessBuilder(
ICryptoHash cryptoHash,
TimeProvider timeProvider,
CompositeGateDetector? gateDetector = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_gateDetector = gateDetector;
}
/// <inheritdoc />
public async Task<PathWitness?> BuildAsync(PathWitnessRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Find path from entrypoint to sink using BFS
var path = FindPath(request.CallGraph, request.EntrypointSymbolId, request.SinkSymbolId);
if (path is null || path.Count == 0)
{
return null; // No path found
}
// Infer language from the call graph nodes
var language = request.CallGraph.Nodes?.FirstOrDefault()?.Lang ?? "unknown";
// Detect gates along the path
var gates = _gateDetector is not null
? await DetectGatesAsync(request.CallGraph, path, language, cancellationToken).ConfigureAwait(false)
: null;
// Get sink node info
var sinkNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.SymbolId == request.SinkSymbolId);
var sinkSymbol = sinkNode?.Display ?? sinkNode?.Symbol?.Demangled ?? request.SinkSymbolId;
// Build the witness
var witness = new PathWitness
{
WitnessId = string.Empty, // Will be set after hashing
Artifact = new WitnessArtifact
{
SbomDigest = request.SbomDigest,
ComponentPurl = request.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = request.VulnId,
Source = request.VulnSource,
AffectedRange = request.AffectedRange
},
Entrypoint = new WitnessEntrypoint
{
Kind = request.EntrypointKind,
Name = request.EntrypointName,
SymbolId = request.EntrypointSymbolId
},
Path = path,
Sink = new WitnessSink
{
Symbol = sinkSymbol,
SymbolId = request.SinkSymbolId,
SinkType = request.SinkType
},
Gates = gates,
Evidence = new WitnessEvidence
{
CallgraphDigest = request.CallgraphDigest,
SurfaceDigest = request.SurfaceDigest,
AnalysisConfigDigest = request.AnalysisConfigDigest,
BuildId = request.BuildId
},
ObservedAt = _timeProvider.GetUtcNow()
};
// Compute witness ID from canonical content
var witnessId = ComputeWitnessId(witness);
witness = witness with { WitnessId = witnessId };
return witness;
}
/// <inheritdoc />
public async IAsyncEnumerable<PathWitness> BuildAllAsync(
BatchWitnessRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
// Find all roots (entrypoints) in the graph
var roots = request.CallGraph.Roots;
if (roots is null || roots.Count == 0)
{
yield break;
}
var witnessCount = 0;
foreach (var root in roots)
{
if (witnessCount >= request.MaxWitnesses)
{
yield break;
}
cancellationToken.ThrowIfCancellationRequested();
// Look up the node to get the symbol name
var rootNode = request.CallGraph.Nodes?.FirstOrDefault(n => n.Id == root.Id);
var singleRequest = new PathWitnessRequest
{
SbomDigest = request.SbomDigest,
ComponentPurl = request.ComponentPurl,
VulnId = request.VulnId,
VulnSource = request.VulnSource,
AffectedRange = request.AffectedRange,
EntrypointSymbolId = rootNode?.SymbolId ?? root.Id,
EntrypointKind = root.Phase ?? "unknown",
EntrypointName = rootNode?.Display ?? root.Source ?? root.Id,
SinkSymbolId = request.SinkSymbolId,
SinkType = request.SinkType,
CallGraph = request.CallGraph,
CallgraphDigest = request.CallgraphDigest,
SurfaceDigest = request.SurfaceDigest,
AnalysisConfigDigest = request.AnalysisConfigDigest,
BuildId = request.BuildId
};
var witness = await BuildAsync(singleRequest, cancellationToken).ConfigureAwait(false);
if (witness is not null)
{
witnessCount++;
yield return witness;
}
}
}
/// <summary>
/// Finds the shortest path from source to target using BFS.
/// </summary>
private List<PathStep>? FindPath(RichGraph graph, string sourceSymbolId, string targetSymbolId)
{
if (graph.Nodes is null || graph.Edges is null)
{
return null;
}
// Build node ID to symbol ID mapping
var nodeIdToSymbolId = graph.Nodes.ToDictionary(
n => n.Id,
n => n.SymbolId,
StringComparer.Ordinal);
// Build adjacency list using From/To (node IDs) mapped to symbol IDs
var adjacency = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var edge in graph.Edges)
{
if (string.IsNullOrEmpty(edge.From) || string.IsNullOrEmpty(edge.To))
{
continue;
}
// Map node IDs to symbol IDs
if (!nodeIdToSymbolId.TryGetValue(edge.From, out var fromSymbolId) ||
!nodeIdToSymbolId.TryGetValue(edge.To, out var toSymbolId))
{
continue;
}
if (!adjacency.TryGetValue(fromSymbolId, out var neighbors))
{
neighbors = new List<string>();
adjacency[fromSymbolId] = neighbors;
}
neighbors.Add(toSymbolId);
}
// BFS to find shortest path
var visited = new HashSet<string>(StringComparer.Ordinal);
var parent = new Dictionary<string, string>(StringComparer.Ordinal);
var queue = new Queue<string>();
queue.Enqueue(sourceSymbolId);
visited.Add(sourceSymbolId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current.Equals(targetSymbolId, StringComparison.Ordinal))
{
// Reconstruct path
return ReconstructPath(graph, parent, sourceSymbolId, targetSymbolId);
}
if (!adjacency.TryGetValue(current, out var neighbors))
{
continue;
}
// Sort neighbors for deterministic ordering
foreach (var neighbor in neighbors.Order(StringComparer.Ordinal))
{
if (visited.Add(neighbor))
{
parent[neighbor] = current;
queue.Enqueue(neighbor);
}
}
}
return null; // No path found
}
/// <summary>
/// Reconstructs the path from parent map.
/// </summary>
private static List<PathStep> ReconstructPath(
RichGraph graph,
Dictionary<string, string> parent,
string source,
string target)
{
var path = new List<PathStep>();
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
var current = target;
while (current is not null)
{
nodeMap.TryGetValue(current, out var node);
// Extract source file/line from Attributes if available
string? file = null;
int? line = null;
int? column = null;
if (node?.Attributes is not null)
{
if (node.Attributes.TryGetValue("file", out var fileValue))
{
file = fileValue;
}
if (node.Attributes.TryGetValue("line", out var lineValue) && int.TryParse(lineValue, out var parsedLine))
{
line = parsedLine;
}
if (node.Attributes.TryGetValue("column", out var colValue) && int.TryParse(colValue, out var parsedCol))
{
column = parsedCol;
}
}
path.Add(new PathStep
{
Symbol = node?.Display ?? node?.Symbol?.Demangled ?? current,
SymbolId = current,
File = file,
Line = line,
Column = column
});
if (current.Equals(source, StringComparison.Ordinal))
{
break;
}
parent.TryGetValue(current, out current);
}
path.Reverse(); // Reverse to get source → target order
return path;
}
/// <summary>
/// Detects gates along the path using the composite gate detector.
/// </summary>
private async Task<List<DetectedGate>?> DetectGatesAsync(
RichGraph graph,
List<PathStep> path,
string language,
CancellationToken cancellationToken)
{
if (_gateDetector is null || path.Count == 0)
{
return null;
}
// Build source file map for the path
var sourceFiles = new Dictionary<string, string>(StringComparer.Ordinal);
var nodeMap = graph.Nodes?.ToDictionary(n => n.SymbolId ?? string.Empty, n => n, StringComparer.Ordinal)
?? new Dictionary<string, RichGraphNode>(StringComparer.Ordinal);
foreach (var step in path)
{
if (nodeMap.TryGetValue(step.SymbolId, out var node) &&
node.Attributes is not null &&
node.Attributes.TryGetValue("file", out var file))
{
sourceFiles[step.SymbolId] = file;
}
}
var context = new CallPathContext
{
CallPath = path.Select(s => s.SymbolId).ToList(),
SourceFiles = sourceFiles.Count > 0 ? sourceFiles : null,
Language = language
};
var result = await _gateDetector.DetectAllAsync(context, cancellationToken).ConfigureAwait(false);
if (result.Gates.Count == 0)
{
return null;
}
return result.Gates.Select(g => new DetectedGate
{
Type = g.Type.ToString(),
GuardSymbol = g.GuardSymbol,
Confidence = g.Confidence,
Detail = g.Detail
}).ToList();
}
/// <summary>
/// Computes a content-addressed witness ID.
/// </summary>
private string ComputeWitnessId(PathWitness witness)
{
// Create a canonical representation for hashing (excluding witness_id itself)
var canonical = new
{
witness.WitnessSchema,
witness.Artifact,
witness.Vuln,
witness.Entrypoint,
witness.Path,
witness.Sink,
witness.Evidence
};
var json = JsonSerializer.SerializeToUtf8Bytes(canonical, JsonOptions);
var hash = _cryptoHash.ComputePrefixedHashForPurpose(json, HashPurpose.Content);
return $"{WitnessSchema.WitnessIdPrefix}{hash}";
}
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Constants for the stellaops.witness.v1 schema.
/// </summary>
public static class WitnessSchema
{
/// <summary>
/// Current witness schema version.
/// </summary>
public const string Version = "stellaops.witness.v1";
/// <summary>
/// Prefix for witness IDs.
/// </summary>
public const string WitnessIdPrefix = "wit:";
/// <summary>
/// Default DSSE payload type for witnesses.
/// </summary>
public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json";
}