Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user