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:
master
2025-12-18 16:19:16 +02:00
parent 00d2c99af9
commit 811f35cba7
114 changed files with 13702 additions and 268 deletions

View File

@@ -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);
}
}