- 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.
379 lines
13 KiB
C#
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}";
|
|
}
|
|
}
|