Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -130,13 +130,13 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
List<BinarySymbol> symbols,
CancellationToken ct)
{
var textSection = await BinaryTextSectionReader.TryReadAsync(path, format, ct);
var textSection = await Disassembly.BinaryTextSectionReader.TryReadAsync(path, format, ct);
if (textSection is null)
{
return Array.Empty<CallGraphEdge>();
}
if (textSection.Architecture == BinaryArchitecture.Unknown)
if (textSection.Architecture == Disassembly.BinaryArchitecture.Unknown)
{
_logger.LogDebug("Skipping disassembly; unknown architecture for {Path}", path);
return Array.Empty<CallGraphEdge>();
@@ -1007,7 +1007,7 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(node.NodeId, node);
}
// Add edges from relocations
// Add edges from relocations with loader rule explanations
foreach (var reloc in relocations)
{
var sourceSymbol = string.IsNullOrWhiteSpace(reloc.SourceSymbol)
@@ -1018,11 +1018,14 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
? $"native:external/{reloc.TargetSymbol}"
: $"native:{binaryName}/{reloc.TargetSymbol}";
var explanation = GuardDetector.ClassifyBinaryEdge(reloc.CallKind, reloc.TargetSymbol);
edges.Add(new CallGraphEdge(
SourceId: sourceId,
TargetId: targetId,
CallKind: reloc.CallKind,
CallSite: $"0x{reloc.Address:X}"));
CallSite: $"0x{reloc.Address:X}",
Explanation: explanation));
}
if (extraEdges.Count > 0)
@@ -1154,14 +1157,6 @@ public sealed class BinaryCallGraphExtractor : ICallGraphExtractor
}
}
internal enum BinaryFormat
{
Unknown,
Elf,
Pe,
MachO
}
internal sealed class BinarySymbol
{
public required string Name { get; init; }

View File

@@ -1,11 +1,12 @@
using System.Text;
using StellaOps.Scanner.CallGraph.Binary;
using TextSection = StellaOps.Scanner.CallGraph.Binary.Disassembly.BinaryTextSection;
namespace StellaOps.Scanner.CallGraph.Binary.Disassembly;
internal static class BinaryTextSectionReader
{
public static async Task<BinaryTextSection?> TryReadAsync(
public static async Task<TextSection?> TryReadAsync(
string path,
BinaryFormat format,
CancellationToken ct)
@@ -21,7 +22,7 @@ internal static class BinaryTextSectionReader
};
}
private static async Task<BinaryTextSection?> TryReadElfTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadElfTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -114,7 +115,7 @@ internal static class BinaryTextSectionReader
stream.Seek(sectionOffset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)sectionSize);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
sectionAddress,
is64Bit ? 64 : 32,
@@ -150,7 +151,7 @@ internal static class BinaryTextSectionReader
return is64Bit ? reader.ReadInt64() : reader.ReadInt32();
}
private static async Task<BinaryTextSection?> TryReadPeTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadPeTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -221,7 +222,7 @@ internal static class BinaryTextSectionReader
stream.Seek(pointerToRawData, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)sizeOfRawData);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
virtualAddress,
is64Bit ? 64 : 32,
@@ -232,7 +233,7 @@ internal static class BinaryTextSectionReader
return null;
}
private static async Task<BinaryTextSection?> TryReadMachOTextSectionAsync(string path, CancellationToken ct)
private static async Task<TextSection?> TryReadMachOTextSectionAsync(string path, CancellationToken ct)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true);
@@ -313,7 +314,7 @@ internal static class BinaryTextSectionReader
stream.Seek(offset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)size);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
addr,
64,
@@ -354,7 +355,7 @@ internal static class BinaryTextSectionReader
stream.Seek(offset, SeekOrigin.Begin);
var bytes = reader.ReadBytes((int)size);
await Task.CompletedTask;
return new BinaryTextSection(
return new TextSection(
bytes,
addr,
32,

View File

@@ -90,16 +90,21 @@ public sealed class FunctionBoundaryDetector
_logger.LogDebug("Found {Count} functions via DWARF", dwarfInfo.Functions.Count);
foreach (var func in dwarfInfo.Functions)
{
// Look up source file from index (0 = no file)
var sourceFile = func.DeclFile > 0 && func.DeclFile <= dwarfInfo.SourceFiles.Count
? dwarfInfo.SourceFiles[(int)func.DeclFile - 1]
: null;
functions.Add(new DetectedFunction
{
Symbol = func.Name,
MangledName = func.LinkageName,
StartAddress = func.LowPc,
EndAddress = func.HighPc,
StartAddress = (long)func.LowPc,
EndAddress = (long)func.HighPc,
Confidence = _options.DwarfConfidence,
DetectionMethod = FunctionDetectionMethod.Dwarf,
SourceFile = func.DeclFile,
SourceLine = func.DeclLine
SourceFile = sourceFile,
SourceLine = func.DeclLine > 0 ? (int?)func.DeclLine : null
});
}
return functions;

View File

@@ -88,11 +88,15 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
var targetNode = CreateInvokedNode(analysisRoot, invoked);
nodesById.TryAdd(targetNode.NodeId, targetNode);
var callKind = ClassifyCallKind(invoked);
var explanation = ClassifyDotNetEdge(invoked, invocation, callKind);
edges.Add(new CallGraphEdge(
SourceId: methodNode.NodeId,
TargetId: targetNode.NodeId,
CallKind: ClassifyCallKind(invoked),
CallSite: FormatCallSite(analysisRoot, invocation)));
CallKind: callKind,
CallSite: FormatCallSite(analysisRoot, invocation),
Explanation: explanation));
}
}
}
@@ -197,6 +201,105 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
return CallKind.Direct;
}
private static CallEdgeExplanation ClassifyDotNetEdge(
IMethodSymbol invoked,
InvocationExpressionSyntax invocation,
CallKind callKind)
{
var containingType = invoked.ContainingType?.ToDisplayString() ?? string.Empty;
var methodName = invoked.Name;
// Reflection-based calls
if (IsReflectionCall(containingType, methodName))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Dynamic assembly/type loading
if (IsDynamicLoading(containingType, methodName))
{
return CallEdgeExplanation.DynamicLoad(0.6);
}
// Check for platform guards
if (IsPlatformGuard(containingType, methodName))
{
return CallEdgeExplanation.PlatformArch("conditional", 0.95);
}
// Check enclosing context for guards
var context = GetInvocationContext(invocation);
if (!string.IsNullOrEmpty(context))
{
var guard = GuardDetector.DetectDotNetGuard(context, null);
if (guard is not null)
{
return guard;
}
}
// Delegate invocations
if (callKind == CallKind.Delegate)
{
return CallEdgeExplanation.DynamicLoad(0.7);
}
// Virtual dispatch
if (callKind == CallKind.Virtual)
{
return new CallEdgeExplanation(CallEdgeExplanationType.DirectCall, 0.9);
}
return CallEdgeExplanation.DirectCall();
}
private static bool IsReflectionCall(string containingType, string methodName)
{
return containingType switch
{
"System.Type" when methodName is "GetType" or "GetMethod" or "GetProperty" => true,
"System.Reflection.MethodInfo" when methodName is "Invoke" => true,
"System.Activator" when methodName is "CreateInstance" => true,
"System.Reflection.Assembly" when methodName is "Load" or "LoadFrom" or "LoadFile" => true,
_ => false
};
}
private static bool IsDynamicLoading(string containingType, string methodName)
{
return containingType switch
{
"System.Reflection.Assembly" when methodName is "Load" or "LoadFrom" or "LoadFile" => true,
"System.Runtime.Loader.AssemblyLoadContext" when methodName is "LoadFromAssemblyPath" => true,
_ => false
};
}
private static bool IsPlatformGuard(string containingType, string methodName)
{
return containingType is "System.Runtime.InteropServices.RuntimeInformation"
&& methodName is "IsOSPlatform";
}
private static string? GetInvocationContext(InvocationExpressionSyntax invocation)
{
// Look for enclosing if statement with environment/platform check
var current = invocation.Parent;
while (current is not null)
{
if (current is IfStatementSyntax ifStatement)
{
return ifStatement.Condition.ToFullString();
}
if (current is ConditionalExpressionSyntax conditional)
{
return conditional.Condition.ToFullString();
}
current = current.Parent;
}
return null;
}
private static CallGraphNode CreateMethodNode(string analysisRoot, IMethodSymbol method, MethodDeclarationSyntax syntax)
{
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));

