Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/Witnesses/PathWitnessBuilder.cs
master 00d2c99af9 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.
2025-12-18 13:15:13 +02:00

379 lines
13 KiB
C#

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}";
}
}