- Implemented TelemetryClient to handle event queuing and flushing to the telemetry endpoint. - Created TtfsTelemetryService for emitting specific telemetry events related to TTFS. - Added tests for TelemetryClient to ensure event queuing and flushing functionality. - Introduced models for reachability drift detection, including DriftResult and DriftedSink. - Developed DriftApiService for interacting with the drift detection API. - Updated FirstSignalCardComponent to emit telemetry events on signal appearance. - Enhanced localization support for first signal component with i18n strings.
430 lines
14 KiB
C#
430 lines
14 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Interface for path explanation service.
|
|
/// </summary>
|
|
public interface IPathExplanationService
|
|
{
|
|
/// <summary>
|
|
/// Explains paths from a RichGraph to a specific sink or vulnerability.
|
|
/// </summary>
|
|
Task<PathExplanationResult> ExplainAsync(
|
|
RichGraph graph,
|
|
PathExplanationQuery query,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Explains a single path by its ID.
|
|
/// </summary>
|
|
Task<ExplainedPath?> ExplainPathAsync(
|
|
RichGraph graph,
|
|
string pathId,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IPathExplanationService"/>.
|
|
/// Reconstructs paths from RichGraph and provides user-friendly explanations.
|
|
/// </summary>
|
|
public sealed class PathExplanationService : IPathExplanationService
|
|
{
|
|
private readonly ILogger<PathExplanationService> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
public PathExplanationService(
|
|
ILogger<PathExplanationService> logger,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<PathExplanationResult> ExplainAsync(
|
|
RichGraph graph,
|
|
PathExplanationQuery query,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(graph);
|
|
query ??= new PathExplanationQuery();
|
|
|
|
var allPaths = new List<ExplainedPath>();
|
|
|
|
// 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);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public Task<ExplainedPath?> 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<ExplainedPath?>(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<string, List<RichGraphEdge>> BuildEdgeLookup(RichGraph graph)
|
|
{
|
|
var lookup = new Dictionary<string, List<RichGraphEdge>>();
|
|
|
|
foreach (var edge in graph.Edges)
|
|
{
|
|
if (!lookup.TryGetValue(edge.From, out var edges))
|
|
{
|
|
edges = new List<RichGraphEdge>();
|
|
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<List<RichGraphNode>> FindPaths(
|
|
RichGraphNode start,
|
|
RichGraphNode end,
|
|
Dictionary<string, RichGraphNode> nodeLookup,
|
|
Dictionary<string, List<RichGraphEdge>> edgeLookup,
|
|
int maxLength)
|
|
{
|
|
var paths = new List<List<RichGraphNode>>();
|
|
var currentPath = new List<RichGraphNode> { start };
|
|
var visited = new HashSet<string> { start.Id };
|
|
|
|
FindPathsDfs(start, end, currentPath, visited, paths, nodeLookup, edgeLookup, maxLength);
|
|
|
|
return paths;
|
|
}
|
|
|
|
private void FindPathsDfs(
|
|
RichGraphNode current,
|
|
RichGraphNode target,
|
|
List<RichGraphNode> currentPath,
|
|
HashSet<string> visited,
|
|
List<List<RichGraphNode>> foundPaths,
|
|
Dictionary<string, RichGraphNode> nodeLookup,
|
|
Dictionary<string, List<RichGraphEdge>> edgeLookup,
|
|
int maxLength)
|
|
{
|
|
if (currentPath.Count > maxLength)
|
|
return;
|
|
|
|
if (current.Id == target.Id)
|
|
{
|
|
foundPaths.Add(new List<RichGraphNode>(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<RichGraphNode> path,
|
|
Dictionary<string, List<RichGraphEdge>> edgeLookup)
|
|
{
|
|
var hops = new List<ExplainedPathHop>();
|
|
var allGates = new List<DetectedGate>();
|
|
|
|
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<DetectedGate>? 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<string, List<RichGraphEdge>> 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<DetectedGate> 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);
|
|
}
|
|
}
|