feat: add security sink detection patterns for JavaScript/TypeScript
- Introduced `sink-detect.js` with various security sink detection patterns categorized by type (e.g., command injection, SQL injection, file operations). - Implemented functions to build a lookup map for fast sink detection and to match sink calls against known patterns. - Added `package-lock.json` for dependency management.
This commit is contained in:
786
src/Cli/StellaOps.Cli/Commands/ReachabilityCommandGroup.cs
Normal file
786
src/Cli/StellaOps.Cli/Commands/ReachabilityCommandGroup.cs
Normal file
@@ -0,0 +1,786 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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?.Count ?? 0}");
|
||||
Console.WriteLine($" Edges: {subgraph.Edges?.Count ?? 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("<", "<")
|
||||
.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
|
||||
}
|
||||
Reference in New Issue
Block a user