feat(telemetry): add telemetry client and services for tracking events
- 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.
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PathExplanationModels.cs
|
||||
// Sprint: SPRINT_3620_0002_0001_path_explanation
|
||||
// Description: Models for explained reachability paths with gate information.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Explanation;
|
||||
|
||||
/// <summary>
|
||||
/// A fully explained path from entrypoint to vulnerable sink.
|
||||
/// </summary>
|
||||
public sealed record ExplainedPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_id")]
|
||||
public required string PathId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink node identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_id")]
|
||||
public required string SinkId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink symbol name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_symbol")]
|
||||
public required string SinkSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sink category from taxonomy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sink_category")]
|
||||
public required SinkCategory SinkCategory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint node identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint_id")]
|
||||
public required string EntrypointId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint symbol name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint_symbol")]
|
||||
public required string EntrypointSymbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entrypoint type from root.
|
||||
/// </summary>
|
||||
[JsonPropertyName("entrypoint_type")]
|
||||
public required EntrypointType EntrypointType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of hops in the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path_length")]
|
||||
public required int PathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered list of hops from entrypoint to sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("hops")]
|
||||
public required IReadOnlyList<ExplainedPathHop> Hops { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gates detected along the path.
|
||||
/// </summary>
|
||||
[JsonPropertyName("gates")]
|
||||
public required IReadOnlyList<DetectedGate> Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Combined gate multiplier in basis points (0-10000).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gate_multiplier_bps")]
|
||||
public required int GateMultiplierBps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE or vulnerability ID this path leads to.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PURL of the affected component.
|
||||
/// </summary>
|
||||
[JsonPropertyName("affected_purl")]
|
||||
public string? AffectedPurl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single hop in an explained path.
|
||||
/// </summary>
|
||||
public sealed record ExplainedPathHop
|
||||
{
|
||||
/// <summary>
|
||||
/// Node identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("node_id")]
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol name (method/function).
|
||||
/// </summary>
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file path (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in source file (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("package")]
|
||||
public required string Package { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Programming language.
|
||||
/// </summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string? Language { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call site information (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("call_site")]
|
||||
public string? CallSite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gates at this hop (edge-level).
|
||||
/// </summary>
|
||||
[JsonPropertyName("gates")]
|
||||
public IReadOnlyList<DetectedGate>? Gates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Distance from entrypoint (0 = entrypoint).
|
||||
/// </summary>
|
||||
[JsonPropertyName("depth")]
|
||||
public int Depth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the entrypoint.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_entrypoint")]
|
||||
public bool IsEntrypoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is the sink.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_sink")]
|
||||
public bool IsSink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
|
||||
public enum EntrypointType
|
||||
{
|
||||
/// <summary>HTTP/REST endpoint.</summary>
|
||||
HttpEndpoint,
|
||||
|
||||
/// <summary>gRPC method.</summary>
|
||||
GrpcMethod,
|
||||
|
||||
/// <summary>GraphQL resolver.</summary>
|
||||
GraphQlResolver,
|
||||
|
||||
/// <summary>CLI command handler.</summary>
|
||||
CliCommand,
|
||||
|
||||
/// <summary>Message queue handler.</summary>
|
||||
MessageHandler,
|
||||
|
||||
/// <summary>Scheduled job/cron handler.</summary>
|
||||
ScheduledJob,
|
||||
|
||||
/// <summary>Event handler.</summary>
|
||||
EventHandler,
|
||||
|
||||
/// <summary>WebSocket handler.</summary>
|
||||
WebSocketHandler,
|
||||
|
||||
/// <summary>Public API method.</summary>
|
||||
PublicApi,
|
||||
|
||||
/// <summary>Unknown entrypoint type.</summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Category of vulnerable sink.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
|
||||
public enum SinkCategory
|
||||
{
|
||||
/// <summary>SQL query execution.</summary>
|
||||
SqlRaw,
|
||||
|
||||
/// <summary>Command execution.</summary>
|
||||
CommandExec,
|
||||
|
||||
/// <summary>File system access.</summary>
|
||||
FileAccess,
|
||||
|
||||
/// <summary>Network/HTTP client.</summary>
|
||||
NetworkClient,
|
||||
|
||||
/// <summary>Deserialization.</summary>
|
||||
Deserialization,
|
||||
|
||||
/// <summary>Path traversal sensitive.</summary>
|
||||
PathTraversal,
|
||||
|
||||
/// <summary>Cryptography weakness.</summary>
|
||||
CryptoWeakness,
|
||||
|
||||
/// <summary>SSRF sensitive.</summary>
|
||||
Ssrf,
|
||||
|
||||
/// <summary>XXE sensitive.</summary>
|
||||
Xxe,
|
||||
|
||||
/// <summary>LDAP injection.</summary>
|
||||
LdapInjection,
|
||||
|
||||
/// <summary>XPath injection.</summary>
|
||||
XPathInjection,
|
||||
|
||||
/// <summary>Log injection.</summary>
|
||||
LogInjection,
|
||||
|
||||
/// <summary>Template injection.</summary>
|
||||
TemplateInjection,
|
||||
|
||||
/// <summary>Other sink category.</summary>
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path explanation query parameters.
|
||||
/// </summary>
|
||||
public sealed record PathExplanationQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by vulnerability ID.
|
||||
/// </summary>
|
||||
public string? VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by sink ID.
|
||||
/// </summary>
|
||||
public string? SinkId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by entrypoint ID.
|
||||
/// </summary>
|
||||
public string? EntrypointId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum path length to return.
|
||||
/// </summary>
|
||||
public int? MaxPathLength { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Include only paths with gates.
|
||||
/// </summary>
|
||||
public bool? HasGates { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of paths to return.
|
||||
/// </summary>
|
||||
public int MaxPaths { get; init; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of path explanation.
|
||||
/// </summary>
|
||||
public sealed record PathExplanationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Explained paths matching the query.
|
||||
/// </summary>
|
||||
[JsonPropertyName("paths")]
|
||||
public required IReadOnlyList<ExplainedPath> Paths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of paths (before limiting).
|
||||
/// </summary>
|
||||
[JsonPropertyName("total_count")]
|
||||
public required int TotalCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether more paths are available.
|
||||
/// </summary>
|
||||
[JsonPropertyName("has_more")]
|
||||
public bool HasMore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Graph hash for provenance.
|
||||
/// </summary>
|
||||
[JsonPropertyName("graph_hash")]
|
||||
public string? GraphHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the explanation was generated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("generated_at")]
|
||||
public DateTimeOffset GeneratedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// PathRenderer.cs
|
||||
// Sprint: SPRINT_3620_0002_0001_path_explanation
|
||||
// Description: Renders explained paths in various output formats.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Explanation;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for path rendering.
|
||||
/// </summary>
|
||||
public enum PathOutputFormat
|
||||
{
|
||||
/// <summary>Plain text format.</summary>
|
||||
Text,
|
||||
|
||||
/// <summary>Markdown format.</summary>
|
||||
Markdown,
|
||||
|
||||
/// <summary>JSON format.</summary>
|
||||
Json
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for path rendering.
|
||||
/// </summary>
|
||||
public interface IPathRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders an explained path in the specified format.
|
||||
/// </summary>
|
||||
string Render(ExplainedPath path, PathOutputFormat format);
|
||||
|
||||
/// <summary>
|
||||
/// Renders multiple explained paths in the specified format.
|
||||
/// </summary>
|
||||
string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a path explanation result in the specified format.
|
||||
/// </summary>
|
||||
string RenderResult(PathExplanationResult result, PathOutputFormat format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IPathRenderer"/>.
|
||||
/// </summary>
|
||||
public sealed class PathRenderer : IPathRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Render(ExplainedPath path, PathOutputFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PathOutputFormat.Text => RenderText(path),
|
||||
PathOutputFormat.Markdown => RenderMarkdown(path),
|
||||
PathOutputFormat.Json => RenderJson(path),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RenderMany(IReadOnlyList<ExplainedPath> paths, PathOutputFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PathOutputFormat.Text => RenderManyText(paths),
|
||||
PathOutputFormat.Markdown => RenderManyMarkdown(paths),
|
||||
PathOutputFormat.Json => RenderManyJson(paths),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RenderResult(PathExplanationResult result, PathOutputFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PathOutputFormat.Text => RenderResultText(result),
|
||||
PathOutputFormat.Markdown => RenderResultMarkdown(result),
|
||||
PathOutputFormat.Json => JsonSerializer.Serialize(result, JsonOptions),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
}
|
||||
|
||||
#region Text Rendering
|
||||
|
||||
private static string RenderText(ExplainedPath path)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine($"{path.EntrypointType}: {path.EntrypointSymbol}");
|
||||
|
||||
// Hops
|
||||
foreach (var hop in path.Hops)
|
||||
{
|
||||
var prefix = hop.IsEntrypoint ? " " : " → ";
|
||||
var location = hop.File is not null && hop.Line.HasValue
|
||||
? $" ({hop.File}:{hop.Line})"
|
||||
: "";
|
||||
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
|
||||
|
||||
sb.AppendLine($"{prefix}{hop.Symbol}{location}{sinkMarker}");
|
||||
}
|
||||
|
||||
// Gates summary
|
||||
if (path.Gates.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
var gatesSummary = string.Join(", ", path.Gates.Select(FormatGateText));
|
||||
sb.AppendLine($"Gates: {gatesSummary}");
|
||||
var percentage = path.GateMultiplierBps / 100.0;
|
||||
sb.AppendLine($"Final multiplier: {percentage:F0}%");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderManyText(IReadOnlyList<ExplainedPath> paths)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Found {paths.Count} path(s):");
|
||||
sb.AppendLine(new string('=', 60));
|
||||
|
||||
for (var i = 0; i < paths.Count; i++)
|
||||
{
|
||||
if (i > 0) sb.AppendLine(new string('-', 60));
|
||||
sb.AppendLine($"Path {i + 1}:");
|
||||
sb.Append(RenderText(paths[i]));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderResultText(PathExplanationResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Path Explanation Result");
|
||||
sb.AppendLine($"Total paths: {result.TotalCount}");
|
||||
sb.AppendLine($"Showing: {result.Paths.Count}");
|
||||
if (result.GraphHash is not null)
|
||||
sb.AppendLine($"Graph: {result.GraphHash}");
|
||||
sb.AppendLine($"Generated: {result.GeneratedAt:u}");
|
||||
sb.AppendLine();
|
||||
sb.Append(RenderManyText(result.Paths.ToList()));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string FormatGateText(DetectedGate gate)
|
||||
{
|
||||
var multiplier = gate.Type switch
|
||||
{
|
||||
GateType.AuthRequired => "30%",
|
||||
GateType.FeatureFlag => "50%",
|
||||
GateType.AdminOnly => "20%",
|
||||
GateType.NonDefaultConfig => "70%",
|
||||
_ => "100%"
|
||||
};
|
||||
|
||||
return $"{gate.Detail} ({gate.Type.ToString().ToLowerInvariant()}, {multiplier})";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Markdown Rendering
|
||||
|
||||
private static string RenderMarkdown(ExplainedPath path)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Header
|
||||
sb.AppendLine($"### {path.EntrypointType}: `{path.EntrypointSymbol}`");
|
||||
sb.AppendLine();
|
||||
|
||||
// Path as a code block
|
||||
sb.AppendLine("```");
|
||||
foreach (var hop in path.Hops)
|
||||
{
|
||||
var arrow = hop.IsEntrypoint ? "" : "→ ";
|
||||
var location = hop.File is not null && hop.Line.HasValue
|
||||
? $" ({hop.File}:{hop.Line})"
|
||||
: "";
|
||||
var sinkMarker = hop.IsSink ? $" [SINK: {path.SinkCategory}]" : "";
|
||||
|
||||
sb.AppendLine($"{arrow}{hop.Symbol}{location}{sinkMarker}");
|
||||
}
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
|
||||
// Gates table
|
||||
if (path.Gates.Count > 0)
|
||||
{
|
||||
sb.AppendLine("**Gates:**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Type | Detail | Multiplier |");
|
||||
sb.AppendLine("|------|--------|------------|");
|
||||
|
||||
foreach (var gate in path.Gates)
|
||||
{
|
||||
var multiplier = gate.Type switch
|
||||
{
|
||||
GateType.AuthRequired => "30%",
|
||||
GateType.FeatureFlag => "50%",
|
||||
GateType.AdminOnly => "20%",
|
||||
GateType.NonDefaultConfig => "70%",
|
||||
_ => "100%"
|
||||
};
|
||||
|
||||
sb.AppendLine($"| {gate.Type} | {gate.Detail} | {multiplier} |");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
var percentage = path.GateMultiplierBps / 100.0;
|
||||
sb.AppendLine($"**Final multiplier:** {percentage:F0}%");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderManyMarkdown(IReadOnlyList<ExplainedPath> paths)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"## Reachability Paths ({paths.Count} found)");
|
||||
sb.AppendLine();
|
||||
|
||||
for (var i = 0; i < paths.Count; i++)
|
||||
{
|
||||
sb.AppendLine($"---");
|
||||
sb.AppendLine($"#### Path {i + 1}");
|
||||
sb.AppendLine();
|
||||
sb.Append(RenderMarkdown(paths[i]));
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderResultMarkdown(PathExplanationResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Path Explanation Result");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Total paths:** {result.TotalCount}");
|
||||
sb.AppendLine($"- **Showing:** {result.Paths.Count}");
|
||||
if (result.HasMore)
|
||||
sb.AppendLine($"- **More available:** Yes");
|
||||
if (result.GraphHash is not null)
|
||||
sb.AppendLine($"- **Graph hash:** `{result.GraphHash}`");
|
||||
sb.AppendLine($"- **Generated:** {result.GeneratedAt:u}");
|
||||
sb.AppendLine();
|
||||
sb.Append(RenderManyMarkdown(result.Paths.ToList()));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region JSON Rendering
|
||||
|
||||
private static string RenderJson(ExplainedPath path)
|
||||
{
|
||||
return JsonSerializer.Serialize(path, JsonOptions);
|
||||
}
|
||||
|
||||
private static string RenderManyJson(IReadOnlyList<ExplainedPath> paths)
|
||||
{
|
||||
return JsonSerializer.Serialize(new { paths }, JsonOptions);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user