Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/ReachabilityCommandGroup.cs
StellaOps Bot 7e384ab610 feat: Implement IsolatedReplayContext for deterministic audit replay
- Added IsolatedReplayContext class to provide an isolated environment for replaying audit bundles without external calls.
- Introduced methods for initializing the context, verifying input digests, and extracting inputs for policy evaluation.
- Created supporting interfaces and options for context configuration.

feat: Create ReplayExecutor for executing policy re-evaluation and verdict comparison

- Developed ReplayExecutor class to handle the execution of replay processes, including input verification and verdict comparison.
- Implemented detailed drift detection and error handling during replay execution.
- Added interfaces for policy evaluation and replay execution options.

feat: Add ScanSnapshotFetcher for fetching scan data and snapshots

- Introduced ScanSnapshotFetcher class to retrieve necessary scan data and snapshots for audit bundle creation.
- Implemented methods to fetch scan metadata, advisory feeds, policy snapshots, and VEX statements.
- Created supporting interfaces for scan data, feed snapshots, and policy snapshots.
2025-12-23 07:46:40 +02:00

787 lines
25 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for reachability subgraph visualization.
/// Implements `stella reachability show` and export commands.
/// </summary>
public static class ReachabilityCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the reachability command group.
/// </summary>
public static Command BuildReachabilityCommand(
IServiceProvider services,
Option<bool> 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<bool> verboseOption,
CancellationToken cancellationToken)
{
var inputOption = new Option<string>("--input", "-i")
{
Description = "Input subgraph JSON file",
Required = true
};
var formatOption = new Option<string>("--format", "-f")
{
Description = "Output format: table (default), json, dot, mermaid, summary"
};
var filterOption = new Option<string?>("--filter")
{
Description = "Filter by finding key or vulnerability ID"
};
var maxDepthOption = new Option<int?>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var inputOption = new Option<string>("--input", "-i")
{
Description = "Input subgraph JSON file",
Required = true
};
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output file path",
Required = true
};
var formatOption = new Option<string>("--format", "-f")
{
Description = "Export format: dot (default), mermaid, svg"
};
var titleOption = new Option<string?>("--title")
{
Description = "Graph title for visualization"
};
var highlightOption = new Option<string?>("--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<int> HandleShowAsync(
IServiceProvider services,
string inputPath,
string format,
string? filter,
int? maxDepth,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<ReachabilitySubgraph>(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<int> HandleExportAsync(
IServiceProvider services,
string inputPath,
string outputPath,
string format,
string? title,
string? highlight,
bool verbose,
CancellationToken ct)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<ReachabilitySubgraph>(json, JsonOptions);
if (subgraph is null)
{
Console.WriteLine("Error: Failed to parse subgraph JSON");
return 1;
}
var highlightNodes = string.IsNullOrWhiteSpace(highlight)
? null
: new HashSet<string>(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<string>(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<string>? 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<string>? 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<string>? highlightNodes)
{
// Generate a simple SVG placeholder
// In production, this would use a proper graph layout algorithm
var sb = new StringBuilder();
sb.AppendLine("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.AppendLine("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"600\">");
sb.AppendLine(" <style>");
sb.AppendLine(" .node { fill: #lightyellow; stroke: #333; stroke-width: 1; }");
sb.AppendLine(" .entrypoint { fill: #90EE90; }");
sb.AppendLine(" .vulnerable { fill: #F08080; }");
sb.AppendLine(" .label { font-family: sans-serif; font-size: 12px; }");
sb.AppendLine(" </style>");
if (!string.IsNullOrWhiteSpace(title))
{
sb.AppendLine($" <text x=\"400\" y=\"30\" text-anchor=\"middle\" class=\"label\" style=\"font-size: 16px; font-weight: bold;\">{EscapeXml(title)}</text>");
}
sb.AppendLine(" <text x=\"400\" y=\"300\" text-anchor=\"middle\" class=\"label\">");
sb.AppendLine($" Nodes: {subgraph.Nodes?.Length ?? 0}, Edges: {subgraph.Edges?.Length ?? 0}");
sb.AppendLine(" </text>");
sb.AppendLine(" <text x=\"400\" y=\"330\" text-anchor=\"middle\" class=\"label\" style=\"font-size: 10px;\">");
sb.AppendLine(" (For full SVG rendering, use: dot -Tsvg subgraph.dot -o subgraph.svg)");
sb.AppendLine(" </text>");
sb.AppendLine("</svg>");
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("<", "&lt;")
.Replace(">", "&gt;");
}
private static string EscapeXml(string value)
{
return value
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&apos;");
}
#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
}