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

@@ -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
}

View File

@@ -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();
}
}

View File

@@ -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")]

View File

@@ -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);

View File

@@ -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));
}
}

View File

@@ -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>