save progress
This commit is contained in:
@@ -40,8 +40,8 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
workspace.WorkspaceFailed += (_, _) => { };
|
||||
|
||||
var solution = resolvedTarget.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
|
||||
? await workspace.OpenSolutionAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)
|
||||
: (await workspace.OpenProjectAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)).Solution;
|
||||
? await workspace.OpenSolutionAsync(resolvedTarget, cancellationToken: cancellationToken).ConfigureAwait(false)
|
||||
: (await workspace.OpenProjectAsync(resolvedTarget, cancellationToken: cancellationToken).ConfigureAwait(false)).Solution;
|
||||
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
@@ -203,18 +203,20 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
var (file, line) = GetSourceLocation(analysisRoot, syntax.GetLocation());
|
||||
|
||||
var (isEntrypoint, entryType) = EntrypointClassifier.IsEntrypoint(method);
|
||||
var symbol = FormatSymbol(method);
|
||||
var sink = SinkRegistry.MatchSink("dotnet", symbol);
|
||||
|
||||
return new CallGraphNode(
|
||||
NodeId: id,
|
||||
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
Symbol: symbol,
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: method.ContainingAssembly?.Name ?? "unknown",
|
||||
Visibility: MapVisibility(method.DeclaredAccessibility),
|
||||
IsEntrypoint: isEntrypoint,
|
||||
EntrypointType: entryType,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
IsSink: sink is not null,
|
||||
SinkCategory: sink?.Category);
|
||||
}
|
||||
|
||||
private static CallGraphNode CreateInvokedNode(string analysisRoot, IMethodSymbol method)
|
||||
@@ -223,11 +225,12 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
var definitionLocation = method.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None;
|
||||
var (file, line) = GetSourceLocation(analysisRoot, definitionLocation);
|
||||
|
||||
var sink = SinkRegistry.MatchSink("dotnet", method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
|
||||
var symbol = FormatSymbol(method);
|
||||
var sink = SinkRegistry.MatchSink("dotnet", symbol);
|
||||
|
||||
return new CallGraphNode(
|
||||
NodeId: id,
|
||||
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
Symbol: symbol,
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: method.ContainingAssembly?.Name ?? "unknown",
|
||||
@@ -303,6 +306,41 @@ public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
return $"dotnet:{method.ContainingAssembly?.Name}:{method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}";
|
||||
}
|
||||
|
||||
private static string FormatSymbol(IMethodSymbol method)
|
||||
{
|
||||
var namespaceName = method.ContainingNamespace is { IsGlobalNamespace: false }
|
||||
? method.ContainingNamespace.ToDisplayString()
|
||||
: string.Empty;
|
||||
|
||||
var typeName = method.ContainingType is null
|
||||
? string.Empty
|
||||
: string.Join('.', GetContainingTypeNames(method.ContainingType));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(namespaceName))
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(typeName)
|
||||
? method.Name
|
||||
: $"{typeName}.{method.Name}";
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(typeName)
|
||||
? $"{namespaceName}.{method.Name}"
|
||||
: $"{namespaceName}.{typeName}.{method.Name}";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetContainingTypeNames(INamedTypeSymbol type)
|
||||
{
|
||||
var stack = new Stack<string>();
|
||||
var current = type;
|
||||
while (current is not null)
|
||||
{
|
||||
stack.Push(current.Name);
|
||||
current = current.ContainingType;
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
private sealed class CallGraphEdgeComparer : IEqualityComparer<CallGraphEdge>
|
||||
{
|
||||
public static readonly CallGraphEdgeComparer Instance = new();
|
||||
|
||||
@@ -122,7 +122,9 @@ public sealed class AdminOnlyDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -95,7 +95,9 @@ public sealed class AuthGateDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -107,7 +107,9 @@ public sealed class FeatureFlagDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Gates.Detectors;
|
||||
|
||||
/// <summary>
|
||||
/// Reads source code directly from the local filesystem.
|
||||
/// </summary>
|
||||
public sealed class FileSystemCodeContentProvider : ICodeContentProvider
|
||||
{
|
||||
public Task<string?> GetContentAsync(string filePath, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
var path = filePath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
return File.ReadAllTextAsync(path, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>?> GetLinesAsync(
|
||||
string filePath,
|
||||
int startLine,
|
||||
int endLine,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startLine <= 0 || endLine <= 0 || endLine < startLine)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = filePath.Trim();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lines = new List<string>(Math.Min(256, endLine - startLine + 1));
|
||||
var currentLine = 0;
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
while (true)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var line = await reader.ReadLineAsync().ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
currentLine++;
|
||||
if (currentLine < startLine)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentLine > endLine)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ public sealed class NonDefaultConfigDetector : IGateDetector
|
||||
language.ToLowerInvariant() switch
|
||||
{
|
||||
"c#" or "cs" => "csharp",
|
||||
"dotnet" or ".net" => "csharp",
|
||||
"js" => "javascript",
|
||||
"node" or "nodejs" => "javascript",
|
||||
"ts" => "typescript",
|
||||
"py" => "python",
|
||||
"rb" => "ruby",
|
||||
|
||||
@@ -26,35 +26,22 @@ public sealed class GateMultiplierCalculator
|
||||
if (gates.Count == 0)
|
||||
return 10000; // 100% - no reduction
|
||||
|
||||
// Group gates by type and take highest confidence per type
|
||||
var gatesByType = gates
|
||||
.GroupBy(g => g.Type)
|
||||
.Select(g => new
|
||||
{
|
||||
Type = g.Key,
|
||||
MaxConfidence = g.Max(x => x.Confidence)
|
||||
})
|
||||
var gateTypes = gates
|
||||
.Select(g => g.Type)
|
||||
.Distinct()
|
||||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
|
||||
// Calculate compound multiplier using product reduction
|
||||
// Each gate multiplier is confidence-weighted
|
||||
double multiplier = 1.0;
|
||||
|
||||
foreach (var gate in gatesByType)
|
||||
// Multiply per-type multipliers; gate instances of the same type do not stack.
|
||||
double multiplierBps = 10000.0;
|
||||
foreach (var gateType in gateTypes)
|
||||
{
|
||||
var baseMultiplierBps = _config.GetMultiplierBps(gate.Type);
|
||||
// Scale multiplier by confidence
|
||||
// Low confidence = less reduction, high confidence = more reduction
|
||||
var effectiveMultiplierBps = InterpolateMultiplier(
|
||||
baseMultiplierBps,
|
||||
10000, // No reduction at 0 confidence
|
||||
gate.MaxConfidence);
|
||||
|
||||
multiplier *= effectiveMultiplierBps / 10000.0;
|
||||
var typeMultiplierBps = _config.GetMultiplierBps(gateType);
|
||||
multiplierBps = multiplierBps * typeMultiplierBps / 10000.0;
|
||||
}
|
||||
|
||||
// Apply floor
|
||||
var result = (int)(multiplier * 10000);
|
||||
var result = (int)Math.Round(multiplierBps);
|
||||
result = Math.Clamp(result, 0, _config.MaxMultipliersBps);
|
||||
return Math.Max(result, _config.MinimumMultiplierBps);
|
||||
}
|
||||
|
||||
@@ -65,8 +52,7 @@ public sealed class GateMultiplierCalculator
|
||||
/// <returns>Multiplier in basis points (10000 = 100%).</returns>
|
||||
public int CalculateSingleMultiplierBps(DetectedGate gate)
|
||||
{
|
||||
var baseMultiplierBps = _config.GetMultiplierBps(gate.Type);
|
||||
return InterpolateMultiplier(baseMultiplierBps, 10000, gate.Confidence);
|
||||
return _config.GetMultiplierBps(gate.Type);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -93,14 +79,6 @@ public sealed class GateMultiplierCalculator
|
||||
{
|
||||
return baseScore * multiplierBps / 10000.0;
|
||||
}
|
||||
|
||||
private static int InterpolateMultiplier(int minBps, int maxBps, double confidence)
|
||||
{
|
||||
// Linear interpolation: higher confidence = lower multiplier (closer to minBps)
|
||||
var range = maxBps - minBps;
|
||||
var reduction = (int)(range * confidence);
|
||||
return maxBps - reduction;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using GateDetectors = StellaOps.Scanner.Reachability.Gates.Detectors;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
public interface IRichGraphGateAnnotator
|
||||
{
|
||||
Task<RichGraph> AnnotateAsync(RichGraph graph, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches richgraph-v1 edges with detected gates and a combined gate multiplier.
|
||||
/// </summary>
|
||||
public sealed class RichGraphGateAnnotator : IRichGraphGateAnnotator
|
||||
{
|
||||
private readonly IReadOnlyList<GateDetectors.IGateDetector> _detectors;
|
||||
private readonly GateDetectors.ICodeContentProvider _codeProvider;
|
||||
private readonly GateMultiplierCalculator _multiplierCalculator;
|
||||
private readonly ILogger<RichGraphGateAnnotator> _logger;
|
||||
|
||||
public RichGraphGateAnnotator(
|
||||
IEnumerable<GateDetectors.IGateDetector> detectors,
|
||||
GateDetectors.ICodeContentProvider codeProvider,
|
||||
GateMultiplierCalculator multiplierCalculator,
|
||||
ILogger<RichGraphGateAnnotator> logger)
|
||||
{
|
||||
_detectors = (detectors ?? Enumerable.Empty<GateDetectors.IGateDetector>())
|
||||
.Where(d => d is not null)
|
||||
.OrderBy(d => d.GateType)
|
||||
.ToList();
|
||||
_codeProvider = codeProvider ?? throw new ArgumentNullException(nameof(codeProvider));
|
||||
_multiplierCalculator = multiplierCalculator ?? throw new ArgumentNullException(nameof(multiplierCalculator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<RichGraph> AnnotateAsync(RichGraph graph, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
|
||||
if (_detectors.Count == 0)
|
||||
{
|
||||
return graph;
|
||||
}
|
||||
|
||||
var trimmed = graph.Trimmed();
|
||||
|
||||
var incomingByNode = trimmed.Edges
|
||||
.GroupBy(e => e.To, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<RichGraphEdge>)g.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var gatesByNode = new Dictionary<string, IReadOnlyList<DetectedGate>>(StringComparer.Ordinal);
|
||||
foreach (var node in trimmed.Nodes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (sourceFile, lineNumber, endLineNumber) = ExtractSourceLocation(node);
|
||||
var annotations = ExtractAnnotations(node.Attributes);
|
||||
|
||||
var detectorNode = new GateDetectors.RichGraphNode
|
||||
{
|
||||
Symbol = node.SymbolId,
|
||||
SourceFile = sourceFile,
|
||||
LineNumber = lineNumber,
|
||||
EndLineNumber = endLineNumber,
|
||||
Annotations = annotations,
|
||||
Metadata = node.Attributes
|
||||
};
|
||||
|
||||
var incomingEdges = incomingByNode.TryGetValue(node.Id, out var edges)
|
||||
? edges.Select(e => new GateDetectors.RichGraphEdge
|
||||
{
|
||||
FromSymbol = e.From,
|
||||
ToSymbol = e.To,
|
||||
EdgeType = e.Kind,
|
||||
Gates = []
|
||||
})
|
||||
.ToList()
|
||||
: [];
|
||||
|
||||
var detected = new List<DetectedGate>();
|
||||
foreach (var detector in _detectors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await detector.DetectAsync(
|
||||
detectorNode,
|
||||
incomingEdges,
|
||||
_codeProvider,
|
||||
node.Lang,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (results is { Count: > 0 })
|
||||
{
|
||||
detected.AddRange(results);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Gate detector {Detector} failed for node {NodeId}.", detector.GateType, node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
gatesByNode[node.Id] = CanonicalizeGates(detected);
|
||||
}
|
||||
|
||||
var annotatedEdges = new List<RichGraphEdge>(trimmed.Edges.Count);
|
||||
foreach (var edge in trimmed.Edges)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
gatesByNode.TryGetValue(edge.From, out var fromGates);
|
||||
gatesByNode.TryGetValue(edge.To, out var toGates);
|
||||
|
||||
var combined = CombineGates(fromGates, toGates);
|
||||
if (combined.Count == 0 && edge.GateMultiplierBps == 10000 && edge.Gates is not { Count: > 0 })
|
||||
{
|
||||
annotatedEdges.Add(edge);
|
||||
continue;
|
||||
}
|
||||
|
||||
var multiplier = combined.Count == 0
|
||||
? edge.GateMultiplierBps
|
||||
: _multiplierCalculator.CalculateCombinedMultiplierBps(combined);
|
||||
|
||||
annotatedEdges.Add(edge with
|
||||
{
|
||||
Gates = combined,
|
||||
GateMultiplierBps = multiplier
|
||||
});
|
||||
}
|
||||
|
||||
return (trimmed with { Edges = annotatedEdges }).Trimmed();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DetectedGate> CombineGates(
|
||||
IReadOnlyList<DetectedGate>? fromGates,
|
||||
IReadOnlyList<DetectedGate>? toGates)
|
||||
{
|
||||
if (fromGates is not { Count: > 0 } && toGates is not { Count: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var combined = new List<DetectedGate>((fromGates?.Count ?? 0) + (toGates?.Count ?? 0));
|
||||
if (fromGates is { Count: > 0 })
|
||||
{
|
||||
combined.AddRange(fromGates);
|
||||
}
|
||||
|
||||
if (toGates is { Count: > 0 })
|
||||
{
|
||||
combined.AddRange(toGates);
|
||||
}
|
||||
|
||||
return CanonicalizeGates(combined);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DetectedGate> CanonicalizeGates(IEnumerable<DetectedGate>? gates)
|
||||
{
|
||||
if (gates is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return gates
|
||||
.Where(g => g is not null && !string.IsNullOrWhiteSpace(g.GuardSymbol))
|
||||
.Select(g => g with
|
||||
{
|
||||
Detail = g.Detail.Trim(),
|
||||
GuardSymbol = g.GuardSymbol.Trim(),
|
||||
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
|
||||
Confidence = Math.Clamp(g.Confidence, 0.0, 1.0),
|
||||
DetectionMethod = g.DetectionMethod.Trim()
|
||||
})
|
||||
.GroupBy(g => (g.Type, g.GuardSymbol))
|
||||
.Select(group => group
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
|
||||
.First())
|
||||
.OrderBy(g => g.Type)
|
||||
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ExtractAnnotations(IReadOnlyDictionary<string, string>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var annotations = new List<string>();
|
||||
AddDelimited(annotations, TryGet(attributes, "annotations"));
|
||||
AddDelimited(annotations, TryGet(attributes, "annotation"));
|
||||
AddDelimited(annotations, TryGet(attributes, "decorators"));
|
||||
AddDelimited(annotations, TryGet(attributes, "decorator"));
|
||||
|
||||
foreach (var kv in attributes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (kv.Key.StartsWith("annotation:", StringComparison.OrdinalIgnoreCase) ||
|
||||
kv.Key.StartsWith("decorator:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var suffix = kv.Key[(kv.Key.IndexOf(':') + 1)..].Trim();
|
||||
if (!string.IsNullOrWhiteSpace(suffix))
|
||||
{
|
||||
annotations.Add(suffix);
|
||||
}
|
||||
|
||||
AddDelimited(annotations, kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = annotations
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.Select(a => a.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(a => a, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static void AddDelimited(List<string> sink, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
if (trimmed.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
sink.Add(item.GetString() ?? string.Empty);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var part in trimmed.Split(new[] { '\r', '\n', ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
sink.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string? SourceFile, int? LineNumber, int? EndLineNumber) ExtractSourceLocation(RichGraphNode node)
|
||||
{
|
||||
var attributes = node.Attributes;
|
||||
var sourceFile = TryGet(attributes, "source_file")
|
||||
?? TryGet(attributes, "sourceFile")
|
||||
?? TryGet(attributes, "file");
|
||||
|
||||
var line = TryGetInt(attributes, "line_number")
|
||||
?? TryGetInt(attributes, "lineNumber")
|
||||
?? TryGetInt(attributes, "line");
|
||||
|
||||
var endLine = TryGetInt(attributes, "end_line_number")
|
||||
?? TryGetInt(attributes, "endLineNumber")
|
||||
?? TryGetInt(attributes, "end_line")
|
||||
?? TryGetInt(attributes, "endLine");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sourceFile))
|
||||
{
|
||||
return (sourceFile.Trim(), line, endLine);
|
||||
}
|
||||
|
||||
if (node.Evidence is { Count: > 0 })
|
||||
{
|
||||
foreach (var evidence in node.Evidence)
|
||||
{
|
||||
if (TryParseFileEvidence(evidence, out var file, out var parsedLine))
|
||||
{
|
||||
return (file, parsedLine, endLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (null, line, endLine);
|
||||
}
|
||||
|
||||
private static bool TryParseFileEvidence(string? evidence, out string filePath, out int? lineNumber)
|
||||
{
|
||||
filePath = string.Empty;
|
||||
lineNumber = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(evidence))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var trimmed = evidence.Trim();
|
||||
if (!trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var remainder = trimmed["file:".Length..];
|
||||
if (string.IsNullOrWhiteSpace(remainder))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastColon = remainder.LastIndexOf(':');
|
||||
if (lastColon > 0)
|
||||
{
|
||||
var maybeLine = remainder[(lastColon + 1)..];
|
||||
if (int.TryParse(maybeLine, out var parsed))
|
||||
{
|
||||
filePath = remainder[..lastColon];
|
||||
lineNumber = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
filePath = remainder;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryGet(IReadOnlyDictionary<string, string>? dict, string key)
|
||||
=> dict is not null && dict.TryGetValue(key, out var value) ? value : null;
|
||||
|
||||
private static int? TryGetInt(IReadOnlyDictionary<string, string>? dict, string key)
|
||||
{
|
||||
if (dict is null || !dict.TryGetValue(key, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -20,22 +21,30 @@ public sealed class ReachabilityRichGraphPublisherService : IRichGraphPublisherS
|
||||
private readonly ISurfaceEnvironment _environment;
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
private readonly IRichGraphPublisher _publisher;
|
||||
private readonly IRichGraphGateAnnotator? _gateAnnotator;
|
||||
|
||||
public ReachabilityRichGraphPublisherService(
|
||||
ISurfaceEnvironment environment,
|
||||
IFileContentAddressableStore cas,
|
||||
IRichGraphPublisher publisher)
|
||||
IRichGraphPublisher publisher,
|
||||
IRichGraphGateAnnotator? gateAnnotator = null)
|
||||
{
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
_gateAnnotator = gateAnnotator;
|
||||
}
|
||||
|
||||
public Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
|
||||
public async Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var richGraph = RichGraphBuilder.FromUnion(graph, "scanner.reachability", "0.1.0");
|
||||
if (_gateAnnotator is not null)
|
||||
{
|
||||
richGraph = await _gateAnnotator.AnnotateAsync(richGraph, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var workRoot = Path.Combine(_environment.Settings.CacheRoot.FullName, "reachability");
|
||||
Directory.CreateDirectory(workRoot);
|
||||
return _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken);
|
||||
return await _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -90,10 +91,34 @@ public sealed record RichGraphEdge(
|
||||
string? SymbolDigest,
|
||||
IReadOnlyList<string>? Evidence,
|
||||
double Confidence,
|
||||
IReadOnlyList<string>? Candidates)
|
||||
IReadOnlyList<string>? Candidates,
|
||||
IReadOnlyList<DetectedGate>? Gates = null,
|
||||
int GateMultiplierBps = 10000)
|
||||
{
|
||||
public RichGraphEdge Trimmed()
|
||||
{
|
||||
var gates = (Gates ?? Array.Empty<DetectedGate>())
|
||||
.Where(g => g is not null)
|
||||
.Select(g => g with
|
||||
{
|
||||
Detail = g.Detail.Trim(),
|
||||
GuardSymbol = g.GuardSymbol.Trim(),
|
||||
SourceFile = string.IsNullOrWhiteSpace(g.SourceFile) ? null : g.SourceFile.Trim(),
|
||||
LineNumber = g.LineNumber,
|
||||
Confidence = ClampConfidence(g.Confidence),
|
||||
DetectionMethod = g.DetectionMethod.Trim()
|
||||
})
|
||||
.GroupBy(g => (g.Type, g.GuardSymbol))
|
||||
.Select(group => group
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.DetectionMethod, StringComparer.Ordinal)
|
||||
.First())
|
||||
.OrderBy(g => g.Type)
|
||||
.ThenBy(g => g.GuardSymbol, StringComparer.Ordinal)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return this with
|
||||
{
|
||||
From = From.Trim(),
|
||||
@@ -107,7 +132,9 @@ public sealed record RichGraphEdge(
|
||||
Candidates = Candidates is null
|
||||
? Array.Empty<string>()
|
||||
: Candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).OrderBy(c => c, StringComparer.Ordinal).ToArray(),
|
||||
Confidence = ClampConfidence(Confidence)
|
||||
Confidence = ClampConfidence(Confidence),
|
||||
Gates = gates,
|
||||
GateMultiplierBps = Math.Clamp(GateMultiplierBps, 0, 10000)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
@@ -153,6 +154,30 @@ public sealed class RichGraphWriter
|
||||
if (!string.IsNullOrWhiteSpace(edge.SymbolDigest)) writer.WriteString("symbol_digest", edge.SymbolDigest);
|
||||
writer.WriteNumber("confidence", edge.Confidence);
|
||||
|
||||
if (edge.Gates is { Count: > 0 } || edge.GateMultiplierBps != 10000)
|
||||
{
|
||||
writer.WriteNumber("gate_multiplier_bps", edge.GateMultiplierBps);
|
||||
}
|
||||
|
||||
if (edge.Gates is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("gates");
|
||||
writer.WriteStartArray();
|
||||
foreach (var gate in edge.Gates)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", GateTypeToLowerCamelCase(gate.Type));
|
||||
writer.WriteString("detail", gate.Detail);
|
||||
writer.WriteString("guard_symbol", gate.GuardSymbol);
|
||||
if (!string.IsNullOrWhiteSpace(gate.SourceFile)) writer.WriteString("source_file", gate.SourceFile);
|
||||
if (gate.LineNumber is not null) writer.WriteNumber("line_number", gate.LineNumber.Value);
|
||||
writer.WriteNumber("confidence", gate.Confidence);
|
||||
writer.WriteString("detection_method", gate.DetectionMethod);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
if (edge.Evidence is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
@@ -188,6 +213,16 @@ public sealed class RichGraphWriter
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string GateTypeToLowerCamelCase(GateType type)
|
||||
=> type switch
|
||||
{
|
||||
GateType.AuthRequired => "authRequired",
|
||||
GateType.FeatureFlag => "featureFlag",
|
||||
GateType.AdminOnly => "adminOnly",
|
||||
GateType.NonDefaultConfig => "nonDefaultConfig",
|
||||
_ => type.ToString()
|
||||
};
|
||||
|
||||
private static void WriteSymbol(Utf8JsonWriter writer, ReachabilitySymbol symbol)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for gate detection and multiplier calculation.
|
||||
/// SPRINT_3405_0001_0001 - Tasks #13, #14, #15
|
||||
/// </summary>
|
||||
public sealed class GateDetectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void GateDetectionResult_Empty_HasNoGates()
|
||||
{
|
||||
// Assert
|
||||
Assert.False(GateDetectionResult.Empty.HasGates);
|
||||
Assert.Empty(GateDetectionResult.Empty.Gates);
|
||||
Assert.Null(GateDetectionResult.Empty.PrimaryGate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateDetectionResult_WithGates_HasPrimaryGate()
|
||||
{
|
||||
// Arrange
|
||||
var gates = new[]
|
||||
{
|
||||
CreateGate(GateType.AuthRequired, 0.7),
|
||||
CreateGate(GateType.FeatureFlag, 0.9),
|
||||
};
|
||||
|
||||
var result = new GateDetectionResult { Gates = gates };
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Equal(2, result.Gates.Count);
|
||||
Assert.Equal(GateType.FeatureFlag, result.PrimaryGate?.Type); // Highest confidence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateMultiplierConfig_Default_HasExpectedValues()
|
||||
{
|
||||
// Arrange
|
||||
var config = GateMultiplierConfig.Default;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3000, config.AuthRequiredMultiplierBps); // 30%
|
||||
Assert.Equal(2000, config.FeatureFlagMultiplierBps); // 20%
|
||||
Assert.Equal(1500, config.AdminOnlyMultiplierBps); // 15%
|
||||
Assert.Equal(5000, config.NonDefaultConfigMultiplierBps); // 50%
|
||||
Assert.Equal(500, config.MinimumMultiplierBps); // 5% floor
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_NoDetectors_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var detector = new CompositeGateDetector([]);
|
||||
var context = CreateContext(["main", "vulnerable_function"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasGates);
|
||||
Assert.Equal(10000, result.CombinedMultiplierBps); // 100%
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_EmptyCallPath_ReturnsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var detector = new CompositeGateDetector([new MockAuthDetector()]);
|
||||
var context = CreateContext([]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.HasGates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_SingleGate_AppliesMultiplier()
|
||||
{
|
||||
// Arrange
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.95));
|
||||
var detector = new CompositeGateDetector([authDetector]);
|
||||
var context = CreateContext(["main", "auth_check", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Single(result.Gates);
|
||||
Assert.Equal(3000, result.CombinedMultiplierBps); // 30% from auth
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_MultipleGateTypes_MultipliesMultipliers()
|
||||
{
|
||||
// Arrange
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9));
|
||||
var featureDetector = new MockFeatureFlagDetector(
|
||||
CreateGate(GateType.FeatureFlag, 0.8));
|
||||
|
||||
var detector = new CompositeGateDetector([authDetector, featureDetector]);
|
||||
var context = CreateContext(["main", "auth_check", "feature_check", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.HasGates);
|
||||
Assert.Equal(2, result.Gates.Count);
|
||||
// 30% * 20% = 6% (600 bps), but floor is 500 bps
|
||||
Assert.Equal(600, result.CombinedMultiplierBps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_DuplicateGates_Deduplicates()
|
||||
{
|
||||
// Arrange - two detectors finding same gate
|
||||
var authDetector1 = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9, "checkAuth"));
|
||||
var authDetector2 = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.7, "checkAuth"));
|
||||
|
||||
var detector = new CompositeGateDetector([authDetector1, authDetector2]);
|
||||
var context = CreateContext(["main", "checkAuth", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result.Gates); // Deduplicated
|
||||
Assert.Equal(0.9, result.Gates[0].Confidence); // Kept higher confidence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_AllGateTypes_AppliesMinimumFloor()
|
||||
{
|
||||
// Arrange - all gate types = very low multiplier
|
||||
var detectors = new IGateDetector[]
|
||||
{
|
||||
new MockAuthDetector(CreateGate(GateType.AuthRequired, 0.9)),
|
||||
new MockFeatureFlagDetector(CreateGate(GateType.FeatureFlag, 0.9)),
|
||||
new MockAdminDetector(CreateGate(GateType.AdminOnly, 0.9)),
|
||||
new MockConfigDetector(CreateGate(GateType.NonDefaultConfig, 0.9)),
|
||||
};
|
||||
|
||||
var detector = new CompositeGateDetector(detectors);
|
||||
var context = CreateContext(["main", "auth", "feature", "admin", "config", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, result.Gates.Count);
|
||||
// 30% * 20% * 15% * 50% = 0.45%, but floor is 5% (500 bps)
|
||||
Assert.Equal(500, result.CombinedMultiplierBps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeGateDetector_DetectorException_ContinuesWithOthers()
|
||||
{
|
||||
// Arrange
|
||||
var failingDetector = new FailingGateDetector();
|
||||
var authDetector = new MockAuthDetector(
|
||||
CreateGate(GateType.AuthRequired, 0.9));
|
||||
|
||||
var detector = new CompositeGateDetector([failingDetector, authDetector]);
|
||||
var context = CreateContext(["main", "vulnerable"]);
|
||||
|
||||
// Act
|
||||
var result = await detector.DetectAllAsync(context);
|
||||
|
||||
// Assert - should still get auth gate despite failing detector
|
||||
Assert.Single(result.Gates);
|
||||
Assert.Equal(GateType.AuthRequired, result.Gates[0].Type);
|
||||
}
|
||||
|
||||
private static DetectedGate CreateGate(GateType type, double confidence, string symbol = "guard_symbol")
|
||||
{
|
||||
return new DetectedGate
|
||||
{
|
||||
Type = type,
|
||||
Detail = $"{type} gate detected",
|
||||
GuardSymbol = symbol,
|
||||
Confidence = confidence,
|
||||
DetectionMethod = "mock",
|
||||
};
|
||||
}
|
||||
|
||||
private static CallPathContext CreateContext(string[] callPath)
|
||||
{
|
||||
return new CallPathContext
|
||||
{
|
||||
CallPath = callPath,
|
||||
Language = "csharp",
|
||||
};
|
||||
}
|
||||
|
||||
// Mock detectors for testing
|
||||
private class MockAuthDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.AuthRequired;
|
||||
|
||||
public MockAuthDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockFeatureFlagDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.FeatureFlag;
|
||||
|
||||
public MockFeatureFlagDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockAdminDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.AdminOnly;
|
||||
|
||||
public MockAdminDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class MockConfigDetector : IGateDetector
|
||||
{
|
||||
private readonly DetectedGate[] _gates;
|
||||
public GateType GateType => GateType.NonDefaultConfig;
|
||||
|
||||
public MockConfigDetector(params DetectedGate[] gates) => _gates = gates;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<DetectedGate>>(_gates);
|
||||
}
|
||||
|
||||
private class FailingGateDetector : IGateDetector
|
||||
{
|
||||
public GateType GateType => GateType.AuthRequired;
|
||||
|
||||
public Task<IReadOnlyList<DetectedGate>> DetectAsync(CallPathContext context, CancellationToken ct)
|
||||
=> throw new InvalidOperationException("Simulated detector failure");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddReachabilityDrift(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton<CodeChangeFactExtractor>();
|
||||
services.TryAddSingleton<DriftCauseExplainer>();
|
||||
services.TryAddSingleton<PathCompressor>();
|
||||
|
||||
services.TryAddSingleton(sp =>
|
||||
{
|
||||
var timeProvider = sp.GetService<TimeProvider>();
|
||||
return new ReachabilityAnalyzer(timeProvider);
|
||||
});
|
||||
|
||||
services.TryAddSingleton<ReachabilityDriftDetector>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
internal static class DeterministicIds
|
||||
{
|
||||
internal static readonly Guid CodeChangeNamespace = new("a420df67-6c4b-4f80-9870-0d070a845b4b");
|
||||
internal static readonly Guid DriftResultNamespace = new("c60e2a63-9bc4-4ff0-9f8c-2a7c11c2f8c4");
|
||||
internal static readonly Guid DriftedSinkNamespace = new("9b8ed5d2-4b6f-4f6f-9e3b-3a81e9f85a25");
|
||||
|
||||
public static Guid Create(Guid namespaceId, params string[] segments)
|
||||
{
|
||||
var normalized = string.Join(
|
||||
'|',
|
||||
segments.Select(static s => (s ?? string.Empty).Trim()));
|
||||
return Create(namespaceId, Encoding.UTF8.GetBytes(normalized));
|
||||
}
|
||||
|
||||
public static Guid Create(Guid namespaceId, ReadOnlySpan<byte> nameBytes)
|
||||
{
|
||||
Span<byte> namespaceBytes = stackalloc byte[16];
|
||||
namespaceId.TryWriteBytes(namespaceBytes);
|
||||
|
||||
Span<byte> buffer = stackalloc byte[namespaceBytes.Length + nameBytes.Length];
|
||||
namespaceBytes.CopyTo(buffer);
|
||||
nameBytes.CopyTo(buffer[namespaceBytes.Length..]);
|
||||
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.TryHashData(buffer, hash, out _);
|
||||
|
||||
Span<byte> guidBytes = stackalloc byte[16];
|
||||
hash[..16].CopyTo(guidBytes);
|
||||
|
||||
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50);
|
||||
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80);
|
||||
|
||||
return new Guid(guidBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
public sealed record CodeChangeFact
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("scanId")]
|
||||
public required string ScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("baseScanId")]
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
[JsonPropertyName("nodeId")]
|
||||
public string? NodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public required string File { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public required CodeChangeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public JsonElement? Details { get; init; }
|
||||
|
||||
[JsonPropertyName("detectedAt")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CodeChangeKind>))]
|
||||
public enum CodeChangeKind
|
||||
{
|
||||
[JsonStringEnumMemberName("added")]
|
||||
Added,
|
||||
|
||||
[JsonStringEnumMemberName("removed")]
|
||||
Removed,
|
||||
|
||||
[JsonStringEnumMemberName("signature_changed")]
|
||||
SignatureChanged,
|
||||
|
||||
[JsonStringEnumMemberName("guard_changed")]
|
||||
GuardChanged,
|
||||
|
||||
[JsonStringEnumMemberName("dependency_changed")]
|
||||
DependencyChanged,
|
||||
|
||||
[JsonStringEnumMemberName("visibility_changed")]
|
||||
VisibilityChanged
|
||||
}
|
||||
|
||||
public sealed record ReachabilityDriftResult
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("baseScanId")]
|
||||
public required string BaseScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("headScanId")]
|
||||
public required string HeadScanId { get; init; }
|
||||
|
||||
[JsonPropertyName("language")]
|
||||
public required string Language { get; init; }
|
||||
|
||||
[JsonPropertyName("detectedAt")]
|
||||
public required DateTimeOffset DetectedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyReachable")]
|
||||
public required ImmutableArray<DriftedSink> NewlyReachable { get; init; }
|
||||
|
||||
[JsonPropertyName("newlyUnreachable")]
|
||||
public required ImmutableArray<DriftedSink> NewlyUnreachable { get; init; }
|
||||
|
||||
[JsonPropertyName("resultDigest")]
|
||||
public required string ResultDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDriftCount")]
|
||||
public int TotalDriftCount => NewlyReachable.Length + NewlyUnreachable.Length;
|
||||
|
||||
[JsonPropertyName("hasMaterialDrift")]
|
||||
public bool HasMaterialDrift => NewlyReachable.Length > 0;
|
||||
}
|
||||
|
||||
public sealed record DriftedSink
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("sinkNodeId")]
|
||||
public required string SinkNodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("sinkCategory")]
|
||||
public required SinkCategory SinkCategory { get; init; }
|
||||
|
||||
[JsonPropertyName("direction")]
|
||||
public required DriftDirection Direction { get; init; }
|
||||
|
||||
[JsonPropertyName("cause")]
|
||||
public required DriftCause Cause { get; init; }
|
||||
|
||||
[JsonPropertyName("path")]
|
||||
public required CompressedPath Path { get; init; }
|
||||
|
||||
[JsonPropertyName("associatedVulns")]
|
||||
public ImmutableArray<AssociatedVuln> AssociatedVulns { get; init; } = ImmutableArray<AssociatedVuln>.Empty;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DriftDirection>))]
|
||||
public enum DriftDirection
|
||||
{
|
||||
[JsonStringEnumMemberName("became_reachable")]
|
||||
BecameReachable,
|
||||
|
||||
[JsonStringEnumMemberName("became_unreachable")]
|
||||
BecameUnreachable
|
||||
}
|
||||
|
||||
public sealed record DriftCause
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public required DriftCauseKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public required string Description { get; init; }
|
||||
|
||||
[JsonPropertyName("changedSymbol")]
|
||||
public string? ChangedSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("changedFile")]
|
||||
public string? ChangedFile { get; init; }
|
||||
|
||||
[JsonPropertyName("changedLine")]
|
||||
public int? ChangedLine { get; init; }
|
||||
|
||||
[JsonPropertyName("codeChangeId")]
|
||||
public Guid? CodeChangeId { get; init; }
|
||||
|
||||
public static DriftCause GuardRemoved(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.GuardRemoved,
|
||||
Description = $"Guard condition removed in {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause NewPublicRoute(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.NewPublicRoute,
|
||||
Description = $"New public entrypoint: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause VisibilityEscalated(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.VisibilityEscalated,
|
||||
Description = $"Visibility escalated to public: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause DependencyUpgraded(string package, string? fromVersion, string? toVersion) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.DependencyUpgraded,
|
||||
Description = $"Dependency changed: {package} {fromVersion ?? "?"} -> {toVersion ?? "?"}",
|
||||
ChangedSymbol = package
|
||||
};
|
||||
|
||||
public static DriftCause GuardAdded(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.GuardAdded,
|
||||
Description = $"Guard condition added in {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause SymbolRemoved(string symbol) =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.SymbolRemoved,
|
||||
Description = $"Symbol removed: {symbol}",
|
||||
ChangedSymbol = symbol
|
||||
};
|
||||
|
||||
public static DriftCause Unknown() =>
|
||||
new()
|
||||
{
|
||||
Kind = DriftCauseKind.Unknown,
|
||||
Description = "Cause could not be determined"
|
||||
};
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<DriftCauseKind>))]
|
||||
public enum DriftCauseKind
|
||||
{
|
||||
[JsonStringEnumMemberName("guard_removed")]
|
||||
GuardRemoved,
|
||||
|
||||
[JsonStringEnumMemberName("guard_added")]
|
||||
GuardAdded,
|
||||
|
||||
[JsonStringEnumMemberName("new_public_route")]
|
||||
NewPublicRoute,
|
||||
|
||||
[JsonStringEnumMemberName("visibility_escalated")]
|
||||
VisibilityEscalated,
|
||||
|
||||
[JsonStringEnumMemberName("dependency_upgraded")]
|
||||
DependencyUpgraded,
|
||||
|
||||
[JsonStringEnumMemberName("symbol_removed")]
|
||||
SymbolRemoved,
|
||||
|
||||
[JsonStringEnumMemberName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
public sealed record CompressedPath
|
||||
{
|
||||
[JsonPropertyName("entrypoint")]
|
||||
public required PathNode Entrypoint { get; init; }
|
||||
|
||||
[JsonPropertyName("sink")]
|
||||
public required PathNode Sink { get; init; }
|
||||
|
||||
[JsonPropertyName("intermediateCount")]
|
||||
public required int IntermediateCount { get; init; }
|
||||
|
||||
[JsonPropertyName("keyNodes")]
|
||||
public required ImmutableArray<PathNode> KeyNodes { get; init; }
|
||||
|
||||
[JsonPropertyName("fullPath")]
|
||||
public ImmutableArray<string>? FullPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed record PathNode
|
||||
{
|
||||
[JsonPropertyName("nodeId")]
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("package")]
|
||||
public string? Package { get; init; }
|
||||
|
||||
[JsonPropertyName("isChanged")]
|
||||
public bool IsChanged { get; init; }
|
||||
|
||||
[JsonPropertyName("changeKind")]
|
||||
public CodeChangeKind? ChangeKind { get; init; }
|
||||
}
|
||||
|
||||
public sealed record AssociatedVuln
|
||||
{
|
||||
[JsonPropertyName("cveId")]
|
||||
public required string CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("epss")]
|
||||
public double? Epss { get; init; }
|
||||
|
||||
[JsonPropertyName("cvss")]
|
||||
public double? Cvss { get; init; }
|
||||
|
||||
[JsonPropertyName("vexStatus")]
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("packagePurl")]
|
||||
public string? PackagePurl { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
public sealed class CodeChangeFactExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public CodeChangeFactExtractor(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public IReadOnlyList<CodeChangeFact> Extract(CallGraphSnapshot baseGraph, CallGraphSnapshot headGraph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseGraph);
|
||||
ArgumentNullException.ThrowIfNull(headGraph);
|
||||
|
||||
var baseTrimmed = baseGraph.Trimmed();
|
||||
var headTrimmed = headGraph.Trimmed();
|
||||
|
||||
if (!string.Equals(baseTrimmed.Language, headTrimmed.Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Language mismatch: base='{baseTrimmed.Language}', head='{headTrimmed.Language}'.");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var baseById = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
var headById = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
var removed = baseById
|
||||
.Where(kvp => !headById.ContainsKey(kvp.Key))
|
||||
.Select(kvp => kvp.Value)
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var added = headById
|
||||
.Where(kvp => !baseById.ContainsKey(kvp.Key))
|
||||
.Select(kvp => kvp.Value)
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var signaturePairs = MatchSignatureChanges(removed, added);
|
||||
var consumedRemoved = new HashSet<string>(signaturePairs.Select(p => p.Removed.NodeId), StringComparer.Ordinal);
|
||||
var consumedAdded = new HashSet<string>(signaturePairs.Select(p => p.Added.NodeId), StringComparer.Ordinal);
|
||||
|
||||
var facts = new List<CodeChangeFact>(added.Length + removed.Length);
|
||||
|
||||
foreach (var pair in signaturePairs)
|
||||
{
|
||||
var details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
fromSymbol = pair.Removed.Symbol,
|
||||
toSymbol = pair.Added.Symbol,
|
||||
fromNodeId = pair.Removed.NodeId,
|
||||
toNodeId = pair.Added.NodeId
|
||||
});
|
||||
|
||||
facts.Add(CreateFact(
|
||||
headTrimmed,
|
||||
baseTrimmed,
|
||||
pair.Added,
|
||||
CodeChangeKind.SignatureChanged,
|
||||
now,
|
||||
details));
|
||||
}
|
||||
|
||||
foreach (var node in added)
|
||||
{
|
||||
if (consumedAdded.Contains(node.NodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
facts.Add(CreateFact(
|
||||
headTrimmed,
|
||||
baseTrimmed,
|
||||
node,
|
||||
CodeChangeKind.Added,
|
||||
now,
|
||||
JsonSerializer.SerializeToElement(new { nodeId = node.NodeId })));
|
||||
}
|
||||
|
||||
foreach (var node in removed)
|
||||
{
|
||||
if (consumedRemoved.Contains(node.NodeId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
facts.Add(CreateFact(
|
||||
headTrimmed,
|
||||
baseTrimmed,
|
||||
node,
|
||||
CodeChangeKind.Removed,
|
||||
now,
|
||||
JsonSerializer.SerializeToElement(new { nodeId = node.NodeId })));
|
||||
}
|
||||
|
||||
foreach (var (nodeId, baseNode) in baseById.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!headById.TryGetValue(nodeId, out var headNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(baseNode.Package, headNode.Package, StringComparison.Ordinal))
|
||||
{
|
||||
var details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
nodeId,
|
||||
from = baseNode.Package,
|
||||
to = headNode.Package
|
||||
});
|
||||
|
||||
facts.Add(CreateFact(
|
||||
headTrimmed,
|
||||
baseTrimmed,
|
||||
headNode,
|
||||
CodeChangeKind.DependencyChanged,
|
||||
now,
|
||||
details));
|
||||
}
|
||||
|
||||
if (baseNode.Visibility != headNode.Visibility)
|
||||
{
|
||||
var details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
nodeId,
|
||||
from = baseNode.Visibility.ToString(),
|
||||
to = headNode.Visibility.ToString()
|
||||
});
|
||||
|
||||
facts.Add(CreateFact(
|
||||
headTrimmed,
|
||||
baseTrimmed,
|
||||
headNode,
|
||||
CodeChangeKind.VisibilityChanged,
|
||||
now,
|
||||
details));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var edgeFact in ExtractEdgeFacts(baseTrimmed, headTrimmed, now))
|
||||
{
|
||||
facts.Add(edgeFact);
|
||||
}
|
||||
|
||||
return facts
|
||||
.OrderBy(f => f.Kind.ToString(), StringComparer.Ordinal)
|
||||
.ThenBy(f => f.File, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(f => f.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static CodeChangeFact CreateFact(
|
||||
CallGraphSnapshot head,
|
||||
CallGraphSnapshot @base,
|
||||
CallGraphNode node,
|
||||
CodeChangeKind kind,
|
||||
DateTimeOffset detectedAt,
|
||||
JsonElement? details)
|
||||
{
|
||||
var id = DeterministicIds.Create(
|
||||
DeterministicIds.CodeChangeNamespace,
|
||||
head.ScanId,
|
||||
@base.ScanId,
|
||||
head.Language,
|
||||
kind.ToString(),
|
||||
node.NodeId,
|
||||
node.File,
|
||||
node.Symbol);
|
||||
|
||||
return new CodeChangeFact
|
||||
{
|
||||
Id = id,
|
||||
ScanId = head.ScanId,
|
||||
BaseScanId = @base.ScanId,
|
||||
Language = head.Language,
|
||||
NodeId = node.NodeId,
|
||||
File = node.File,
|
||||
Symbol = node.Symbol,
|
||||
Kind = kind,
|
||||
Details = details,
|
||||
DetectedAt = detectedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<CodeChangeFact> ExtractEdgeFacts(
|
||||
CallGraphSnapshot baseTrimmed,
|
||||
CallGraphSnapshot headTrimmed,
|
||||
DateTimeOffset detectedAt)
|
||||
{
|
||||
var baseEdges = baseTrimmed.Edges
|
||||
.Select(EdgeKey.Create)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var headEdges = headTrimmed.Edges
|
||||
.Select(EdgeKey.Create)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var baseById = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
var headById = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
foreach (var key in headEdges.Except(baseEdges).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
if (!EdgeKey.TryParse(key, out var parsed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!headById.TryGetValue(parsed.SourceId, out var sourceNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
nodeId = sourceNode.NodeId,
|
||||
change = "edge_added",
|
||||
sourceId = parsed.SourceId,
|
||||
targetId = parsed.TargetId,
|
||||
callKind = parsed.CallKind,
|
||||
callSite = parsed.CallSite
|
||||
});
|
||||
|
||||
yield return CreateFact(headTrimmed, baseTrimmed, sourceNode, CodeChangeKind.GuardChanged, detectedAt, details);
|
||||
}
|
||||
|
||||
foreach (var key in baseEdges.Except(headEdges).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
if (!EdgeKey.TryParse(key, out var parsed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!baseById.TryGetValue(parsed.SourceId, out var sourceNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var details = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
nodeId = sourceNode.NodeId,
|
||||
change = "edge_removed",
|
||||
sourceId = parsed.SourceId,
|
||||
targetId = parsed.TargetId,
|
||||
callKind = parsed.CallKind,
|
||||
callSite = parsed.CallSite
|
||||
});
|
||||
|
||||
yield return CreateFact(headTrimmed, baseTrimmed, sourceNode, CodeChangeKind.GuardChanged, detectedAt, details);
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<(CallGraphNode Removed, CallGraphNode Added)> MatchSignatureChanges(
|
||||
ImmutableArray<CallGraphNode> removed,
|
||||
ImmutableArray<CallGraphNode> added)
|
||||
{
|
||||
var removedByKey = removed
|
||||
.GroupBy(BuildSignatureKey, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList(), StringComparer.Ordinal);
|
||||
|
||||
var addedByKey = added
|
||||
.GroupBy(BuildSignatureKey, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(n => n.NodeId, StringComparer.Ordinal).ToList(), StringComparer.Ordinal);
|
||||
|
||||
var pairs = new List<(CallGraphNode Removed, CallGraphNode Added)>();
|
||||
|
||||
foreach (var key in removedByKey.Keys.OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
if (!addedByKey.TryGetValue(key, out var addedCandidates))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var removedCandidates = removedByKey[key];
|
||||
var count = Math.Min(removedCandidates.Count, addedCandidates.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
pairs.Add((removedCandidates[i], addedCandidates[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return pairs
|
||||
.OrderBy(p => p.Removed.NodeId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.Added.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static string BuildSignatureKey(CallGraphNode node)
|
||||
{
|
||||
var file = node.File?.Trim() ?? string.Empty;
|
||||
var symbolKey = GetSymbolKey(node.Symbol);
|
||||
return $"{file}|{symbolKey}";
|
||||
}
|
||||
|
||||
private static string GetSymbolKey(string symbol)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = symbol.Trim();
|
||||
var parenIndex = trimmed.IndexOf('(');
|
||||
if (parenIndex > 0)
|
||||
{
|
||||
trimmed = trimmed[..parenIndex];
|
||||
}
|
||||
|
||||
return trimmed.Replace("global::", string.Empty, StringComparison.Ordinal).Trim();
|
||||
}
|
||||
|
||||
private readonly record struct EdgeKey(string SourceId, string TargetId, string CallKind, string? CallSite)
|
||||
{
|
||||
public static string Create(CallGraphEdge edge)
|
||||
{
|
||||
var callSite = string.IsNullOrWhiteSpace(edge.CallSite) ? string.Empty : edge.CallSite.Trim();
|
||||
return $"{edge.SourceId}|{edge.TargetId}|{edge.CallKind}|{callSite}";
|
||||
}
|
||||
|
||||
public static bool TryParse(string key, out EdgeKey parsed)
|
||||
{
|
||||
var parts = key.Split('|');
|
||||
if (parts.Length != 4)
|
||||
{
|
||||
parsed = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new EdgeKey(parts[0], parts[1], parts[2], string.IsNullOrWhiteSpace(parts[3]) ? null : parts[3]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
public sealed class DriftCauseExplainer
|
||||
{
|
||||
public DriftCause ExplainNewlyReachable(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
string sinkNodeId,
|
||||
ImmutableArray<string> pathNodeIds,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseGraph);
|
||||
ArgumentNullException.ThrowIfNull(headGraph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sinkNodeId);
|
||||
ArgumentNullException.ThrowIfNull(codeChanges);
|
||||
|
||||
var baseTrimmed = baseGraph.Trimmed();
|
||||
var headTrimmed = headGraph.Trimmed();
|
||||
|
||||
if (!pathNodeIds.IsDefaultOrEmpty)
|
||||
{
|
||||
var entrypointId = pathNodeIds[0];
|
||||
var isNewEntrypoint = !baseTrimmed.EntrypointIds.Contains(entrypointId, StringComparer.Ordinal)
|
||||
&& headTrimmed.EntrypointIds.Contains(entrypointId, StringComparer.Ordinal);
|
||||
|
||||
if (isNewEntrypoint)
|
||||
{
|
||||
var symbol = ResolveSymbol(headTrimmed, entrypointId) ?? entrypointId;
|
||||
return DriftCause.NewPublicRoute(symbol);
|
||||
}
|
||||
}
|
||||
|
||||
var escalated = FindVisibilityEscalation(baseTrimmed, headTrimmed, pathNodeIds, codeChanges);
|
||||
if (escalated is not null)
|
||||
{
|
||||
return escalated;
|
||||
}
|
||||
|
||||
var dependency = FindDependencyChange(baseTrimmed, headTrimmed, pathNodeIds, codeChanges);
|
||||
if (dependency is not null)
|
||||
{
|
||||
return dependency;
|
||||
}
|
||||
|
||||
var guardRemoved = FindEdgeAdded(baseTrimmed, headTrimmed, pathNodeIds);
|
||||
if (guardRemoved is not null)
|
||||
{
|
||||
return guardRemoved;
|
||||
}
|
||||
|
||||
return DriftCause.Unknown();
|
||||
}
|
||||
|
||||
public DriftCause ExplainNewlyUnreachable(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
string sinkNodeId,
|
||||
ImmutableArray<string> basePathNodeIds,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseGraph);
|
||||
ArgumentNullException.ThrowIfNull(headGraph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sinkNodeId);
|
||||
ArgumentNullException.ThrowIfNull(codeChanges);
|
||||
|
||||
var baseTrimmed = baseGraph.Trimmed();
|
||||
var headTrimmed = headGraph.Trimmed();
|
||||
|
||||
if (!headTrimmed.Nodes.Any(n => n.NodeId == sinkNodeId))
|
||||
{
|
||||
var symbol = ResolveSymbol(baseTrimmed, sinkNodeId) ?? sinkNodeId;
|
||||
return DriftCause.SymbolRemoved(symbol);
|
||||
}
|
||||
|
||||
var guardAdded = FindEdgeRemoved(baseTrimmed, headTrimmed, basePathNodeIds);
|
||||
if (guardAdded is not null)
|
||||
{
|
||||
return guardAdded;
|
||||
}
|
||||
|
||||
return DriftCause.Unknown();
|
||||
}
|
||||
|
||||
private static DriftCause? FindVisibilityEscalation(
|
||||
CallGraphSnapshot baseTrimmed,
|
||||
CallGraphSnapshot headTrimmed,
|
||||
ImmutableArray<string> pathNodeIds,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
if (pathNodeIds.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseById = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
var headById = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
foreach (var nodeId in pathNodeIds)
|
||||
{
|
||||
if (!baseById.TryGetValue(nodeId, out var baseNode) || !headById.TryGetValue(nodeId, out var headNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baseNode.Visibility == Visibility.Public || headNode.Visibility != Visibility.Public)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matching = codeChanges
|
||||
.Where(c => c.Kind == CodeChangeKind.VisibilityChanged && string.Equals(c.NodeId, nodeId, StringComparison.Ordinal))
|
||||
.OrderBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
return matching is not null
|
||||
? new DriftCause
|
||||
{
|
||||
Kind = DriftCauseKind.VisibilityEscalated,
|
||||
Description = $"Visibility escalated to public: {headNode.Symbol}",
|
||||
ChangedSymbol = headNode.Symbol,
|
||||
ChangedFile = headNode.File,
|
||||
ChangedLine = headNode.Line,
|
||||
CodeChangeId = matching.Id
|
||||
}
|
||||
: DriftCause.VisibilityEscalated(headNode.Symbol);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DriftCause? FindDependencyChange(
|
||||
CallGraphSnapshot baseTrimmed,
|
||||
CallGraphSnapshot headTrimmed,
|
||||
ImmutableArray<string> pathNodeIds,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges)
|
||||
{
|
||||
if (pathNodeIds.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseById = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
var headById = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
foreach (var nodeId in pathNodeIds)
|
||||
{
|
||||
if (!baseById.TryGetValue(nodeId, out var baseNode) || !headById.TryGetValue(nodeId, out var headNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(baseNode.Package, headNode.Package, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var matching = codeChanges
|
||||
.Where(c => c.Kind == CodeChangeKind.DependencyChanged && string.Equals(c.NodeId, nodeId, StringComparison.Ordinal))
|
||||
.OrderBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
return matching is not null
|
||||
? new DriftCause
|
||||
{
|
||||
Kind = DriftCauseKind.DependencyUpgraded,
|
||||
Description = $"Dependency changed: {baseNode.Package} -> {headNode.Package}",
|
||||
ChangedSymbol = headNode.Package,
|
||||
ChangedFile = headNode.File,
|
||||
ChangedLine = headNode.Line,
|
||||
CodeChangeId = matching.Id
|
||||
}
|
||||
: DriftCause.DependencyUpgraded(headNode.Package, baseNode.Package, headNode.Package);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DriftCause? FindEdgeAdded(
|
||||
CallGraphSnapshot baseTrimmed,
|
||||
CallGraphSnapshot headTrimmed,
|
||||
ImmutableArray<string> pathNodeIds)
|
||||
{
|
||||
if (pathNodeIds.IsDefaultOrEmpty || pathNodeIds.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseEdges = baseTrimmed.Edges
|
||||
.Select(e => $"{e.SourceId}|{e.TargetId}")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var headEdges = headTrimmed.Edges
|
||||
.Select(e => $"{e.SourceId}|{e.TargetId}")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var headById = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < pathNodeIds.Length - 1; i++)
|
||||
{
|
||||
var from = pathNodeIds[i];
|
||||
var to = pathNodeIds[i + 1];
|
||||
var key = $"{from}|{to}";
|
||||
|
||||
if (headEdges.Contains(key) && !baseEdges.Contains(key) && headById.TryGetValue(from, out var node))
|
||||
{
|
||||
return DriftCause.GuardRemoved(node.Symbol);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DriftCause? FindEdgeRemoved(
|
||||
CallGraphSnapshot baseTrimmed,
|
||||
CallGraphSnapshot headTrimmed,
|
||||
ImmutableArray<string> basePathNodeIds)
|
||||
{
|
||||
if (basePathNodeIds.IsDefaultOrEmpty || basePathNodeIds.Length < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseEdges = baseTrimmed.Edges
|
||||
.Select(e => $"{e.SourceId}|{e.TargetId}")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var headEdges = headTrimmed.Edges
|
||||
.Select(e => $"{e.SourceId}|{e.TargetId}")
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var baseById = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < basePathNodeIds.Length - 1; i++)
|
||||
{
|
||||
var from = basePathNodeIds[i];
|
||||
var to = basePathNodeIds[i + 1];
|
||||
var key = $"{from}|{to}";
|
||||
|
||||
if (baseEdges.Contains(key) && !headEdges.Contains(key) && baseById.TryGetValue(from, out var node))
|
||||
{
|
||||
return DriftCause.GuardAdded(node.Symbol);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ResolveSymbol(CallGraphSnapshot graph, string nodeId)
|
||||
=> graph.Nodes.FirstOrDefault(n => string.Equals(n.NodeId, nodeId, StringComparison.Ordinal))?.Symbol;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
public sealed class PathCompressor
|
||||
{
|
||||
private readonly int _maxKeyNodes;
|
||||
|
||||
public PathCompressor(int maxKeyNodes = 5)
|
||||
{
|
||||
_maxKeyNodes = maxKeyNodes <= 0 ? 5 : maxKeyNodes;
|
||||
}
|
||||
|
||||
public CompressedPath Compress(
|
||||
ImmutableArray<string> pathNodeIds,
|
||||
CallGraphSnapshot graph,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges,
|
||||
bool includeFullPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentNullException.ThrowIfNull(codeChanges);
|
||||
|
||||
var trimmed = graph.Trimmed();
|
||||
var nodeMap = trimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
if (pathNodeIds.IsDefaultOrEmpty)
|
||||
{
|
||||
var empty = CreatePathNode(nodeMap, string.Empty, codeChanges);
|
||||
return new CompressedPath
|
||||
{
|
||||
Entrypoint = empty,
|
||||
Sink = empty,
|
||||
IntermediateCount = 0,
|
||||
KeyNodes = ImmutableArray<PathNode>.Empty,
|
||||
FullPath = includeFullPath ? ImmutableArray<string>.Empty : null
|
||||
};
|
||||
}
|
||||
|
||||
var entryId = pathNodeIds[0];
|
||||
var sinkId = pathNodeIds[^1];
|
||||
|
||||
var entry = CreatePathNode(nodeMap, entryId, codeChanges);
|
||||
var sink = CreatePathNode(nodeMap, sinkId, codeChanges);
|
||||
|
||||
var intermediateCount = Math.Max(0, pathNodeIds.Length - 2);
|
||||
var intermediates = intermediateCount == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: pathNodeIds.Skip(1).Take(pathNodeIds.Length - 2).ToImmutableArray();
|
||||
|
||||
var changedNodes = new HashSet<string>(
|
||||
codeChanges
|
||||
.Select(c => c.NodeId)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id!)
|
||||
.Distinct(StringComparer.Ordinal),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var keyNodeIds = new List<string>(_maxKeyNodes);
|
||||
|
||||
foreach (var nodeId in intermediates)
|
||||
{
|
||||
if (changedNodes.Contains(nodeId))
|
||||
{
|
||||
keyNodeIds.Add(nodeId);
|
||||
if (keyNodeIds.Count >= _maxKeyNodes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (keyNodeIds.Count < _maxKeyNodes && intermediates.Length > 0)
|
||||
{
|
||||
var remaining = _maxKeyNodes - keyNodeIds.Count;
|
||||
var candidates = intermediates.Where(id => !keyNodeIds.Contains(id, StringComparer.Ordinal)).ToList();
|
||||
if (candidates.Count > 0 && remaining > 0)
|
||||
{
|
||||
var step = (candidates.Count + 1.0) / (remaining + 1.0);
|
||||
for (var i = 1; i <= remaining; i++)
|
||||
{
|
||||
var index = (int)Math.Round(i * step) - 1;
|
||||
index = Math.Clamp(index, 0, candidates.Count - 1);
|
||||
keyNodeIds.Add(candidates[index]);
|
||||
if (keyNodeIds.Count >= _maxKeyNodes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var keyNodes = keyNodeIds
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(id => CreatePathNode(nodeMap, id, codeChanges))
|
||||
.OrderBy(n => IndexOf(pathNodeIds, n.NodeId), Comparer<int>.Default)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new CompressedPath
|
||||
{
|
||||
Entrypoint = entry,
|
||||
Sink = sink,
|
||||
IntermediateCount = intermediateCount,
|
||||
KeyNodes = keyNodes,
|
||||
FullPath = includeFullPath ? pathNodeIds : null
|
||||
};
|
||||
}
|
||||
|
||||
private static PathNode CreatePathNode(
|
||||
IReadOnlyDictionary<string, CallGraphNode> nodeMap,
|
||||
string nodeId,
|
||||
IReadOnlyList<CodeChangeFact> changes)
|
||||
{
|
||||
nodeMap.TryGetValue(nodeId, out var node);
|
||||
|
||||
var change = changes
|
||||
.Where(c => string.Equals(c.NodeId, nodeId, StringComparison.Ordinal))
|
||||
.OrderBy(c => c.Kind.ToString(), StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Id)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new PathNode
|
||||
{
|
||||
NodeId = nodeId,
|
||||
Symbol = node?.Symbol ?? string.Empty,
|
||||
File = string.IsNullOrWhiteSpace(node?.File) ? null : node.File,
|
||||
Line = node?.Line > 0 ? node.Line : null,
|
||||
Package = string.IsNullOrWhiteSpace(node?.Package) ? null : node.Package,
|
||||
IsChanged = change is not null,
|
||||
ChangeKind = change?.Kind
|
||||
};
|
||||
}
|
||||
|
||||
private static int IndexOf(ImmutableArray<string> path, string nodeId)
|
||||
{
|
||||
for (var i = 0; i < path.Length; i++)
|
||||
{
|
||||
if (string.Equals(path[i], nodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.ReachabilityDrift.Services;
|
||||
|
||||
public sealed class ReachabilityDriftDetector
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ReachabilityAnalyzer _reachabilityAnalyzer;
|
||||
private readonly DriftCauseExplainer _causeExplainer;
|
||||
private readonly PathCompressor _pathCompressor;
|
||||
|
||||
public ReachabilityDriftDetector(
|
||||
TimeProvider? timeProvider = null,
|
||||
ReachabilityAnalyzer? reachabilityAnalyzer = null,
|
||||
DriftCauseExplainer? causeExplainer = null,
|
||||
PathCompressor? pathCompressor = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_reachabilityAnalyzer = reachabilityAnalyzer ?? new ReachabilityAnalyzer(_timeProvider);
|
||||
_causeExplainer = causeExplainer ?? new DriftCauseExplainer();
|
||||
_pathCompressor = pathCompressor ?? new PathCompressor();
|
||||
}
|
||||
|
||||
public ReachabilityDriftResult Detect(
|
||||
CallGraphSnapshot baseGraph,
|
||||
CallGraphSnapshot headGraph,
|
||||
IReadOnlyList<CodeChangeFact> codeChanges,
|
||||
bool includeFullPath = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseGraph);
|
||||
ArgumentNullException.ThrowIfNull(headGraph);
|
||||
ArgumentNullException.ThrowIfNull(codeChanges);
|
||||
|
||||
var baseTrimmed = baseGraph.Trimmed();
|
||||
var headTrimmed = headGraph.Trimmed();
|
||||
|
||||
if (!string.Equals(baseTrimmed.Language, headTrimmed.Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Language mismatch: base='{baseTrimmed.Language}', head='{headTrimmed.Language}'.");
|
||||
}
|
||||
|
||||
var baseReachability = _reachabilityAnalyzer.Analyze(baseTrimmed);
|
||||
var headReachability = _reachabilityAnalyzer.Analyze(headTrimmed);
|
||||
|
||||
var baseReachable = baseReachability.ReachableSinkIds.ToHashSet(StringComparer.Ordinal);
|
||||
var headReachable = headReachability.ReachableSinkIds.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var headPaths = headReachability.Paths
|
||||
.ToDictionary(p => p.SinkId, p => p.NodeIds, StringComparer.Ordinal);
|
||||
|
||||
var basePaths = baseReachability.Paths
|
||||
.ToDictionary(p => p.SinkId, p => p.NodeIds, StringComparer.Ordinal);
|
||||
|
||||
var baseNodes = baseTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
var headNodes = headTrimmed.Nodes.ToDictionary(n => n.NodeId, StringComparer.Ordinal);
|
||||
|
||||
var newlyReachableIds = headReachable
|
||||
.Except(baseReachable)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var newlyUnreachableIds = baseReachable
|
||||
.Except(headReachable)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var detectedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var resultDigest = ComputeDigest(
|
||||
baseTrimmed.ScanId,
|
||||
headTrimmed.ScanId,
|
||||
headTrimmed.Language,
|
||||
newlyReachableIds,
|
||||
newlyUnreachableIds);
|
||||
|
||||
var driftId = DeterministicIds.Create(
|
||||
DeterministicIds.DriftResultNamespace,
|
||||
baseTrimmed.ScanId,
|
||||
headTrimmed.ScanId,
|
||||
headTrimmed.Language,
|
||||
resultDigest);
|
||||
|
||||
var newlyReachable = newlyReachableIds
|
||||
.Select(sinkId =>
|
||||
{
|
||||
headNodes.TryGetValue(sinkId, out var sinkNode);
|
||||
sinkNode ??= new CallGraphNode(sinkId, sinkId, string.Empty, 0, string.Empty, Visibility.Private, false, null, true, null);
|
||||
|
||||
var path = headPaths.TryGetValue(sinkId, out var nodeIds) ? nodeIds : ImmutableArray<string>.Empty;
|
||||
if (path.IsDefaultOrEmpty)
|
||||
{
|
||||
path = ImmutableArray.Create(sinkId);
|
||||
}
|
||||
|
||||
var cause = _causeExplainer.ExplainNewlyReachable(baseTrimmed, headTrimmed, sinkId, path, codeChanges);
|
||||
var compressed = _pathCompressor.Compress(path, headTrimmed, codeChanges, includeFullPath);
|
||||
|
||||
return new DriftedSink
|
||||
{
|
||||
Id = DeterministicIds.Create(DeterministicIds.DriftedSinkNamespace, driftId.ToString("n"), sinkId),
|
||||
SinkNodeId = sinkId,
|
||||
Symbol = sinkNode.Symbol,
|
||||
SinkCategory = sinkNode.SinkCategory ?? Reachability.SinkCategory.CmdExec,
|
||||
Direction = DriftDirection.BecameReachable,
|
||||
Cause = cause,
|
||||
Path = compressed
|
||||
};
|
||||
})
|
||||
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var newlyUnreachable = newlyUnreachableIds
|
||||
.Select(sinkId =>
|
||||
{
|
||||
baseNodes.TryGetValue(sinkId, out var sinkNode);
|
||||
sinkNode ??= new CallGraphNode(sinkId, sinkId, string.Empty, 0, string.Empty, Visibility.Private, false, null, true, null);
|
||||
|
||||
var path = basePaths.TryGetValue(sinkId, out var nodeIds) ? nodeIds : ImmutableArray<string>.Empty;
|
||||
if (path.IsDefaultOrEmpty)
|
||||
{
|
||||
path = ImmutableArray.Create(sinkId);
|
||||
}
|
||||
|
||||
var cause = _causeExplainer.ExplainNewlyUnreachable(baseTrimmed, headTrimmed, sinkId, path, codeChanges);
|
||||
var compressed = _pathCompressor.Compress(path, baseTrimmed, codeChanges, includeFullPath);
|
||||
|
||||
return new DriftedSink
|
||||
{
|
||||
Id = DeterministicIds.Create(DeterministicIds.DriftedSinkNamespace, driftId.ToString("n"), sinkId),
|
||||
SinkNodeId = sinkId,
|
||||
Symbol = sinkNode.Symbol,
|
||||
SinkCategory = sinkNode.SinkCategory ?? Reachability.SinkCategory.CmdExec,
|
||||
Direction = DriftDirection.BecameUnreachable,
|
||||
Cause = cause,
|
||||
Path = compressed
|
||||
};
|
||||
})
|
||||
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ReachabilityDriftResult
|
||||
{
|
||||
Id = driftId,
|
||||
BaseScanId = baseTrimmed.ScanId,
|
||||
HeadScanId = headTrimmed.ScanId,
|
||||
Language = headTrimmed.Language,
|
||||
DetectedAt = detectedAt,
|
||||
NewlyReachable = newlyReachable,
|
||||
NewlyUnreachable = newlyUnreachable,
|
||||
ResultDigest = resultDigest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeDigest(
|
||||
string baseScanId,
|
||||
string headScanId,
|
||||
string language,
|
||||
ImmutableArray<string> newlyReachableIds,
|
||||
ImmutableArray<string> newlyUnreachableIds)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(baseScanId.Trim()).Append('|');
|
||||
builder.Append(headScanId.Trim()).Append('|');
|
||||
builder.Append(language.Trim().ToLowerInvariant()).Append('|');
|
||||
builder.Append(string.Join(',', newlyReachableIds)).Append('|');
|
||||
builder.Append(string.Join(',', newlyUnreachableIds));
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class MaterialRiskChangeDetector
|
||||
RiskStateSnapshot previous,
|
||||
RiskStateSnapshot current)
|
||||
{
|
||||
if (previous.FindingKey != current.FindingKey)
|
||||
if (!FindingKeysMatch(previous.FindingKey, current.FindingKey))
|
||||
throw new ArgumentException("FindingKey mismatch between snapshots");
|
||||
|
||||
var changes = new List<DetectedChange>();
|
||||
@@ -56,6 +56,11 @@ public sealed class MaterialRiskChangeDetector
|
||||
CurrentStateHash: current.ComputeStateHash());
|
||||
}
|
||||
|
||||
public MaterialRiskChangeResult DetectChanges(
|
||||
RiskStateSnapshot previous,
|
||||
RiskStateSnapshot current)
|
||||
=> Compare(previous, current);
|
||||
|
||||
/// <summary>
|
||||
/// R1: Reachability Flip - reachable changes false→true or true→false
|
||||
/// </summary>
|
||||
@@ -286,40 +291,79 @@ public sealed class MaterialRiskChangeDetector
|
||||
if (changes.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Sum weighted changes
|
||||
var weightedSum = 0.0;
|
||||
foreach (var change in changes)
|
||||
// Priority scoring per Smart-Diff advisory (A9):
|
||||
// + 1000 if new.kev
|
||||
// + 500 if new.reachable
|
||||
// + 200 if RANGE_FLIP to affected
|
||||
// + 150 if VEX_FLIP to affected
|
||||
// + 0..100 based on EPSS (epss * 100)
|
||||
// + policy weight: +300 if BLOCK, +100 if WARN
|
||||
|
||||
var score = 0;
|
||||
|
||||
if (current.Kev)
|
||||
score += 1000;
|
||||
|
||||
if (current.Reachable == true)
|
||||
score += 500;
|
||||
|
||||
if (changes.Any(c => c.Rule == DetectionRule.R3_RangeBoundary
|
||||
&& c.Direction == RiskDirection.Increased
|
||||
&& current.InAffectedRange == true))
|
||||
{
|
||||
var directionMultiplier = change.Direction switch
|
||||
{
|
||||
RiskDirection.Increased => 1.0,
|
||||
RiskDirection.Decreased => -0.5,
|
||||
RiskDirection.Neutral => 0.0,
|
||||
_ => 0.0
|
||||
};
|
||||
weightedSum += change.Weight * directionMultiplier;
|
||||
score += 200;
|
||||
}
|
||||
|
||||
// Base severity from EPSS or default
|
||||
var baseSeverity = current.EpssScore ?? 0.5;
|
||||
|
||||
// KEV boost
|
||||
var kevBoost = current.Kev ? 1.5 : 1.0;
|
||||
|
||||
// Confidence factor from lattice state
|
||||
var confidence = current.LatticeState switch
|
||||
if (changes.Any(c => c.Rule == DetectionRule.R2_VexFlip
|
||||
&& c.Direction == RiskDirection.Increased
|
||||
&& current.VexStatus == VexStatusType.Affected))
|
||||
{
|
||||
"certain_reachable" => 1.0,
|
||||
"likely_reachable" => 0.9,
|
||||
"uncertain" => 0.7,
|
||||
"likely_unreachable" => 0.5,
|
||||
"certain_unreachable" => 0.3,
|
||||
_ => 0.7
|
||||
score += 150;
|
||||
}
|
||||
|
||||
if (current.EpssScore is not null)
|
||||
{
|
||||
var epss = Math.Clamp(current.EpssScore.Value, 0.0, 1.0);
|
||||
score += (int)Math.Round(epss * 100.0, 0, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
score += current.PolicyDecision switch
|
||||
{
|
||||
PolicyDecisionType.Block => 300,
|
||||
PolicyDecisionType.Warn => 100,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
var score = baseSeverity * weightedSum * kevBoost * confidence;
|
||||
return score;
|
||||
}
|
||||
|
||||
// Clamp to [-1, 1]
|
||||
return Math.Clamp(score, -1.0, 1.0);
|
||||
private static bool FindingKeysMatch(FindingKey previous, FindingKey current)
|
||||
{
|
||||
if (!StringComparer.Ordinal.Equals(previous.VulnId, current.VulnId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var prevPurl = NormalizePurlForComparison(previous.ComponentPurl);
|
||||
var currPurl = NormalizePurlForComparison(current.ComponentPurl);
|
||||
return StringComparer.Ordinal.Equals(prevPurl, currPurl);
|
||||
}
|
||||
|
||||
private static string NormalizePurlForComparison(string purl)
|
||||
{
|
||||
// Strip the version segment (`@<version>`) while preserving qualifiers (`?`) and subpath (`#`).
|
||||
var atIndex = purl.IndexOf('@');
|
||||
if (atIndex < 0)
|
||||
{
|
||||
return purl;
|
||||
}
|
||||
|
||||
var endIndex = purl.IndexOfAny(['?', '#'], atIndex);
|
||||
if (endIndex < 0)
|
||||
{
|
||||
endIndex = purl.Length;
|
||||
}
|
||||
|
||||
return purl.Remove(atIndex, endIndex - atIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ public sealed class MaterialRiskChangeOptions
|
||||
/// <summary>
|
||||
/// EPSS score threshold for R4 detection.
|
||||
/// </summary>
|
||||
public double EpssThreshold { get; init; } = 0.5;
|
||||
public double EpssThreshold { get; init; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Weight for policy decision flip.
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed record RiskStateSnapshot(
|
||||
builder.Append(PolicyDecision?.ToString() ?? "null");
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,9 @@ public sealed record SarifResult(
|
||||
[property: JsonPropertyName("level")] SarifLevel Level,
|
||||
[property: JsonPropertyName("message")] SarifMessage Message,
|
||||
[property: JsonPropertyName("locations")] ImmutableArray<SarifLocation>? Locations = null,
|
||||
[property: JsonPropertyName("fingerprints")] ImmutableDictionary<string, string>? Fingerprints = null,
|
||||
[property: JsonPropertyName("partialFingerprints")] ImmutableDictionary<string, string>? PartialFingerprints = null,
|
||||
[property: JsonPropertyName("properties")] ImmutableDictionary<string, object>? Properties = null);
|
||||
[property: JsonPropertyName("fingerprints")] ImmutableSortedDictionary<string, string>? Fingerprints = null,
|
||||
[property: JsonPropertyName("partialFingerprints")] ImmutableSortedDictionary<string, string>? PartialFingerprints = null,
|
||||
[property: JsonPropertyName("properties")] ImmutableSortedDictionary<string, object>? Properties = null);
|
||||
|
||||
/// <summary>
|
||||
/// Location of a result.
|
||||
@@ -157,7 +157,7 @@ public sealed record SarifInvocation(
|
||||
public sealed record SarifArtifact(
|
||||
[property: JsonPropertyName("location")] SarifArtifactLocation Location,
|
||||
[property: JsonPropertyName("mimeType")] string? MimeType = null,
|
||||
[property: JsonPropertyName("hashes")] ImmutableDictionary<string, string>? Hashes = null);
|
||||
[property: JsonPropertyName("hashes")] ImmutableSortedDictionary<string, string>? Hashes = null);
|
||||
|
||||
/// <summary>
|
||||
/// Version control information.
|
||||
|
||||
@@ -293,10 +293,10 @@ public sealed class SarifOutputGenerator
|
||||
Level: level,
|
||||
Message: new SarifMessage(message),
|
||||
Locations: locations,
|
||||
Fingerprints: ImmutableDictionary.CreateRange(new[]
|
||||
Fingerprints: ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[]
|
||||
{
|
||||
KeyValuePair.Create("purl", change.ComponentPurl),
|
||||
KeyValuePair.Create("vulnId", change.VulnId),
|
||||
KeyValuePair.Create("purl", change.ComponentPurl)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -322,10 +322,10 @@ public sealed class SarifOutputGenerator
|
||||
RuleId: "SDIFF003",
|
||||
Level: SarifLevel.Note,
|
||||
Message: new SarifMessage(message),
|
||||
Fingerprints: ImmutableDictionary.CreateRange(new[]
|
||||
Fingerprints: ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[]
|
||||
{
|
||||
KeyValuePair.Create("purl", candidate.ComponentPurl),
|
||||
KeyValuePair.Create("vulnId", candidate.VulnId),
|
||||
KeyValuePair.Create("purl", candidate.ComponentPurl)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -338,10 +338,10 @@ public sealed class SarifOutputGenerator
|
||||
RuleId: "SDIFF004",
|
||||
Level: SarifLevel.Warning,
|
||||
Message: new SarifMessage(message),
|
||||
Fingerprints: ImmutableDictionary.CreateRange(new[]
|
||||
Fingerprints: ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[]
|
||||
{
|
||||
KeyValuePair.Create("purl", change.ComponentPurl),
|
||||
KeyValuePair.Create("vulnId", change.VulnId),
|
||||
KeyValuePair.Create("purl", change.ComponentPurl)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -350,15 +350,15 @@ public sealed class SarifOutputGenerator
|
||||
return new SarifInvocation(
|
||||
ExecutionSuccessful: true,
|
||||
StartTimeUtc: input.ScanTime,
|
||||
EndTimeUtc: DateTimeOffset.UtcNow);
|
||||
EndTimeUtc: null);
|
||||
}
|
||||
|
||||
private static ImmutableArray<SarifArtifact> CreateArtifacts(SmartDiffSarifInput input)
|
||||
{
|
||||
var artifacts = new List<SarifArtifact>();
|
||||
|
||||
// Collect unique file paths from results
|
||||
var paths = new HashSet<string>();
|
||||
// Collect unique file paths from results (sorted for determinism).
|
||||
var paths = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var change in input.MaterialChanges)
|
||||
{
|
||||
|
||||
@@ -79,6 +79,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
|
||||
services.AddScoped<ICallGraphSnapshotRepository, PostgresCallGraphSnapshotRepository>();
|
||||
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
|
||||
services.AddScoped<ICodeChangeRepository, PostgresCodeChangeRepository>();
|
||||
services.AddScoped<IReachabilityDriftResultRepository, PostgresReachabilityDriftResultRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
-- Call graph snapshots + reachability analysis results
|
||||
-- Sprint: SPRINT_3600_0002_0001_call_graph_infrastructure
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS scanner;
|
||||
-- Note: migrations are executed with the module schema as the active search_path.
|
||||
-- Keep objects unqualified so integration tests can run in isolated schemas.
|
||||
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id()
|
||||
RETURNS UUID AS $$
|
||||
BEGIN
|
||||
RETURN NULLIF(current_setting('app.tenant_id', TRUE), '')::UUID;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: scanner.call_graph_snapshots
|
||||
-- Table: call_graph_snapshots
|
||||
-- Purpose: Cache call graph snapshots per scan/language for reachability drift.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS scanner.call_graph_snapshots (
|
||||
CREATE TABLE IF NOT EXISTS call_graph_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
@@ -27,24 +35,26 @@ CREATE TABLE IF NOT EXISTS scanner.call_graph_snapshots (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_tenant_scan
|
||||
ON scanner.call_graph_snapshots (tenant_id, scan_id, language);
|
||||
ON call_graph_snapshots (tenant_id, scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_graph_digest
|
||||
ON scanner.call_graph_snapshots (graph_digest);
|
||||
ON call_graph_snapshots (graph_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_extracted_at
|
||||
ON scanner.call_graph_snapshots USING BRIN (extracted_at);
|
||||
ON call_graph_snapshots USING BRIN (extracted_at);
|
||||
|
||||
ALTER TABLE scanner.call_graph_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots;
|
||||
CREATE POLICY call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
ALTER TABLE call_graph_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS call_graph_snapshots_tenant_isolation ON call_graph_snapshots;
|
||||
CREATE POLICY call_graph_snapshots_tenant_isolation ON call_graph_snapshots
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE scanner.call_graph_snapshots IS 'Call graph snapshots per scan/language for reachability drift detection.';
|
||||
COMMENT ON TABLE call_graph_snapshots IS 'Call graph snapshots per scan/language for reachability drift detection.';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: scanner.reachability_results
|
||||
-- Table: reachability_results
|
||||
-- Purpose: Cache reachability BFS results (reachable sinks + shortest paths).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS scanner.reachability_results (
|
||||
CREATE TABLE IF NOT EXISTS reachability_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
@@ -63,16 +73,17 @@ CREATE TABLE IF NOT EXISTS scanner.reachability_results (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_tenant_scan
|
||||
ON scanner.reachability_results (tenant_id, scan_id, language);
|
||||
ON reachability_results (tenant_id, scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_graph_digest
|
||||
ON scanner.reachability_results (graph_digest);
|
||||
ON reachability_results (graph_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_computed_at
|
||||
ON scanner.reachability_results USING BRIN (computed_at);
|
||||
ON reachability_results USING BRIN (computed_at);
|
||||
|
||||
ALTER TABLE scanner.reachability_results ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS reachability_results_tenant_isolation ON scanner.reachability_results;
|
||||
CREATE POLICY reachability_results_tenant_isolation ON scanner.reachability_results
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE scanner.reachability_results IS 'Reachability analysis results per scan/language with shortest paths.';
|
||||
ALTER TABLE reachability_results ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS reachability_results_tenant_isolation ON reachability_results;
|
||||
CREATE POLICY reachability_results_tenant_isolation ON reachability_results
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE reachability_results IS 'Reachability analysis results per scan/language with shortest paths.';
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Reachability drift: code changes + drift results
|
||||
-- Sprint: SPRINT_3600_0003_0001_drift_detection_engine
|
||||
|
||||
-- Note: migrations are executed with the module schema as the active search_path.
|
||||
-- Keep objects unqualified so integration tests can run in isolated schemas.
|
||||
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id()
|
||||
RETURNS UUID AS $$
|
||||
BEGIN
|
||||
RETURN NULLIF(current_setting('app.tenant_id', TRUE), '')::UUID;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: code_changes
|
||||
-- Purpose: Store coarse code change facts extracted from call graph diffs.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS code_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
scan_id TEXT NOT NULL,
|
||||
base_scan_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
|
||||
node_id TEXT,
|
||||
file TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
change_kind TEXT NOT NULL,
|
||||
details JSONB,
|
||||
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT code_changes_unique UNIQUE (tenant_id, scan_id, base_scan_id, language, symbol, change_kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_tenant_scan
|
||||
ON code_changes (tenant_id, scan_id, base_scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_symbol
|
||||
ON code_changes (symbol);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_kind
|
||||
ON code_changes (change_kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_code_changes_detected_at
|
||||
ON code_changes USING BRIN (detected_at);
|
||||
|
||||
ALTER TABLE code_changes ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS code_changes_tenant_isolation ON code_changes;
|
||||
CREATE POLICY code_changes_tenant_isolation ON code_changes
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE code_changes IS 'Code change facts for reachability drift analysis.';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Extend: material_risk_changes
|
||||
-- Purpose: Store drift-specific attachments alongside Smart-Diff R1 changes.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE material_risk_changes
|
||||
ADD COLUMN IF NOT EXISTS base_scan_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS cause TEXT,
|
||||
ADD COLUMN IF NOT EXISTS cause_kind TEXT,
|
||||
ADD COLUMN IF NOT EXISTS path_nodes JSONB,
|
||||
ADD COLUMN IF NOT EXISTS associated_vulns JSONB;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_risk_changes_cause_kind
|
||||
ON material_risk_changes(cause_kind)
|
||||
WHERE cause_kind IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_risk_changes_base_scan
|
||||
ON material_risk_changes(base_scan_id)
|
||||
WHERE base_scan_id IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: reachability_drift_results
|
||||
-- Purpose: Aggregate drift results per scan pair and language.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS reachability_drift_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
base_scan_id TEXT NOT NULL,
|
||||
head_scan_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
|
||||
newly_reachable_count INT NOT NULL DEFAULT 0,
|
||||
newly_unreachable_count INT NOT NULL DEFAULT 0,
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
result_digest TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT reachability_drift_unique UNIQUE (tenant_id, base_scan_id, head_scan_id, language, result_digest)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_drift_head
|
||||
ON reachability_drift_results (tenant_id, head_scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_drift_detected_at
|
||||
ON reachability_drift_results USING BRIN (detected_at);
|
||||
|
||||
ALTER TABLE reachability_drift_results ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS drift_results_tenant_isolation ON reachability_drift_results;
|
||||
CREATE POLICY drift_results_tenant_isolation ON reachability_drift_results
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE reachability_drift_results IS 'Aggregate drift results per scan pair + language.';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: drifted_sinks
|
||||
-- Purpose: Individual sink drift records (paged by API).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS drifted_sinks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
drift_result_id UUID NOT NULL REFERENCES reachability_drift_results(id) ON DELETE CASCADE,
|
||||
|
||||
sink_node_id TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
sink_category TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
|
||||
cause_kind TEXT NOT NULL,
|
||||
cause_description TEXT NOT NULL,
|
||||
cause_symbol TEXT,
|
||||
cause_file TEXT,
|
||||
cause_line INT,
|
||||
|
||||
code_change_id UUID REFERENCES code_changes(id),
|
||||
compressed_path JSONB NOT NULL,
|
||||
associated_vulns JSONB,
|
||||
|
||||
CONSTRAINT drifted_sinks_unique UNIQUE (drift_result_id, sink_node_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_drift
|
||||
ON drifted_sinks (drift_result_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_direction
|
||||
ON drifted_sinks (direction);
|
||||
CREATE INDEX IF NOT EXISTS idx_drifted_sinks_category
|
||||
ON drifted_sinks (sink_category);
|
||||
|
||||
ALTER TABLE drifted_sinks ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS drifted_sinks_tenant_isolation ON drifted_sinks;
|
||||
CREATE POLICY drifted_sinks_tenant_isolation ON drifted_sinks
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE drifted_sinks IS 'Individual drifted sink records with causes and compressed paths.';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- scanner api ingestion persistence (startup migration)
|
||||
-- Purpose: Store idempotency state for Scanner.WebService ingestion endpoints.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS callgraph_ingestions (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
scan_id TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
node_count INT NOT NULL,
|
||||
edge_count INT NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
callgraph_json JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT callgraph_ingestions_unique_per_scan UNIQUE (tenant_id, scan_id, content_digest)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_callgraph_ingestions_scan
|
||||
ON callgraph_ingestions (tenant_id, scan_id, created_at_utc DESC, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_callgraph_ingestions_digest
|
||||
ON callgraph_ingestions (tenant_id, content_digest);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- =============================================================================
|
||||
-- 010_smart_diff_priority_score_widen.sql
|
||||
-- Purpose: Widen Smart-Diff material risk change priority_score to support
|
||||
-- advisory scoring formula (can exceed NUMERIC(6,4)).
|
||||
--
|
||||
-- Note: migrations are executed inside a transaction by the migration runner.
|
||||
-- Do not include BEGIN/COMMIT in migration files.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE material_risk_changes
|
||||
ALTER COLUMN priority_score TYPE NUMERIC(12, 4)
|
||||
USING priority_score::NUMERIC(12, 4);
|
||||
@@ -11,4 +11,5 @@ internal static class MigrationIds
|
||||
public const string UnknownsRankingContainment = "007_unknowns_ranking_containment.sql";
|
||||
public const string EpssIntegration = "008_epss_integration.sql";
|
||||
public const string CallGraphTables = "009_call_graph_tables.sql";
|
||||
public const string ReachabilityDriftTables = "010_reachability_drift_tables.sql";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
@@ -16,6 +19,9 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresCallGraphSnapshotRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string CallGraphSnapshotsTable => $"{SchemaName}.call_graph_snapshots";
|
||||
|
||||
public PostgresCallGraphSnapshotRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresCallGraphSnapshotRepository> logger)
|
||||
@@ -29,8 +35,8 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var trimmed = snapshot.Trimmed();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.call_graph_snapshots (
|
||||
var sql = $"""
|
||||
INSERT INTO {CallGraphSnapshotsTable} (
|
||||
tenant_id,
|
||||
scan_id,
|
||||
language,
|
||||
@@ -63,12 +69,11 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
|
||||
""";
|
||||
|
||||
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
|
||||
var tenantId = GetCurrentTenantId();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantId = TenantId,
|
||||
ScanId = trimmed.ScanId,
|
||||
Language = trimmed.Language,
|
||||
GraphDigest = trimmed.GraphDigest,
|
||||
@@ -93,18 +98,18 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
const string sql = """
|
||||
var sql = $"""
|
||||
SELECT snapshot_json
|
||||
FROM scanner.call_graph_snapshots
|
||||
FROM {CallGraphSnapshotsTable}
|
||||
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
|
||||
ORDER BY extracted_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = GetCurrentTenantId(),
|
||||
TenantId = TenantId,
|
||||
ScanId = scanId,
|
||||
Language = language
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
@@ -116,10 +121,5 @@ public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepo
|
||||
|
||||
return JsonSerializer.Deserialize<CallGraphSnapshot>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresCodeChangeRepository : ICodeChangeRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresCodeChangeRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string CodeChangesTable => $"{SchemaName}.code_changes";
|
||||
|
||||
public PostgresCodeChangeRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresCodeChangeRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {CodeChangesTable} (
|
||||
id,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
base_scan_id,
|
||||
language,
|
||||
node_id,
|
||||
file,
|
||||
symbol,
|
||||
change_kind,
|
||||
details,
|
||||
detected_at
|
||||
) VALUES (
|
||||
@Id,
|
||||
@TenantId,
|
||||
@ScanId,
|
||||
@BaseScanId,
|
||||
@Language,
|
||||
@NodeId,
|
||||
@File,
|
||||
@Symbol,
|
||||
@ChangeKind,
|
||||
@Details::jsonb,
|
||||
@DetectedAt
|
||||
)
|
||||
ON CONFLICT (tenant_id, scan_id, base_scan_id, language, symbol, change_kind) DO UPDATE SET
|
||||
node_id = EXCLUDED.node_id,
|
||||
file = EXCLUDED.file,
|
||||
details = EXCLUDED.details,
|
||||
detected_at = EXCLUDED.detected_at
|
||||
""";
|
||||
|
||||
var rows = changes.Select(change => new
|
||||
{
|
||||
change.Id,
|
||||
TenantId,
|
||||
ScanId = change.ScanId.Trim(),
|
||||
BaseScanId = change.BaseScanId.Trim(),
|
||||
Language = change.Language.Trim(),
|
||||
NodeId = string.IsNullOrWhiteSpace(change.NodeId) ? null : change.NodeId.Trim(),
|
||||
File = change.File.Trim(),
|
||||
Symbol = change.Symbol.Trim(),
|
||||
ChangeKind = ToDbValue(change.Kind),
|
||||
Details = SerializeDetails(change.Details),
|
||||
DetectedAt = change.DetectedAt.UtcDateTime
|
||||
}).ToList();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, rows, cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored {Count} code change facts scan={ScanId} base={BaseScanId} lang={Language}",
|
||||
changes.Count,
|
||||
changes[0].ScanId,
|
||||
changes[0].BaseScanId,
|
||||
changes[0].Language);
|
||||
}
|
||||
|
||||
private static string? SerializeDetails(JsonElement? details)
|
||||
=> details is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null }
|
||||
? details.Value.GetRawText()
|
||||
: null;
|
||||
|
||||
private static string ToDbValue(CodeChangeKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
CodeChangeKind.Added => "added",
|
||||
CodeChangeKind.Removed => "removed",
|
||||
CodeChangeKind.SignatureChanged => "signature_changed",
|
||||
CodeChangeKind.GuardChanged => "guard_changed",
|
||||
CodeChangeKind.DependencyChanged => "dependency_changed",
|
||||
CodeChangeKind.VisibilityChanged => "visibility_changed",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresReachabilityDriftResultRepository : IReachabilityDriftResultRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresReachabilityDriftResultRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string DriftResultsTable => $"{SchemaName}.reachability_drift_results";
|
||||
private string DriftedSinksTable => $"{SchemaName}.drifted_sinks";
|
||||
|
||||
public PostgresReachabilityDriftResultRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresReachabilityDriftResultRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var insertResultSql = $"""
|
||||
INSERT INTO {DriftResultsTable} (
|
||||
id,
|
||||
tenant_id,
|
||||
base_scan_id,
|
||||
head_scan_id,
|
||||
language,
|
||||
newly_reachable_count,
|
||||
newly_unreachable_count,
|
||||
detected_at,
|
||||
result_digest
|
||||
) VALUES (
|
||||
@Id,
|
||||
@TenantId,
|
||||
@BaseScanId,
|
||||
@HeadScanId,
|
||||
@Language,
|
||||
@NewlyReachableCount,
|
||||
@NewlyUnreachableCount,
|
||||
@DetectedAt,
|
||||
@ResultDigest
|
||||
)
|
||||
ON CONFLICT (tenant_id, base_scan_id, head_scan_id, language, result_digest) DO UPDATE SET
|
||||
newly_reachable_count = EXCLUDED.newly_reachable_count,
|
||||
newly_unreachable_count = EXCLUDED.newly_unreachable_count,
|
||||
detected_at = EXCLUDED.detected_at
|
||||
RETURNING id
|
||||
""";
|
||||
|
||||
var deleteSinksSql = $"""
|
||||
DELETE FROM {DriftedSinksTable}
|
||||
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId
|
||||
""";
|
||||
|
||||
var insertSinkSql = $"""
|
||||
INSERT INTO {DriftedSinksTable} (
|
||||
id,
|
||||
tenant_id,
|
||||
drift_result_id,
|
||||
sink_node_id,
|
||||
symbol,
|
||||
sink_category,
|
||||
direction,
|
||||
cause_kind,
|
||||
cause_description,
|
||||
cause_symbol,
|
||||
cause_file,
|
||||
cause_line,
|
||||
code_change_id,
|
||||
compressed_path,
|
||||
associated_vulns
|
||||
) VALUES (
|
||||
@Id,
|
||||
@TenantId,
|
||||
@DriftId,
|
||||
@SinkNodeId,
|
||||
@Symbol,
|
||||
@SinkCategory,
|
||||
@Direction,
|
||||
@CauseKind,
|
||||
@CauseDescription,
|
||||
@CauseSymbol,
|
||||
@CauseFile,
|
||||
@CauseLine,
|
||||
@CodeChangeId,
|
||||
@CompressedPath::jsonb,
|
||||
@AssociatedVulns::jsonb
|
||||
)
|
||||
ON CONFLICT (drift_result_id, sink_node_id) DO UPDATE SET
|
||||
symbol = EXCLUDED.symbol,
|
||||
sink_category = EXCLUDED.sink_category,
|
||||
direction = EXCLUDED.direction,
|
||||
cause_kind = EXCLUDED.cause_kind,
|
||||
cause_description = EXCLUDED.cause_description,
|
||||
cause_symbol = EXCLUDED.cause_symbol,
|
||||
cause_file = EXCLUDED.cause_file,
|
||||
cause_line = EXCLUDED.cause_line,
|
||||
code_change_id = EXCLUDED.code_change_id,
|
||||
compressed_path = EXCLUDED.compressed_path,
|
||||
associated_vulns = EXCLUDED.associated_vulns
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var driftId = await connection.ExecuteScalarAsync<Guid>(new CommandDefinition(
|
||||
insertResultSql,
|
||||
new
|
||||
{
|
||||
result.Id,
|
||||
TenantId,
|
||||
BaseScanId = result.BaseScanId.Trim(),
|
||||
HeadScanId = result.HeadScanId.Trim(),
|
||||
Language = result.Language.Trim(),
|
||||
NewlyReachableCount = result.NewlyReachable.Length,
|
||||
NewlyUnreachableCount = result.NewlyUnreachable.Length,
|
||||
DetectedAt = result.DetectedAt.UtcDateTime,
|
||||
result.ResultDigest
|
||||
},
|
||||
transaction: transaction,
|
||||
cancellationToken: ct))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
deleteSinksSql,
|
||||
new { TenantId, DriftId = driftId },
|
||||
transaction: transaction,
|
||||
cancellationToken: ct))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var sinkRows = EnumerateSinkRows(driftId, result.NewlyReachable, DriftDirection.BecameReachable)
|
||||
.Concat(EnumerateSinkRows(driftId, result.NewlyUnreachable, DriftDirection.BecameUnreachable))
|
||||
.ToList();
|
||||
|
||||
if (sinkRows.Count > 0)
|
||||
{
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
insertSinkSql,
|
||||
sinkRows,
|
||||
transaction: transaction,
|
||||
cancellationToken: ct))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored drift result drift={DriftId} base={BaseScanId} head={HeadScanId} lang={Language}",
|
||||
driftId,
|
||||
result.BaseScanId,
|
||||
result.HeadScanId,
|
||||
result.Language);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store drift result base={BaseScanId} head={HeadScanId}", result.BaseScanId, result.HeadScanId);
|
||||
await transaction.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(headScanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
var sql = $"""
|
||||
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
|
||||
FROM {DriftResultsTable}
|
||||
WHERE tenant_id = @TenantId AND head_scan_id = @HeadScanId AND language = @Language
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var header = await connection.QuerySingleOrDefaultAsync<DriftHeaderRow>(new CommandDefinition(
|
||||
sql,
|
||||
new
|
||||
{
|
||||
TenantId,
|
||||
HeadScanId = headScanId.Trim(),
|
||||
Language = language.Trim()
|
||||
},
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await LoadResultAsync(connection, header, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT id, base_scan_id, head_scan_id, language, detected_at, result_digest
|
||||
FROM {DriftResultsTable}
|
||||
WHERE tenant_id = @TenantId AND id = @DriftId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var header = await connection.QuerySingleOrDefaultAsync<DriftHeaderRow>(new CommandDefinition(
|
||||
sql,
|
||||
new
|
||||
{
|
||||
TenantId,
|
||||
DriftId = driftId
|
||||
},
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await LoadResultAsync(connection, header, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT 1
|
||||
FROM {DriftResultsTable}
|
||||
WHERE tenant_id = @TenantId AND id = @DriftId
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var result = await connection.ExecuteScalarAsync<int?>(new CommandDefinition(
|
||||
sql,
|
||||
new { TenantId, DriftId = driftId },
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
return result is not null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
|
||||
Guid driftId,
|
||||
DriftDirection direction,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
}
|
||||
|
||||
if (limit <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||
}
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
id,
|
||||
sink_node_id,
|
||||
symbol,
|
||||
sink_category,
|
||||
direction,
|
||||
cause_kind,
|
||||
cause_description,
|
||||
cause_symbol,
|
||||
cause_file,
|
||||
cause_line,
|
||||
code_change_id,
|
||||
compressed_path,
|
||||
associated_vulns
|
||||
FROM {DriftedSinksTable}
|
||||
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId AND direction = @Direction
|
||||
ORDER BY sink_node_id ASC
|
||||
OFFSET @Offset LIMIT @Limit
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<DriftSinkRow>(new CommandDefinition(
|
||||
sql,
|
||||
new
|
||||
{
|
||||
TenantId,
|
||||
DriftId = driftId,
|
||||
Direction = ToDbValue(direction),
|
||||
Offset = offset,
|
||||
Limit = limit
|
||||
},
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
return rows.Select(r => r.ToModel(direction)).ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<object> EnumerateSinkRows(Guid driftId, ImmutableArray<DriftedSink> sinks, DriftDirection direction)
|
||||
{
|
||||
foreach (var sink in sinks)
|
||||
{
|
||||
var pathJson = JsonSerializer.Serialize(sink.Path, JsonOptions);
|
||||
var vulnsJson = sink.AssociatedVulns.IsDefaultOrEmpty
|
||||
? null
|
||||
: JsonSerializer.Serialize(sink.AssociatedVulns, JsonOptions);
|
||||
|
||||
yield return new
|
||||
{
|
||||
sink.Id,
|
||||
TenantId,
|
||||
DriftId = driftId,
|
||||
SinkNodeId = sink.SinkNodeId,
|
||||
Symbol = sink.Symbol,
|
||||
SinkCategory = ToDbValue(sink.SinkCategory),
|
||||
Direction = ToDbValue(direction),
|
||||
CauseKind = ToDbValue(sink.Cause.Kind),
|
||||
CauseDescription = sink.Cause.Description,
|
||||
CauseSymbol = sink.Cause.ChangedSymbol,
|
||||
CauseFile = sink.Cause.ChangedFile,
|
||||
CauseLine = sink.Cause.ChangedLine,
|
||||
CodeChangeId = sink.Cause.CodeChangeId,
|
||||
CompressedPath = pathJson,
|
||||
AssociatedVulns = vulnsJson
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ReachabilityDriftResult> LoadResultAsync(
|
||||
System.Data.IDbConnection connection,
|
||||
DriftHeaderRow header,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var sinksSql = $"""
|
||||
SELECT
|
||||
id,
|
||||
sink_node_id,
|
||||
symbol,
|
||||
sink_category,
|
||||
direction,
|
||||
cause_kind,
|
||||
cause_description,
|
||||
cause_symbol,
|
||||
cause_file,
|
||||
cause_line,
|
||||
code_change_id,
|
||||
compressed_path,
|
||||
associated_vulns
|
||||
FROM {DriftedSinksTable}
|
||||
WHERE tenant_id = @TenantId AND drift_result_id = @DriftId
|
||||
ORDER BY direction ASC, sink_node_id ASC
|
||||
""";
|
||||
|
||||
var rows = (await connection.QueryAsync<DriftSinkRow>(new CommandDefinition(
|
||||
sinksSql,
|
||||
new { TenantId, DriftId = header.id },
|
||||
cancellationToken: ct)).ConfigureAwait(false)).ToList();
|
||||
|
||||
var reachable = rows
|
||||
.Where(r => string.Equals(r.direction, ToDbValue(DriftDirection.BecameReachable), StringComparison.Ordinal))
|
||||
.Select(r => r.ToModel(DriftDirection.BecameReachable))
|
||||
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var unreachable = rows
|
||||
.Where(r => string.Equals(r.direction, ToDbValue(DriftDirection.BecameUnreachable), StringComparison.Ordinal))
|
||||
.Select(r => r.ToModel(DriftDirection.BecameUnreachable))
|
||||
.OrderBy(s => s.SinkNodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ReachabilityDriftResult
|
||||
{
|
||||
Id = header.id,
|
||||
BaseScanId = header.base_scan_id,
|
||||
HeadScanId = header.head_scan_id,
|
||||
Language = header.language,
|
||||
DetectedAt = header.detected_at,
|
||||
NewlyReachable = reachable,
|
||||
NewlyUnreachable = unreachable,
|
||||
ResultDigest = header.result_digest
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDbValue(DriftDirection direction)
|
||||
=> direction == DriftDirection.BecameReachable ? "became_reachable" : "became_unreachable";
|
||||
|
||||
private static string ToDbValue(DriftCauseKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
DriftCauseKind.GuardRemoved => "guard_removed",
|
||||
DriftCauseKind.GuardAdded => "guard_added",
|
||||
DriftCauseKind.NewPublicRoute => "new_public_route",
|
||||
DriftCauseKind.VisibilityEscalated => "visibility_escalated",
|
||||
DriftCauseKind.DependencyUpgraded => "dependency_upgraded",
|
||||
DriftCauseKind.SymbolRemoved => "symbol_removed",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToDbValue(SinkCategory category)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
SinkCategory.CmdExec => "CMD_EXEC",
|
||||
SinkCategory.UnsafeDeser => "UNSAFE_DESER",
|
||||
SinkCategory.SqlRaw => "SQL_RAW",
|
||||
SinkCategory.Ssrf => "SSRF",
|
||||
SinkCategory.FileWrite => "FILE_WRITE",
|
||||
SinkCategory.PathTraversal => "PATH_TRAVERSAL",
|
||||
SinkCategory.TemplateInjection => "TEMPLATE_INJECTION",
|
||||
SinkCategory.CryptoWeak => "CRYPTO_WEAK",
|
||||
SinkCategory.AuthzBypass => "AUTHZ_BYPASS",
|
||||
_ => category.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static DriftCauseKind ParseCauseKind(string value)
|
||||
{
|
||||
return value.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"guard_removed" => DriftCauseKind.GuardRemoved,
|
||||
"guard_added" => DriftCauseKind.GuardAdded,
|
||||
"new_public_route" => DriftCauseKind.NewPublicRoute,
|
||||
"visibility_escalated" => DriftCauseKind.VisibilityEscalated,
|
||||
"dependency_upgraded" => DriftCauseKind.DependencyUpgraded,
|
||||
"symbol_removed" => DriftCauseKind.SymbolRemoved,
|
||||
_ => DriftCauseKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static SinkCategory ParseSinkCategory(string value)
|
||||
{
|
||||
return value.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"CMD_EXEC" => SinkCategory.CmdExec,
|
||||
"UNSAFE_DESER" => SinkCategory.UnsafeDeser,
|
||||
"SQL_RAW" => SinkCategory.SqlRaw,
|
||||
"SSRF" => SinkCategory.Ssrf,
|
||||
"FILE_WRITE" => SinkCategory.FileWrite,
|
||||
"PATH_TRAVERSAL" => SinkCategory.PathTraversal,
|
||||
"TEMPLATE_INJECTION" => SinkCategory.TemplateInjection,
|
||||
"CRYPTO_WEAK" => SinkCategory.CryptoWeak,
|
||||
"AUTHZ_BYPASS" => SinkCategory.AuthzBypass,
|
||||
_ => SinkCategory.CmdExec
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class DriftHeaderRow
|
||||
{
|
||||
public Guid id { get; init; }
|
||||
public string base_scan_id { get; init; } = string.Empty;
|
||||
public string head_scan_id { get; init; } = string.Empty;
|
||||
public string language { get; init; } = string.Empty;
|
||||
public DateTimeOffset detected_at { get; init; }
|
||||
public string result_digest { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class DriftSinkRow
|
||||
{
|
||||
public Guid id { get; init; }
|
||||
public string sink_node_id { get; init; } = string.Empty;
|
||||
public string symbol { get; init; } = string.Empty;
|
||||
public string sink_category { get; init; } = string.Empty;
|
||||
public string direction { get; init; } = string.Empty;
|
||||
public string cause_kind { get; init; } = string.Empty;
|
||||
public string cause_description { get; init; } = string.Empty;
|
||||
public string? cause_symbol { get; init; }
|
||||
public string? cause_file { get; init; }
|
||||
public int? cause_line { get; init; }
|
||||
public Guid? code_change_id { get; init; }
|
||||
public string compressed_path { get; init; } = "{}";
|
||||
public string? associated_vulns { get; init; }
|
||||
|
||||
public DriftedSink ToModel(DriftDirection direction)
|
||||
{
|
||||
var path = JsonSerializer.Deserialize<CompressedPath>(compressed_path, JsonOptions)
|
||||
?? new CompressedPath
|
||||
{
|
||||
Entrypoint = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
|
||||
Sink = new PathNode { NodeId = string.Empty, Symbol = string.Empty },
|
||||
IntermediateCount = 0,
|
||||
KeyNodes = ImmutableArray<PathNode>.Empty
|
||||
};
|
||||
|
||||
var vulns = string.IsNullOrWhiteSpace(associated_vulns)
|
||||
? ImmutableArray<AssociatedVuln>.Empty
|
||||
: (JsonSerializer.Deserialize<AssociatedVuln[]>(associated_vulns!, JsonOptions) ?? [])
|
||||
.ToImmutableArray();
|
||||
|
||||
return new DriftedSink
|
||||
{
|
||||
Id = id,
|
||||
SinkNodeId = sink_node_id,
|
||||
Symbol = symbol,
|
||||
SinkCategory = ParseSinkCategory(sink_category),
|
||||
Direction = direction,
|
||||
Cause = new DriftCause
|
||||
{
|
||||
Kind = ParseCauseKind(cause_kind),
|
||||
Description = cause_description,
|
||||
ChangedSymbol = cause_symbol,
|
||||
ChangedFile = cause_file,
|
||||
ChangedLine = cause_line,
|
||||
CodeChangeId = code_change_id
|
||||
},
|
||||
Path = path,
|
||||
AssociatedVulns = vulns
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresReachabilityResultRepository : IReachabilityResultRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
@@ -16,6 +19,9 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresReachabilityResultRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string ReachabilityResultsTable => $"{SchemaName}.reachability_results";
|
||||
|
||||
public PostgresReachabilityResultRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresReachabilityResultRepository> logger)
|
||||
@@ -29,8 +35,8 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var trimmed = result.Trimmed();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.reachability_results (
|
||||
var sql = $"""
|
||||
INSERT INTO {ReachabilityResultsTable} (
|
||||
tenant_id,
|
||||
scan_id,
|
||||
language,
|
||||
@@ -59,12 +65,11 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
|
||||
""";
|
||||
|
||||
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
|
||||
var tenantId = GetCurrentTenantId();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantId = TenantId,
|
||||
ScanId = trimmed.ScanId,
|
||||
Language = trimmed.Language,
|
||||
GraphDigest = trimmed.GraphDigest,
|
||||
@@ -87,18 +92,18 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
const string sql = """
|
||||
var sql = $"""
|
||||
SELECT result_json
|
||||
FROM scanner.reachability_results
|
||||
FROM {ReachabilityResultsTable}
|
||||
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = GetCurrentTenantId(),
|
||||
TenantId = TenantId,
|
||||
ScanId = scanId,
|
||||
Language = language
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
@@ -110,10 +115,5 @@ public sealed class PostgresReachabilityResultRepository : IReachabilityResultRe
|
||||
|
||||
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public interface ICodeChangeRepository
|
||||
{
|
||||
Task StoreAsync(IReadOnlyList<CodeChangeFact> changes, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using StellaOps.Scanner.ReachabilityDrift;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public interface IReachabilityDriftResultRepository
|
||||
{
|
||||
Task StoreAsync(ReachabilityDriftResult result, CancellationToken ct = default);
|
||||
|
||||
Task<ReachabilityDriftResult?> TryGetLatestForHeadAsync(string headScanId, string language, CancellationToken ct = default);
|
||||
|
||||
Task<ReachabilityDriftResult?> TryGetByIdAsync(Guid driftId, CancellationToken ct = default);
|
||||
|
||||
Task<bool> ExistsAsync(Guid driftId, CancellationToken ct = default);
|
||||
|
||||
Task<IReadOnlyList<DriftedSink>> ListSinksAsync(
|
||||
Guid driftId,
|
||||
DriftDirection direction,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.ReachabilityDrift\\StellaOps.Scanner.ReachabilityDrift.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
| Task ID | Sprint | Status | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `PROOFSPINE-3100-DB` | `docs/implplan/SPRINT_3100_0001_0001_proof_spine_system.md` | DOING | Add Postgres migrations and repository for ProofSpine persistence (`proof_spines`, `proof_segments`, `proof_spine_history`). |
|
||||
| `SCAN-API-3103-004` | `docs/implplan/SPRINT_3103_0001_0001_scanner_api_ingestion_completion.md` | DOING | Fix scanner storage connection/schema issues surfaced by Scanner WebService ingestion tests. |
|
||||
|
||||
Reference in New Issue
Block a user