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:
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user