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,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;
}

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

View File

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