View File

@@ -0,0 +1,249 @@
// Licensed to StellaOps under the AGPL-3.0-or-later license.
using System.Text.RegularExpressions;
namespace StellaOps.Scanner.CallGraph;
/// <summary>
/// Detects environment guards, feature flags, and platform checks in source code.
/// Used to classify edge explanations in call graph extraction.
/// </summary>
public static partial class GuardDetector
{
/// <summary>
/// Detects guards in JavaScript/TypeScript code context.
/// </summary>
public static CallEdgeExplanation? DetectJavaScriptGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// Environment variable checks: process.env.X, process.env['X']
var envMatch = JsEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=truthy");
}
// Platform checks: process.platform === 'linux'
var platformMatch = JsPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform);
}
// Dynamic require/import: require(variable), import(variable)
if (JsDynamicImportPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Feature flag patterns: config.enableX, flags.featureX
var featureFlagMatch = JsFeatureFlagPattern().Match(sourceContext);
if (featureFlagMatch.Success)
{
var flag = featureFlagMatch.Groups["flag"].Value;
return CallEdgeExplanation.FeatureFlag($"{flag}=true", 0.85);
}
return null;
}
/// <summary>
/// Detects guards in Python code context.
/// </summary>
public static CallEdgeExplanation? DetectPythonGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// os.environ.get('X'), os.getenv('X')
var envMatch = PyEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=truthy");
}
// sys.platform checks
var platformMatch = PyPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform);
}
// importlib.import_module(variable)
if (PyDynamicImportPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Feature flag patterns: settings.FEATURE_X, config['enable_feature']
var featureFlagMatch = PyFeatureFlagPattern().Match(sourceContext);
if (featureFlagMatch.Success)
{
var flag = featureFlagMatch.Groups["flag"].Value;
return CallEdgeExplanation.FeatureFlag($"{flag}=True", 0.85);
}
return null;
}
/// <summary>
/// Detects guards in Java code context.
/// </summary>
public static CallEdgeExplanation? DetectJavaGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// System.getenv("X")
var envMatch = JavaEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=present");
}
// System.getProperty("X")
var propertyMatch = JavaPropertyPattern().Match(sourceContext);
if (propertyMatch.Success)
{
var prop = propertyMatch.Groups["prop"].Value;
return CallEdgeExplanation.FeatureFlag($"{prop}=true", 0.85);
}
// System.getProperty("os.name")
var osMatch = JavaOsPattern().Match(sourceContext);
if (osMatch.Success)
{
var os = osMatch.Groups["os"].Value;
return CallEdgeExplanation.PlatformArch(os.ToLowerInvariant());
}
// Class.forName(variable), classLoader.loadClass(variable)
if (JavaReflectionPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
return null;
}
/// <summary>
/// Detects guards in C#/.NET code context.
/// </summary>
public static CallEdgeExplanation? DetectDotNetGuard(string sourceContext, string? callSite)
{
if (string.IsNullOrWhiteSpace(sourceContext))
return null;
// Environment.GetEnvironmentVariable("X")
var envMatch = DotNetEnvVarPattern().Match(sourceContext);
if (envMatch.Success)
{
var varName = envMatch.Groups["var"].Value;
return CallEdgeExplanation.EnvGuard($"{varName}=present");
}
// RuntimeInformation.IsOSPlatform(OSPlatform.X)
var platformMatch = DotNetPlatformPattern().Match(sourceContext);
if (platformMatch.Success)
{
var platform = platformMatch.Groups["platform"].Value;
return CallEdgeExplanation.PlatformArch(platform.ToLowerInvariant());
}
// configuration["FeatureFlags:X"]
var configMatch = DotNetConfigPattern().Match(sourceContext);
if (configMatch.Success)
{
var key = configMatch.Groups["key"].Value;
return CallEdgeExplanation.FeatureFlag($"{key}=true", 0.85);
}
// Type.GetType(variable), Activator.CreateInstance(variable)
if (DotNetReflectionPattern().IsMatch(sourceContext))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
return null;
}
/// <summary>
/// Classifies binary edge types based on loader mechanism.
/// </summary>
public static CallEdgeExplanation ClassifyBinaryEdge(CallKind callKind, string? targetSymbol)
{
return callKind switch
{
CallKind.Plt => CallEdgeExplanation.LoaderRule("PLT",
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty
.Add("loader", "PLT")
.Add("symbol", targetSymbol ?? "unknown")),
CallKind.Iat => CallEdgeExplanation.LoaderRule("IAT",
System.Collections.Immutable.ImmutableDictionary<string, string>.Empty
.Add("loader", "IAT")
.Add("symbol", targetSymbol ?? "unknown")),
CallKind.Dynamic => CallEdgeExplanation.DynamicLoad(0.6),
CallKind.Reflection => CallEdgeExplanation.ReflectionCall(0.5),
_ => CallEdgeExplanation.DirectCall()
};
}
// JavaScript patterns
[GeneratedRegex(@"process\.env(?:\.(?<var>\w+)|\[(?<quote>['""])(?<var>\w+)\k<quote>\])", RegexOptions.Compiled)]
private static partial Regex JsEnvVarPattern();
[GeneratedRegex(@"process\.platform\s*===?\s*['""](?<platform>\w+)['""]", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JsPlatformPattern();
[GeneratedRegex(@"(?:require|import)\s*\(\s*[^'""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex JsDynamicImportPattern();
[GeneratedRegex(@"(?:config|flags|features|settings|options)\s*\.\s*(?<flag>enable\w+|feature\w+|use\w+|is\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JsFeatureFlagPattern();
// Python patterns
[GeneratedRegex(@"os\.(?:environ\.get|getenv)\s*\(\s*['""](?<var>\w+)['""]", RegexOptions.Compiled)]
private static partial Regex PyEnvVarPattern();
[GeneratedRegex(@"sys\.platform\s*==\s*['""](?<platform>\w+)['""]", RegexOptions.Compiled)]
private static partial Regex PyPlatformPattern();
[GeneratedRegex(@"importlib\.import_module\s*\(\s*[^'""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex PyDynamicImportPattern();
[GeneratedRegex(@"(?:settings|config|flags)\s*(?:\.|(?:\[(?<quote>['""]))|(?:\.get\s*\(\s*['""]))\s*(?<flag>FEATURE_\w+|ENABLE_\w+|USE_\w+)", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex PyFeatureFlagPattern();
// Java patterns
[GeneratedRegex(@"System\.getenv\s*\(\s*""(?<var>\w+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaEnvVarPattern();
[GeneratedRegex(@"System\.getProperty\s*\(\s*""(?<prop>[\w.]+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaPropertyPattern();
[GeneratedRegex(@"System\.getProperty\s*\(\s*""os\.name""\s*\).*?(?:contains|startsWith|equals)\s*\(\s*""(?<os>\w+)""", RegexOptions.Compiled | RegexOptions.IgnoreCase)]
private static partial Regex JavaOsPattern();
[GeneratedRegex(@"(?:Class\.forName|classLoader\.loadClass|getClass\(\)\.getMethod)\s*\(\s*[^""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex JavaReflectionPattern();
// .NET patterns
[GeneratedRegex(@"Environment\.GetEnvironmentVariable\s*\(\s*""(?<var>\w+)""\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetEnvVarPattern();
[GeneratedRegex(@"RuntimeInformation\.IsOSPlatform\s*\(\s*OSPlatform\.(?<platform>\w+)\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetPlatformPattern();
[GeneratedRegex(@"configuration\s*\[\s*""(?<key>[^""]+)""\s*\]", RegexOptions.Compiled)]
private static partial Regex DotNetConfigPattern();
[GeneratedRegex(@"(?:Type\.GetType|Activator\.CreateInstance|Assembly\.Load)\s*\(\s*[^""]+\s*\)", RegexOptions.Compiled)]
private static partial Regex DotNetReflectionPattern();
}

View File

@@ -115,9 +115,12 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(nodeId, node);
// Add edges for method invocations
// Add edges for method invocations with edge explanations
foreach (var call in method.Calls)
{
var callKind = MapCallKind(call.Opcode);
var explanation = ClassifyJavaEdge(call, callKind);
// Only include edges to internal methods
if (!packageClasses.Contains(call.TargetClass))
{
@@ -141,8 +144,9 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
edges.Add(new CallGraphEdge(
SourceId: nodeId,
TargetId: sinkNodeId,
CallKind: MapCallKind(call.Opcode),
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
CallKind: callKind,
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}",
Explanation: explanation));
}
continue;
}
@@ -151,8 +155,9 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
edges.Add(new CallGraphEdge(
SourceId: nodeId,
TargetId: targetNodeId,
CallKind: MapCallKind(call.Opcode),
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}"));
CallKind: callKind,
CallSite: $"{classInfo.SourceFile}:{method.LineNumber}",
Explanation: explanation));
}
}
}
@@ -306,4 +311,60 @@ public sealed class JavaCallGraphExtractor : ICallGraphExtractor
_ => CallKind.Direct
};
}
private static CallEdgeExplanation ClassifyJavaEdge(JavaMethodCall call, CallKind callKind)
{
// Reflection-based calls
if (IsReflectionCall(call.TargetClass, call.MethodName))
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Dynamic class loading
if (IsDynamicLoading(call.TargetClass, call.MethodName))
{
return CallEdgeExplanation.DynamicLoad(0.6);
}
// Check for environment/property guard patterns in context
if (!string.IsNullOrEmpty(call.Context))
{
var guard = GuardDetector.DetectJavaGuard(call.Context, null);
if (guard is not null)
{
return guard;
}
}
// InvokeDynamic (lambda, method references)
if (callKind == CallKind.Delegate)
{
return CallEdgeExplanation.DynamicLoad(0.7);
}
// Default direct call
return CallEdgeExplanation.DirectCall();
}
private static bool IsReflectionCall(string targetClass, string methodName)
{
return targetClass switch
{
"java.lang.Class" when methodName is "forName" or "getDeclaredMethod" or "getMethod" => true,
"java.lang.reflect.Method" when methodName is "invoke" => true,
"java.lang.reflect.Constructor" when methodName is "newInstance" => true,
_ => false
};
}
private static bool IsDynamicLoading(string targetClass, string methodName)
{
return targetClass switch
{
"java.lang.ClassLoader" when methodName is "loadClass" => true,
"java.net.URLClassLoader" when methodName is "loadClass" => true,
"java.util.ServiceLoader" when methodName is "load" or "iterator" => true,
_ => false
};
}
}

View File

@@ -152,6 +152,12 @@ public sealed record JavaMethodCall
/// Whether this call uses invokedynamic.
/// </summary>
public bool IsDynamic { get; init; }
/// <summary>
/// Context around the call site for guard detection.
/// Contains surrounding bytecode context that may indicate conditional execution.
/// </summary>
public string? Context { get; init; }
}
/// <summary>

View File

@@ -147,6 +147,12 @@ public sealed record JsNodeInfo
/// Decorators or annotations.
/// </summary>
public IReadOnlyList<string> Annotations { get; init; } = [];
/// <summary>
/// Condition context if this function is inside a conditional block
/// (e.g., if (process.env.FEATURE_X) { ... }).
/// </summary>
public string? ConditionContext { get; init; }
}
/// <summary>
@@ -173,6 +179,12 @@ public sealed record JsEdgeInfo
/// Call site position.
/// </summary>
public JsPositionInfo? Site { get; init; }
/// <summary>
/// Context around the call (for guard detection).
/// Contains surrounding code that may include conditionals.
/// </summary>
public string? Context { get; init; }
}
/// <summary>

View File

@@ -192,12 +192,18 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
SinkCategory: MapSinkCategory(sinkCategory));
}).ToList();
// Convert edges
var edges = result.Edges.Select(e => new CallGraphEdge(
CallGraphNodeIds.Compute(e.From),
CallGraphNodeIds.Compute(e.To),
MapCallKind(e.Kind)
)).ToList();
// Convert edges with explanations
var edges = result.Edges.Select(e =>
{
var callKind = MapCallKind(e.Kind);
var explanation = ClassifyEdge(e, callKind, result.Nodes);
return new CallGraphEdge(
CallGraphNodeIds.Compute(e.From),
CallGraphNodeIds.Compute(e.To),
callKind,
null,
explanation);
}).ToList();
// Create sink nodes for detected sinks (these may not be in the nodes list)
foreach (var sink in result.Sinks)
@@ -219,9 +225,14 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
IsSink: true,
SinkCategory: MapSinkCategory(sink.Category)));
// Add edge from caller to sink
// Add edge from caller to sink with explanation
var callerNodeId = CallGraphNodeIds.Compute(sink.Caller);
edges.Add(new CallGraphEdge(callerNodeId, sinkNodeId, CallKind.Direct));
edges.Add(new CallGraphEdge(
callerNodeId,
sinkNodeId,
CallKind.Direct,
null,
CallEdgeExplanation.DirectCall()));
}
}
@@ -311,6 +322,49 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
_ => null
};
private static CallEdgeExplanation ClassifyEdge(JsEdgeInfo edge, CallKind callKind, IReadOnlyList<JsNodeInfo> nodes)
{
// Check for dynamic imports
if (callKind == CallKind.Dynamic)
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Check for reflection-based calls
if (callKind == CallKind.Reflection)
{
return CallEdgeExplanation.ReflectionCall(0.5);
}
// Check for guard conditions in the edge context
if (!string.IsNullOrEmpty(edge.Context))
{
var guard = GuardDetector.DetectJavaScriptGuard(edge.Context, null);
if (guard is not null)
{
return guard;
}
}
// Check source node for conditional context
var sourceNode = nodes.FirstOrDefault(n => n.Id == edge.From);
if (sourceNode?.ConditionContext is not null)
{
var guard = GuardDetector.DetectJavaScriptGuard(sourceNode.ConditionContext, null);
if (guard is not null)
{
return guard;
}
}
// Default: static import for module imports, direct call otherwise
return edge.Kind?.ToLowerInvariant() switch
{
"import" or "require" => CallEdgeExplanation.Import(),
_ => CallEdgeExplanation.DirectCall()
};
}
private static string? ResolveProjectDirectory(string targetPath)
{
if (string.IsNullOrWhiteSpace(targetPath))
@@ -380,7 +434,7 @@ public sealed class NodeCallGraphExtractor : ICallGraphExtractor
IsSink: sink is not null,
SinkCategory: sink?.Category));
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct));
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct, null, CallEdgeExplanation.DirectCall()));
previousId = nodeId;
}

