// ----------------------------------------------------------------------------- // PathExplanationService.cs // Sprint: SPRINT_3620_0002_0001_path_explanation // Description: Service for reconstructing and explaining reachability paths. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Reachability.Gates; namespace StellaOps.Scanner.Reachability.Explanation; /// /// Interface for path explanation service. /// public interface IPathExplanationService { /// /// Explains paths from a RichGraph to a specific sink or vulnerability. /// Task ExplainAsync( RichGraph graph, PathExplanationQuery query, CancellationToken cancellationToken = default); /// /// Explains a single path by its ID. /// Task ExplainPathAsync( RichGraph graph, string pathId, CancellationToken cancellationToken = default); } /// /// Default implementation of . /// Reconstructs paths from RichGraph and provides user-friendly explanations. /// public sealed class PathExplanationService : IPathExplanationService { private readonly ILogger _logger; private readonly TimeProvider _timeProvider; public PathExplanationService( ILogger logger, TimeProvider? timeProvider = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } /// public Task ExplainAsync( RichGraph graph, PathExplanationQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(graph); query ??= new PathExplanationQuery(); var allPaths = new List(); // Build node lookup var nodeLookup = graph.Nodes.ToDictionary(n => n.Id); var edgeLookup = BuildEdgeLookup(graph); // Find paths from each root to sinks foreach (var root in graph.Roots) { cancellationToken.ThrowIfCancellationRequested(); var rootNode = nodeLookup.GetValueOrDefault(root.Id); if (rootNode is null) continue; var sinkNodes = graph.Nodes.Where(n => IsSink(n)).ToList(); foreach (var sink in sinkNodes) { // Apply query filters if (query.SinkId is not null && sink.Id != query.SinkId) continue; var paths = FindPaths( rootNode, sink, nodeLookup, edgeLookup, query.MaxPathLength ?? 20); foreach (var path in paths) { var explained = BuildExplainedPath( root, rootNode, sink, path, edgeLookup); // Apply gate filter if (query.HasGates == true && explained.Gates.Count == 0) continue; allPaths.Add(explained); } } } // Sort by path length, then by gate multiplier (higher = more protected) var sortedPaths = allPaths .OrderBy(p => p.PathLength) .ThenByDescending(p => p.GateMultiplierBps) .ToList(); var totalCount = sortedPaths.Count; var limitedPaths = sortedPaths.Take(query.MaxPaths).ToList(); var result = new PathExplanationResult { Paths = limitedPaths, TotalCount = totalCount, HasMore = totalCount > query.MaxPaths, GraphHash = null, // RichGraph does not have a Meta property; hash is computed at serialization GeneratedAt = _timeProvider.GetUtcNow() }; return Task.FromResult(result); } /// public Task ExplainPathAsync( RichGraph graph, string pathId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(graph); // Path ID format: {rootId}:{sinkId}:{pathIndex} var parts = pathId?.Split(':'); if (parts is not { Length: >= 2 }) { return Task.FromResult(null); } var query = new PathExplanationQuery { EntrypointId = parts[0], SinkId = parts[1], MaxPaths = 100 }; var resultTask = ExplainAsync(graph, query, cancellationToken); return resultTask.ContinueWith(t => { if (t.Result.Paths.Count == 0) return null; // If path index specified, return that specific one if (parts.Length >= 3 && int.TryParse(parts[2], out var idx) && idx < t.Result.Paths.Count) { return t.Result.Paths[idx]; } return t.Result.Paths[0]; }, cancellationToken); } private static Dictionary> BuildEdgeLookup(RichGraph graph) { var lookup = new Dictionary>(); foreach (var edge in graph.Edges) { if (!lookup.TryGetValue(edge.From, out var edges)) { edges = new List(); lookup[edge.From] = edges; } edges.Add(edge); } return lookup; } private static bool IsSink(RichGraphNode node) { // Check if node has sink-like characteristics return node.Kind?.Contains("sink", StringComparison.OrdinalIgnoreCase) == true || node.Attributes?.ContainsKey("is_sink") == true; } private List> FindPaths( RichGraphNode start, RichGraphNode end, Dictionary nodeLookup, Dictionary> edgeLookup, int maxLength) { var paths = new List>(); var currentPath = new List { start }; var visited = new HashSet { start.Id }; FindPathsDfs(start, end, currentPath, visited, paths, nodeLookup, edgeLookup, maxLength); return paths; } private void FindPathsDfs( RichGraphNode current, RichGraphNode target, List currentPath, HashSet visited, List> foundPaths, Dictionary nodeLookup, Dictionary> edgeLookup, int maxLength) { if (currentPath.Count > maxLength) return; if (current.Id == target.Id) { foundPaths.Add(new List(currentPath)); return; } if (!edgeLookup.TryGetValue(current.Id, out var outEdges)) return; foreach (var edge in outEdges) { if (visited.Contains(edge.To)) continue; if (!nodeLookup.TryGetValue(edge.To, out var nextNode)) continue; visited.Add(edge.To); currentPath.Add(nextNode); FindPathsDfs(nextNode, target, currentPath, visited, foundPaths, nodeLookup, edgeLookup, maxLength); currentPath.RemoveAt(currentPath.Count - 1); visited.Remove(edge.To); } } private ExplainedPath BuildExplainedPath( RichGraphRoot root, RichGraphNode rootNode, RichGraphNode sinkNode, List path, Dictionary> edgeLookup) { var hops = new List(); var allGates = new List(); for (var i = 0; i < path.Count; i++) { var node = path[i]; var isFirst = i == 0; var isLast = i == path.Count - 1; // Get edge gates IReadOnlyList? edgeGates = null; if (i < path.Count - 1) { var edge = GetEdge(path[i].Id, path[i + 1].Id, edgeLookup); if (edge?.Gates is not null) { edgeGates = edge.Gates; allGates.AddRange(edge.Gates); } } hops.Add(new ExplainedPathHop { NodeId = node.Id, Symbol = node.Display ?? node.SymbolId ?? node.Id, File = GetNodeFile(node), Line = GetNodeLine(node), Package = GetNodePackage(node), Language = node.Lang, CallSite = GetCallSite(node), Gates = edgeGates, Depth = i, IsEntrypoint = isFirst, IsSink = isLast }); } // Calculate combined gate multiplier var multiplierBps = CalculateGateMultiplier(allGates); return new ExplainedPath { PathId = $"{rootNode.Id}:{sinkNode.Id}:{0}", SinkId = sinkNode.Id, SinkSymbol = sinkNode.Display ?? sinkNode.SymbolId ?? sinkNode.Id, SinkCategory = InferSinkCategory(sinkNode), EntrypointId = rootNode.Id, EntrypointSymbol = rootNode.Display ?? rootNode.SymbolId ?? rootNode.Id, EntrypointType = InferEntrypointType(root, rootNode), PathLength = path.Count, Hops = hops, Gates = allGates, GateMultiplierBps = multiplierBps }; } private static RichGraphEdge? GetEdge(string from, string to, Dictionary> edgeLookup) { if (!edgeLookup.TryGetValue(from, out var edges)) return null; return edges.FirstOrDefault(e => e.To == to); } private static string? GetNodeFile(RichGraphNode node) { if (node.Attributes?.TryGetValue("file", out var file) == true) return file; if (node.Attributes?.TryGetValue("source_file", out file) == true) return file; return null; } private static int? GetNodeLine(RichGraphNode node) { if (node.Attributes?.TryGetValue("line", out var line) == true && int.TryParse(line, out var lineNum)) return lineNum; return null; } private static string GetNodePackage(RichGraphNode node) { if (node.Purl is not null) { // Extract package name from PURL var purl = node.Purl; var nameStart = purl.LastIndexOf('/') + 1; var nameEnd = purl.IndexOf('@', nameStart); if (nameEnd < 0) nameEnd = purl.Length; return purl.Substring(nameStart, nameEnd - nameStart); } if (node.Attributes?.TryGetValue("package", out var pkg) == true) return pkg; return node.SymbolId?.Split('.').FirstOrDefault() ?? "unknown"; } private static string? GetCallSite(RichGraphNode node) { if (node.Attributes?.TryGetValue("call_site", out var site) == true) return site; return null; } private static SinkCategory InferSinkCategory(RichGraphNode node) { var kind = node.Kind?.ToLowerInvariant() ?? ""; var symbol = (node.SymbolId ?? "").ToLowerInvariant(); if (kind.Contains("sql") || symbol.Contains("query") || symbol.Contains("execute")) return SinkCategory.SqlRaw; if (kind.Contains("exec") || symbol.Contains("command") || symbol.Contains("process")) return SinkCategory.CommandExec; if (kind.Contains("file") || symbol.Contains("write") || symbol.Contains("read")) return SinkCategory.FileAccess; if (kind.Contains("http") || symbol.Contains("request")) return SinkCategory.NetworkClient; if (kind.Contains("deserialize") || symbol.Contains("deserialize")) return SinkCategory.Deserialization; if (kind.Contains("path")) return SinkCategory.PathTraversal; return SinkCategory.Other; } private static EntrypointType InferEntrypointType(RichGraphRoot root, RichGraphNode node) { var phase = root.Phase?.ToLowerInvariant() ?? ""; var kind = node.Kind?.ToLowerInvariant() ?? ""; var display = (node.Display ?? "").ToLowerInvariant(); if (kind.Contains("http") || display.Contains("get ") || display.Contains("post ")) return EntrypointType.HttpEndpoint; if (kind.Contains("grpc")) return EntrypointType.GrpcMethod; if (kind.Contains("graphql")) return EntrypointType.GraphQlResolver; if (kind.Contains("cli") || kind.Contains("command")) return EntrypointType.CliCommand; if (kind.Contains("message") || kind.Contains("handler")) return EntrypointType.MessageHandler; if (kind.Contains("scheduled") || kind.Contains("cron")) return EntrypointType.ScheduledJob; if (kind.Contains("websocket")) return EntrypointType.WebSocketHandler; if (phase == "library" || kind.Contains("public")) return EntrypointType.PublicApi; return EntrypointType.Unknown; } private static int CalculateGateMultiplier(List gates) { if (gates.Count == 0) return 10000; // 100% (no reduction) // Apply gates multiplicatively var multiplier = 10000.0; // Start at 100% in basis points foreach (var gate in gates.DistinctBy(g => g.Type)) { var gateMultiplier = gate.Type switch { GateType.AuthRequired => 3000, // 30% GateType.FeatureFlag => 5000, // 50% GateType.AdminOnly => 2000, // 20% GateType.NonDefaultConfig => 7000, // 70% _ => 10000 }; multiplier = multiplier * gateMultiplier / 10000; } return (int)Math.Round(multiplier); } }