using System; using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using StellaOps.Cryptography; using StellaOps.Scanner.Reachability.Gates; namespace StellaOps.Scanner.Reachability; /// /// Writes richgraph-v1 documents to disk with canonical ordering and compliance-profile-aware hashing. /// Uses for content addressing, which resolves to: /// - BLAKE3-256 for "world" profile /// - SHA-256 for "fips" profile /// - GOST3411-2012-256 for "gost" profile /// - SM3 for "sm" profile /// public sealed class RichGraphWriter { private readonly ICryptoHash _cryptoHash; private static readonly JsonWriterOptions JsonOptions = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Indented = false, SkipValidation = false }; /// /// Creates a new RichGraphWriter with the specified crypto hash service. /// /// Crypto hash service for compliance-aware hashing. public RichGraphWriter(ICryptoHash cryptoHash) { _cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash)); } public async Task WriteAsync( RichGraph graph, string outputRoot, string analysisId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(graph); ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot); ArgumentException.ThrowIfNullOrWhiteSpace(analysisId); var trimmed = graph.Trimmed(); var root = Path.Combine(outputRoot, "reachability_graphs", analysisId); Directory.CreateDirectory(root); var graphPath = Path.Combine(root, "richgraph-v1.json"); await using (var stream = File.Create(graphPath)) await using (var writer = new Utf8JsonWriter(stream, JsonOptions)) { WriteGraph(writer, trimmed); await writer.FlushAsync(cancellationToken).ConfigureAwait(false); } var bytes = await File.ReadAllBytesAsync(graphPath, cancellationToken).ConfigureAwait(false); var graphHash = _cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Graph); var metaPath = Path.Combine(root, "meta.json"); await using (var stream = File.Create(metaPath)) await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) { writer.WriteStartObject(); writer.WriteString("schema", trimmed.Schema); writer.WriteString("graph_hash", graphHash); writer.WritePropertyName("files"); writer.WriteStartArray(); writer.WriteStartObject(); writer.WriteString("path", graphPath); writer.WriteString("hash", graphHash); writer.WriteEndObject(); writer.WriteEndArray(); writer.WriteEndObject(); await writer.FlushAsync(cancellationToken).ConfigureAwait(false); } return new RichGraphWriteResult(graphPath, metaPath, graphHash, trimmed.Nodes.Count, trimmed.Edges.Count); } private static void WriteGraph(Utf8JsonWriter writer, RichGraph graph) { writer.WriteStartObject(); writer.WriteString("schema", graph.Schema); writer.WritePropertyName("analyzer"); writer.WriteStartObject(); writer.WriteString("name", graph.Analyzer.Name); writer.WriteString("version", graph.Analyzer.Version); if (!string.IsNullOrWhiteSpace(graph.Analyzer.ToolchainDigest)) { writer.WriteString("toolchain_digest", graph.Analyzer.ToolchainDigest); } writer.WriteEndObject(); writer.WritePropertyName("nodes"); writer.WriteStartArray(); foreach (var node in graph.Nodes) { writer.WriteStartObject(); writer.WriteString("id", node.Id); writer.WriteString("symbol_id", node.SymbolId); writer.WriteString("lang", node.Lang); writer.WriteString("kind", node.Kind); if (!string.IsNullOrWhiteSpace(node.Display)) writer.WriteString("display", node.Display); if (!string.IsNullOrWhiteSpace(node.CodeId)) writer.WriteString("code_id", node.CodeId); if (!string.IsNullOrWhiteSpace(node.Purl)) writer.WriteString("purl", node.Purl); if (!string.IsNullOrWhiteSpace(node.BuildId)) writer.WriteString("build_id", node.BuildId); if (!string.IsNullOrWhiteSpace(node.CodeBlockHash)) writer.WriteString("code_block_hash", node.CodeBlockHash); if (!string.IsNullOrWhiteSpace(node.SymbolDigest)) writer.WriteString("symbol_digest", node.SymbolDigest); if (node.Symbol is not null) { writer.WritePropertyName("symbol"); WriteSymbol(writer, node.Symbol); } if (node.Evidence is { Count: > 0 }) { writer.WritePropertyName("evidence"); writer.WriteStartArray(); foreach (var e in node.Evidence) writer.WriteStringValue(e); writer.WriteEndArray(); } if (node.Attributes is { Count: > 0 }) { writer.WritePropertyName("attributes"); writer.WriteStartObject(); foreach (var kv in node.Attributes) { writer.WriteString(kv.Key, kv.Value); } writer.WriteEndObject(); } writer.WriteEndObject(); } writer.WriteEndArray(); writer.WritePropertyName("edges"); writer.WriteStartArray(); foreach (var edge in graph.Edges) { writer.WriteStartObject(); writer.WriteString("from", edge.From); writer.WriteString("to", edge.To); writer.WriteString("kind", edge.Kind); if (!string.IsNullOrWhiteSpace(edge.Purl)) writer.WriteString("purl", edge.Purl); if (!string.IsNullOrWhiteSpace(edge.SymbolDigest)) writer.WriteString("symbol_digest", edge.SymbolDigest); writer.WriteNumber("confidence", edge.Confidence); if (edge.Gates is { Count: > 0 } || edge.GateMultiplierBps != 10000) { writer.WriteNumber("gate_multiplier_bps", edge.GateMultiplierBps); } if (edge.Gates is { Count: > 0 }) { writer.WritePropertyName("gates"); writer.WriteStartArray(); foreach (var gate in edge.Gates) { writer.WriteStartObject(); writer.WriteString("type", GateTypeToLowerCamelCase(gate.Type)); writer.WriteString("detail", gate.Detail); writer.WriteString("guard_symbol", gate.GuardSymbol); if (!string.IsNullOrWhiteSpace(gate.SourceFile)) writer.WriteString("source_file", gate.SourceFile); if (gate.LineNumber is not null) writer.WriteNumber("line_number", gate.LineNumber.Value); writer.WriteNumber("confidence", gate.Confidence); writer.WriteString("detection_method", gate.DetectionMethod); writer.WriteEndObject(); } writer.WriteEndArray(); } if (edge.Evidence is { Count: > 0 }) { writer.WritePropertyName("evidence"); writer.WriteStartArray(); foreach (var e in edge.Evidence) writer.WriteStringValue(e); writer.WriteEndArray(); } if (edge.Candidates is { Count: > 0 }) { writer.WritePropertyName("candidates"); writer.WriteStartArray(); foreach (var c in edge.Candidates) writer.WriteStringValue(c); writer.WriteEndArray(); } writer.WriteEndObject(); } writer.WriteEndArray(); writer.WritePropertyName("roots"); writer.WriteStartArray(); foreach (var root in graph.Roots) { writer.WriteStartObject(); writer.WriteString("id", root.Id); writer.WriteString("phase", root.Phase); if (!string.IsNullOrWhiteSpace(root.Source)) writer.WriteString("source", root.Source); writer.WriteEndObject(); } writer.WriteEndArray(); writer.WriteEndObject(); } private static string GateTypeToLowerCamelCase(GateType type) => type switch { GateType.AuthRequired => "authRequired", GateType.FeatureFlag => "featureFlag", GateType.AdminOnly => "adminOnly", GateType.NonDefaultConfig => "nonDefaultConfig", _ => type.ToString() }; private static void WriteSymbol(Utf8JsonWriter writer, ReachabilitySymbol symbol) { writer.WriteStartObject(); if (!string.IsNullOrWhiteSpace(symbol.Mangled)) { writer.WriteString("mangled", symbol.Mangled); } if (!string.IsNullOrWhiteSpace(symbol.Demangled)) { writer.WriteString("demangled", symbol.Demangled); } if (!string.IsNullOrWhiteSpace(symbol.Source)) { writer.WriteString("source", symbol.Source); } if (symbol.Confidence is not null) { writer.WriteNumber("confidence", symbol.Confidence.Value); } writer.WriteEndObject(); } } public sealed record RichGraphWriteResult( string GraphPath, string MetaPath, string GraphHash, int NodeCount, int EdgeCount);