View File

@@ -93,14 +93,16 @@ public sealed class PythonCallGraphExtractor : ICallGraphExtractor
nodesById.TryAdd(node.NodeId, node);
// Extract function calls
// Extract function calls with edge explanations
foreach (var call in func.Calls)
{
var explanation = ClassifyPythonEdge(func, call);
edges.Add(new CallGraphEdge(
SourceId: func.NodeId,
TargetId: call.TargetNodeId,
CallKind: CallKind.Direct,
CallSite: $"{relativePath}:{call.Line}"));
CallSite: $"{relativePath}:{call.Line}",
Explanation: explanation));
}
}
}
@@ -409,6 +411,41 @@ public sealed class PythonCallGraphExtractor : ICallGraphExtractor
// Simplified: would need proper AST parsing for accurate results
return [];
}
private static CallEdgeExplanation ClassifyPythonEdge(PythonFunctionInfo func, PythonCallInfo call)
{
// Check for guard conditions in function context
if (!string.IsNullOrEmpty(call.Context))
{
var guard = GuardDetector.DetectPythonGuard(call.Context, null);
if (guard is not null)
{
return guard;
}
}
// Check if this is a dynamic import
if (call.IsDynamicImport)
{
return CallEdgeExplanation.DynamicLoad(0.5);
}
// Check decorator context for conditional patterns
if (func.Decorators.Count > 0)
{
var decoratorContext = string.Join(" ", func.Decorators);
var guard = GuardDetector.DetectPythonGuard(decoratorContext, null);
if (guard is not null)
{
return guard;
}
}
// Default to import for module-level, direct call for others
return func.IsRouteHandler
? CallEdgeExplanation.DirectCall()
: CallEdgeExplanation.Import();
}
}
internal sealed class PythonProjectInfo
@@ -437,4 +474,6 @@ internal sealed class PythonCallInfo
{
public required string TargetNodeId { get; init; }
public int Line { get; init; }
public string? Context { get; init; }
public bool IsDynamicImport { get; init; }
}

View File

@@ -96,14 +96,16 @@ 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("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()
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim(),
Explanation = Explanation?.Trimmed()
};
}
@@ -128,6 +130,110 @@ public enum CallKind
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
{
@@ -228,6 +334,28 @@ public static class CallGraphDigests
{
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();

View File

@@ -12,17 +12,17 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gee.External.Capstone" Version="2.3.0" />
<PackageReference Include="Iced" Version="1.21.0" />
<PackageReference Include="Microsoft.Build.Locator" Version="1.10.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
<PackageReference Include="Gee.External.Capstone" />
<PackageReference Include="Iced" />
<PackageReference Include="Microsoft.Build.Locator" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>