release orchestrator v1 draft and build fixes
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
// Shared enums for call graph analysis.
|
||||
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Visibility level of a code symbol.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<Visibility>))]
|
||||
public enum Visibility
|
||||
{
|
||||
Public,
|
||||
Internal,
|
||||
Protected,
|
||||
Private
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kind of call edge in a call graph.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CallKind>))]
|
||||
public enum CallKind
|
||||
{
|
||||
Direct,
|
||||
Virtual,
|
||||
Delegate,
|
||||
Reflection,
|
||||
Dynamic,
|
||||
Plt,
|
||||
Iat
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of entrypoint in a call graph.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
|
||||
public enum EntrypointType
|
||||
{
|
||||
HttpHandler,
|
||||
GrpcMethod,
|
||||
CliCommand,
|
||||
BackgroundJob,
|
||||
ScheduledJob,
|
||||
MessageHandler,
|
||||
EventSubscriber,
|
||||
WebSocketHandler,
|
||||
EventHandler,
|
||||
Lambda,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <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
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
// Shared call graph models for Scanner CallGraph and Reachability modules.
|
||||
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// A point-in-time snapshot of a call graph for analysis.
|
||||
/// </summary>
|
||||
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")] ImmutableArray<CallGraphNode> Nodes,
|
||||
[property: JsonPropertyName("edges")] ImmutableArray<CallGraphEdge> Edges,
|
||||
[property: JsonPropertyName("entrypointIds")] ImmutableArray<string> EntrypointIds,
|
||||
[property: JsonPropertyName("sinkIds")] ImmutableArray<string> SinkIds)
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a trimmed, normalized copy of this snapshot for deterministic operations.
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the call graph representing a method/function.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge in the call graph representing a call relationship.
|
||||
/// </summary>
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <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()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path from an entrypoint to a sink in the call graph.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityPath(
|
||||
[property: JsonPropertyName("entrypointId")] string EntrypointId,
|
||||
[property: JsonPropertyName("sinkId")] string SinkId,
|
||||
[property: JsonPropertyName("nodeIds")] 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of reachability analysis on a call graph.
|
||||
/// </summary>
|
||||
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")] ImmutableArray<string> ReachableNodeIds,
|
||||
[property: JsonPropertyName("reachableSinkIds")] ImmutableArray<string> ReachableSinkIds,
|
||||
[property: JsonPropertyName("paths")] 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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for reachability analysis.
|
||||
/// </summary>
|
||||
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).</summary>
|
||||
public int MaxDepth { get; init; } = 256;
|
||||
|
||||
/// <summary>Maximum number of paths to return per sink (default = 10).</summary>
|
||||
public int MaxPathsPerSink { get; init; } = 10;
|
||||
|
||||
/// <summary>Maximum total paths to return (default = 100).</summary>
|
||||
public int MaxTotalPaths { get; init; } = 100;
|
||||
|
||||
/// <summary>Whether to include node metadata in path reconstruction (default = true).</summary>
|
||||
public bool IncludeNodeMetadata { get; init; } = true;
|
||||
|
||||
/// <summary>Explicit list of sink node IDs to target (default = null, meaning use snapshot.SinkIds).</summary>
|
||||
public ImmutableArray<string>? ExplicitSinks { get; init; }
|
||||
|
||||
/// <summary>Validates options and returns sanitized values.</summary>
|
||||
public ReachabilityAnalysisOptions Validated()
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utilities for computing deterministic identifiers for call graph elements.
|
||||
/// </summary>
|
||||
public static class CallGraphNodeIds
|
||||
{
|
||||
/// <summary>Computes a deterministic node ID from a stable symbol identifier.</summary>
|
||||
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()}";
|
||||
}
|
||||
|
||||
/// <summary>Builds a stable symbol identifier from language and symbol.</summary>
|
||||
public static string StableSymbolId(string language, string symbol)
|
||||
=> $"{language.Trim().ToLowerInvariant()}:{symbol.Trim()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Utilities for computing digests of call graph snapshots and results.
|
||||
/// </summary>
|
||||
public static class CallGraphDigests
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
/// <summary>Computes a deterministic digest for a call graph snapshot.</summary>
|
||||
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))
|
||||
{
|
||||
WriteGraphDigestPayload(writer, trimmed);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.ToArray());
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>Computes a deterministic digest for a reachability analysis result.</summary>
|
||||
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))
|
||||
{
|
||||
WriteResultDigestPayload(writer, trimmed);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.ToArray());
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void WriteGraphDigestPayload(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 WriteResultDigestPayload(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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.CallGraph")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Reachability")]
|
||||
[assembly: InternalsVisibleTo("StellaOps.Scanner.Contracts.Tests")]
|
||||
@@ -0,0 +1,94 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
// Shared contracts for Scanner CallGraph and Reachability modules.
|
||||
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Security-relevant sink categories for reachability analysis.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<SinkCategory>))]
|
||||
public enum SinkCategory
|
||||
{
|
||||
/// <summary>Command/process execution (e.g., Runtime.exec, Process.Start)</summary>
|
||||
[JsonStringEnumMemberName("CMD_EXEC")]
|
||||
CmdExec,
|
||||
|
||||
/// <summary>Unsafe deserialization (e.g., BinaryFormatter, pickle.loads)</summary>
|
||||
[JsonStringEnumMemberName("UNSAFE_DESER")]
|
||||
UnsafeDeser,
|
||||
|
||||
/// <summary>Raw SQL execution (e.g., SqlCommand with string concat)</summary>
|
||||
[JsonStringEnumMemberName("SQL_RAW")]
|
||||
SqlRaw,
|
||||
|
||||
/// <summary>SQL injection (e.g., unparameterized queries with user input)</summary>
|
||||
[JsonStringEnumMemberName("SQL_INJECTION")]
|
||||
SqlInjection,
|
||||
|
||||
/// <summary>Server-side request forgery (e.g., HttpClient with user input)</summary>
|
||||
[JsonStringEnumMemberName("SSRF")]
|
||||
Ssrf,
|
||||
|
||||
/// <summary>Arbitrary file write (e.g., File.WriteAllBytes with user path)</summary>
|
||||
[JsonStringEnumMemberName("FILE_WRITE")]
|
||||
FileWrite,
|
||||
|
||||
/// <summary>Path traversal (e.g., Path.Combine with ../)</summary>
|
||||
[JsonStringEnumMemberName("PATH_TRAVERSAL")]
|
||||
PathTraversal,
|
||||
|
||||
/// <summary>Template/expression injection (e.g., Razor, JEXL)</summary>
|
||||
[JsonStringEnumMemberName("TEMPLATE_INJECTION")]
|
||||
TemplateInjection,
|
||||
|
||||
/// <summary>Weak cryptography (e.g., MD5, DES, ECB mode)</summary>
|
||||
[JsonStringEnumMemberName("CRYPTO_WEAK")]
|
||||
CryptoWeak,
|
||||
|
||||
/// <summary>Authorization bypass (e.g., JWT none alg, missing authz check)</summary>
|
||||
[JsonStringEnumMemberName("AUTHZ_BYPASS")]
|
||||
AuthzBypass,
|
||||
|
||||
/// <summary>LDAP injection (e.g., DirContext.search with user input)</summary>
|
||||
[JsonStringEnumMemberName("LDAP_INJECTION")]
|
||||
LdapInjection,
|
||||
|
||||
/// <summary>XPath injection (e.g., XPath.evaluate with user input)</summary>
|
||||
[JsonStringEnumMemberName("XPATH_INJECTION")]
|
||||
XPathInjection,
|
||||
|
||||
/// <summary>XML External Entity injection (XXE)</summary>
|
||||
[JsonStringEnumMemberName("XXE")]
|
||||
XxeInjection,
|
||||
|
||||
/// <summary>Code/expression injection (e.g., eval, ScriptEngine)</summary>
|
||||
[JsonStringEnumMemberName("CODE_INJECTION")]
|
||||
CodeInjection,
|
||||
|
||||
/// <summary>Log injection (e.g., unvalidated user input in logs)</summary>
|
||||
[JsonStringEnumMemberName("LOG_INJECTION")]
|
||||
LogInjection,
|
||||
|
||||
/// <summary>Reflection-based attacks (e.g., Class.forName with user input)</summary>
|
||||
[JsonStringEnumMemberName("REFLECTION")]
|
||||
Reflection,
|
||||
|
||||
/// <summary>Open redirect (e.g., sendRedirect with user-controlled URL)</summary>
|
||||
[JsonStringEnumMemberName("OPEN_REDIRECT")]
|
||||
OpenRedirect
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A known dangerous sink with its metadata.
|
||||
/// </summary>
|
||||
public sealed record SinkDefinition(
|
||||
SinkCategory Category,
|
||||
string SymbolPattern,
|
||||
string Language,
|
||||
string? Framework = null,
|
||||
string? Description = null,
|
||||
string? CweId = null,
|
||||
double SeverityWeight = 1.0);
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (c) StellaOps
|
||||
// Registry of known dangerous sinks per language.
|
||||
|
||||
namespace StellaOps.Scanner.Contracts;
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of known dangerous sinks per language.
|
||||
/// </summary>
|
||||
public static class SinkRegistry
|
||||
{
|
||||
private static readonly FrozenDictionary<string, ImmutableArray<SinkDefinition>> SinksByLanguage = BuildRegistry();
|
||||
|
||||
private static FrozenDictionary<string, ImmutableArray<SinkDefinition>> BuildRegistry()
|
||||
{
|
||||
var builder = new Dictionary<string, List<SinkDefinition>>(StringComparer.Ordinal);
|
||||
|
||||
// .NET sinks
|
||||
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.Process.Start", cweId: "CWE-78");
|
||||
AddSink(builder, "dotnet", SinkCategory.CmdExec, "System.Diagnostics.ProcessStartInfo", cweId: "CWE-78");
|
||||
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "System.Runtime.Serialization.Formatters.Binary.BinaryFormatter.Deserialize", cweId: "CWE-502");
|
||||
AddSink(builder, "dotnet", SinkCategory.UnsafeDeser, "Newtonsoft.Json.JsonConvert.DeserializeObject", cweId: "CWE-502", framework: "Newtonsoft.Json");
|
||||
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "System.Data.SqlClient.SqlCommand.ExecuteReader", cweId: "CWE-89");
|
||||
AddSink(builder, "dotnet", SinkCategory.SqlRaw, "Microsoft.EntityFrameworkCore.RelationalQueryableExtensions.FromSqlRaw", cweId: "CWE-89", framework: "EFCore");
|
||||
AddSink(builder, "dotnet", SinkCategory.Ssrf, "System.Net.Http.HttpClient.GetAsync", cweId: "CWE-918");
|
||||
AddSink(builder, "dotnet", SinkCategory.FileWrite, "System.IO.File.WriteAllBytes", cweId: "CWE-73");
|
||||
AddSink(builder, "dotnet", SinkCategory.PathTraversal, "System.IO.Path.Combine", cweId: "CWE-22");
|
||||
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.MD5.Create", cweId: "CWE-327");
|
||||
AddSink(builder, "dotnet", SinkCategory.CryptoWeak, "System.Security.Cryptography.DES.Create", cweId: "CWE-327");
|
||||
|
||||
// Java sinks
|
||||
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.Runtime.exec", cweId: "CWE-78");
|
||||
AddSink(builder, "java", SinkCategory.CmdExec, "java.lang.ProcessBuilder.start", cweId: "CWE-78");
|
||||
AddSink(builder, "java", SinkCategory.UnsafeDeser, "java.io.ObjectInputStream.readObject", cweId: "CWE-502");
|
||||
AddSink(builder, "java", SinkCategory.SqlRaw, "java.sql.Statement.executeQuery", cweId: "CWE-89");
|
||||
AddSink(builder, "java", SinkCategory.Ssrf, "java.net.URL.openConnection", cweId: "CWE-918");
|
||||
AddSink(builder, "java", SinkCategory.TemplateInjection, "org.springframework.expression.ExpressionParser.parseExpression", cweId: "CWE-917", framework: "Spring");
|
||||
|
||||
// Node.js sinks
|
||||
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.exec", cweId: "CWE-78");
|
||||
AddSink(builder, "node", SinkCategory.CmdExec, "child_process.spawn", cweId: "CWE-78");
|
||||
AddSink(builder, "node", SinkCategory.UnsafeDeser, "node-serialize.unserialize", cweId: "CWE-502");
|
||||
AddSink(builder, "node", SinkCategory.SqlRaw, "mysql.query", cweId: "CWE-89");
|
||||
AddSink(builder, "node", SinkCategory.PathTraversal, "path.join", cweId: "CWE-22");
|
||||
AddSink(builder, "node", SinkCategory.TemplateInjection, "eval", cweId: "CWE-94");
|
||||
|
||||
// Python sinks
|
||||
AddSink(builder, "python", SinkCategory.CmdExec, "os.system", cweId: "CWE-78");
|
||||
AddSink(builder, "python", SinkCategory.CmdExec, "subprocess.call", cweId: "CWE-78");
|
||||
AddSink(builder, "python", SinkCategory.UnsafeDeser, "pickle.loads", cweId: "CWE-502");
|
||||
AddSink(builder, "python", SinkCategory.UnsafeDeser, "yaml.load", cweId: "CWE-502");
|
||||
AddSink(builder, "python", SinkCategory.SqlRaw, "sqlite3.Cursor.execute", cweId: "CWE-89");
|
||||
AddSink(builder, "python", SinkCategory.TemplateInjection, "jinja2.Template.render", cweId: "CWE-1336", framework: "Jinja2");
|
||||
|
||||
// Go sinks
|
||||
AddSink(builder, "go", SinkCategory.CmdExec, "os/exec.Command", cweId: "CWE-78");
|
||||
AddSink(builder, "go", SinkCategory.CmdExec, "os/exec.CommandContext", cweId: "CWE-78");
|
||||
AddSink(builder, "go", SinkCategory.SqlRaw, "database/sql.DB.Query", cweId: "CWE-89");
|
||||
AddSink(builder, "go", SinkCategory.SqlRaw, "database/sql.DB.Exec", cweId: "CWE-89");
|
||||
AddSink(builder, "go", SinkCategory.Ssrf, "net/http.Get", cweId: "CWE-918");
|
||||
AddSink(builder, "go", SinkCategory.PathTraversal, "filepath.Join", cweId: "CWE-22");
|
||||
|
||||
// Ruby sinks
|
||||
AddSink(builder, "ruby", SinkCategory.CmdExec, "Kernel.system", cweId: "CWE-78");
|
||||
AddSink(builder, "ruby", SinkCategory.CmdExec, "Kernel.exec", cweId: "CWE-78");
|
||||
AddSink(builder, "ruby", SinkCategory.UnsafeDeser, "Marshal.load", cweId: "CWE-502");
|
||||
AddSink(builder, "ruby", SinkCategory.UnsafeDeser, "YAML.load", cweId: "CWE-502");
|
||||
AddSink(builder, "ruby", SinkCategory.SqlRaw, "ActiveRecord::Base.connection.execute", cweId: "CWE-89", framework: "Rails");
|
||||
AddSink(builder, "ruby", SinkCategory.TemplateInjection, "ERB.new", cweId: "CWE-1336");
|
||||
|
||||
// PHP sinks
|
||||
AddSink(builder, "php", SinkCategory.CmdExec, "exec", cweId: "CWE-78");
|
||||
AddSink(builder, "php", SinkCategory.CmdExec, "shell_exec", cweId: "CWE-78");
|
||||
AddSink(builder, "php", SinkCategory.CmdExec, "system", cweId: "CWE-78");
|
||||
AddSink(builder, "php", SinkCategory.UnsafeDeser, "unserialize", cweId: "CWE-502");
|
||||
AddSink(builder, "php", SinkCategory.SqlRaw, "mysqli_query", cweId: "CWE-89");
|
||||
AddSink(builder, "php", SinkCategory.SqlRaw, "PDO::query", cweId: "CWE-89");
|
||||
AddSink(builder, "php", SinkCategory.FileWrite, "file_put_contents", cweId: "CWE-73");
|
||||
AddSink(builder, "php", SinkCategory.CodeInjection, "eval", cweId: "CWE-94");
|
||||
|
||||
return builder.ToFrozenDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static void AddSink(
|
||||
Dictionary<string, List<SinkDefinition>> builder,
|
||||
string language,
|
||||
SinkCategory category,
|
||||
string symbolPattern,
|
||||
string? cweId = null,
|
||||
string? framework = null)
|
||||
{
|
||||
if (!builder.TryGetValue(language, out var list))
|
||||
{
|
||||
list = [];
|
||||
builder[language] = list;
|
||||
}
|
||||
|
||||
list.Add(new SinkDefinition(
|
||||
Category: category,
|
||||
SymbolPattern: symbolPattern,
|
||||
Language: language,
|
||||
Framework: framework,
|
||||
CweId: cweId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sink definitions for a language.
|
||||
/// </summary>
|
||||
public static ImmutableArray<SinkDefinition> GetSinksForLanguage(string language)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
return ImmutableArray<SinkDefinition>.Empty;
|
||||
}
|
||||
|
||||
return SinksByLanguage.GetValueOrDefault(language.Trim().ToLowerInvariant(), ImmutableArray<SinkDefinition>.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered languages.
|
||||
/// </summary>
|
||||
public static IEnumerable<string> GetRegisteredLanguages() => SinksByLanguage.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a symbol matches any known sink.
|
||||
/// </summary>
|
||||
public static SinkDefinition? MatchSink(string language, string symbol)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(language) || string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sinks = GetSinksForLanguage(language);
|
||||
return sinks.FirstOrDefault(sink => symbol.Contains(sink.SymbolPattern, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<Description>Shared contracts for Scanner CallGraph and Reachability modules to break circular dependencies</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.CallGraph" />
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Reachability" />
|
||||
<InternalsVisibleTo Include="StellaOps.Scanner.Contracts.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user