save progress
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user