release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -1,86 +1,4 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Configuration options for <see cref="ReachabilityAnalyzer"/>.
/// Defines limits and ordering rules for deterministic path output.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
/// Contract: ReachabilityAnalyzer → PathWitnessBuilder output contract
///
/// Determinism guarantees:
/// - Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC)
/// - Node IDs within paths are ordered from entrypoint to sink (caller → callee)
/// - Maximum caps prevent unbounded output
/// </remarks>
public sealed record ReachabilityAnalysisOptions
{
/// <summary>
/// Default options with sensible limits.
/// </summary>
public static ReachabilityAnalysisOptions Default { get; } = new();
/// <summary>
/// Maximum depth for BFS traversal (0 = unlimited, default = 256).
/// Prevents infinite loops in cyclic graphs.
/// </summary>
public int MaxDepth { get; init; } = 256;
/// <summary>
/// Maximum number of paths to return per sink (default = 10).
/// Limits witness explosion when many entrypoints reach the same sink.
/// </summary>
public int MaxPathsPerSink { get; init; } = 10;
/// <summary>
/// Maximum total paths to return (default = 100).
/// Hard cap to prevent memory issues with highly connected graphs.
/// </summary>
public int MaxTotalPaths { get; init; } = 100;
/// <summary>
/// Whether to include node metadata in path reconstruction (default = true).
/// When false, paths only contain node IDs without additional context.
/// </summary>
public bool IncludeNodeMetadata { get; init; } = true;
/// <summary>
/// Explicit list of sink node IDs to target (default = null, meaning use snapshot.SinkIds).
/// When set, analysis will only find paths to these specific sinks.
/// This enables targeted witness generation for specific vulnerabilities.
/// </summary>
/// <remarks>
/// Sprint: SPRINT_3700_0001_0001 (WIT-007B)
/// Enables: PathWitnessBuilder can request paths to specific trigger methods.
/// </remarks>
public ImmutableArray<string>? ExplicitSinks { get; init; }
/// <summary>
/// Validates options and returns sanitized values.
/// </summary>
public ReachabilityAnalysisOptions Validated()
{
// Normalize explicit sinks: trim, dedupe, order
ImmutableArray<string>? normalizedSinks = null;
if (ExplicitSinks.HasValue && !ExplicitSinks.Value.IsDefaultOrEmpty)
{
normalizedSinks = ExplicitSinks.Value
.Where(s => !string.IsNullOrWhiteSpace(s))
.Select(s => s.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(s => s, StringComparer.Ordinal)
.ToImmutableArray();
}
return new ReachabilityAnalysisOptions
{
MaxDepth = MaxDepth <= 0 ? 256 : Math.Min(MaxDepth, 1024),
MaxPathsPerSink = MaxPathsPerSink <= 0 ? 10 : Math.Min(MaxPathsPerSink, 100),
MaxTotalPaths = MaxTotalPaths <= 0 ? 100 : Math.Min(MaxTotalPaths, 1000),
IncludeNodeMetadata = IncludeNodeMetadata,
ExplicitSinks = normalizedSinks
};
}
}
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// ReachabilityAnalysisOptions is now defined in StellaOps.Scanner.Contracts.
// This file exists only for file system tracking - the type is imported via global using.

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph;

View File

@@ -8,7 +8,7 @@ using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.CallGraph.Binary.Analysis;
using StellaOps.Scanner.CallGraph.Binary.Disassembly;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Binary;

View File

@@ -4,7 +4,7 @@
// Description: Classifies binary symbols as entrypoints based on naming patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Binary;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Bun;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Bun functions as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Bun;

View File

@@ -4,7 +4,7 @@
// Description: Matches Bun/JS function calls to security sink categories.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Bun;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Deno;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Deno functions as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Deno;

View File

@@ -4,7 +4,7 @@
// Description: Matches Deno function calls to security sink categories.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Deno;

View File

@@ -4,7 +4,7 @@ using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.MSBuild;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.DotNet;

View File

@@ -8,7 +8,7 @@ using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Go;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Go functions as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Go;

View File

@@ -4,7 +4,7 @@
// Description: Matches Go function calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Go;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Java;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Java methods as entrypoints based on framework annotations.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Java;

View File

@@ -4,7 +4,7 @@
// Description: Matches Java method calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Java;

View File

@@ -8,7 +8,7 @@ using System.Collections.Immutable;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.JavaScript;

View File

@@ -4,7 +4,7 @@
// Description: Classifies JavaScript/TypeScript functions as entrypoints.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.JavaScript;

View File

@@ -4,7 +4,7 @@
// Description: Matches JavaScript/TypeScript function calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.JavaScript;

View File

@@ -2,7 +2,7 @@ using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Node;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Php;

View File

@@ -4,7 +4,7 @@
// Description: Classifies PHP functions as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Php;

View File

@@ -4,7 +4,7 @@
// Description: Matches PHP function calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Php;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Python;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Python functions as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Python;

View File

@@ -4,7 +4,7 @@
// Description: Matches Python function calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Python;

View File

@@ -7,7 +7,7 @@
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Ruby;

View File

@@ -4,7 +4,7 @@
// Description: Classifies Ruby methods as entrypoints based on framework patterns.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Ruby;

View File

@@ -4,7 +4,7 @@
// Description: Matches Ruby method calls to known security sinks.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Contracts;
namespace StellaOps.Scanner.CallGraph.Ruby;

View File

@@ -1,516 +1,13 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Scanner.CallGraph.Serialization;
using StellaOps.Scanner.Reachability;
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) StellaOps
// Re-exports types from Contracts for backward compatibility.
namespace StellaOps.Scanner.CallGraph;
// All call graph models are now defined in StellaOps.Scanner.Contracts.
// This file provides type aliases for backward compatibility with code
// that uses the StellaOps.Scanner.CallGraph namespace.
public sealed record CallGraphSnapshot(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("graphDigest")] string GraphDigest,
[property: JsonPropertyName("language")] string Language,
[property: JsonPropertyName("extractedAt")] DateTimeOffset ExtractedAt,
[property: JsonPropertyName("nodes")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<CallGraphNode>))]
ImmutableArray<CallGraphNode> Nodes,
[property: JsonPropertyName("edges")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<CallGraphEdge>))]
ImmutableArray<CallGraphEdge> Edges,
[property: JsonPropertyName("entrypointIds")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<string>))]
ImmutableArray<string> EntrypointIds,
[property: JsonPropertyName("sinkIds")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<string>))]
ImmutableArray<string> SinkIds)
{
public CallGraphSnapshot Trimmed()
{
var nodes = (Nodes.IsDefault ? ImmutableArray<CallGraphNode>.Empty : Nodes)
.Where(n => !string.IsNullOrWhiteSpace(n.NodeId))
.Select(n => n.Trimmed())
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
.ToImmutableArray();
global using StellaOps.Scanner.Contracts;
var edges = (Edges.IsDefault ? ImmutableArray<CallGraphEdge>.Empty : Edges)
.Where(e => !string.IsNullOrWhiteSpace(e.SourceId) && !string.IsNullOrWhiteSpace(e.TargetId))
.Select(e => e.Trimmed())
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
.ToImmutableArray();
var entrypoints = (EntrypointIds.IsDefault ? ImmutableArray<string>.Empty : EntrypointIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var sinks = (SinkIds.IsDefault ? ImmutableArray<string>.Empty : SinkIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
return this with
{
ScanId = ScanId?.Trim() ?? string.Empty,
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
Language = Language?.Trim() ?? string.Empty,
Nodes = nodes,
Edges = edges,
EntrypointIds = entrypoints,
SinkIds = sinks
};
}
}
public sealed record CallGraphNode(
[property: JsonPropertyName("nodeId")] string NodeId,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("file")] string File,
[property: JsonPropertyName("line")] int Line,
[property: JsonPropertyName("package")] string Package,
[property: JsonPropertyName("visibility")] Visibility Visibility,
[property: JsonPropertyName("isEntrypoint")] bool IsEntrypoint,
[property: JsonPropertyName("entrypointType")] EntrypointType? EntrypointType,
[property: JsonPropertyName("isSink")] bool IsSink,
[property: JsonPropertyName("sinkCategory")] SinkCategory? SinkCategory)
{
public CallGraphNode Trimmed()
=> this with
{
NodeId = NodeId?.Trim() ?? string.Empty,
Symbol = Symbol?.Trim() ?? string.Empty,
File = File?.Trim() ?? string.Empty,
Package = Package?.Trim() ?? string.Empty
};
}
public sealed record CallGraphEdge(
[property: JsonPropertyName("sourceId")] string SourceId,
[property: JsonPropertyName("targetId")] string TargetId,
[property: JsonPropertyName("callKind")] CallKind CallKind,
[property: JsonPropertyName("callSite")] string? CallSite = null,
[property: JsonPropertyName("explanation")] CallEdgeExplanation? Explanation = null)
{
public CallGraphEdge Trimmed()
=> this with
{
SourceId = SourceId?.Trim() ?? string.Empty,
TargetId = TargetId?.Trim() ?? string.Empty,
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim(),
Explanation = Explanation?.Trimmed()
};
}
[JsonConverter(typeof(JsonStringEnumConverter<Visibility>))]
public enum Visibility
{
Public,
Internal,
Protected,
Private
}
[JsonConverter(typeof(JsonStringEnumConverter<CallKind>))]
public enum CallKind
{
Direct,
Virtual,
Delegate,
Reflection,
Dynamic,
Plt,
Iat
}
/// <summary>
/// Explanation type for call graph edges.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<CallEdgeExplanationType>))]
public enum CallEdgeExplanationType
{
/// <summary>Static import (ES6 import, Python import, using directive).</summary>
Import,
/// <summary>Dynamic load (require(), dlopen, LoadLibrary).</summary>
DynamicLoad,
/// <summary>Reflection invocation (Class.forName, Type.GetType).</summary>
Reflection,
/// <summary>Foreign function interface (JNI, P/Invoke, ctypes).</summary>
Ffi,
/// <summary>Environment variable guard (process.env.X, os.environ.get).</summary>
EnvGuard,
/// <summary>Feature flag check (LaunchDarkly, unleash, custom flags).</summary>
FeatureFlag,
/// <summary>Platform/architecture guard (process.platform, runtime.GOOS).</summary>
PlatformArch,
/// <summary>Taint gate (sanitization, validation).</summary>
TaintGate,
/// <summary>Loader rule (PLT/IAT/GOT entry).</summary>
LoaderRule,
/// <summary>Direct call (static, virtual, delegate).</summary>
DirectCall,
/// <summary>Cannot determine explanation type.</summary>
Unknown
}
/// <summary>
/// Explanation for why an edge exists in the call graph.
/// </summary>
public sealed record CallEdgeExplanation(
[property: JsonPropertyName("type")] CallEdgeExplanationType Type,
[property: JsonPropertyName("confidence")] double Confidence,
[property: JsonPropertyName("guard")] string? Guard = null,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string>? Metadata = null)
{
/// <summary>
/// Creates a simple direct call explanation with full confidence.
/// </summary>
public static CallEdgeExplanation DirectCall() =>
new(CallEdgeExplanationType.DirectCall, 1.0);
/// <summary>
/// Creates an import explanation with full confidence.
/// </summary>
public static CallEdgeExplanation Import(string? location = null) =>
new(CallEdgeExplanationType.Import, 1.0);
/// <summary>
/// Creates a dynamic load explanation with medium confidence.
/// </summary>
public static CallEdgeExplanation DynamicLoad(double confidence = 0.5) =>
new(CallEdgeExplanationType.DynamicLoad, confidence);
/// <summary>
/// Creates an environment guard explanation.
/// </summary>
public static CallEdgeExplanation EnvGuard(string guard, double confidence = 0.9) =>
new(CallEdgeExplanationType.EnvGuard, confidence, guard);
/// <summary>
/// Creates a feature flag explanation.
/// </summary>
public static CallEdgeExplanation FeatureFlag(string flag, double confidence = 0.85) =>
new(CallEdgeExplanationType.FeatureFlag, confidence, flag);
/// <summary>
/// Creates a platform/architecture guard explanation.
/// </summary>
public static CallEdgeExplanation PlatformArch(string platform, double confidence = 0.95) =>
new(CallEdgeExplanationType.PlatformArch, confidence, $"platform={platform}");
/// <summary>
/// Creates a reflection explanation.
/// </summary>
public static CallEdgeExplanation ReflectionCall(double confidence = 0.5) =>
new(CallEdgeExplanationType.Reflection, confidence);
/// <summary>
/// Creates a loader rule explanation (PLT/IAT/GOT).
/// </summary>
public static CallEdgeExplanation LoaderRule(string loaderType, ImmutableDictionary<string, string>? metadata = null) =>
new(CallEdgeExplanationType.LoaderRule, 0.8, null, metadata ?? ImmutableDictionary<string, string>.Empty.Add("loader", loaderType));
public CallEdgeExplanation Trimmed() =>
this with
{
Guard = string.IsNullOrWhiteSpace(Guard) ? null : Guard.Trim()
};
}
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
public enum EntrypointType
{
HttpHandler,
GrpcMethod,
CliCommand,
BackgroundJob,
ScheduledJob,
MessageHandler,
EventSubscriber,
WebSocketHandler,
EventHandler,
Lambda,
Unknown
}
public static class CallGraphDigests
{
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false
};
public static string ComputeGraphDigest(CallGraphSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
var trimmed = snapshot.Trimmed();
using var buffer = new MemoryStream(capacity: 64 * 1024);
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
{
WriteDigestPayload(writer, trimmed);
writer.Flush();
}
var hash = SHA256.HashData(buffer.ToArray());
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public static string ComputeResultDigest(ReachabilityAnalysisResult result)
{
ArgumentNullException.ThrowIfNull(result);
var trimmed = result.Trimmed();
using var buffer = new MemoryStream(capacity: 64 * 1024);
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
{
WriteDigestPayload(writer, trimmed);
writer.Flush();
}
var hash = SHA256.HashData(buffer.ToArray());
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static void WriteDigestPayload(Utf8JsonWriter writer, CallGraphSnapshot snapshot)
{
writer.WriteStartObject();
writer.WriteString("schema", "stellaops.callgraph@v1");
writer.WriteString("language", snapshot.Language);
writer.WritePropertyName("nodes");
writer.WriteStartArray();
foreach (var node in snapshot.Nodes)
{
writer.WriteStartObject();
writer.WriteString("nodeId", node.NodeId);
writer.WriteString("symbol", node.Symbol);
writer.WriteString("file", node.File);
writer.WriteNumber("line", node.Line);
writer.WriteString("package", node.Package);
writer.WriteString("visibility", node.Visibility.ToString());
writer.WriteBoolean("isEntrypoint", node.IsEntrypoint);
if (node.EntrypointType is not null)
{
writer.WriteString("entrypointType", node.EntrypointType.Value.ToString());
}
writer.WriteBoolean("isSink", node.IsSink);
if (node.SinkCategory is not null)
{
writer.WriteString("sinkCategory", node.SinkCategory.Value.ToString());
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("edges");
writer.WriteStartArray();
foreach (var edge in snapshot.Edges)
{
writer.WriteStartObject();
writer.WriteString("sourceId", edge.SourceId);
writer.WriteString("targetId", edge.TargetId);
writer.WriteString("callKind", edge.CallKind.ToString());
if (!string.IsNullOrWhiteSpace(edge.CallSite))
{
writer.WriteString("callSite", edge.CallSite);
}
if (edge.Explanation is not null)
{
writer.WritePropertyName("explanation");
writer.WriteStartObject();
writer.WriteString("type", edge.Explanation.Type.ToString());
writer.WriteNumber("confidence", edge.Explanation.Confidence);
if (!string.IsNullOrWhiteSpace(edge.Explanation.Guard))
{
writer.WriteString("guard", edge.Explanation.Guard);
}
if (edge.Explanation.Metadata is { Count: > 0 })
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var kv in edge.Explanation.Metadata.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
writer.WriteString(kv.Key, kv.Value);
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("entrypointIds");
writer.WriteStartArray();
foreach (var id in snapshot.EntrypointIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("sinkIds");
writer.WriteStartArray();
foreach (var id in snapshot.SinkIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteDigestPayload(Utf8JsonWriter writer, ReachabilityAnalysisResult result)
{
writer.WriteStartObject();
writer.WriteString("schema", "stellaops.reachability@v1");
writer.WriteString("graphDigest", result.GraphDigest);
writer.WriteString("language", result.Language);
writer.WritePropertyName("reachableNodeIds");
writer.WriteStartArray();
foreach (var id in result.ReachableNodeIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("reachableSinkIds");
writer.WriteStartArray();
foreach (var id in result.ReachableSinkIds)
{
writer.WriteStringValue(id);
}
writer.WriteEndArray();
writer.WritePropertyName("paths");
writer.WriteStartArray();
foreach (var path in result.Paths)
{
writer.WriteStartObject();
writer.WriteString("entrypointId", path.EntrypointId);
writer.WriteString("sinkId", path.SinkId);
writer.WritePropertyName("nodeIds");
writer.WriteStartArray();
foreach (var nodeId in path.NodeIds)
{
writer.WriteStringValue(nodeId);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
}
public sealed record ReachabilityPath(
[property: JsonPropertyName("entrypointId")] string EntrypointId,
[property: JsonPropertyName("sinkId")] string SinkId,
[property: JsonPropertyName("nodeIds")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<string>))]
ImmutableArray<string> NodeIds)
{
public ReachabilityPath Trimmed()
{
var nodes = (NodeIds.IsDefault ? ImmutableArray<string>.Empty : NodeIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.ToImmutableArray();
return this with
{
EntrypointId = EntrypointId?.Trim() ?? string.Empty,
SinkId = SinkId?.Trim() ?? string.Empty,
NodeIds = nodes
};
}
}
public sealed record ReachabilityAnalysisResult(
[property: JsonPropertyName("scanId")] string ScanId,
[property: JsonPropertyName("graphDigest")] string GraphDigest,
[property: JsonPropertyName("language")] string Language,
[property: JsonPropertyName("computedAt")] DateTimeOffset ComputedAt,
[property: JsonPropertyName("reachableNodeIds")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<string>))]
ImmutableArray<string> ReachableNodeIds,
[property: JsonPropertyName("reachableSinkIds")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<string>))]
ImmutableArray<string> ReachableSinkIds,
[property: JsonPropertyName("paths")]
[property: JsonConverter(typeof(ImmutableArrayJsonConverter<ReachabilityPath>))]
ImmutableArray<ReachabilityPath> Paths,
[property: JsonPropertyName("resultDigest")] string ResultDigest)
{
public ReachabilityAnalysisResult Trimmed()
{
var reachableNodes = (ReachableNodeIds.IsDefault ? ImmutableArray<string>.Empty : ReachableNodeIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var reachableSinks = (ReachableSinkIds.IsDefault ? ImmutableArray<string>.Empty : ReachableSinkIds)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToImmutableArray();
var paths = (Paths.IsDefault ? ImmutableArray<ReachabilityPath>.Empty : Paths)
.Select(p => p.Trimmed())
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
.ToImmutableArray();
return this with
{
ScanId = ScanId?.Trim() ?? string.Empty,
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
Language = Language?.Trim() ?? string.Empty,
ResultDigest = ResultDigest?.Trim() ?? string.Empty,
ReachableNodeIds = reachableNodes,
ReachableSinkIds = reachableSinks,
Paths = paths
};
}
}
public static class CallGraphNodeIds
{
public static string Compute(string stableSymbolId)
{
if (string.IsNullOrWhiteSpace(stableSymbolId))
{
throw new ArgumentException("Symbol id must be provided.", nameof(stableSymbolId));
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(stableSymbolId.Trim()));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
public static string StableSymbolId(string language, string symbol)
=> $"{language.Trim().ToLowerInvariant()}:{symbol.Trim()}";
}
// Type aliases for backward compatibility.
// Code using StellaOps.Scanner.CallGraph.CallGraphSnapshot etc. will continue to work
// because the global using imports all types from Contracts into this namespace.

View File

@@ -27,8 +27,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Scanner.Evidence\\StellaOps.Scanner.Evidence.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.Reachability\\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Contracts\StellaOps.Scanner.Contracts.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Evidence\StellaOps.Scanner.Evidence.csproj" />
</ItemGroup>
</Project>