// ----------------------------------------------------------------------------- // ReachabilityCommandGroup.cs // Sprint: SPRINT_4400_0001_0002_reachability_subgraph_attestation // Description: CLI commands for reachability subgraph visualization // ----------------------------------------------------------------------------- using System.CommandLine; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace StellaOps.Cli.Commands; /// /// Command group for reachability subgraph visualization. /// Implements `stella reachability show` and export commands. /// public static class ReachabilityCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the reachability command group. /// public static Command BuildReachabilityCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var reachability = new Command("reachability", "Reachability subgraph operations"); reachability.Add(BuildShowCommand(services, verboseOption, cancellationToken)); reachability.Add(BuildExportCommand(services, verboseOption, cancellationToken)); return reachability; } private static Command BuildShowCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var inputOption = new Option("--input", "-i") { Description = "Input subgraph JSON file", Required = true }; var formatOption = new Option("--format", "-f") { Description = "Output format: table (default), json, dot, mermaid, summary" }; var filterOption = new Option("--filter") { Description = "Filter by finding key or vulnerability ID" }; var maxDepthOption = new Option("--max-depth") { Description = "Maximum path depth to display" }; var show = new Command("show", "Display reachability subgraph") { inputOption, formatOption, filterOption, maxDepthOption, verboseOption }; show.SetAction(async (parseResult, _) => { var inputPath = parseResult.GetValue(inputOption) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "table"; var filter = parseResult.GetValue(filterOption); var maxDepth = parseResult.GetValue(maxDepthOption); var verbose = parseResult.GetValue(verboseOption); return await HandleShowAsync( services, inputPath, format, filter, maxDepth, verbose, cancellationToken); }); return show; } private static Command BuildExportCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var inputOption = new Option("--input", "-i") { Description = "Input subgraph JSON file", Required = true }; var outputOption = new Option("--output", "-o") { Description = "Output file path", Required = true }; var formatOption = new Option("--format", "-f") { Description = "Export format: dot (default), mermaid, svg" }; var titleOption = new Option("--title") { Description = "Graph title for visualization" }; var highlightOption = new Option("--highlight") { Description = "Comma-separated node IDs to highlight" }; var export = new Command("export", "Export subgraph to visualization format") { inputOption, outputOption, formatOption, titleOption, highlightOption, verboseOption }; export.SetAction(async (parseResult, _) => { var inputPath = parseResult.GetValue(inputOption) ?? string.Empty; var outputPath = parseResult.GetValue(outputOption) ?? string.Empty; var format = parseResult.GetValue(formatOption) ?? "dot"; var title = parseResult.GetValue(titleOption); var highlight = parseResult.GetValue(highlightOption); var verbose = parseResult.GetValue(verboseOption); return await HandleExportAsync( services, inputPath, outputPath, format, title, highlight, verbose, cancellationToken); }); return export; } private static async Task HandleShowAsync( IServiceProvider services, string inputPath, string format, string? filter, int? maxDepth, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(ReachabilityCommandGroup)); try { if (!File.Exists(inputPath)) { Console.WriteLine($"Error: Input file not found: {inputPath}"); return 1; } var json = await File.ReadAllTextAsync(inputPath, ct); var subgraph = JsonSerializer.Deserialize(json, JsonOptions); if (subgraph is null) { Console.WriteLine("Error: Failed to parse subgraph JSON"); return 1; } // Apply filter if specified if (!string.IsNullOrWhiteSpace(filter)) { subgraph = FilterSubgraph(subgraph, filter); } // Apply max depth if specified if (maxDepth.HasValue && maxDepth.Value > 0) { subgraph = TruncateToDepth(subgraph, maxDepth.Value); } var output = format.ToLowerInvariant() switch { "json" => JsonSerializer.Serialize(subgraph, JsonOptions), "dot" => GenerateDot(subgraph, null), "mermaid" => GenerateMermaid(subgraph, null), "summary" => GenerateSummary(subgraph), _ => GenerateTable(subgraph) }; Console.WriteLine(output); return 0; } catch (JsonException ex) { logger?.LogError(ex, "Failed to parse subgraph JSON"); Console.WriteLine($"Error: Invalid JSON: {ex.Message}"); return 1; } catch (Exception ex) { logger?.LogError(ex, "Show command failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static async Task HandleExportAsync( IServiceProvider services, string inputPath, string outputPath, string format, string? title, string? highlight, bool verbose, CancellationToken ct) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger(typeof(ReachabilityCommandGroup)); try { if (!File.Exists(inputPath)) { Console.WriteLine($"Error: Input file not found: {inputPath}"); return 1; } var json = await File.ReadAllTextAsync(inputPath, ct); var subgraph = JsonSerializer.Deserialize(json, JsonOptions); if (subgraph is null) { Console.WriteLine("Error: Failed to parse subgraph JSON"); return 1; } var highlightNodes = string.IsNullOrWhiteSpace(highlight) ? null : new HashSet(highlight.Split(',').Select(s => s.Trim()), StringComparer.Ordinal); var output = format.ToLowerInvariant() switch { "mermaid" => GenerateMermaid(subgraph, title, highlightNodes), "svg" => GenerateSvg(subgraph, title, highlightNodes), _ => GenerateDot(subgraph, title, highlightNodes) }; await File.WriteAllTextAsync(outputPath, output, ct); Console.WriteLine($"Exported subgraph to: {outputPath}"); if (verbose) { Console.WriteLine($" Format: {format}"); Console.WriteLine($" Nodes: {subgraph.Nodes?.Length ?? 0}"); Console.WriteLine($" Edges: {subgraph.Edges?.Length ?? 0}"); } return 0; } catch (Exception ex) { logger?.LogError(ex, "Export command failed unexpectedly"); Console.WriteLine($"Error: {ex.Message}"); return 1; } } private static ReachabilitySubgraph FilterSubgraph(ReachabilitySubgraph subgraph, string filter) { // Check if filter matches any finding keys var matchingKeys = subgraph.FindingKeys? .Where(k => k.Contains(filter, StringComparison.OrdinalIgnoreCase)) .ToList() ?? []; if (matchingKeys.Count == 0) { // No match - return empty subgraph return subgraph with { Nodes = [], Edges = [], FindingKeys = [] }; } // For now, return subgraph as-is (filtering would require more complex graph traversal) return subgraph with { FindingKeys = matchingKeys.ToArray() }; } private static ReachabilitySubgraph TruncateToDepth(ReachabilitySubgraph subgraph, int maxDepth) { // Simple BFS-based truncation from entrypoints var entrypoints = subgraph.Nodes? .Where(n => n.Type == "entrypoint") .Select(n => n.Id) .ToHashSet(StringComparer.Ordinal) ?? []; if (entrypoints.Count == 0) { return subgraph; } var edgeLookup = subgraph.Edges? .GroupBy(e => e.From) .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal) ?? []; var visited = new HashSet(StringComparer.Ordinal); var queue = new Queue<(string Id, int Depth)>(); foreach (var entry in entrypoints) { queue.Enqueue((entry, 0)); visited.Add(entry); } while (queue.Count > 0) { var (nodeId, depth) = queue.Dequeue(); if (depth >= maxDepth) { continue; } if (edgeLookup.TryGetValue(nodeId, out var edges)) { foreach (var edge in edges) { if (visited.Add(edge.To)) { queue.Enqueue((edge.To, depth + 1)); } } } } var filteredNodes = subgraph.Nodes? .Where(n => visited.Contains(n.Id)) .ToArray() ?? []; var filteredEdges = subgraph.Edges? .Where(e => visited.Contains(e.From) && visited.Contains(e.To)) .ToArray() ?? []; return subgraph with { Nodes = filteredNodes, Edges = filteredEdges }; } private static string GenerateTable(ReachabilitySubgraph subgraph) { var sb = new StringBuilder(); sb.AppendLine("Reachability Subgraph"); sb.AppendLine(new string('=', 60)); sb.AppendLine(); // Finding keys if (subgraph.FindingKeys is { Length: > 0 }) { sb.AppendLine("Finding Keys:"); foreach (var key in subgraph.FindingKeys) { sb.AppendLine($" • {key}"); } sb.AppendLine(); } // Nodes summary var nodesByType = subgraph.Nodes? .GroupBy(n => n.Type) .ToDictionary(g => g.Key, g => g.Count()) ?? []; sb.AppendLine("Nodes:"); sb.AppendLine($" Total: {subgraph.Nodes?.Length ?? 0}"); foreach (var (type, count) in nodesByType.OrderBy(kv => kv.Key)) { sb.AppendLine($" {type}: {count}"); } sb.AppendLine(); // Edges summary sb.AppendLine($"Edges: {subgraph.Edges?.Length ?? 0}"); sb.AppendLine(); // Paths from entrypoints to vulnerable nodes var entrypoints = subgraph.Nodes?.Where(n => n.Type == "entrypoint").ToList() ?? []; var vulnerables = subgraph.Nodes?.Where(n => n.Type == "vulnerable").ToList() ?? []; if (entrypoints.Count > 0 && vulnerables.Count > 0) { sb.AppendLine("Paths:"); foreach (var entry in entrypoints.Take(3)) { foreach (var vuln in vulnerables.Take(3)) { sb.AppendLine($" {entry.Symbol} → ... → {vuln.Symbol}"); } } if (entrypoints.Count > 3 || vulnerables.Count > 3) { sb.AppendLine(" ... (truncated)"); } } // Metadata if (subgraph.AnalysisMetadata is not null) { sb.AppendLine(); sb.AppendLine("Analysis Metadata:"); sb.AppendLine($" Analyzer: {subgraph.AnalysisMetadata.Analyzer}"); sb.AppendLine($" Version: {subgraph.AnalysisMetadata.AnalyzerVersion}"); sb.AppendLine($" Confidence: {subgraph.AnalysisMetadata.Confidence:P0}"); sb.AppendLine($" Completeness: {subgraph.AnalysisMetadata.Completeness}"); } return sb.ToString(); } private static string GenerateSummary(ReachabilitySubgraph subgraph) { var entrypoints = subgraph.Nodes?.Count(n => n.Type == "entrypoint") ?? 0; var vulnerables = subgraph.Nodes?.Count(n => n.Type == "vulnerable") ?? 0; return $"Nodes: {subgraph.Nodes?.Length ?? 0}, Edges: {subgraph.Edges?.Length ?? 0}, " + $"Entrypoints: {entrypoints}, Vulnerable: {vulnerables}, " + $"FindingKeys: {subgraph.FindingKeys?.Length ?? 0}"; } private static string GenerateDot( ReachabilitySubgraph subgraph, string? title, HashSet? highlightNodes = null) { var sb = new StringBuilder(); sb.AppendLine("digraph reachability {"); sb.AppendLine(" rankdir=LR;"); sb.AppendLine(" node [shape=box, fontname=\"Helvetica\"];"); sb.AppendLine(" edge [fontname=\"Helvetica\", fontsize=10];"); if (!string.IsNullOrWhiteSpace(title)) { sb.AppendLine($" label=\"{EscapeDotString(title)}\";"); sb.AppendLine(" labelloc=t;"); } // Define node styles by type sb.AppendLine(); sb.AppendLine(" // Node type styles"); sb.AppendLine(" node [style=filled];"); foreach (var node in subgraph.Nodes ?? []) { var color = node.Type switch { "entrypoint" => "lightgreen", "vulnerable" => "lightcoral", "call" => "lightyellow", _ => "lightgray" }; var shape = node.Type switch { "entrypoint" => "ellipse", "vulnerable" => "octagon", _ => "box" }; var isHighlighted = highlightNodes?.Contains(node.Id) == true; var style = isHighlighted ? "filled,bold" : "filled"; var penwidth = isHighlighted ? "3" : "1"; var label = EscapeDotString(node.Symbol ?? node.Id); var tooltip = node.File is not null ? $"{node.File}:{node.Line}" : node.Symbol ?? node.Id; sb.AppendLine($" \"{node.Id}\" [label=\"{label}\", fillcolor=\"{color}\", shape=\"{shape}\", style=\"{style}\", penwidth=\"{penwidth}\", tooltip=\"{EscapeDotString(tooltip)}\"];"); } sb.AppendLine(); sb.AppendLine(" // Edges"); foreach (var edge in subgraph.Edges ?? []) { var edgeLabel = edge.Gate is not null ? $"[{edge.Gate.GateType}]" : string.Empty; var color = edge.Gate is not null ? "blue" : "black"; var style = edge.Confidence < 0.5 ? "dashed" : "solid"; sb.Append($" \"{edge.From}\" -> \"{edge.To}\""); sb.Append($" [color=\"{color}\", style=\"{style}\""); if (!string.IsNullOrEmpty(edgeLabel)) { sb.Append($", label=\"{EscapeDotString(edgeLabel)}\""); } sb.AppendLine("];"); } sb.AppendLine("}"); return sb.ToString(); } private static string GenerateMermaid( ReachabilitySubgraph subgraph, string? title, HashSet? highlightNodes = null) { var sb = new StringBuilder(); if (!string.IsNullOrWhiteSpace(title)) { sb.AppendLine($"---"); sb.AppendLine($"title: {title}"); sb.AppendLine($"---"); } sb.AppendLine("graph LR"); // Define subgraphs for node types var entrypoints = subgraph.Nodes?.Where(n => n.Type == "entrypoint").ToList() ?? []; var vulnerables = subgraph.Nodes?.Where(n => n.Type == "vulnerable").ToList() ?? []; var others = subgraph.Nodes?.Where(n => n.Type != "entrypoint" && n.Type != "vulnerable").ToList() ?? []; if (entrypoints.Count > 0) { sb.AppendLine(" subgraph Entrypoints"); foreach (var node in entrypoints) { var label = SanitizeMermaidLabel(node.Symbol ?? node.Id); var nodeId = SanitizeMermaidId(node.Id); sb.AppendLine($" {nodeId}([{label}])"); } sb.AppendLine(" end"); } if (vulnerables.Count > 0) { sb.AppendLine(" subgraph Vulnerable"); foreach (var node in vulnerables) { var label = SanitizeMermaidLabel(node.Symbol ?? node.Id); var nodeId = SanitizeMermaidId(node.Id); sb.AppendLine($" {nodeId}{{{{{label}}}}}"); } sb.AppendLine(" end"); } foreach (var node in others) { var label = SanitizeMermaidLabel(node.Symbol ?? node.Id); var nodeId = SanitizeMermaidId(node.Id); sb.AppendLine($" {nodeId}[{label}]"); } sb.AppendLine(); // Edges foreach (var edge in subgraph.Edges ?? []) { var fromId = SanitizeMermaidId(edge.From); var toId = SanitizeMermaidId(edge.To); var edgeStyle = edge.Gate is not null ? "-.->|" + edge.Gate.GateType + "|" : "-->"; sb.AppendLine($" {fromId} {edgeStyle} {toId}"); } // Styling sb.AppendLine(); sb.AppendLine(" classDef entrypoint fill:#90EE90,stroke:#333"); sb.AppendLine(" classDef vulnerable fill:#F08080,stroke:#333"); if (entrypoints.Count > 0) { var entryIds = string.Join(",", entrypoints.Select(n => SanitizeMermaidId(n.Id))); sb.AppendLine($" class {entryIds} entrypoint"); } if (vulnerables.Count > 0) { var vulnIds = string.Join(",", vulnerables.Select(n => SanitizeMermaidId(n.Id))); sb.AppendLine($" class {vulnIds} vulnerable"); } if (highlightNodes is { Count: > 0 }) { sb.AppendLine(" classDef highlight stroke:#f00,stroke-width:3px"); var highlightIds = string.Join(",", highlightNodes.Select(SanitizeMermaidId)); sb.AppendLine($" class {highlightIds} highlight"); } return sb.ToString(); } private static string GenerateSvg( ReachabilitySubgraph subgraph, string? title, HashSet? highlightNodes) { // Generate a simple SVG placeholder // In production, this would use a proper graph layout algorithm var sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(" "); if (!string.IsNullOrWhiteSpace(title)) { sb.AppendLine($" {EscapeXml(title)}"); } sb.AppendLine(" "); sb.AppendLine($" Nodes: {subgraph.Nodes?.Length ?? 0}, Edges: {subgraph.Edges?.Length ?? 0}"); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" (For full SVG rendering, use: dot -Tsvg subgraph.dot -o subgraph.svg)"); sb.AppendLine(" "); sb.AppendLine(""); return sb.ToString(); } private static string EscapeDotString(string value) { return value .Replace("\\", "\\\\") .Replace("\"", "\\\"") .Replace("\n", "\\n") .Replace("\r", ""); } private static string SanitizeMermaidId(string id) { // Mermaid IDs must be alphanumeric with underscores return new string(id .Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_') .ToArray()); } private static string SanitizeMermaidLabel(string label) { // Escape special characters for Mermaid labels return label .Replace("\"", "'") .Replace("[", "(") .Replace("]", ")") .Replace("{", "(") .Replace("}", ")") .Replace("|", "\\|") .Replace("<", "<") .Replace(">", ">"); } private static string EscapeXml(string value) { return value .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } #region DTOs private sealed record ReachabilitySubgraph { [JsonPropertyName("version")] public string? Version { get; init; } [JsonPropertyName("findingKeys")] public string[]? FindingKeys { get; init; } [JsonPropertyName("nodes")] public ReachabilityNode[]? Nodes { get; init; } [JsonPropertyName("edges")] public ReachabilityEdge[]? Edges { get; init; } [JsonPropertyName("analysisMetadata")] public AnalysisMetadata? AnalysisMetadata { get; init; } } private sealed record ReachabilityNode { [JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("type")] public required string Type { get; init; } [JsonPropertyName("symbol")] public string? Symbol { get; init; } [JsonPropertyName("file")] public string? File { get; init; } [JsonPropertyName("line")] public int? Line { get; init; } [JsonPropertyName("purl")] public string? Purl { get; init; } } private sealed record ReachabilityEdge { [JsonPropertyName("from")] public required string From { get; init; } [JsonPropertyName("to")] public required string To { get; init; } [JsonPropertyName("type")] public string? Type { get; init; } [JsonPropertyName("confidence")] public double Confidence { get; init; } [JsonPropertyName("gate")] public GateInfo? Gate { get; init; } } private sealed record GateInfo { [JsonPropertyName("gateType")] public required string GateType { get; init; } [JsonPropertyName("condition")] public string? Condition { get; init; } } private sealed record AnalysisMetadata { [JsonPropertyName("analyzer")] public required string Analyzer { get; init; } [JsonPropertyName("analyzerVersion")] public required string AnalyzerVersion { get; init; } [JsonPropertyName("confidence")] public double Confidence { get; init; } [JsonPropertyName("completeness")] public required string Completeness { get; init; } } #endregion }