save progress

This commit is contained in:
StellaOps Bot
2025-12-18 09:10:36 +02:00
parent b4235c134c
commit 28823a8960
169 changed files with 11995 additions and 449 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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