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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user