Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# AGENTS - Scanner Reachability Library
|
||||
|
||||
## Mission
|
||||
Deliver deterministic reachability analysis, slice generation, and evidence artifacts used by Scanner and downstream policy/VEX workflows.
|
||||
|
||||
## Roles
|
||||
- Backend engineer (.NET 10, C# preview).
|
||||
- QA engineer (unit/integration tests with deterministic fixtures).
|
||||
|
||||
## Required Reading
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- `docs/reachability/DELIVERY_GUIDE.md`
|
||||
- `docs/reachability/slice-schema.md`
|
||||
- `docs/reachability/replay-verification.md`
|
||||
|
||||
## Working Directory & Boundaries
|
||||
- Primary scope: `src/Scanner/__Libraries/StellaOps.Scanner.Reachability/`
|
||||
- Tests: `src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/`
|
||||
- Avoid cross-module edits unless explicitly noted in the sprint.
|
||||
|
||||
## Determinism & Offline Rules
|
||||
- Stable ordering for graphs, slices, and diffs.
|
||||
- UTC timestamps only; avoid wall-clock nondeterminism.
|
||||
- Offline-first: no external network calls; use CAS and local caches.
|
||||
|
||||
## Testing Expectations
|
||||
- Add schema validation and round-trip tests for slice artifacts.
|
||||
- Ensure deterministic serialization bytes for any DSSE payloads.
|
||||
- Run `dotnet test src/Scanner/StellaOps.Scanner.sln` when feasible.
|
||||
|
||||
## Workflow
|
||||
- Update sprint status on task transitions.
|
||||
- Record decisions/risks in sprint Execution Log and Decisions & Risks.
|
||||
@@ -0,0 +1,17 @@
|
||||
using StellaOps.Scanner.Reachability.Subgraph;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Attestation;
|
||||
|
||||
public sealed record ReachabilitySubgraphPublishResult(
|
||||
string SubgraphDigest,
|
||||
string? CasUri,
|
||||
string AttestationDigest,
|
||||
byte[] DsseEnvelopeBytes);
|
||||
|
||||
public interface IReachabilitySubgraphPublisher
|
||||
{
|
||||
Task<ReachabilitySubgraphPublishResult> PublishAsync(
|
||||
ReachabilitySubgraph subgraph,
|
||||
string subjectDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -47,6 +47,18 @@ public static class ReachabilityAttestationServiceCollectionExtensions
|
||||
|
||||
// Register options
|
||||
services.AddOptions<ReachabilityWitnessOptions>();
|
||||
services.AddOptions<ReachabilitySubgraphOptions>();
|
||||
|
||||
// Register subgraph publisher
|
||||
services.TryAddSingleton<IReachabilitySubgraphPublisher>(sp =>
|
||||
new ReachabilitySubgraphPublisher(
|
||||
sp.GetRequiredService<IOptions<ReachabilitySubgraphOptions>>(),
|
||||
sp.GetRequiredService<ICryptoHash>(),
|
||||
sp.GetRequiredService<ILogger<ReachabilitySubgraphPublisher>>(),
|
||||
timeProvider: sp.GetService<TimeProvider>(),
|
||||
cas: sp.GetService<IFileContentAddressableStore>(),
|
||||
dsseSigningService: sp.GetService<IDsseSigningService>(),
|
||||
cryptoProfile: sp.GetService<ICryptoProfile>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
@@ -64,4 +76,18 @@ public static class ReachabilityAttestationServiceCollectionExtensions
|
||||
services.Configure(configure);
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures reachability subgraph options.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Configuration action.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection ConfigureReachabilitySubgraphOptions(
|
||||
this IServiceCollection services,
|
||||
Action<ReachabilitySubgraphOptions> configure)
|
||||
{
|
||||
services.Configure(configure);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Scanner.Reachability.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// Options for reachability subgraph attestation.
|
||||
/// </summary>
|
||||
public sealed class ReachabilitySubgraphOptions
|
||||
{
|
||||
public const string SectionName = "Scanner:ReachabilitySubgraph";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to generate DSSE attestations.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to store subgraph payloads in CAS when available.
|
||||
/// </summary>
|
||||
public bool StoreInCas { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional signing key identifier.
|
||||
/// </summary>
|
||||
public string? SigningKeyId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Attestor.ProofChain.Predicates;
|
||||
using StellaOps.Attestor.ProofChain.Statements;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
using StellaOps.Scanner.Reachability.Subgraph;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Attestation;
|
||||
|
||||
public sealed class ReachabilitySubgraphPublisher : IReachabilitySubgraphPublisher
|
||||
{
|
||||
private static readonly JsonSerializerOptions DsseJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ReachabilitySubgraphOptions _options;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly ILogger<ReachabilitySubgraphPublisher> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IFileContentAddressableStore? _cas;
|
||||
private readonly IDsseSigningService? _dsseSigningService;
|
||||
private readonly ICryptoProfile? _cryptoProfile;
|
||||
|
||||
public ReachabilitySubgraphPublisher(
|
||||
IOptions<ReachabilitySubgraphOptions> options,
|
||||
ICryptoHash cryptoHash,
|
||||
ILogger<ReachabilitySubgraphPublisher> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
IFileContentAddressableStore? cas = null,
|
||||
IDsseSigningService? dsseSigningService = null,
|
||||
ICryptoProfile? cryptoProfile = null)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_cas = cas;
|
||||
_dsseSigningService = dsseSigningService;
|
||||
_cryptoProfile = cryptoProfile;
|
||||
}
|
||||
|
||||
public async Task<ReachabilitySubgraphPublishResult> PublishAsync(
|
||||
ReachabilitySubgraph subgraph,
|
||||
string subjectDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subgraph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_logger.LogDebug("Reachability subgraph attestation disabled");
|
||||
return new ReachabilitySubgraphPublishResult(
|
||||
SubgraphDigest: string.Empty,
|
||||
CasUri: null,
|
||||
AttestationDigest: string.Empty,
|
||||
DsseEnvelopeBytes: Array.Empty<byte>());
|
||||
}
|
||||
|
||||
var normalized = subgraph.Normalize();
|
||||
var subgraphBytes = CanonicalJson.SerializeToUtf8Bytes(normalized);
|
||||
var subgraphDigest = _cryptoHash.ComputePrefixedHashForPurpose(subgraphBytes, HashPurpose.Graph);
|
||||
|
||||
string? casUri = null;
|
||||
if (_options.StoreInCas)
|
||||
{
|
||||
casUri = await StoreSubgraphAsync(subgraphBytes, subgraphDigest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var statement = BuildStatement(normalized, subgraphDigest, casUri, subjectDigest);
|
||||
var statementBytes = CanonicalJson.SerializeToUtf8Bytes(statement);
|
||||
|
||||
var (envelope, envelopeBytes) = await CreateDsseEnvelopeAsync(statement, statementBytes, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var attestationDigest = _cryptoHash.ComputePrefixedHashForPurpose(envelopeBytes, HashPurpose.Attestation);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created reachability subgraph attestation: graphDigest={GraphDigest}, attestationDigest={AttestationDigest}",
|
||||
subgraphDigest,
|
||||
attestationDigest);
|
||||
|
||||
return new ReachabilitySubgraphPublishResult(
|
||||
SubgraphDigest: subgraphDigest,
|
||||
CasUri: casUri,
|
||||
AttestationDigest: attestationDigest,
|
||||
DsseEnvelopeBytes: envelopeBytes);
|
||||
}
|
||||
|
||||
private ReachabilitySubgraphStatement BuildStatement(
|
||||
ReachabilitySubgraph subgraph,
|
||||
string subgraphDigest,
|
||||
string? casUri,
|
||||
string subjectDigest)
|
||||
{
|
||||
var analysis = subgraph.AnalysisMetadata;
|
||||
var predicate = new ReachabilitySubgraphPredicate
|
||||
{
|
||||
SchemaVersion = subgraph.Version,
|
||||
GraphDigest = subgraphDigest,
|
||||
GraphCasUri = casUri,
|
||||
FindingKeys = subgraph.FindingKeys,
|
||||
Analysis = new ReachabilitySubgraphAnalysis
|
||||
{
|
||||
Analyzer = analysis?.Analyzer ?? "reachability",
|
||||
AnalyzerVersion = analysis?.AnalyzerVersion ?? "unknown",
|
||||
Confidence = analysis?.Confidence ?? 0.5,
|
||||
Completeness = analysis?.Completeness ?? "partial",
|
||||
GeneratedAt = analysis?.GeneratedAt ?? _timeProvider.GetUtcNow(),
|
||||
HashAlgorithm = _cryptoHash.GetAlgorithmForPurpose(HashPurpose.Graph)
|
||||
}
|
||||
};
|
||||
|
||||
return new ReachabilitySubgraphStatement
|
||||
{
|
||||
Subject =
|
||||
[
|
||||
BuildSubject(subjectDigest)
|
||||
],
|
||||
Predicate = predicate
|
||||
};
|
||||
}
|
||||
|
||||
private static Subject BuildSubject(string digest)
|
||||
{
|
||||
var (algorithm, value) = SplitDigest(digest);
|
||||
return new Subject
|
||||
{
|
||||
Name = digest,
|
||||
Digest = new Dictionary<string, string> { [algorithm] = value }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> StoreSubgraphAsync(byte[] subgraphBytes, string subgraphDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cas is null)
|
||||
{
|
||||
_logger.LogWarning("CAS storage requested but no CAS store configured; skipping subgraph storage.");
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = ExtractHashDigest(subgraphDigest);
|
||||
var existing = await _cas.TryGetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null)
|
||||
{
|
||||
await using var stream = new MemoryStream(subgraphBytes, writable: false);
|
||||
await _cas.PutAsync(new FileCasPutRequest(key, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return $"cas://reachability/subgraphs/{key}";
|
||||
}
|
||||
|
||||
private async Task<(DsseEnvelope Envelope, byte[] EnvelopeBytes)> CreateDsseEnvelopeAsync(
|
||||
ReachabilitySubgraphStatement statement,
|
||||
byte[] statementBytes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string payloadType = "application/vnd.in-toto+json";
|
||||
|
||||
if (_dsseSigningService is not null)
|
||||
{
|
||||
var profile = _cryptoProfile ?? new InlineCryptoProfile(_options.SigningKeyId ?? "scanner-deterministic", "hs256");
|
||||
var signed = await _dsseSigningService.SignAsync(statement, payloadType, profile, cancellationToken).ConfigureAwait(false);
|
||||
return (signed, SerializeDsseEnvelope(signed));
|
||||
}
|
||||
|
||||
var signature = SHA256.HashData(statementBytes);
|
||||
var envelope = new DsseEnvelope(
|
||||
payloadType,
|
||||
Convert.ToBase64String(statementBytes),
|
||||
new[] { new DsseSignature(_options.SigningKeyId ?? "scanner-deterministic", Convert.ToBase64String(signature)) });
|
||||
return (envelope, SerializeDsseEnvelope(envelope));
|
||||
}
|
||||
|
||||
private static byte[] SerializeDsseEnvelope(DsseEnvelope envelope)
|
||||
{
|
||||
var signatures = envelope.Signatures
|
||||
.OrderBy(s => s.KeyId, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.Sig, StringComparer.Ordinal)
|
||||
.Select(s => new { keyid = s.KeyId, sig = s.Sig })
|
||||
.ToArray();
|
||||
|
||||
var dto = new
|
||||
{
|
||||
payloadType = envelope.PayloadType,
|
||||
payload = envelope.Payload,
|
||||
signatures
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(dto, DsseJsonOptions);
|
||||
}
|
||||
|
||||
private static string ExtractHashDigest(string prefixedHash)
|
||||
{
|
||||
var colonIndex = prefixedHash.IndexOf(':');
|
||||
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
|
||||
}
|
||||
|
||||
private static (string Algorithm, string Value) SplitDigest(string digest)
|
||||
{
|
||||
var colonIndex = digest.IndexOf(':');
|
||||
if (colonIndex <= 0 || colonIndex == digest.Length - 1)
|
||||
{
|
||||
return ("sha256", digest);
|
||||
}
|
||||
|
||||
return (digest[..colonIndex], digest[(colonIndex + 1)..]);
|
||||
}
|
||||
|
||||
private sealed record InlineCryptoProfile(string KeyId, string Algorithm) : ICryptoProfile;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.MiniMap;
|
||||
|
||||
public interface IMiniMapExtractor
|
||||
{
|
||||
ReachabilityMiniMap Extract(RichGraph graph, string vulnerableComponent, int maxPaths = 10);
|
||||
}
|
||||
|
||||
public sealed class MiniMapExtractor : IMiniMapExtractor
|
||||
{
|
||||
public ReachabilityMiniMap Extract(
|
||||
RichGraph graph,
|
||||
string vulnerableComponent,
|
||||
int maxPaths = 10)
|
||||
{
|
||||
// Find vulnerable component node
|
||||
var vulnNode = graph.Nodes.FirstOrDefault(n =>
|
||||
n.Purl == vulnerableComponent ||
|
||||
n.SymbolId?.Contains(vulnerableComponent) == true);
|
||||
|
||||
if (vulnNode is null)
|
||||
{
|
||||
return CreateNotFoundMap(vulnerableComponent);
|
||||
}
|
||||
|
||||
// Find all entrypoints
|
||||
var entrypoints = graph.Nodes
|
||||
.Where(n => IsEntrypoint(n))
|
||||
.ToList();
|
||||
|
||||
// BFS from each entrypoint to vulnerable component
|
||||
var paths = new List<MiniMapPath>();
|
||||
var entrypointInfos = new List<MiniMapEntrypoint>();
|
||||
|
||||
foreach (var ep in entrypoints)
|
||||
{
|
||||
var epPaths = FindPaths(graph, ep, vulnNode, maxDepth: 20);
|
||||
|
||||
if (epPaths.Count > 0)
|
||||
{
|
||||
entrypointInfos.Add(new MiniMapEntrypoint
|
||||
{
|
||||
Node = ToMiniMapNode(ep),
|
||||
Kind = ClassifyEntrypoint(ep),
|
||||
PathCount = epPaths.Count,
|
||||
ShortestPathLength = epPaths.Min(p => p.Length)
|
||||
});
|
||||
|
||||
paths.AddRange(epPaths.Take(maxPaths / Math.Max(entrypoints.Count, 1) + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine state
|
||||
var state = paths.Count > 0
|
||||
? (paths.Any(p => p.HasRuntimeEvidence)
|
||||
? ReachabilityState.ConfirmedReachable
|
||||
: ReachabilityState.StaticReachable)
|
||||
: ReachabilityState.StaticUnreachable;
|
||||
|
||||
// Calculate confidence
|
||||
var confidence = CalculateConfidence(paths, entrypointInfos, graph);
|
||||
|
||||
return new ReachabilityMiniMap
|
||||
{
|
||||
FindingId = Guid.Empty, // Set by caller
|
||||
VulnerabilityId = string.Empty, // Set by caller
|
||||
VulnerableComponent = ToMiniMapNode(vulnNode),
|
||||
Entrypoints = entrypointInfos.OrderBy(e => e.ShortestPathLength).ToList(),
|
||||
Paths = paths.OrderBy(p => p.Length).Take(maxPaths).ToList(),
|
||||
State = state,
|
||||
Confidence = confidence,
|
||||
GraphDigest = ComputeGraphDigest(graph),
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilityMiniMap CreateNotFoundMap(string vulnerableComponent)
|
||||
{
|
||||
return new ReachabilityMiniMap
|
||||
{
|
||||
FindingId = Guid.Empty,
|
||||
VulnerabilityId = string.Empty,
|
||||
VulnerableComponent = new MiniMapNode
|
||||
{
|
||||
Id = vulnerableComponent,
|
||||
Label = vulnerableComponent,
|
||||
Type = MiniMapNodeType.VulnerableComponent
|
||||
},
|
||||
Entrypoints = Array.Empty<MiniMapEntrypoint>(),
|
||||
Paths = Array.Empty<MiniMapPath>(),
|
||||
State = ReachabilityState.Unknown,
|
||||
Confidence = 0m,
|
||||
GraphDigest = string.Empty,
|
||||
AnalyzedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsEntrypoint(RichGraphNode node)
|
||||
{
|
||||
return node.Kind is "entrypoint" or "export" or "main" or "handler";
|
||||
}
|
||||
|
||||
private static EntrypointKind ClassifyEntrypoint(RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes?.ContainsKey("http_method") == true)
|
||||
return EntrypointKind.HttpEndpoint;
|
||||
if (node.Attributes?.ContainsKey("grpc_service") == true)
|
||||
return EntrypointKind.GrpcMethod;
|
||||
if (node.Kind == "main")
|
||||
return EntrypointKind.MainFunction;
|
||||
if (node.Kind == "handler")
|
||||
return EntrypointKind.EventHandler;
|
||||
if (node.Attributes?.ContainsKey("cli_command") == true)
|
||||
return EntrypointKind.CliCommand;
|
||||
|
||||
return EntrypointKind.PublicApi;
|
||||
}
|
||||
|
||||
private List<MiniMapPath> FindPaths(
|
||||
RichGraph graph,
|
||||
RichGraphNode start,
|
||||
RichGraphNode end,
|
||||
int maxDepth)
|
||||
{
|
||||
var paths = new List<MiniMapPath>();
|
||||
var queue = new Queue<(RichGraphNode node, List<RichGraphNode> path)>();
|
||||
queue.Enqueue((start, new List<RichGraphNode> { start }));
|
||||
|
||||
while (queue.Count > 0 && paths.Count < 100)
|
||||
{
|
||||
var (current, path) = queue.Dequeue();
|
||||
|
||||
if (path.Count > maxDepth) continue;
|
||||
|
||||
if (current.Id == end.Id)
|
||||
{
|
||||
paths.Add(BuildPath(path, graph));
|
||||
continue;
|
||||
}
|
||||
|
||||
var edges = graph.Edges.Where(e => e.From == current.Id);
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var nextNode = graph.Nodes.FirstOrDefault(n => n.Id == edge.To);
|
||||
if (nextNode is not null && !path.Any(n => n.Id == nextNode.Id))
|
||||
{
|
||||
var newPath = new List<RichGraphNode>(path) { nextNode };
|
||||
queue.Enqueue((nextNode, newPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
private static MiniMapPath BuildPath(List<RichGraphNode> nodes, RichGraph graph)
|
||||
{
|
||||
var steps = nodes.Select((n, i) =>
|
||||
{
|
||||
var edge = i < nodes.Count - 1
|
||||
? graph.Edges.FirstOrDefault(e => e.From == n.Id && e.To == nodes[i + 1].Id)
|
||||
: null;
|
||||
|
||||
return new MiniMapPathStep
|
||||
{
|
||||
Index = i,
|
||||
Node = ToMiniMapNode(n),
|
||||
CallType = edge?.Kind
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var hasRuntime = graph.Edges
|
||||
.Where(e => nodes.Any(n => n.Id == e.From))
|
||||
.Any(e => e.Evidence?.Contains("runtime") == true);
|
||||
|
||||
return new MiniMapPath
|
||||
{
|
||||
PathId = $"path:{ComputePathHash(nodes)}",
|
||||
EntrypointId = nodes.First().Id,
|
||||
Steps = steps,
|
||||
HasRuntimeEvidence = hasRuntime,
|
||||
PathConfidence = hasRuntime ? 0.95m : 0.75m
|
||||
};
|
||||
}
|
||||
|
||||
private static MiniMapNode ToMiniMapNode(RichGraphNode node)
|
||||
{
|
||||
var sourceFile = node.Attributes?.GetValueOrDefault("source_file");
|
||||
int? lineNumber = null;
|
||||
if (node.Attributes?.TryGetValue("line", out var lineStr) == true && int.TryParse(lineStr, out var line))
|
||||
{
|
||||
lineNumber = line;
|
||||
}
|
||||
|
||||
return new MiniMapNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Label = node.Display ?? node.SymbolId ?? node.Id,
|
||||
Type = node.Kind switch
|
||||
{
|
||||
"entrypoint" or "export" or "main" => MiniMapNodeType.Entrypoint,
|
||||
"function" or "method" => MiniMapNodeType.Function,
|
||||
"class" => MiniMapNodeType.Class,
|
||||
"module" or "package" => MiniMapNodeType.Module,
|
||||
"sink" => MiniMapNodeType.Sink,
|
||||
_ => MiniMapNodeType.Function
|
||||
},
|
||||
Purl = node.Purl,
|
||||
SourceFile = sourceFile,
|
||||
LineNumber = lineNumber
|
||||
};
|
||||
}
|
||||
|
||||
private static decimal CalculateConfidence(
|
||||
List<MiniMapPath> paths,
|
||||
List<MiniMapEntrypoint> entrypoints,
|
||||
RichGraph graph)
|
||||
{
|
||||
if (paths.Count == 0) return 0.9m; // High confidence in unreachability
|
||||
|
||||
var runtimePaths = paths.Count(p => p.HasRuntimeEvidence);
|
||||
var runtimeRatio = paths.Count > 0 ? (decimal)runtimePaths / paths.Count : 0m;
|
||||
|
||||
return 0.6m + (0.3m * runtimeRatio);
|
||||
}
|
||||
|
||||
private static string ComputePathHash(List<RichGraphNode> nodes)
|
||||
{
|
||||
var ids = string.Join("|", nodes.Select(n => n.Id));
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(ids));
|
||||
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeGraphDigest(RichGraph graph)
|
||||
{
|
||||
var nodeIds = string.Join(",", graph.Nodes.Select(n => n.Id).OrderBy(x => x));
|
||||
var edgeIds = string.Join(",", graph.Edges.Select(e => $"{e.From}->{e.To}").OrderBy(x => x));
|
||||
var combined = $"{nodeIds}|{edgeIds}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(combined));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
namespace StellaOps.Scanner.Reachability.MiniMap;
|
||||
|
||||
/// <summary>
|
||||
/// Condensed reachability visualization for a finding.
|
||||
/// Shows paths from entrypoints to vulnerable component to sinks.
|
||||
/// </summary>
|
||||
public sealed record ReachabilityMiniMap
|
||||
{
|
||||
/// <summary>
|
||||
/// Finding this map is for.
|
||||
/// </summary>
|
||||
public required Guid FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Vulnerability ID.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The vulnerable component.
|
||||
/// </summary>
|
||||
public required MiniMapNode VulnerableComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry points that reach the vulnerable component.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MiniMapEntrypoint> Entrypoints { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Paths from entrypoints to vulnerable component.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MiniMapPath> Paths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Overall reachability state.
|
||||
/// </summary>
|
||||
public required ReachabilityState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence of the analysis.
|
||||
/// </summary>
|
||||
public required decimal Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Full graph digest for verification.
|
||||
/// </summary>
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When analysis was performed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AnalyzedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A node in the mini-map.
|
||||
/// </summary>
|
||||
public sealed record MiniMapNode
|
||||
{
|
||||
/// <summary>
|
||||
/// Node identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Display label.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node type.
|
||||
/// </summary>
|
||||
public required MiniMapNodeType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL (if applicable).
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source file location.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Line number in source.
|
||||
/// </summary>
|
||||
public int? LineNumber { get; init; }
|
||||
}
|
||||
|
||||
public enum MiniMapNodeType
|
||||
{
|
||||
Entrypoint,
|
||||
Function,
|
||||
Class,
|
||||
Module,
|
||||
VulnerableComponent,
|
||||
Sink
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An entry point in the mini-map.
|
||||
/// </summary>
|
||||
public sealed record MiniMapEntrypoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point node.
|
||||
/// </summary>
|
||||
public required MiniMapNode Node { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entry point kind.
|
||||
/// </summary>
|
||||
public required EntrypointKind Kind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of paths from this entrypoint.
|
||||
/// </summary>
|
||||
public required int PathCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Shortest path length to vulnerable component.
|
||||
/// </summary>
|
||||
public required int ShortestPathLength { get; init; }
|
||||
}
|
||||
|
||||
public enum EntrypointKind
|
||||
{
|
||||
HttpEndpoint,
|
||||
GrpcMethod,
|
||||
MessageHandler,
|
||||
CliCommand,
|
||||
MainFunction,
|
||||
PublicApi,
|
||||
EventHandler,
|
||||
Other
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A path from entrypoint to vulnerable component.
|
||||
/// </summary>
|
||||
public sealed record MiniMapPath
|
||||
{
|
||||
/// <summary>
|
||||
/// Path identifier.
|
||||
/// </summary>
|
||||
public required string PathId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Starting entrypoint ID.
|
||||
/// </summary>
|
||||
public required string EntrypointId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Ordered steps in the path.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<MiniMapPathStep> Steps { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path length.
|
||||
/// </summary>
|
||||
public int Length => Steps.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Whether path has runtime corroboration.
|
||||
/// </summary>
|
||||
public bool HasRuntimeEvidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence for this specific path.
|
||||
/// </summary>
|
||||
public decimal PathConfidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A step in a path.
|
||||
/// </summary>
|
||||
public sealed record MiniMapPathStep
|
||||
{
|
||||
/// <summary>
|
||||
/// Step index (0-based).
|
||||
/// </summary>
|
||||
public required int Index { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Node at this step.
|
||||
/// </summary>
|
||||
public required MiniMapNode Node { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Call type to next step.
|
||||
/// </summary>
|
||||
public string? CallType { get; init; }
|
||||
}
|
||||
|
||||
public enum ReachabilityState
|
||||
{
|
||||
Unknown,
|
||||
StaticReachable,
|
||||
StaticUnreachable,
|
||||
ConfirmedReachable,
|
||||
ConfirmedUnreachable
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
public sealed class RichGraphReader
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public async Task<RichGraph> ReadAsync(Stream stream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
|
||||
var document = await JsonSerializer.DeserializeAsync<RichGraphDocument>(
|
||||
stream,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize richgraph payload.");
|
||||
}
|
||||
|
||||
return Map(document);
|
||||
}
|
||||
|
||||
public RichGraph Read(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<RichGraphDocument>(payload, SerializerOptions);
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to deserialize richgraph payload.");
|
||||
}
|
||||
|
||||
return Map(document);
|
||||
}
|
||||
|
||||
private static RichGraph Map(RichGraphDocument document)
|
||||
{
|
||||
var analyzerDoc = document.Analyzer;
|
||||
var analyzer = new RichGraphAnalyzer(
|
||||
analyzerDoc?.Name ?? "scanner.reachability",
|
||||
analyzerDoc?.Version ?? "0.1.0",
|
||||
analyzerDoc?.ToolchainDigest);
|
||||
|
||||
var nodes = document.Nodes?
|
||||
.Select(MapNode)
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n.Id))
|
||||
.ToList() ?? new List<RichGraphNode>();
|
||||
|
||||
var edges = document.Edges?
|
||||
.Select(MapEdge)
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.From) && !string.IsNullOrWhiteSpace(e.To))
|
||||
.ToList() ?? new List<RichGraphEdge>();
|
||||
|
||||
var roots = document.Roots?
|
||||
.Select(r => new RichGraphRoot(
|
||||
r.Id ?? string.Empty,
|
||||
string.IsNullOrWhiteSpace(r.Phase) ? "runtime" : r.Phase,
|
||||
r.Source))
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Id))
|
||||
.ToList() ?? new List<RichGraphRoot>();
|
||||
|
||||
return new RichGraph(nodes, edges, roots, analyzer, document.Schema ?? "richgraph-v1").Trimmed();
|
||||
}
|
||||
|
||||
private static RichGraphNode MapNode(RichGraphNodeDocument node)
|
||||
{
|
||||
var symbol = node.Symbol is null
|
||||
? null
|
||||
: new ReachabilitySymbol(
|
||||
node.Symbol.Mangled,
|
||||
node.Symbol.Demangled,
|
||||
node.Symbol.Source,
|
||||
node.Symbol.Confidence);
|
||||
|
||||
return new RichGraphNode(
|
||||
Id: node.Id ?? string.Empty,
|
||||
SymbolId: string.IsNullOrWhiteSpace(node.SymbolId) ? (node.Id ?? string.Empty) : node.SymbolId,
|
||||
CodeId: node.CodeId,
|
||||
Purl: node.Purl,
|
||||
Lang: string.IsNullOrWhiteSpace(node.Lang) ? "unknown" : node.Lang,
|
||||
Kind: string.IsNullOrWhiteSpace(node.Kind) ? "unknown" : node.Kind,
|
||||
Display: node.Display,
|
||||
BuildId: node.BuildId,
|
||||
Evidence: node.Evidence,
|
||||
Attributes: node.Attributes,
|
||||
SymbolDigest: node.SymbolDigest,
|
||||
Symbol: symbol,
|
||||
CodeBlockHash: node.CodeBlockHash);
|
||||
}
|
||||
|
||||
private static RichGraphEdge MapEdge(RichGraphEdgeDocument edge)
|
||||
{
|
||||
IReadOnlyList<DetectedGate>? gates = null;
|
||||
if (edge.Gates is { Count: > 0 })
|
||||
{
|
||||
gates = edge.Gates.Select(MapGate).ToList();
|
||||
}
|
||||
|
||||
return new RichGraphEdge(
|
||||
From: edge.From ?? string.Empty,
|
||||
To: edge.To ?? string.Empty,
|
||||
Kind: string.IsNullOrWhiteSpace(edge.Kind) ? "call" : edge.Kind,
|
||||
Purl: edge.Purl,
|
||||
SymbolDigest: edge.SymbolDigest,
|
||||
Evidence: edge.Evidence,
|
||||
Confidence: edge.Confidence,
|
||||
Candidates: edge.Candidates,
|
||||
Gates: gates,
|
||||
GateMultiplierBps: edge.GateMultiplierBps);
|
||||
}
|
||||
|
||||
private static DetectedGate MapGate(RichGraphGateDocument gate)
|
||||
{
|
||||
return new DetectedGate
|
||||
{
|
||||
Type = ParseGateType(gate.Type),
|
||||
Detail = gate.Detail ?? string.Empty,
|
||||
GuardSymbol = gate.GuardSymbol ?? string.Empty,
|
||||
SourceFile = gate.SourceFile,
|
||||
LineNumber = gate.LineNumber,
|
||||
Confidence = gate.Confidence,
|
||||
DetectionMethod = gate.DetectionMethod ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static GateType ParseGateType(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return GateType.NonDefaultConfig;
|
||||
}
|
||||
|
||||
var normalized = value
|
||||
.Trim()
|
||||
.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||
.ToLowerInvariant();
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
"authrequired" => GateType.AuthRequired,
|
||||
"featureflag" => GateType.FeatureFlag,
|
||||
"adminonly" => GateType.AdminOnly,
|
||||
"nondefaultconfig" => GateType.NonDefaultConfig,
|
||||
_ => GateType.NonDefaultConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RichGraphDocument
|
||||
{
|
||||
[JsonPropertyName("schema")]
|
||||
public string? Schema { get; init; }
|
||||
|
||||
[JsonPropertyName("analyzer")]
|
||||
public RichGraphAnalyzerDocument? Analyzer { get; init; }
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public List<RichGraphNodeDocument>? Nodes { get; init; }
|
||||
|
||||
[JsonPropertyName("edges")]
|
||||
public List<RichGraphEdgeDocument>? Edges { get; init; }
|
||||
|
||||
[JsonPropertyName("roots")]
|
||||
public List<RichGraphRootDocument>? Roots { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphAnalyzerDocument
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("toolchain_digest")]
|
||||
public string? ToolchainDigest { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphNodeDocument
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_id")]
|
||||
public string? SymbolId { get; init; }
|
||||
|
||||
[JsonPropertyName("code_id")]
|
||||
public string? CodeId { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public string? Lang { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("display")]
|
||||
public string? Display { get; init; }
|
||||
|
||||
[JsonPropertyName("build_id")]
|
||||
public string? BuildId { get; init; }
|
||||
|
||||
[JsonPropertyName("code_block_hash")]
|
||||
public string? CodeBlockHash { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_digest")]
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public List<string>? Evidence { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
public Dictionary<string, string>? Attributes { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public RichGraphSymbolDocument? Symbol { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphSymbolDocument
|
||||
{
|
||||
[JsonPropertyName("mangled")]
|
||||
public string? Mangled { get; init; }
|
||||
|
||||
[JsonPropertyName("demangled")]
|
||||
public string? Demangled { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double? Confidence { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphEdgeDocument
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public string? From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string? To { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol_digest")]
|
||||
public string? SymbolDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 0.0;
|
||||
|
||||
[JsonPropertyName("gate_multiplier_bps")]
|
||||
public int GateMultiplierBps { get; init; } = 10000;
|
||||
|
||||
[JsonPropertyName("gates")]
|
||||
public List<RichGraphGateDocument>? Gates { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public List<string>? Evidence { get; init; }
|
||||
|
||||
[JsonPropertyName("candidates")]
|
||||
public List<string>? Candidates { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphGateDocument
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; init; }
|
||||
|
||||
[JsonPropertyName("detail")]
|
||||
public string? Detail { get; init; }
|
||||
|
||||
[JsonPropertyName("guard_symbol")]
|
||||
public string? GuardSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("source_file")]
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
[JsonPropertyName("line_number")]
|
||||
public int? LineNumber { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; } = 0.0;
|
||||
|
||||
[JsonPropertyName("detection_method")]
|
||||
public string? DetectionMethod { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class RichGraphRootDocument
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
[JsonPropertyName("phase")]
|
||||
public string? Phase { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public string? Source { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for runtime-static graph merging.
|
||||
/// </summary>
|
||||
public sealed record RuntimeStaticMergeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Confidence boost for edges observed at runtime. Default: 1.0 (max).
|
||||
/// </summary>
|
||||
public double ObservedConfidenceBoost { get; init; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Base confidence for runtime-only edges (not in static graph). Default: 0.9.
|
||||
/// </summary>
|
||||
public double RuntimeOnlyConfidence { get; init; } = 0.9;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum observation count to include a runtime-only edge. Default: 1.
|
||||
/// </summary>
|
||||
public int MinObservationCount { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age of observations to consider fresh. Default: 7 days.
|
||||
/// </summary>
|
||||
public TimeSpan FreshnessWindow { get; init; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to add edges from runtime that don't exist in static graph.
|
||||
/// </summary>
|
||||
public bool AddRuntimeOnlyEdges { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of merging runtime traces with static call graph.
|
||||
/// </summary>
|
||||
public sealed record RuntimeStaticMergeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Merged graph with runtime annotations.
|
||||
/// </summary>
|
||||
public required CallGraph MergedGraph { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the merge operation.
|
||||
/// </summary>
|
||||
public required MergeStatistics Statistics { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Edges that were observed at runtime.
|
||||
/// </summary>
|
||||
public ImmutableArray<ObservedEdge> ObservedEdges { get; init; } = ImmutableArray<ObservedEdge>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Edges added from runtime that weren't in static graph.
|
||||
/// </summary>
|
||||
public ImmutableArray<RuntimeOnlyEdge> RuntimeOnlyEdges { get; init; } = ImmutableArray<RuntimeOnlyEdge>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics from the merge operation.
|
||||
/// </summary>
|
||||
public sealed record MergeStatistics
|
||||
{
|
||||
public int StaticEdgeCount { get; init; }
|
||||
public int RuntimeEventCount { get; init; }
|
||||
public int MatchedEdgeCount { get; init; }
|
||||
public int RuntimeOnlyEdgeCount { get; init; }
|
||||
public int UnmatchedStaticEdgeCount { get; init; }
|
||||
public double CoverageRatio => StaticEdgeCount > 0
|
||||
? (double)MatchedEdgeCount / StaticEdgeCount
|
||||
: 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge that was observed at runtime.
|
||||
/// </summary>
|
||||
public sealed record ObservedEdge
|
||||
{
|
||||
public required string From { get; init; }
|
||||
public required string To { get; init; }
|
||||
public required DateTimeOffset FirstObserved { get; init; }
|
||||
public required DateTimeOffset LastObserved { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public string? TraceDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An edge that only exists in runtime observations (dynamic dispatch, etc).
|
||||
/// </summary>
|
||||
public sealed record RuntimeOnlyEdge
|
||||
{
|
||||
public required string From { get; init; }
|
||||
public required string To { get; init; }
|
||||
public required DateTimeOffset FirstObserved { get; init; }
|
||||
public required DateTimeOffset LastObserved { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public required string Origin { get; init; } // "runtime", "dynamic_dispatch", etc.
|
||||
public string? TraceDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a runtime call event from eBPF/ETW collectors.
|
||||
/// </summary>
|
||||
public sealed record RuntimeCallEvent
|
||||
{
|
||||
public required ulong Timestamp { get; init; }
|
||||
public required uint Pid { get; init; }
|
||||
public required uint Tid { get; init; }
|
||||
public required string CallerSymbol { get; init; }
|
||||
public required string CalleeSymbol { get; init; }
|
||||
public required string BinaryPath { get; init; }
|
||||
public string? TraceDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges runtime trace observations with static call graphs.
|
||||
/// </summary>
|
||||
public sealed class RuntimeStaticMerger
|
||||
{
|
||||
private readonly RuntimeStaticMergeOptions _options;
|
||||
private readonly ILogger<RuntimeStaticMerger> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public RuntimeStaticMerger(
|
||||
RuntimeStaticMergeOptions? options = null,
|
||||
ILogger<RuntimeStaticMerger>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_options = options ?? new RuntimeStaticMergeOptions();
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RuntimeStaticMerger>.Instance;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merge runtime events into a static call graph.
|
||||
/// </summary>
|
||||
public RuntimeStaticMergeResult Merge(
|
||||
CallGraph staticGraph,
|
||||
IEnumerable<RuntimeCallEvent> runtimeEvents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(staticGraph);
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvents);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var freshnessThreshold = now - _options.FreshnessWindow;
|
||||
|
||||
// Index static edges for fast lookup
|
||||
var staticEdgeIndex = BuildStaticEdgeIndex(staticGraph);
|
||||
|
||||
// Aggregate runtime events by edge
|
||||
var runtimeEdgeAggregates = AggregateRuntimeEvents(runtimeEvents);
|
||||
|
||||
var observedEdges = new List<ObservedEdge>();
|
||||
var runtimeOnlyEdges = new List<RuntimeOnlyEdge>();
|
||||
var modifiedEdges = new List<CallEdge>();
|
||||
var matchedEdgeKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (edgeKey, aggregate) in runtimeEdgeAggregates)
|
||||
{
|
||||
// Skip stale observations
|
||||
if (aggregate.LastObserved < freshnessThreshold)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip low observation counts
|
||||
if (aggregate.ObservationCount < _options.MinObservationCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (staticEdgeIndex.TryGetValue(edgeKey, out var staticEdge))
|
||||
{
|
||||
// Edge exists in static graph - mark as observed
|
||||
matchedEdgeKeys.Add(edgeKey);
|
||||
|
||||
var observedMetadata = new ObservedEdgeMetadata
|
||||
{
|
||||
FirstObserved = aggregate.FirstObserved,
|
||||
LastObserved = aggregate.LastObserved,
|
||||
ObservationCount = aggregate.ObservationCount,
|
||||
TraceDigest = aggregate.TraceDigest
|
||||
};
|
||||
|
||||
var boostedEdge = staticEdge with
|
||||
{
|
||||
Confidence = _options.ObservedConfidenceBoost,
|
||||
Observed = observedMetadata
|
||||
};
|
||||
|
||||
modifiedEdges.Add(boostedEdge);
|
||||
observedEdges.Add(new ObservedEdge
|
||||
{
|
||||
From = aggregate.From,
|
||||
To = aggregate.To,
|
||||
FirstObserved = aggregate.FirstObserved,
|
||||
LastObserved = aggregate.LastObserved,
|
||||
ObservationCount = aggregate.ObservationCount,
|
||||
TraceDigest = aggregate.TraceDigest
|
||||
});
|
||||
}
|
||||
else if (_options.AddRuntimeOnlyEdges)
|
||||
{
|
||||
// Edge only exists in runtime - add it
|
||||
var runtimeEdge = new CallEdge
|
||||
{
|
||||
From = aggregate.From,
|
||||
To = aggregate.To,
|
||||
Kind = CallEdgeKind.Dynamic,
|
||||
Confidence = ComputeRuntimeOnlyConfidence(aggregate),
|
||||
Evidence = "runtime_observation",
|
||||
Observed = new ObservedEdgeMetadata
|
||||
{
|
||||
FirstObserved = aggregate.FirstObserved,
|
||||
LastObserved = aggregate.LastObserved,
|
||||
ObservationCount = aggregate.ObservationCount,
|
||||
TraceDigest = aggregate.TraceDigest
|
||||
}
|
||||
};
|
||||
|
||||
modifiedEdges.Add(runtimeEdge);
|
||||
runtimeOnlyEdges.Add(new RuntimeOnlyEdge
|
||||
{
|
||||
From = aggregate.From,
|
||||
To = aggregate.To,
|
||||
FirstObserved = aggregate.FirstObserved,
|
||||
LastObserved = aggregate.LastObserved,
|
||||
ObservationCount = aggregate.ObservationCount,
|
||||
Origin = "runtime",
|
||||
TraceDigest = aggregate.TraceDigest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build merged edge list: unmatched static + modified
|
||||
var mergedEdges = new List<CallEdge>();
|
||||
foreach (var edge in staticGraph.Edges)
|
||||
{
|
||||
var key = BuildEdgeKey(edge.From, edge.To);
|
||||
if (!matchedEdgeKeys.Contains(key))
|
||||
{
|
||||
mergedEdges.Add(edge);
|
||||
}
|
||||
}
|
||||
mergedEdges.AddRange(modifiedEdges);
|
||||
|
||||
var mergedGraph = staticGraph with
|
||||
{
|
||||
Edges = mergedEdges.ToImmutableArray()
|
||||
};
|
||||
|
||||
var statistics = new MergeStatistics
|
||||
{
|
||||
StaticEdgeCount = staticGraph.Edges.Length,
|
||||
RuntimeEventCount = runtimeEdgeAggregates.Count,
|
||||
MatchedEdgeCount = matchedEdgeKeys.Count,
|
||||
RuntimeOnlyEdgeCount = runtimeOnlyEdges.Count,
|
||||
UnmatchedStaticEdgeCount = staticGraph.Edges.Length - matchedEdgeKeys.Count
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Merged runtime traces: {Matched}/{Static} edges observed ({Coverage:P1}), {RuntimeOnly} runtime-only edges added",
|
||||
statistics.MatchedEdgeCount,
|
||||
statistics.StaticEdgeCount,
|
||||
statistics.CoverageRatio,
|
||||
statistics.RuntimeOnlyEdgeCount);
|
||||
|
||||
return new RuntimeStaticMergeResult
|
||||
{
|
||||
MergedGraph = mergedGraph,
|
||||
Statistics = statistics,
|
||||
ObservedEdges = observedEdges.ToImmutableArray(),
|
||||
RuntimeOnlyEdges = runtimeOnlyEdges.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, CallEdge> BuildStaticEdgeIndex(CallGraph graph)
|
||||
{
|
||||
var index = new Dictionary<string, CallEdge>(StringComparer.Ordinal);
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
var key = BuildEdgeKey(edge.From, edge.To);
|
||||
index.TryAdd(key, edge);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
private static Dictionary<string, RuntimeEdgeAggregate> AggregateRuntimeEvents(
|
||||
IEnumerable<RuntimeCallEvent> events)
|
||||
{
|
||||
var aggregates = new Dictionary<string, RuntimeEdgeAggregate>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var evt in events)
|
||||
{
|
||||
var key = BuildEdgeKey(evt.CallerSymbol, evt.CalleeSymbol);
|
||||
|
||||
if (aggregates.TryGetValue(key, out var existing))
|
||||
{
|
||||
aggregates[key] = existing with
|
||||
{
|
||||
ObservationCount = existing.ObservationCount + 1,
|
||||
LastObserved = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000))
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
var timestamp = DateTimeOffset.FromUnixTimeMilliseconds((long)(evt.Timestamp / 1_000_000));
|
||||
aggregates[key] = new RuntimeEdgeAggregate
|
||||
{
|
||||
From = evt.CallerSymbol,
|
||||
To = evt.CalleeSymbol,
|
||||
FirstObserved = timestamp,
|
||||
LastObserved = timestamp,
|
||||
ObservationCount = 1,
|
||||
TraceDigest = evt.TraceDigest
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return aggregates;
|
||||
}
|
||||
|
||||
private double ComputeRuntimeOnlyConfidence(RuntimeEdgeAggregate aggregate)
|
||||
{
|
||||
// Higher observation count = higher confidence, capped at runtime-only max
|
||||
var countFactor = Math.Min(1.0, aggregate.ObservationCount / 10.0);
|
||||
return _options.RuntimeOnlyConfidence * (0.5 + 0.5 * countFactor);
|
||||
}
|
||||
|
||||
private static string BuildEdgeKey(string from, string to) => $"{from}->{to}";
|
||||
|
||||
private sealed record RuntimeEdgeAggregate
|
||||
{
|
||||
public required string From { get; init; }
|
||||
public required string To { get; init; }
|
||||
public required DateTimeOffset FirstObserved { get; init; }
|
||||
public required DateTimeOffset LastObserved { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public string? TraceDigest { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Cache for reachability slices to avoid redundant computation.
|
||||
/// </summary>
|
||||
public interface ISliceCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to get a cached slice result.
|
||||
/// </summary>
|
||||
Task<CachedSliceResult?> TryGetAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Store a slice result in cache.
|
||||
/// </summary>
|
||||
Task SetAsync(
|
||||
string cacheKey,
|
||||
CachedSliceResult result,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Remove a slice from cache.
|
||||
/// </summary>
|
||||
Task RemoveAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clear all cached slices.
|
||||
/// </summary>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get cache statistics.
|
||||
/// </summary>
|
||||
CacheStatistics GetStatistics();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached slice result.
|
||||
/// </summary>
|
||||
public sealed record CachedSliceResult
|
||||
{
|
||||
public required string SliceDigest { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required IReadOnlyList<string> PathWitnesses { get; init; }
|
||||
public required DateTimeOffset CachedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache statistics.
|
||||
/// </summary>
|
||||
public sealed record CacheStatistics
|
||||
{
|
||||
public required long HitCount { get; init; }
|
||||
public required long MissCount { get; init; }
|
||||
public required long EntryCount { get; init; }
|
||||
public required long EstimatedSizeBytes { get; init; }
|
||||
|
||||
public double HitRate => (HitCount + MissCount) == 0
|
||||
? 0.0
|
||||
: (double)HitCount / (HitCount + MissCount);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of slice cache with TTL and memory pressure handling.
|
||||
/// </summary>
|
||||
public sealed class InMemorySliceCache : ISliceCache, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly ILogger<InMemorySliceCache> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly Timer _evictionTimer;
|
||||
private readonly SemaphoreSlim _evictionLock = new(1, 1);
|
||||
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private const long MaxCacheSizeBytes = 1_073_741_824; // 1GB
|
||||
private const int EvictionIntervalSeconds = 60;
|
||||
|
||||
public InMemorySliceCache(
|
||||
ILogger<InMemorySliceCache> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_evictionTimer = new Timer(
|
||||
_ => _ = EvictExpiredEntriesAsync(CancellationToken.None),
|
||||
null,
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds),
|
||||
TimeSpan.FromSeconds(EvictionIntervalSeconds));
|
||||
}
|
||||
|
||||
public Task<CachedSliceResult?> TryGetAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out var entry))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (entry.ExpiresAt > now)
|
||||
{
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
_logger.LogDebug("Cache hit for key {CacheKey}", cacheKey);
|
||||
return Task.FromResult<CachedSliceResult?>(entry.Result);
|
||||
}
|
||||
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
_logger.LogDebug("Cache entry expired for key {CacheKey}", cacheKey);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
_logger.LogDebug("Cache miss for key {CacheKey}", cacheKey);
|
||||
return Task.FromResult<CachedSliceResult?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(
|
||||
string cacheKey,
|
||||
CachedSliceResult result,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var entry = new CacheEntry(result, now + ttl, EstimateSize(result));
|
||||
|
||||
_cache.AddOrUpdate(cacheKey, entry, (_, _) => entry);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Cached slice with key {CacheKey}, expires at {ExpiresAt}",
|
||||
cacheKey,
|
||||
entry.ExpiresAt);
|
||||
|
||||
_ = CheckMemoryPressureAsync(cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(
|
||||
string cacheKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
_logger.LogDebug("Removed cache entry for key {CacheKey}", cacheKey);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_cache.Clear();
|
||||
_logger.LogInformation("Cleared all cache entries");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public CacheStatistics GetStatistics()
|
||||
{
|
||||
var estimatedSize = _cache.Values.Sum(e => e.EstimatedSizeBytes);
|
||||
|
||||
return new CacheStatistics
|
||||
{
|
||||
HitCount = Interlocked.Read(ref _hitCount),
|
||||
MissCount = Interlocked.Read(ref _missCount),
|
||||
EntryCount = _cache.Count,
|
||||
EstimatedSizeBytes = estimatedSize
|
||||
};
|
||||
}
|
||||
|
||||
private async Task EvictExpiredEntriesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _cache
|
||||
.Where(kv => kv.Value.ExpiresAt <= now)
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Evicted {Count} expired cache entries", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CheckMemoryPressureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stats = GetStatistics();
|
||||
if (stats.EstimatedSizeBytes <= MaxCacheSizeBytes)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _evictionLock.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var orderedEntries = _cache
|
||||
.OrderBy(kv => kv.Value.ExpiresAt)
|
||||
.ToList();
|
||||
|
||||
var evictionCount = Math.Max(1, orderedEntries.Count / 10);
|
||||
var toEvict = orderedEntries.Take(evictionCount);
|
||||
|
||||
foreach (var entry in toEvict)
|
||||
{
|
||||
_cache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Memory pressure detected. Evicted {Count} entries. Cache size: {SizeBytes} bytes",
|
||||
evictionCount,
|
||||
stats.EstimatedSizeBytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evictionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static long EstimateSize(CachedSliceResult result)
|
||||
{
|
||||
const int baseObjectSize = 128;
|
||||
const int stringOverhead = 32;
|
||||
const int pathWitnessAvgSize = 256;
|
||||
|
||||
var size = baseObjectSize;
|
||||
size += result.SliceDigest.Length * 2 + stringOverhead;
|
||||
size += result.Verdict.Length * 2 + stringOverhead;
|
||||
size += result.PathWitnesses.Count * pathWitnessAvgSize;
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_evictionTimer?.Dispose();
|
||||
_evictionLock?.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(
|
||||
CachedSliceResult Result,
|
||||
DateTimeOffset ExpiresAt,
|
||||
long EstimatedSizeBytes);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Reachability.Runtime;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Options for observed path slice generation.
|
||||
/// </summary>
|
||||
public sealed record ObservedPathSliceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold to include in slice. Default: 0.0 (include all).
|
||||
/// </summary>
|
||||
public double MinConfidence { get; init; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include runtime-only edges. Default: true.
|
||||
/// </summary>
|
||||
public bool IncludeRuntimeOnlyEdges { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to promote observed edges to highest confidence. Default: true.
|
||||
/// </summary>
|
||||
public bool PromoteObservedConfidence { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates reachability slices that incorporate runtime observations.
|
||||
/// </summary>
|
||||
public sealed class ObservedPathSliceGenerator
|
||||
{
|
||||
private readonly SliceExtractor _baseExtractor;
|
||||
private readonly RuntimeStaticMerger _merger;
|
||||
private readonly ObservedPathSliceOptions _options;
|
||||
private readonly ILogger<ObservedPathSliceGenerator> _logger;
|
||||
|
||||
public ObservedPathSliceGenerator(
|
||||
SliceExtractor baseExtractor,
|
||||
RuntimeStaticMerger merger,
|
||||
ObservedPathSliceOptions? options = null,
|
||||
ILogger<ObservedPathSliceGenerator>? logger = null)
|
||||
{
|
||||
_baseExtractor = baseExtractor ?? throw new ArgumentNullException(nameof(baseExtractor));
|
||||
_merger = merger ?? throw new ArgumentNullException(nameof(merger));
|
||||
_options = options ?? new ObservedPathSliceOptions();
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ObservedPathSliceGenerator>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a slice with runtime observations merged in.
|
||||
/// </summary>
|
||||
public ReachabilitySlice ExtractWithObservations(
|
||||
SliceExtractionRequest request,
|
||||
IEnumerable<RuntimeCallEvent> runtimeEvents)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(runtimeEvents);
|
||||
|
||||
// First merge runtime observations into the graph
|
||||
var mergeResult = _merger.Merge(request.Graph, runtimeEvents);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged {Matched} observed edges, {RuntimeOnly} runtime-only edges (coverage: {Coverage:P1})",
|
||||
mergeResult.Statistics.MatchedEdgeCount,
|
||||
mergeResult.Statistics.RuntimeOnlyEdgeCount,
|
||||
mergeResult.Statistics.CoverageRatio);
|
||||
|
||||
// Extract slice from merged graph
|
||||
var mergedRequest = request with { Graph = mergeResult.MergedGraph };
|
||||
var baseSlice = _baseExtractor.Extract(mergedRequest);
|
||||
|
||||
// Enhance verdict based on observations
|
||||
var enhancedVerdict = EnhanceVerdict(baseSlice.Verdict, mergeResult);
|
||||
|
||||
// Filter and transform edges based on options
|
||||
var enhancedSubgraph = EnhanceSubgraph(baseSlice.Subgraph, mergeResult);
|
||||
|
||||
return baseSlice with
|
||||
{
|
||||
Verdict = enhancedVerdict,
|
||||
Subgraph = enhancedSubgraph
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if any paths in the slice have been observed at runtime.
|
||||
/// </summary>
|
||||
public bool HasObservedPaths(ReachabilitySlice slice)
|
||||
{
|
||||
return slice.Subgraph.Edges.Any(e => e.Observed != null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get coverage statistics for a slice.
|
||||
/// </summary>
|
||||
public ObservationCoverage GetCoverage(ReachabilitySlice slice)
|
||||
{
|
||||
var totalEdges = slice.Subgraph.Edges.Length;
|
||||
var observedEdges = slice.Subgraph.Edges.Count(e => e.Observed != null);
|
||||
|
||||
return new ObservationCoverage
|
||||
{
|
||||
TotalEdges = totalEdges,
|
||||
ObservedEdges = observedEdges,
|
||||
CoverageRatio = totalEdges > 0 ? (double)observedEdges / totalEdges : 0.0,
|
||||
HasFullCoverage = totalEdges > 0 && observedEdges == totalEdges
|
||||
};
|
||||
}
|
||||
|
||||
private SliceVerdict EnhanceVerdict(SliceVerdict baseVerdict, RuntimeStaticMergeResult mergeResult)
|
||||
{
|
||||
// If we have observed paths to targets, upgrade to observed_reachable
|
||||
var hasObservedPathToTarget = mergeResult.ObservedEdges.Any();
|
||||
|
||||
if (hasObservedPathToTarget && baseVerdict.Status == SliceVerdictStatus.Reachable)
|
||||
{
|
||||
return baseVerdict with
|
||||
{
|
||||
Status = SliceVerdictStatus.ObservedReachable,
|
||||
Confidence = 1.0, // Maximum confidence for runtime-observed
|
||||
Reasons = baseVerdict.Reasons.Add("Runtime observation confirms reachability")
|
||||
};
|
||||
}
|
||||
|
||||
// If static analysis said unreachable but we observed it, override
|
||||
if (hasObservedPathToTarget && baseVerdict.Status == SliceVerdictStatus.Unreachable)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Runtime observation contradicts static analysis (unreachable -> observed_reachable)");
|
||||
|
||||
return baseVerdict with
|
||||
{
|
||||
Status = SliceVerdictStatus.ObservedReachable,
|
||||
Confidence = 1.0,
|
||||
Reasons = baseVerdict.Reasons.Add("Runtime observation overrides static analysis")
|
||||
};
|
||||
}
|
||||
|
||||
// Boost confidence if we have supporting observations
|
||||
if (mergeResult.Statistics.CoverageRatio > 0)
|
||||
{
|
||||
var boostedConfidence = Math.Min(1.0,
|
||||
baseVerdict.Confidence + (1.0 - baseVerdict.Confidence) * mergeResult.Statistics.CoverageRatio);
|
||||
|
||||
return baseVerdict with
|
||||
{
|
||||
Confidence = boostedConfidence,
|
||||
Reasons = baseVerdict.Reasons.Add($"Confidence boosted by {mergeResult.Statistics.CoverageRatio:P0} runtime coverage")
|
||||
};
|
||||
}
|
||||
|
||||
return baseVerdict;
|
||||
}
|
||||
|
||||
private SliceSubgraph EnhanceSubgraph(SliceSubgraph baseSubgraph, RuntimeStaticMergeResult mergeResult)
|
||||
{
|
||||
var enhancedEdges = baseSubgraph.Edges
|
||||
.Select(edge => EnhanceEdge(edge, mergeResult))
|
||||
.Where(edge => edge.Confidence >= _options.MinConfidence)
|
||||
.ToImmutableArray();
|
||||
|
||||
return baseSubgraph with { Edges = enhancedEdges };
|
||||
}
|
||||
|
||||
private SliceEdge EnhanceEdge(SliceEdge edge, RuntimeStaticMergeResult mergeResult)
|
||||
{
|
||||
// Check if this edge was observed
|
||||
var observed = mergeResult.ObservedEdges
|
||||
.FirstOrDefault(o => o.From == edge.From && o.To == edge.To);
|
||||
|
||||
if (observed != null)
|
||||
{
|
||||
var confidence = _options.PromoteObservedConfidence ? 1.0 : edge.Confidence;
|
||||
|
||||
return edge with
|
||||
{
|
||||
Confidence = confidence,
|
||||
Observed = new ObservedEdgeMetadata
|
||||
{
|
||||
FirstObserved = observed.FirstObserved,
|
||||
LastObserved = observed.LastObserved,
|
||||
ObservationCount = observed.ObservationCount,
|
||||
TraceDigest = observed.TraceDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this is a runtime-only edge
|
||||
var runtimeOnly = mergeResult.RuntimeOnlyEdges
|
||||
.FirstOrDefault(r => r.From == edge.From && r.To == edge.To);
|
||||
|
||||
if (runtimeOnly != null && _options.IncludeRuntimeOnlyEdges)
|
||||
{
|
||||
return edge with
|
||||
{
|
||||
Kind = SliceEdgeKind.Dynamic,
|
||||
Evidence = $"runtime:{runtimeOnly.Origin}",
|
||||
Observed = new ObservedEdgeMetadata
|
||||
{
|
||||
FirstObserved = runtimeOnly.FirstObserved,
|
||||
LastObserved = runtimeOnly.LastObserved,
|
||||
ObservationCount = runtimeOnly.ObservationCount,
|
||||
TraceDigest = runtimeOnly.TraceDigest
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return edge;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coverage statistics for runtime observations.
|
||||
/// </summary>
|
||||
public sealed record ObservationCoverage
|
||||
{
|
||||
public int TotalEdges { get; init; }
|
||||
public int ObservedEdges { get; init; }
|
||||
public double CoverageRatio { get; init; }
|
||||
public bool HasFullCoverage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Policy binding mode for slices.
|
||||
/// </summary>
|
||||
public enum PolicyBindingMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Slice is invalid if policy changes at all.
|
||||
/// </summary>
|
||||
Strict,
|
||||
|
||||
/// <summary>
|
||||
/// Slice is valid with newer policy versions only.
|
||||
/// </summary>
|
||||
Forward,
|
||||
|
||||
/// <summary>
|
||||
/// Slice is valid with any policy version.
|
||||
/// </summary>
|
||||
Any
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy binding information for a reachability slice.
|
||||
/// </summary>
|
||||
public sealed record PolicyBinding
|
||||
{
|
||||
/// <summary>
|
||||
/// Content-addressed hash of the policy DSL.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyDigest")]
|
||||
public required string PolicyDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Semantic version of the policy.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyVersion")]
|
||||
public required string PolicyVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the policy was bound to this slice.
|
||||
/// </summary>
|
||||
[JsonPropertyName("boundAt")]
|
||||
public required DateTimeOffset BoundAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Binding mode for validation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public required PolicyBindingMode Mode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy name/identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("policyName")]
|
||||
public string? PolicyName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional policy source (e.g., git commit hash).
|
||||
/// </summary>
|
||||
[JsonPropertyName("policySource")]
|
||||
public string? PolicySource { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy binding validation.
|
||||
/// </summary>
|
||||
public sealed record PolicyBindingValidationResult
|
||||
{
|
||||
public required bool Valid { get; init; }
|
||||
public string? FailureReason { get; init; }
|
||||
public required PolicyBinding SlicePolicy { get; init; }
|
||||
public required PolicyBinding CurrentPolicy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for policy bindings.
|
||||
/// </summary>
|
||||
public sealed class PolicyBindingValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate a policy binding against current policy.
|
||||
/// </summary>
|
||||
public PolicyBindingValidationResult Validate(
|
||||
PolicyBinding sliceBinding,
|
||||
PolicyBinding currentPolicy)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sliceBinding);
|
||||
ArgumentNullException.ThrowIfNull(currentPolicy);
|
||||
|
||||
var result = sliceBinding.Mode switch
|
||||
{
|
||||
PolicyBindingMode.Strict => ValidateStrict(sliceBinding, currentPolicy),
|
||||
PolicyBindingMode.Forward => ValidateForward(sliceBinding, currentPolicy),
|
||||
PolicyBindingMode.Any => ValidateAny(sliceBinding, currentPolicy),
|
||||
_ => throw new ArgumentException($"Unknown policy binding mode: {sliceBinding.Mode}")
|
||||
};
|
||||
|
||||
return result with
|
||||
{
|
||||
SlicePolicy = sliceBinding,
|
||||
CurrentPolicy = currentPolicy
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyBindingValidationResult ValidateStrict(
|
||||
PolicyBinding sliceBinding,
|
||||
PolicyBinding currentPolicy)
|
||||
{
|
||||
var digestMatch = string.Equals(
|
||||
sliceBinding.PolicyDigest,
|
||||
currentPolicy.PolicyDigest,
|
||||
StringComparison.Ordinal);
|
||||
|
||||
return new PolicyBindingValidationResult
|
||||
{
|
||||
Valid = digestMatch,
|
||||
FailureReason = digestMatch
|
||||
? null
|
||||
: $"Policy digest mismatch. Slice bound to {sliceBinding.PolicyDigest}, current is {currentPolicy.PolicyDigest}.",
|
||||
SlicePolicy = sliceBinding,
|
||||
CurrentPolicy = currentPolicy
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyBindingValidationResult ValidateForward(
|
||||
PolicyBinding sliceBinding,
|
||||
PolicyBinding currentPolicy)
|
||||
{
|
||||
// Check if current policy version is newer or equal
|
||||
if (!Version.TryParse(sliceBinding.PolicyVersion, out var sliceVersion) ||
|
||||
!Version.TryParse(currentPolicy.PolicyVersion, out var currentVersion))
|
||||
{
|
||||
return new PolicyBindingValidationResult
|
||||
{
|
||||
Valid = false,
|
||||
FailureReason = "Invalid version format for forward compatibility check.",
|
||||
SlicePolicy = sliceBinding,
|
||||
CurrentPolicy = currentPolicy
|
||||
};
|
||||
}
|
||||
|
||||
var isForwardCompatible = currentVersion >= sliceVersion;
|
||||
|
||||
return new PolicyBindingValidationResult
|
||||
{
|
||||
Valid = isForwardCompatible,
|
||||
FailureReason = isForwardCompatible
|
||||
? null
|
||||
: $"Policy version downgrade detected. Slice bound to {sliceVersion}, current is {currentVersion}.",
|
||||
SlicePolicy = sliceBinding,
|
||||
CurrentPolicy = currentPolicy
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyBindingValidationResult ValidateAny(
|
||||
PolicyBinding sliceBinding,
|
||||
PolicyBinding currentPolicy)
|
||||
{
|
||||
// Always valid in 'any' mode
|
||||
return new PolicyBindingValidationResult
|
||||
{
|
||||
Valid = true,
|
||||
FailureReason = null,
|
||||
SlicePolicy = sliceBinding,
|
||||
CurrentPolicy = currentPolicy
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices.Replay;
|
||||
|
||||
/// <summary>
|
||||
/// Computes detailed diffs between two reachability slices.
|
||||
/// </summary>
|
||||
public sealed class SliceDiffComputer
|
||||
{
|
||||
public SliceDiffResult Compute(ReachabilitySlice original, ReachabilitySlice recomputed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(original);
|
||||
ArgumentNullException.ThrowIfNull(recomputed);
|
||||
|
||||
var normalizedOriginal = original.Normalize();
|
||||
var normalizedRecomputed = recomputed.Normalize();
|
||||
|
||||
var nodesDiff = ComputeNodesDiff(
|
||||
normalizedOriginal.Subgraph.Nodes,
|
||||
normalizedRecomputed.Subgraph.Nodes);
|
||||
|
||||
var edgesDiff = ComputeEdgesDiff(
|
||||
normalizedOriginal.Subgraph.Edges,
|
||||
normalizedRecomputed.Subgraph.Edges);
|
||||
|
||||
var verdictDiff = ComputeVerdictDiff(
|
||||
normalizedOriginal.Verdict,
|
||||
normalizedRecomputed.Verdict);
|
||||
|
||||
var hasChanges = nodesDiff.HasChanges || edgesDiff.HasChanges || verdictDiff is not null;
|
||||
|
||||
return new SliceDiffResult(
|
||||
Match: !hasChanges,
|
||||
NodesDiff: nodesDiff,
|
||||
EdgesDiff: edgesDiff,
|
||||
VerdictDiff: verdictDiff);
|
||||
}
|
||||
|
||||
private static NodesDiff ComputeNodesDiff(
|
||||
ImmutableArray<SliceNode> original,
|
||||
ImmutableArray<SliceNode> recomputed)
|
||||
{
|
||||
var originalIds = original.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var recomputedIds = recomputed.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = originalIds.Except(recomputedIds).Order(StringComparer.Ordinal).ToImmutableArray();
|
||||
var extra = recomputedIds.Except(originalIds).Order(StringComparer.Ordinal).ToImmutableArray();
|
||||
|
||||
var hasChanges = missing.Length > 0 || extra.Length > 0;
|
||||
|
||||
return new NodesDiff(missing, extra, hasChanges);
|
||||
}
|
||||
|
||||
private static EdgesDiff ComputeEdgesDiff(
|
||||
ImmutableArray<SliceEdge> original,
|
||||
ImmutableArray<SliceEdge> recomputed)
|
||||
{
|
||||
var originalKeys = original
|
||||
.Select(e => EdgeKey(e))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var recomputedKeys = recomputed
|
||||
.Select(e => EdgeKey(e))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = originalKeys.Except(recomputedKeys).Order(StringComparer.Ordinal).ToImmutableArray();
|
||||
var extra = recomputedKeys.Except(originalKeys).Order(StringComparer.Ordinal).ToImmutableArray();
|
||||
|
||||
var hasChanges = missing.Length > 0 || extra.Length > 0;
|
||||
|
||||
return new EdgesDiff(missing, extra, hasChanges);
|
||||
}
|
||||
|
||||
private static string EdgeKey(SliceEdge edge)
|
||||
=> $"{edge.From}→{edge.To}:{edge.Kind}";
|
||||
|
||||
private static string? ComputeVerdictDiff(SliceVerdict original, SliceVerdict recomputed)
|
||||
{
|
||||
if (original.Status != recomputed.Status)
|
||||
{
|
||||
return $"Status changed: {original.Status} → {recomputed.Status}";
|
||||
}
|
||||
|
||||
var confidenceDiff = Math.Abs(original.Confidence - recomputed.Confidence);
|
||||
if (confidenceDiff > 0.01)
|
||||
{
|
||||
return $"Confidence changed: {original.Confidence:F3} → {recomputed.Confidence:F3} (Δ={confidenceDiff:F3})";
|
||||
}
|
||||
|
||||
if (original.UnknownCount != recomputed.UnknownCount)
|
||||
{
|
||||
return $"Unknown count changed: {original.UnknownCount} → {recomputed.UnknownCount}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SliceDiffResult(
|
||||
bool Match,
|
||||
NodesDiff NodesDiff,
|
||||
EdgesDiff EdgesDiff,
|
||||
string? VerdictDiff);
|
||||
|
||||
public sealed record NodesDiff(
|
||||
ImmutableArray<string> Missing,
|
||||
ImmutableArray<string> Extra,
|
||||
bool HasChanges);
|
||||
|
||||
public sealed record EdgesDiff(
|
||||
ImmutableArray<string> Missing,
|
||||
ImmutableArray<string> Extra,
|
||||
bool HasChanges);
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Options for slice caching behavior.
|
||||
/// </summary>
|
||||
public sealed class SliceCacheOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Cache time-to-live. Default: 1 hour.
|
||||
/// </summary>
|
||||
public TimeSpan Ttl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of cached items before eviction. Default: 10000.
|
||||
/// </summary>
|
||||
public int MaxItems { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether caching is enabled. Default: true.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory LRU cache for reachability slices with TTL eviction.
|
||||
/// </summary>
|
||||
public sealed class SliceCache : ISliceCache, IDisposable
|
||||
{
|
||||
private readonly SliceCacheOptions _options;
|
||||
private readonly ConcurrentDictionary<string, CacheItem> _cache = new(StringComparer.Ordinal);
|
||||
private readonly Timer _evictionTimer;
|
||||
private long _hitCount;
|
||||
private long _missCount;
|
||||
private bool _disposed;
|
||||
|
||||
public SliceCache(IOptions<SliceCacheOptions> options)
|
||||
{
|
||||
_options = options?.Value ?? new SliceCacheOptions();
|
||||
_evictionTimer = new Timer(EvictExpired, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
public Task<CachedSliceResult?> TryGetAsync(string cacheKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return Task.FromResult<CachedSliceResult?>(null);
|
||||
}
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out var item))
|
||||
{
|
||||
if (item.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
item.LastAccessed = DateTimeOffset.UtcNow;
|
||||
Interlocked.Increment(ref _hitCount);
|
||||
var result = new CachedSliceResult
|
||||
{
|
||||
SliceDigest = item.Digest,
|
||||
Verdict = item.Verdict,
|
||||
Confidence = item.Confidence,
|
||||
PathWitnesses = item.PathWitnesses,
|
||||
CachedAt = item.CachedAt
|
||||
};
|
||||
return Task.FromResult<CachedSliceResult?>(result);
|
||||
}
|
||||
|
||||
// Expired - remove and return miss
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _missCount);
|
||||
return Task.FromResult<CachedSliceResult?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync(string cacheKey, CachedSliceResult result, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
if (!_options.Enabled) return Task.CompletedTask;
|
||||
|
||||
// Evict if at capacity
|
||||
if (_cache.Count >= _options.MaxItems)
|
||||
{
|
||||
EvictLru();
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var item = new CacheItem
|
||||
{
|
||||
Digest = result.SliceDigest,
|
||||
Verdict = result.Verdict,
|
||||
Confidence = result.Confidence,
|
||||
PathWitnesses = result.PathWitnesses.ToList(),
|
||||
CachedAt = now,
|
||||
ExpiresAt = now.Add(ttl),
|
||||
LastAccessed = now
|
||||
};
|
||||
|
||||
_cache[cacheKey] = item;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string cacheKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
_cache.Clear();
|
||||
Interlocked.Exchange(ref _hitCount, 0);
|
||||
Interlocked.Exchange(ref _missCount, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public CacheStatistics GetStatistics() => new()
|
||||
{
|
||||
HitCount = Interlocked.Read(ref _hitCount),
|
||||
MissCount = Interlocked.Read(ref _missCount),
|
||||
EntryCount = _cache.Count,
|
||||
EstimatedSizeBytes = _cache.Count * 1024 // Rough estimate
|
||||
};
|
||||
|
||||
private void EvictExpired(object? state)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var keysToRemove = _cache
|
||||
.Where(kvp => kvp.Value.ExpiresAt <= now)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private void EvictLru()
|
||||
{
|
||||
// Remove oldest 10% of items
|
||||
var toRemove = Math.Max(1, _options.MaxItems / 10);
|
||||
var oldest = _cache
|
||||
.OrderBy(kvp => kvp.Value.LastAccessed)
|
||||
.Take(toRemove)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in oldest)
|
||||
{
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_evictionTimer.Dispose();
|
||||
}
|
||||
|
||||
private sealed class CacheItem
|
||||
{
|
||||
public required string Digest { get; init; }
|
||||
public required string Verdict { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required List<string> PathWitnesses { get; init; }
|
||||
public required DateTimeOffset CachedAt { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public DateTimeOffset LastAccessed { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed class SliceCasStorage
|
||||
{
|
||||
private readonly SliceHasher _hasher;
|
||||
private readonly SliceDsseSigner _signer;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public SliceCasStorage(SliceHasher hasher, SliceDsseSigner signer, ICryptoHash cryptoHash)
|
||||
{
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
public async Task<SliceCasResult> StoreAsync(
|
||||
ReachabilitySlice slice,
|
||||
IFileContentAddressableStore cas,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
ArgumentNullException.ThrowIfNull(cas);
|
||||
|
||||
var digestResult = _hasher.ComputeDigest(slice);
|
||||
var casKey = ExtractDigestHex(digestResult.Digest);
|
||||
|
||||
await using (var sliceStream = new MemoryStream(digestResult.CanonicalBytes, writable: false))
|
||||
{
|
||||
await cas.PutAsync(new FileCasPutRequest(casKey, sliceStream, leaveOpen: false), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var signed = await _signer.SignAsync(slice, cancellationToken).ConfigureAwait(false);
|
||||
var envelopeBytes = CanonicalJson.SerializeToUtf8Bytes(signed.Envelope);
|
||||
var dsseDigest = _cryptoHash.ComputePrefixedHashForPurpose(envelopeBytes, HashPurpose.Attestation);
|
||||
var dsseKey = $"{casKey}.dsse";
|
||||
|
||||
await using (var dsseStream = new MemoryStream(envelopeBytes, writable: false))
|
||||
{
|
||||
await cas.PutAsync(new FileCasPutRequest(dsseKey, dsseStream, leaveOpen: false), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new SliceCasResult(
|
||||
signed.SliceDigest,
|
||||
$"cas://slices/{casKey}",
|
||||
dsseDigest,
|
||||
$"cas://slices/{dsseKey}",
|
||||
signed);
|
||||
}
|
||||
|
||||
private static string ExtractDigestHex(string prefixed)
|
||||
{
|
||||
var colonIndex = prefixed.IndexOf(':');
|
||||
return colonIndex >= 0 ? prefixed[(colonIndex + 1)..] : prefixed;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SliceCasResult(
|
||||
string SliceDigest,
|
||||
string SliceCasUri,
|
||||
string DsseDigest,
|
||||
string DsseCasUri,
|
||||
SignedSlice SignedSlice);
|
||||
@@ -0,0 +1,178 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Computes detailed diffs between two slices for replay verification.
|
||||
/// </summary>
|
||||
public sealed class SliceDiffComputer
|
||||
{
|
||||
/// <summary>
|
||||
/// Compare two slices and produce a detailed diff.
|
||||
/// </summary>
|
||||
public SliceDiffResult Compare(ReachabilitySlice original, ReachabilitySlice recomputed)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(original);
|
||||
ArgumentNullException.ThrowIfNull(recomputed);
|
||||
|
||||
var nodeDiff = CompareNodes(original.Subgraph.Nodes, recomputed.Subgraph.Nodes);
|
||||
var edgeDiff = CompareEdges(original.Subgraph.Edges, recomputed.Subgraph.Edges);
|
||||
var verdictDiff = CompareVerdicts(original.Verdict, recomputed.Verdict);
|
||||
|
||||
var match = nodeDiff.MissingNodes.IsEmpty &&
|
||||
nodeDiff.ExtraNodes.IsEmpty &&
|
||||
edgeDiff.MissingEdges.IsEmpty &&
|
||||
edgeDiff.ExtraEdges.IsEmpty &&
|
||||
verdictDiff == null;
|
||||
|
||||
return new SliceDiffResult
|
||||
{
|
||||
Match = match,
|
||||
MissingNodes = nodeDiff.MissingNodes,
|
||||
ExtraNodes = nodeDiff.ExtraNodes,
|
||||
MissingEdges = edgeDiff.MissingEdges,
|
||||
ExtraEdges = edgeDiff.ExtraEdges,
|
||||
VerdictDiff = verdictDiff
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute a cache key for a query based on its parameters.
|
||||
/// </summary>
|
||||
public static string ComputeCacheKey(string scanId, string? cveId, IEnumerable<string>? symbols, IEnumerable<string>? entrypoints, string? policyHash)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append("scan:").Append(scanId ?? "").Append('|');
|
||||
sb.Append("cve:").Append(cveId ?? "").Append('|');
|
||||
|
||||
if (symbols != null)
|
||||
{
|
||||
foreach (var s in symbols.OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append("sym:").Append(s).Append(',');
|
||||
}
|
||||
}
|
||||
sb.Append('|');
|
||||
|
||||
if (entrypoints != null)
|
||||
{
|
||||
foreach (var e in entrypoints.OrderBy(x => x, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append("ep:").Append(e).Append(',');
|
||||
}
|
||||
}
|
||||
sb.Append('|');
|
||||
|
||||
sb.Append("policy:").Append(policyHash ?? "");
|
||||
|
||||
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString()));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static NodeDiffResult CompareNodes(ImmutableArray<SliceNode> original, ImmutableArray<SliceNode> recomputed)
|
||||
{
|
||||
var originalIds = original.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var recomputedIds = recomputed.Select(n => n.Id).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = originalIds.Except(recomputedIds)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extra = recomputedIds.Except(originalIds)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new NodeDiffResult(missing, extra);
|
||||
}
|
||||
|
||||
private static EdgeDiffResult CompareEdges(ImmutableArray<SliceEdge> original, ImmutableArray<SliceEdge> recomputed)
|
||||
{
|
||||
static string EdgeKey(SliceEdge e) => $"{e.From}->{e.To}:{e.Kind}";
|
||||
|
||||
var originalKeys = original.Select(EdgeKey).ToHashSet(StringComparer.Ordinal);
|
||||
var recomputedKeys = recomputed.Select(EdgeKey).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = originalKeys.Except(recomputedKeys)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extra = recomputedKeys.Except(originalKeys)
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new EdgeDiffResult(missing, extra);
|
||||
}
|
||||
|
||||
private static string? CompareVerdicts(SliceVerdict original, SliceVerdict recomputed)
|
||||
{
|
||||
if (original.Status != recomputed.Status)
|
||||
{
|
||||
return $"Status: {original.Status} -> {recomputed.Status}";
|
||||
}
|
||||
|
||||
if (Math.Abs(original.Confidence - recomputed.Confidence) > 0.0001)
|
||||
{
|
||||
return $"Confidence: {original.Confidence:F4} -> {recomputed.Confidence:F4}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly record struct NodeDiffResult(ImmutableArray<string> MissingNodes, ImmutableArray<string> ExtraNodes);
|
||||
private readonly record struct EdgeDiffResult(ImmutableArray<string> MissingEdges, ImmutableArray<string> ExtraEdges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of slice comparison.
|
||||
/// </summary>
|
||||
public sealed record SliceDiffResult
|
||||
{
|
||||
public required bool Match { get; init; }
|
||||
public ImmutableArray<string> MissingNodes { get; init; } = ImmutableArray<string>.Empty;
|
||||
public ImmutableArray<string> ExtraNodes { get; init; } = ImmutableArray<string>.Empty;
|
||||
public ImmutableArray<string> MissingEdges { get; init; } = ImmutableArray<string>.Empty;
|
||||
public ImmutableArray<string> ExtraEdges { get; init; } = ImmutableArray<string>.Empty;
|
||||
public string? VerdictDiff { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable diff summary.
|
||||
/// </summary>
|
||||
public string ToSummary()
|
||||
{
|
||||
if (Match) return "Slices match exactly.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Slice diff:");
|
||||
|
||||
if (!MissingNodes.IsDefaultOrEmpty)
|
||||
{
|
||||
sb.AppendLine($" Missing nodes ({MissingNodes.Length}): {string.Join(", ", MissingNodes.Take(5))}{(MissingNodes.Length > 5 ? "..." : "")}");
|
||||
}
|
||||
|
||||
if (!ExtraNodes.IsDefaultOrEmpty)
|
||||
{
|
||||
sb.AppendLine($" Extra nodes ({ExtraNodes.Length}): {string.Join(", ", ExtraNodes.Take(5))}{(ExtraNodes.Length > 5 ? "..." : "")}");
|
||||
}
|
||||
|
||||
if (!MissingEdges.IsDefaultOrEmpty)
|
||||
{
|
||||
sb.AppendLine($" Missing edges ({MissingEdges.Length}): {string.Join(", ", MissingEdges.Take(5))}{(MissingEdges.Length > 5 ? "..." : "")}");
|
||||
}
|
||||
|
||||
if (!ExtraEdges.IsDefaultOrEmpty)
|
||||
{
|
||||
sb.AppendLine($" Extra edges ({ExtraEdges.Length}): {string.Join(", ", ExtraEdges.Take(5))}{(ExtraEdges.Length > 5 ? "..." : "")}");
|
||||
}
|
||||
|
||||
if (VerdictDiff != null)
|
||||
{
|
||||
sb.AppendLine($" Verdict changed: {VerdictDiff}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed class SliceDsseSigner
|
||||
{
|
||||
private readonly IDsseSigningService _signingService;
|
||||
private readonly ICryptoProfile _cryptoProfile;
|
||||
private readonly SliceHasher _hasher;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SliceDsseSigner(
|
||||
IDsseSigningService signingService,
|
||||
ICryptoProfile cryptoProfile,
|
||||
SliceHasher hasher,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_signingService = signingService ?? throw new ArgumentNullException(nameof(signingService));
|
||||
_cryptoProfile = cryptoProfile ?? throw new ArgumentNullException(nameof(cryptoProfile));
|
||||
_hasher = hasher ?? throw new ArgumentNullException(nameof(hasher));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<SignedSlice> SignAsync(ReachabilitySlice slice, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
|
||||
var normalized = slice.Normalize();
|
||||
var digestResult = _hasher.ComputeDigest(normalized);
|
||||
|
||||
var envelope = await _signingService.SignAsync(
|
||||
normalized,
|
||||
SliceSchema.DssePayloadType,
|
||||
_cryptoProfile,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SignedSlice(
|
||||
Slice: normalized,
|
||||
SliceDigest: digestResult.Digest,
|
||||
Envelope: envelope,
|
||||
SignedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SignedSlice(
|
||||
ReachabilitySlice Slice,
|
||||
string SliceDigest,
|
||||
DsseEnvelope Envelope,
|
||||
DateTimeOffset SignedAt);
|
||||
@@ -0,0 +1,568 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Core;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed class SliceExtractor
|
||||
{
|
||||
private readonly VerdictComputer _verdictComputer;
|
||||
|
||||
public SliceExtractor(VerdictComputer verdictComputer)
|
||||
{
|
||||
_verdictComputer = verdictComputer ?? throw new ArgumentNullException(nameof(verdictComputer));
|
||||
}
|
||||
|
||||
public ReachabilitySlice Extract(SliceExtractionRequest request, SliceVerdictOptions? verdictOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var graph = request.Graph;
|
||||
var query = request.Query;
|
||||
|
||||
var nodeLookup = graph.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var entrypoints = ResolveEntrypoints(query, graph, nodeLookup);
|
||||
var targets = ResolveTargets(query, graph);
|
||||
|
||||
if (entrypoints.Count == 0 || targets.Count == 0)
|
||||
{
|
||||
return BuildEmptySlice(request, entrypoints.Count == 0, targets.Count == 0);
|
||||
}
|
||||
|
||||
var forwardEdges = BuildEdgeLookup(graph.Edges);
|
||||
var reverseEdges = BuildReverseEdgeLookup(graph.Edges);
|
||||
|
||||
var reachableFromEntrypoints = Traverse(entrypoints, forwardEdges);
|
||||
var canReachTargets = Traverse(targets, reverseEdges);
|
||||
|
||||
var includedNodes = new HashSet<string>(reachableFromEntrypoints, StringComparer.Ordinal);
|
||||
includedNodes.IntersectWith(canReachTargets);
|
||||
foreach (var entry in entrypoints)
|
||||
{
|
||||
includedNodes.Add(entry);
|
||||
}
|
||||
foreach (var target in targets)
|
||||
{
|
||||
includedNodes.Add(target);
|
||||
}
|
||||
|
||||
var subgraphEdges = graph.Edges
|
||||
.Where(e => includedNodes.Contains(e.From) && includedNodes.Contains(e.To))
|
||||
.Where(e => reachableFromEntrypoints.Contains(e.From) && canReachTargets.Contains(e.To))
|
||||
.ToList();
|
||||
|
||||
var subgraphNodes = includedNodes
|
||||
.Where(nodeLookup.ContainsKey)
|
||||
.Select(id => nodeLookup[id])
|
||||
.ToList();
|
||||
|
||||
var nodes = subgraphNodes
|
||||
.Select(node => MapNode(node, entrypoints, targets))
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = subgraphEdges
|
||||
.Select(MapEdge)
|
||||
.ToImmutableArray();
|
||||
|
||||
var paths = BuildPathSummaries(entrypoints, targets, subgraphEdges, nodeLookup);
|
||||
var unknownEdges = edges.Count(e => e.Kind == SliceEdgeKind.Unknown || e.Confidence < 0.5);
|
||||
var verdict = _verdictComputer.Compute(paths, unknownEdges, verdictOptions);
|
||||
|
||||
return new ReachabilitySlice
|
||||
{
|
||||
Inputs = request.Inputs,
|
||||
Query = request.Query,
|
||||
Subgraph = new SliceSubgraph { Nodes = nodes, Edges = edges },
|
||||
Verdict = verdict,
|
||||
Manifest = request.Manifest
|
||||
}.Normalize();
|
||||
}
|
||||
|
||||
private static ReachabilitySlice BuildEmptySlice(SliceExtractionRequest request, bool missingEntrypoints, bool missingTargets)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
if (missingEntrypoints)
|
||||
{
|
||||
reasons.Add("missing_entrypoints");
|
||||
}
|
||||
if (missingTargets)
|
||||
{
|
||||
reasons.Add("missing_targets");
|
||||
}
|
||||
|
||||
return new ReachabilitySlice
|
||||
{
|
||||
Inputs = request.Inputs,
|
||||
Query = request.Query,
|
||||
Subgraph = new SliceSubgraph(),
|
||||
Verdict = new SliceVerdict
|
||||
{
|
||||
Status = SliceVerdictStatus.Unknown,
|
||||
Confidence = 0.0,
|
||||
Reasons = reasons.ToImmutableArray()
|
||||
},
|
||||
Manifest = request.Manifest
|
||||
}.Normalize();
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveEntrypoints(
|
||||
SliceQuery query,
|
||||
RichGraph graph,
|
||||
Dictionary<string, RichGraphNode> nodeLookup)
|
||||
{
|
||||
var entrypoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
var explicitEntrypoints = query.Entrypoints;
|
||||
|
||||
if (!explicitEntrypoints.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var entry in explicitEntrypoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = entry.Trim();
|
||||
if (nodeLookup.ContainsKey(trimmed))
|
||||
{
|
||||
entrypoints.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var root in graph.Roots ?? Array.Empty<RichGraphRoot>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = root.Id.Trim();
|
||||
if (nodeLookup.ContainsKey(trimmed))
|
||||
{
|
||||
entrypoints.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveTargets(SliceQuery query, RichGraph graph)
|
||||
{
|
||||
var targets = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (query.TargetSymbols.IsDefaultOrEmpty)
|
||||
{
|
||||
return targets;
|
||||
}
|
||||
|
||||
foreach (var target in query.TargetSymbols)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = target.Trim();
|
||||
if (IsPackageTarget(trimmed))
|
||||
{
|
||||
var packageTargets = graph.Nodes
|
||||
.Where(n => string.Equals(n.Purl, trimmed, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(IsPublicNode)
|
||||
.Select(n => n.Id);
|
||||
|
||||
foreach (var nodeId in packageTargets)
|
||||
{
|
||||
targets.Add(nodeId);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (string.Equals(node.Id, trimmed, StringComparison.Ordinal) ||
|
||||
string.Equals(node.SymbolId, trimmed, StringComparison.Ordinal))
|
||||
{
|
||||
targets.Add(node.Id);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.Display) &&
|
||||
string.Equals(node.Display, trimmed, StringComparison.Ordinal))
|
||||
{
|
||||
targets.Add(node.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static bool IsPackageTarget(string value)
|
||||
=> value.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsPublicNode(RichGraphNode node)
|
||||
{
|
||||
if (node.Attributes is not null &&
|
||||
node.Attributes.TryGetValue("visibility", out var visibility) &&
|
||||
!string.IsNullOrWhiteSpace(visibility))
|
||||
{
|
||||
return visibility.Equals("public", StringComparison.OrdinalIgnoreCase)
|
||||
|| visibility.Equals("exported", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(IReadOnlyList<RichGraphEdge> edges)
|
||||
{
|
||||
var lookup = new Dictionary<string, List<RichGraphEdge>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges ?? Array.Empty<RichGraphEdge>())
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.From, out var list))
|
||||
{
|
||||
list = new List<RichGraphEdge>();
|
||||
lookup[edge.From] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var list in lookup.Values)
|
||||
{
|
||||
list.Sort(CompareForward);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildReverseEdgeLookup(IReadOnlyList<RichGraphEdge> edges)
|
||||
{
|
||||
var lookup = new Dictionary<string, List<RichGraphEdge>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges ?? Array.Empty<RichGraphEdge>())
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.To, out var list))
|
||||
{
|
||||
list = new List<RichGraphEdge>();
|
||||
lookup[edge.To] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var list in lookup.Values)
|
||||
{
|
||||
list.Sort(CompareReverse);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static HashSet<string> Traverse(
|
||||
HashSet<string> seeds,
|
||||
Dictionary<string, List<RichGraphEdge>> edgeLookup)
|
||||
{
|
||||
var visited = new HashSet<string>(seeds, StringComparer.Ordinal);
|
||||
var queue = new Queue<string>(seeds);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!edgeLookup.TryGetValue(current, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var next = edge.From == current ? edge.To : edge.From;
|
||||
if (!visited.Add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static SliceNode MapNode(
|
||||
RichGraphNode node,
|
||||
HashSet<string> entrypoints,
|
||||
HashSet<string> targets)
|
||||
{
|
||||
var kind = SliceNodeKind.Intermediate;
|
||||
if (entrypoints.Contains(node.Id))
|
||||
{
|
||||
kind = SliceNodeKind.Entrypoint;
|
||||
}
|
||||
else if (targets.Contains(node.Id))
|
||||
{
|
||||
kind = SliceNodeKind.Target;
|
||||
}
|
||||
|
||||
return new SliceNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Symbol = node.Display ?? node.SymbolId ?? node.Id,
|
||||
Kind = kind,
|
||||
File = ExtractAttribute(node, "file") ?? ExtractAttribute(node, "source_file"),
|
||||
Line = ExtractIntAttribute(node, "line"),
|
||||
Purl = node.Purl,
|
||||
Attributes = node.Attributes
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceEdge MapEdge(RichGraphEdge edge)
|
||||
{
|
||||
return new SliceEdge
|
||||
{
|
||||
From = edge.From,
|
||||
To = edge.To,
|
||||
Kind = MapEdgeKind(edge.Kind),
|
||||
Confidence = edge.Confidence,
|
||||
Evidence = edge.Evidence?.FirstOrDefault(),
|
||||
Gate = MapGate(edge.Gates)
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceEdgeKind MapEdgeKind(string? kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
return SliceEdgeKind.Direct;
|
||||
}
|
||||
|
||||
var normalized = kind.Trim().ToLowerInvariant();
|
||||
if (normalized.Contains("plt", StringComparison.Ordinal))
|
||||
{
|
||||
return SliceEdgeKind.Plt;
|
||||
}
|
||||
|
||||
if (normalized.Contains("iat", StringComparison.Ordinal))
|
||||
{
|
||||
return SliceEdgeKind.Iat;
|
||||
}
|
||||
|
||||
return normalized switch
|
||||
{
|
||||
EdgeTypes.Dynamic => SliceEdgeKind.Dynamic,
|
||||
EdgeTypes.Dlopen => SliceEdgeKind.Dynamic,
|
||||
EdgeTypes.Loads => SliceEdgeKind.Dynamic,
|
||||
EdgeTypes.Call => SliceEdgeKind.Direct,
|
||||
EdgeTypes.Import => SliceEdgeKind.Direct,
|
||||
_ => SliceEdgeKind.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceGateInfo? MapGate(IReadOnlyList<DetectedGate>? gates)
|
||||
{
|
||||
if (gates is null || gates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var gate = gates
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
return new SliceGateInfo
|
||||
{
|
||||
Type = gate.Type switch
|
||||
{
|
||||
GateType.FeatureFlag => SliceGateType.FeatureFlag,
|
||||
GateType.AuthRequired => SliceGateType.Auth,
|
||||
GateType.NonDefaultConfig => SliceGateType.Config,
|
||||
GateType.AdminOnly => SliceGateType.AdminOnly,
|
||||
_ => SliceGateType.Config
|
||||
},
|
||||
Condition = gate.Detail,
|
||||
Satisfied = false
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<SlicePathSummary> BuildPathSummaries(
|
||||
HashSet<string> entrypoints,
|
||||
HashSet<string> targets,
|
||||
IReadOnlyList<RichGraphEdge> edges,
|
||||
Dictionary<string, RichGraphNode> nodeLookup)
|
||||
{
|
||||
var edgeLookup = BuildEdgeLookup(edges);
|
||||
var edgeMap = new Dictionary<(string From, string To), RichGraphEdge>();
|
||||
foreach (var edge in edges
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal))
|
||||
{
|
||||
var key = (edge.From, edge.To);
|
||||
if (!edgeMap.TryGetValue(key, out var existing) || edge.Confidence > existing.Confidence)
|
||||
{
|
||||
edgeMap[key] = edge;
|
||||
}
|
||||
}
|
||||
var results = new List<SlicePathSummary>();
|
||||
var pathIndex = 0;
|
||||
|
||||
foreach (var entry in entrypoints.OrderBy(e => e, StringComparer.Ordinal))
|
||||
{
|
||||
foreach (var target in targets.OrderBy(t => t, StringComparer.Ordinal))
|
||||
{
|
||||
var path = FindShortestPath(entry, target, edgeLookup);
|
||||
if (path is null || path.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var minConfidence = 1.0;
|
||||
var witnessParts = new List<string>();
|
||||
for (var i = 0; i < path.Count; i++)
|
||||
{
|
||||
if (nodeLookup.TryGetValue(path[i], out var node))
|
||||
{
|
||||
witnessParts.Add(node.Display ?? node.SymbolId ?? node.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
witnessParts.Add(path[i]);
|
||||
}
|
||||
|
||||
if (i == path.Count - 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (edgeMap.TryGetValue((path[i], path[i + 1]), out var edge))
|
||||
{
|
||||
minConfidence = Math.Min(minConfidence, edge.Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
var witness = string.Join(" -> ", witnessParts);
|
||||
results.Add(new SlicePathSummary(
|
||||
PathId: $"path:{entry}:{target}:{pathIndex++}",
|
||||
MinConfidence: minConfidence,
|
||||
PathWitness: witness));
|
||||
}
|
||||
}
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static List<string>? FindShortestPath(
|
||||
string start,
|
||||
string target,
|
||||
Dictionary<string, List<RichGraphEdge>> edgeLookup)
|
||||
{
|
||||
var queue = new Queue<string>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal) { start };
|
||||
var previous = new Dictionary<string, string?>(StringComparer.Ordinal) { [start] = null };
|
||||
|
||||
queue.Enqueue(start);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (string.Equals(current, target, StringComparison.Ordinal))
|
||||
{
|
||||
return BuildPath(target, previous);
|
||||
}
|
||||
|
||||
if (!edgeLookup.TryGetValue(current, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var next = edge.To;
|
||||
if (!visited.Add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
previous[next] = current;
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int CompareForward(RichGraphEdge left, RichGraphEdge right)
|
||||
{
|
||||
var result = string.Compare(left.To, right.To, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = string.Compare(left.Kind, right.Kind, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return left.Confidence.CompareTo(right.Confidence);
|
||||
}
|
||||
|
||||
private static int CompareReverse(RichGraphEdge left, RichGraphEdge right)
|
||||
{
|
||||
var result = string.Compare(left.From, right.From, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = string.Compare(left.Kind, right.Kind, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return left.Confidence.CompareTo(right.Confidence);
|
||||
}
|
||||
|
||||
private static List<string> BuildPath(string target, Dictionary<string, string?> previous)
|
||||
{
|
||||
var path = new List<string>();
|
||||
string? current = target;
|
||||
while (current is not null)
|
||||
{
|
||||
path.Add(current);
|
||||
current = previous[current];
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static string? ExtractAttribute(RichGraphNode node, string key)
|
||||
{
|
||||
if (node.Attributes is not null && node.Attributes.TryGetValue(key, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ExtractIntAttribute(RichGraphNode node, string key)
|
||||
{
|
||||
var value = ExtractAttribute(node, key);
|
||||
if (value is not null && int.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SliceExtractionRequest(
|
||||
RichGraph Graph,
|
||||
SliceInputs Inputs,
|
||||
SliceQuery Query,
|
||||
ScanManifest Manifest);
|
||||
@@ -0,0 +1,27 @@
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed class SliceHasher
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
|
||||
public SliceHasher(ICryptoHash cryptoHash)
|
||||
{
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
}
|
||||
|
||||
public SliceDigestResult ComputeDigest(ReachabilitySlice slice)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
|
||||
var normalized = slice.Normalize();
|
||||
var bytes = CanonicalJson.SerializeToUtf8Bytes(normalized);
|
||||
var digest = _cryptoHash.ComputePrefixedHashForPurpose(bytes, HashPurpose.Graph);
|
||||
|
||||
return new SliceDigestResult(digest, bytes);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SliceDigestResult(string Digest, byte[] CanonicalBytes);
|
||||
@@ -0,0 +1,392 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed record ReachabilitySlice
|
||||
{
|
||||
[JsonPropertyName("_type")]
|
||||
public string Type { get; init; } = SliceSchema.PredicateType;
|
||||
|
||||
[JsonPropertyName("inputs")]
|
||||
public required SliceInputs Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("query")]
|
||||
public required SliceQuery Query { get; init; }
|
||||
|
||||
[JsonPropertyName("subgraph")]
|
||||
public required SliceSubgraph Subgraph { get; init; }
|
||||
|
||||
[JsonPropertyName("verdict")]
|
||||
public required SliceVerdict Verdict { get; init; }
|
||||
|
||||
[JsonPropertyName("manifest")]
|
||||
public required ScanManifest Manifest { get; init; }
|
||||
|
||||
public ReachabilitySlice Normalize() => SliceNormalization.Normalize(this);
|
||||
}
|
||||
|
||||
public sealed record SliceInputs
|
||||
{
|
||||
[JsonPropertyName("graphDigest")]
|
||||
public required string GraphDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("binaryDigests")]
|
||||
public ImmutableArray<string> BinaryDigests { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("sbomDigest")]
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigests")]
|
||||
public ImmutableArray<string> LayerDigests { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record SliceQuery
|
||||
{
|
||||
[JsonPropertyName("cveId")]
|
||||
public string? CveId { get; init; }
|
||||
|
||||
[JsonPropertyName("targetSymbols")]
|
||||
public ImmutableArray<string> TargetSymbols { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("entrypoints")]
|
||||
public ImmutableArray<string> Entrypoints { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("policyHash")]
|
||||
public string? PolicyHash { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SliceSubgraph
|
||||
{
|
||||
[JsonPropertyName("nodes")]
|
||||
public ImmutableArray<SliceNode> Nodes { get; init; } = ImmutableArray<SliceNode>.Empty;
|
||||
|
||||
[JsonPropertyName("edges")]
|
||||
public ImmutableArray<SliceEdge> Edges { get; init; } = ImmutableArray<SliceEdge>.Empty;
|
||||
}
|
||||
|
||||
public enum SliceNodeKind
|
||||
{
|
||||
Entrypoint,
|
||||
Intermediate,
|
||||
Target,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public sealed record SliceNode
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
[JsonConverter(typeof(SnakeCaseStringEnumConverter))]
|
||||
public required SliceNodeKind Kind { get; init; }
|
||||
|
||||
[JsonPropertyName("file")]
|
||||
public string? File { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
public enum SliceEdgeKind
|
||||
{
|
||||
Direct,
|
||||
Plt,
|
||||
Iat,
|
||||
Dynamic,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public sealed record SliceEdge
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
[JsonConverter(typeof(SnakeCaseStringEnumConverter))]
|
||||
public SliceEdgeKind Kind { get; init; } = SliceEdgeKind.Direct;
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public string? Evidence { get; init; }
|
||||
|
||||
[JsonPropertyName("gate")]
|
||||
public SliceGateInfo? Gate { get; init; }
|
||||
|
||||
[JsonPropertyName("observed")]
|
||||
public ObservedEdgeMetadata? Observed { get; init; }
|
||||
}
|
||||
|
||||
public enum SliceGateType
|
||||
{
|
||||
FeatureFlag,
|
||||
Auth,
|
||||
Config,
|
||||
AdminOnly
|
||||
}
|
||||
|
||||
public sealed record SliceGateInfo
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
[JsonConverter(typeof(SnakeCaseStringEnumConverter))]
|
||||
public required SliceGateType Type { get; init; }
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public required string Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("satisfied")]
|
||||
public required bool Satisfied { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ObservedEdgeMetadata
|
||||
{
|
||||
[JsonPropertyName("firstObserved")]
|
||||
public required DateTimeOffset FirstObserved { get; init; }
|
||||
|
||||
[JsonPropertyName("lastObserved")]
|
||||
public required DateTimeOffset LastObserved { get; init; }
|
||||
|
||||
[JsonPropertyName("count")]
|
||||
public required int ObservationCount { get; init; }
|
||||
|
||||
[JsonPropertyName("traceDigest")]
|
||||
public string? TraceDigest { get; init; }
|
||||
}
|
||||
|
||||
public enum SliceVerdictStatus
|
||||
{
|
||||
Reachable,
|
||||
Unreachable,
|
||||
Unknown,
|
||||
Gated,
|
||||
ObservedReachable
|
||||
}
|
||||
|
||||
public sealed record GatedPath
|
||||
{
|
||||
[JsonPropertyName("pathId")]
|
||||
public required string PathId { get; init; }
|
||||
|
||||
[JsonPropertyName("gateType")]
|
||||
public required string GateType { get; init; }
|
||||
|
||||
[JsonPropertyName("gateCondition")]
|
||||
public required string GateCondition { get; init; }
|
||||
|
||||
[JsonPropertyName("gateSatisfied")]
|
||||
public required bool GateSatisfied { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SliceVerdict
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
[JsonConverter(typeof(SnakeCaseStringEnumConverter))]
|
||||
public required SliceVerdictStatus Status { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("reasons")]
|
||||
public ImmutableArray<string> Reasons { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("pathWitnesses")]
|
||||
public ImmutableArray<string> PathWitnesses { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("unknownCount")]
|
||||
public int UnknownCount { get; init; }
|
||||
|
||||
[JsonPropertyName("gatedPaths")]
|
||||
public ImmutableArray<GatedPath> GatedPaths { get; init; } = ImmutableArray<GatedPath>.Empty;
|
||||
}
|
||||
|
||||
internal static class SliceNormalization
|
||||
{
|
||||
public static ReachabilitySlice Normalize(ReachabilitySlice slice)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(slice);
|
||||
|
||||
return slice with
|
||||
{
|
||||
Type = string.IsNullOrWhiteSpace(slice.Type) ? SliceSchema.PredicateType : slice.Type.Trim(),
|
||||
Inputs = Normalize(slice.Inputs),
|
||||
Query = Normalize(slice.Query),
|
||||
Subgraph = Normalize(slice.Subgraph),
|
||||
Verdict = Normalize(slice.Verdict),
|
||||
Manifest = slice.Manifest
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceInputs Normalize(SliceInputs inputs)
|
||||
{
|
||||
return inputs with
|
||||
{
|
||||
GraphDigest = inputs.GraphDigest.Trim(),
|
||||
BinaryDigests = NormalizeStrings(inputs.BinaryDigests),
|
||||
SbomDigest = string.IsNullOrWhiteSpace(inputs.SbomDigest) ? null : inputs.SbomDigest.Trim(),
|
||||
LayerDigests = NormalizeStrings(inputs.LayerDigests)
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceQuery Normalize(SliceQuery query)
|
||||
{
|
||||
return query with
|
||||
{
|
||||
CveId = string.IsNullOrWhiteSpace(query.CveId) ? null : query.CveId.Trim(),
|
||||
TargetSymbols = NormalizeStrings(query.TargetSymbols),
|
||||
Entrypoints = NormalizeStrings(query.Entrypoints),
|
||||
PolicyHash = string.IsNullOrWhiteSpace(query.PolicyHash) ? null : query.PolicyHash.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceSubgraph Normalize(SliceSubgraph subgraph)
|
||||
{
|
||||
var nodes = subgraph.Nodes
|
||||
.Where(n => n is not null)
|
||||
.Select(Normalize)
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = subgraph.Edges
|
||||
.Where(e => e is not null)
|
||||
.Select(Normalize)
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind.ToString(), StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return subgraph with { Nodes = nodes, Edges = edges };
|
||||
}
|
||||
|
||||
private static SliceNode Normalize(SliceNode node)
|
||||
{
|
||||
return node with
|
||||
{
|
||||
Id = node.Id.Trim(),
|
||||
Symbol = node.Symbol.Trim(),
|
||||
File = string.IsNullOrWhiteSpace(node.File) ? null : node.File.Trim(),
|
||||
Purl = string.IsNullOrWhiteSpace(node.Purl) ? null : node.Purl.Trim(),
|
||||
Attributes = NormalizeAttributes(node.Attributes)
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceEdge Normalize(SliceEdge edge)
|
||||
{
|
||||
return edge with
|
||||
{
|
||||
From = edge.From.Trim(),
|
||||
To = edge.To.Trim(),
|
||||
Confidence = Math.Clamp(edge.Confidence, 0.0, 1.0),
|
||||
Evidence = string.IsNullOrWhiteSpace(edge.Evidence) ? null : edge.Evidence.Trim(),
|
||||
Gate = Normalize(edge.Gate),
|
||||
Observed = Normalize(edge.Observed)
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceGateInfo? Normalize(SliceGateInfo? gate)
|
||||
{
|
||||
if (gate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return gate with
|
||||
{
|
||||
Condition = gate.Condition.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static ObservedEdgeMetadata? Normalize(ObservedEdgeMetadata? observed)
|
||||
{
|
||||
if (observed is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return observed with
|
||||
{
|
||||
FirstObserved = observed.FirstObserved.ToUniversalTime(),
|
||||
LastObserved = observed.LastObserved.ToUniversalTime(),
|
||||
ObservationCount = Math.Max(0, observed.ObservationCount),
|
||||
TraceDigest = string.IsNullOrWhiteSpace(observed.TraceDigest) ? null : observed.TraceDigest.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static SliceVerdict Normalize(SliceVerdict verdict)
|
||||
{
|
||||
return verdict with
|
||||
{
|
||||
Confidence = Math.Clamp(verdict.Confidence, 0.0, 1.0),
|
||||
Reasons = NormalizeStrings(verdict.Reasons),
|
||||
PathWitnesses = NormalizeStrings(verdict.PathWitnesses),
|
||||
UnknownCount = Math.Max(0, verdict.UnknownCount),
|
||||
GatedPaths = verdict.GatedPaths
|
||||
.Select(Normalize)
|
||||
.OrderBy(p => p.PathId, StringComparer.Ordinal)
|
||||
.ToImmutableArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static GatedPath Normalize(GatedPath path)
|
||||
{
|
||||
return path with
|
||||
{
|
||||
PathId = path.PathId.Trim(),
|
||||
GateType = path.GateType.Trim(),
|
||||
GateCondition = path.GateCondition.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeStrings(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? NormalizeAttributes(IReadOnlyDictionary<string, string>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return attributes
|
||||
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
|
||||
.ToImmutableSortedDictionary(
|
||||
kv => kv.Key.Trim(),
|
||||
kv => kv.Value.Trim(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SnakeCaseStringEnumConverter : JsonStringEnumConverter
|
||||
{
|
||||
public SnakeCaseStringEnumConverter() : base(JsonNamingPolicy.SnakeCaseLower)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for the reachability slice schema.
|
||||
/// </summary>
|
||||
public static class SliceSchema
|
||||
{
|
||||
public const string PredicateType = "stellaops.dev/predicates/reachability-slice@v1";
|
||||
public const string JsonSchemaUri = "https://stellaops.dev/schemas/stellaops-slice.v1.schema.json";
|
||||
public const string DssePayloadType = "application/vnd.stellaops.slice.v1+json";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Slices;
|
||||
|
||||
public sealed class VerdictComputer
|
||||
{
|
||||
public SliceVerdict Compute(
|
||||
IReadOnlyList<SlicePathSummary> paths,
|
||||
int unknownEdgeCount,
|
||||
SliceVerdictOptions? options = null)
|
||||
{
|
||||
options ??= new SliceVerdictOptions();
|
||||
var hasPath = paths.Count > 0;
|
||||
var minConfidence = hasPath ? paths.Min(p => p.MinConfidence) : 0.0;
|
||||
var unknowns = Math.Max(0, unknownEdgeCount);
|
||||
|
||||
SliceVerdictStatus status;
|
||||
if (hasPath && minConfidence > options.ReachableThreshold && unknowns == 0)
|
||||
{
|
||||
status = SliceVerdictStatus.Reachable;
|
||||
}
|
||||
else if (!hasPath && unknowns == 0)
|
||||
{
|
||||
status = SliceVerdictStatus.Unreachable;
|
||||
}
|
||||
else
|
||||
{
|
||||
status = SliceVerdictStatus.Unknown;
|
||||
}
|
||||
|
||||
var confidence = status switch
|
||||
{
|
||||
SliceVerdictStatus.Reachable => minConfidence,
|
||||
SliceVerdictStatus.Unreachable => options.UnreachableConfidence,
|
||||
_ => hasPath ? Math.Min(minConfidence, options.UnknownConfidence) : options.UnknownConfidence
|
||||
};
|
||||
|
||||
var reasons = BuildReasons(status, hasPath, unknowns, minConfidence, options);
|
||||
var witnesses = paths
|
||||
.Select(p => p.PathWitness)
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new SliceVerdict
|
||||
{
|
||||
Status = status,
|
||||
Confidence = confidence,
|
||||
Reasons = reasons,
|
||||
PathWitnesses = witnesses,
|
||||
UnknownCount = unknowns
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildReasons(
|
||||
SliceVerdictStatus status,
|
||||
bool hasPath,
|
||||
int unknowns,
|
||||
double minConfidence,
|
||||
SliceVerdictOptions options)
|
||||
{
|
||||
var reasons = new List<string>();
|
||||
switch (status)
|
||||
{
|
||||
case SliceVerdictStatus.Reachable:
|
||||
reasons.Add("path_exists_high_confidence");
|
||||
break;
|
||||
case SliceVerdictStatus.Unreachable:
|
||||
reasons.Add("no_paths_found");
|
||||
break;
|
||||
default:
|
||||
if (!hasPath)
|
||||
{
|
||||
reasons.Add("no_paths_found_with_unknowns");
|
||||
}
|
||||
else if (minConfidence < options.UnknownThreshold)
|
||||
{
|
||||
reasons.Add("low_confidence_path");
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add("unknown_edges_present");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (unknowns > 0)
|
||||
{
|
||||
reasons.Add($"unknown_edges:{unknowns}");
|
||||
}
|
||||
|
||||
return reasons.OrderBy(r => r, StringComparer.Ordinal).ToImmutableArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SliceVerdictOptions
|
||||
{
|
||||
public double ReachableThreshold { get; init; } = 0.7;
|
||||
public double UnknownThreshold { get; init; } = 0.5;
|
||||
public double UnreachableConfidence { get; init; } = 0.9;
|
||||
public double UnknownConfidence { get; init; } = 0.4;
|
||||
}
|
||||
|
||||
public sealed record SlicePathSummary(
|
||||
string PathId,
|
||||
double MinConfidence,
|
||||
string? PathWitness);
|
||||
@@ -10,6 +10,7 @@
|
||||
<PackageReference Include="Npgsql" Version="9.0.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Core\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
|
||||
@@ -17,6 +18,7 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Scanner.Analyzers.Native\StellaOps.Scanner.Analyzers.Native.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Subgraph;
|
||||
|
||||
public sealed record ReachabilitySubgraphRequest(
|
||||
RichGraph Graph,
|
||||
ImmutableArray<string> FindingKeys,
|
||||
ImmutableArray<string> TargetSymbols,
|
||||
ImmutableArray<string> Entrypoints,
|
||||
string? AnalyzerName = null,
|
||||
string? AnalyzerVersion = null,
|
||||
double Confidence = 0.9,
|
||||
string Completeness = "partial");
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a focused subgraph from the full reachability graph.
|
||||
/// </summary>
|
||||
public sealed class ReachabilitySubgraphExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ReachabilitySubgraphExtractor(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public ReachabilitySubgraph Extract(ReachabilitySubgraphRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Graph);
|
||||
|
||||
var graph = request.Graph;
|
||||
var nodeLookup = graph.Nodes.ToDictionary(n => n.Id, StringComparer.Ordinal);
|
||||
var entrypoints = ResolveEntrypoints(request, graph, nodeLookup);
|
||||
var targets = ResolveTargets(request, graph, nodeLookup);
|
||||
|
||||
if (entrypoints.Count == 0 || targets.Count == 0)
|
||||
{
|
||||
return BuildEmptySubgraph(request).Normalize();
|
||||
}
|
||||
|
||||
var forwardEdges = BuildEdgeLookup(graph.Edges);
|
||||
var reverseEdges = BuildReverseEdgeLookup(graph.Edges);
|
||||
|
||||
var reachableFromEntrypoints = Traverse(entrypoints, forwardEdges);
|
||||
var canReachTargets = Traverse(targets, reverseEdges);
|
||||
|
||||
var includedNodes = new HashSet<string>(reachableFromEntrypoints, StringComparer.Ordinal);
|
||||
includedNodes.IntersectWith(canReachTargets);
|
||||
foreach (var entry in entrypoints)
|
||||
{
|
||||
includedNodes.Add(entry);
|
||||
}
|
||||
foreach (var target in targets)
|
||||
{
|
||||
includedNodes.Add(target);
|
||||
}
|
||||
|
||||
var subgraphEdges = graph.Edges
|
||||
.Where(e => includedNodes.Contains(e.From) && includedNodes.Contains(e.To))
|
||||
.Where(e => reachableFromEntrypoints.Contains(e.From) && canReachTargets.Contains(e.To))
|
||||
.ToList();
|
||||
|
||||
var subgraphNodes = includedNodes
|
||||
.Where(nodeLookup.ContainsKey)
|
||||
.Select(id => nodeLookup[id])
|
||||
.ToList();
|
||||
|
||||
var nodes = subgraphNodes
|
||||
.Select(node => MapNode(node, entrypoints, targets))
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = subgraphEdges
|
||||
.Select(MapEdge)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new ReachabilitySubgraph
|
||||
{
|
||||
FindingKeys = request.FindingKeys,
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
AnalysisMetadata = BuildMetadata(request, graph)
|
||||
}.Normalize();
|
||||
}
|
||||
|
||||
private ReachabilitySubgraph BuildEmptySubgraph(ReachabilitySubgraphRequest request)
|
||||
{
|
||||
return new ReachabilitySubgraph
|
||||
{
|
||||
FindingKeys = request.FindingKeys,
|
||||
Nodes = [],
|
||||
Edges = [],
|
||||
AnalysisMetadata = BuildMetadata(request, request.Graph)
|
||||
};
|
||||
}
|
||||
|
||||
private ReachabilitySubgraphMetadata BuildMetadata(ReachabilitySubgraphRequest request, RichGraph graph)
|
||||
{
|
||||
var analyzerName = request.AnalyzerName ?? graph.Analyzer.Name;
|
||||
var analyzerVersion = request.AnalyzerVersion ?? graph.Analyzer.Version;
|
||||
return new ReachabilitySubgraphMetadata
|
||||
{
|
||||
Analyzer = string.IsNullOrWhiteSpace(analyzerName) ? "reachability" : analyzerName,
|
||||
AnalyzerVersion = string.IsNullOrWhiteSpace(analyzerVersion) ? "unknown" : analyzerVersion,
|
||||
Confidence = Math.Clamp(request.Confidence, 0.0, 1.0),
|
||||
Completeness = string.IsNullOrWhiteSpace(request.Completeness) ? "partial" : request.Completeness,
|
||||
GeneratedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveEntrypoints(
|
||||
ReachabilitySubgraphRequest request,
|
||||
RichGraph graph,
|
||||
Dictionary<string, RichGraphNode> nodeLookup)
|
||||
{
|
||||
var entrypoints = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (!request.Entrypoints.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var entry in request.Entrypoints)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = entry.Trim();
|
||||
if (nodeLookup.ContainsKey(trimmed))
|
||||
{
|
||||
entrypoints.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var root in graph.Roots ?? Array.Empty<RichGraphRoot>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = root.Id.Trim();
|
||||
if (nodeLookup.ContainsKey(trimmed))
|
||||
{
|
||||
entrypoints.Add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entrypoints;
|
||||
}
|
||||
|
||||
private static HashSet<string> ResolveTargets(
|
||||
ReachabilitySubgraphRequest request,
|
||||
RichGraph graph,
|
||||
Dictionary<string, RichGraphNode> nodeLookup)
|
||||
{
|
||||
var targets = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
if (request.TargetSymbols.IsDefaultOrEmpty)
|
||||
{
|
||||
return targets;
|
||||
}
|
||||
|
||||
foreach (var target in request.TargetSymbols)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = target.Trim();
|
||||
if (IsPackageTarget(trimmed))
|
||||
{
|
||||
foreach (var node in graph.Nodes.Where(n => string.Equals(n.Purl, trimmed, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(node.Id))
|
||||
{
|
||||
targets.Add(node.Id);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
if (string.Equals(node.Id, trimmed, StringComparison.Ordinal) ||
|
||||
string.Equals(node.SymbolId, trimmed, StringComparison.Ordinal))
|
||||
{
|
||||
targets.Add(node.Id);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(node.Display) &&
|
||||
string.Equals(node.Display, trimmed, StringComparison.Ordinal))
|
||||
{
|
||||
targets.Add(node.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static bool IsPackageTarget(string value)
|
||||
=> value.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildEdgeLookup(IReadOnlyList<RichGraphEdge> edges)
|
||||
{
|
||||
var lookup = new Dictionary<string, List<RichGraphEdge>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges ?? Array.Empty<RichGraphEdge>())
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.From, out var list))
|
||||
{
|
||||
list = new List<RichGraphEdge>();
|
||||
lookup[edge.From] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var list in lookup.Values)
|
||||
{
|
||||
list.Sort(CompareForward);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static Dictionary<string, List<RichGraphEdge>> BuildReverseEdgeLookup(IReadOnlyList<RichGraphEdge> edges)
|
||||
{
|
||||
var lookup = new Dictionary<string, List<RichGraphEdge>>(StringComparer.Ordinal);
|
||||
foreach (var edge in edges ?? Array.Empty<RichGraphEdge>())
|
||||
{
|
||||
if (!lookup.TryGetValue(edge.To, out var list))
|
||||
{
|
||||
list = new List<RichGraphEdge>();
|
||||
lookup[edge.To] = list;
|
||||
}
|
||||
|
||||
list.Add(edge);
|
||||
}
|
||||
|
||||
foreach (var list in lookup.Values)
|
||||
{
|
||||
list.Sort(CompareReverse);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static HashSet<string> Traverse(
|
||||
HashSet<string> seeds,
|
||||
Dictionary<string, List<RichGraphEdge>> edgeLookup)
|
||||
{
|
||||
var visited = new HashSet<string>(seeds, StringComparer.Ordinal);
|
||||
var queue = new Queue<string>(seeds);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!edgeLookup.TryGetValue(current, out var edges))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
var next = edge.From == current ? edge.To : edge.From;
|
||||
if (!visited.Add(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphNode MapNode(
|
||||
RichGraphNode node,
|
||||
HashSet<string> entrypoints,
|
||||
HashSet<string> targets)
|
||||
{
|
||||
var type = ReachabilitySubgraphNodeType.Call;
|
||||
if (entrypoints.Contains(node.Id))
|
||||
{
|
||||
type = ReachabilitySubgraphNodeType.Entrypoint;
|
||||
}
|
||||
else if (targets.Contains(node.Id))
|
||||
{
|
||||
type = ReachabilitySubgraphNodeType.Vulnerable;
|
||||
}
|
||||
|
||||
return new ReachabilitySubgraphNode
|
||||
{
|
||||
Id = node.Id,
|
||||
Symbol = node.Display ?? node.SymbolId ?? node.Id,
|
||||
Type = type,
|
||||
File = ExtractAttribute(node, "file") ?? ExtractAttribute(node, "source_file"),
|
||||
Line = ExtractIntAttribute(node, "line"),
|
||||
Purl = node.Purl,
|
||||
Attributes = node.Attributes
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphEdge MapEdge(RichGraphEdge edge)
|
||||
{
|
||||
return new ReachabilitySubgraphEdge
|
||||
{
|
||||
From = edge.From,
|
||||
To = edge.To,
|
||||
Type = string.IsNullOrWhiteSpace(edge.Kind) ? "call" : edge.Kind,
|
||||
Confidence = edge.Confidence,
|
||||
Evidence = edge.Evidence?.FirstOrDefault(),
|
||||
Gate = MapGate(edge.Gates)
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphGate? MapGate(IReadOnlyList<DetectedGate>? gates)
|
||||
{
|
||||
if (gates is null || gates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var gate = gates
|
||||
.OrderByDescending(g => g.Confidence)
|
||||
.ThenBy(g => g.Detail, StringComparer.Ordinal)
|
||||
.First();
|
||||
|
||||
return new ReachabilitySubgraphGate
|
||||
{
|
||||
GateType = ReachabilityGateMappings.ToGateTypeString(gate.Type),
|
||||
Condition = gate.Detail,
|
||||
GuardSymbol = gate.GuardSymbol,
|
||||
Confidence = gate.Confidence,
|
||||
SourceFile = gate.SourceFile,
|
||||
Line = gate.LineNumber,
|
||||
DetectionMethod = gate.DetectionMethod
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareForward(RichGraphEdge left, RichGraphEdge right)
|
||||
{
|
||||
var result = string.Compare(left.To, right.To, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = string.Compare(left.Kind, right.Kind, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return left.Confidence.CompareTo(right.Confidence);
|
||||
}
|
||||
|
||||
private static int CompareReverse(RichGraphEdge left, RichGraphEdge right)
|
||||
{
|
||||
var result = string.Compare(left.From, right.From, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
result = string.Compare(left.Kind, right.Kind, StringComparison.Ordinal);
|
||||
if (result != 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return left.Confidence.CompareTo(right.Confidence);
|
||||
}
|
||||
|
||||
private static string? ExtractAttribute(RichGraphNode node, string key)
|
||||
{
|
||||
if (node.Attributes is not null && node.Attributes.TryGetValue(key, out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int? ExtractIntAttribute(RichGraphNode node, string key)
|
||||
{
|
||||
var value = ExtractAttribute(node, key);
|
||||
if (value is not null && int.TryParse(value, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability.Gates;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability.Subgraph;
|
||||
|
||||
/// <summary>
|
||||
/// Portable reachability subgraph representation.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraph
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; init; } = "1.0";
|
||||
|
||||
[JsonPropertyName("findingKeys")]
|
||||
public ImmutableArray<string> FindingKeys { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public ImmutableArray<ReachabilitySubgraphNode> Nodes { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("edges")]
|
||||
public ImmutableArray<ReachabilitySubgraphEdge> Edges { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("analysisMetadata")]
|
||||
public ReachabilitySubgraphMetadata? AnalysisMetadata { get; init; }
|
||||
|
||||
public ReachabilitySubgraph Normalize() => ReachabilitySubgraphNormalizer.Normalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subgraph node.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphNode
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public required string Id { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ReachabilitySubgraphNodeType>))]
|
||||
public required ReachabilitySubgraphNodeType Type { 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("purl")]
|
||||
public string? Purl { get; init; }
|
||||
|
||||
[JsonPropertyName("attributes")]
|
||||
public IReadOnlyDictionary<string, string>? Attributes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subgraph node type.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ReachabilitySubgraphNodeType>))]
|
||||
public enum ReachabilitySubgraphNodeType
|
||||
{
|
||||
[JsonStringEnumMemberName("entrypoint")]
|
||||
Entrypoint,
|
||||
|
||||
[JsonStringEnumMemberName("call")]
|
||||
Call,
|
||||
|
||||
[JsonStringEnumMemberName("vulnerable")]
|
||||
Vulnerable,
|
||||
|
||||
[JsonStringEnumMemberName("unknown")]
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subgraph edge.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphEdge
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public required string From { get; init; }
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public required string To { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence")]
|
||||
public string? Evidence { get; init; }
|
||||
|
||||
[JsonPropertyName("gate")]
|
||||
public ReachabilitySubgraphGate? Gate { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate metadata associated with a subgraph edge.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphGate
|
||||
{
|
||||
[JsonPropertyName("gateType")]
|
||||
public required string GateType { get; init; }
|
||||
|
||||
[JsonPropertyName("condition")]
|
||||
public required string Condition { get; init; }
|
||||
|
||||
[JsonPropertyName("guardSymbol")]
|
||||
public required string GuardSymbol { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("sourceFile")]
|
||||
public string? SourceFile { get; init; }
|
||||
|
||||
[JsonPropertyName("line")]
|
||||
public int? Line { get; init; }
|
||||
|
||||
[JsonPropertyName("detectionMethod")]
|
||||
public string? DetectionMethod { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata about the subgraph extraction.
|
||||
/// </summary>
|
||||
public sealed record ReachabilitySubgraphMetadata
|
||||
{
|
||||
[JsonPropertyName("analyzer")]
|
||||
public required string Analyzer { get; init; }
|
||||
|
||||
[JsonPropertyName("analyzerVersion")]
|
||||
public required string AnalyzerVersion { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("completeness")]
|
||||
public required string Completeness { get; init; }
|
||||
|
||||
[JsonPropertyName("generatedAt")]
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
}
|
||||
|
||||
internal static class ReachabilitySubgraphNormalizer
|
||||
{
|
||||
public static ReachabilitySubgraph Normalize(ReachabilitySubgraph subgraph)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(subgraph);
|
||||
|
||||
var nodes = subgraph.Nodes
|
||||
.Where(n => n is not null)
|
||||
.Select(Normalize)
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = subgraph.Edges
|
||||
.Where(e => e is not null)
|
||||
.Select(Normalize)
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Type, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var findingKeys = subgraph.FindingKeys
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k))
|
||||
.Select(k => k.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(k => k, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return subgraph with
|
||||
{
|
||||
Version = string.IsNullOrWhiteSpace(subgraph.Version) ? "1.0" : subgraph.Version.Trim(),
|
||||
FindingKeys = findingKeys,
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
AnalysisMetadata = Normalize(subgraph.AnalysisMetadata)
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphNode Normalize(ReachabilitySubgraphNode node)
|
||||
{
|
||||
return node with
|
||||
{
|
||||
Id = node.Id.Trim(),
|
||||
Symbol = node.Symbol.Trim(),
|
||||
File = string.IsNullOrWhiteSpace(node.File) ? null : node.File.Trim(),
|
||||
Purl = string.IsNullOrWhiteSpace(node.Purl) ? null : node.Purl.Trim(),
|
||||
Attributes = NormalizeAttributes(node.Attributes)
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphEdge Normalize(ReachabilitySubgraphEdge edge)
|
||||
{
|
||||
return edge with
|
||||
{
|
||||
From = edge.From.Trim(),
|
||||
To = edge.To.Trim(),
|
||||
Type = string.IsNullOrWhiteSpace(edge.Type) ? "call" : edge.Type.Trim(),
|
||||
Confidence = Math.Clamp(edge.Confidence, 0.0, 1.0),
|
||||
Evidence = string.IsNullOrWhiteSpace(edge.Evidence) ? null : edge.Evidence.Trim(),
|
||||
Gate = Normalize(edge.Gate)
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphGate? Normalize(ReachabilitySubgraphGate? gate)
|
||||
{
|
||||
if (gate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return gate with
|
||||
{
|
||||
GateType = gate.GateType.Trim(),
|
||||
Condition = gate.Condition.Trim(),
|
||||
GuardSymbol = gate.GuardSymbol.Trim(),
|
||||
DetectionMethod = string.IsNullOrWhiteSpace(gate.DetectionMethod) ? null : gate.DetectionMethod.Trim(),
|
||||
SourceFile = string.IsNullOrWhiteSpace(gate.SourceFile) ? null : gate.SourceFile.Trim(),
|
||||
Confidence = Math.Clamp(gate.Confidence, 0.0, 1.0)
|
||||
};
|
||||
}
|
||||
|
||||
private static ReachabilitySubgraphMetadata? Normalize(ReachabilitySubgraphMetadata? metadata)
|
||||
{
|
||||
if (metadata is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata with
|
||||
{
|
||||
Analyzer = metadata.Analyzer.Trim(),
|
||||
AnalyzerVersion = metadata.AnalyzerVersion.Trim(),
|
||||
Completeness = metadata.Completeness.Trim(),
|
||||
Confidence = Math.Clamp(metadata.Confidence, 0.0, 1.0),
|
||||
GeneratedAt = metadata.GeneratedAt.ToUniversalTime()
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? NormalizeAttributes(IReadOnlyDictionary<string, string>? attributes)
|
||||
{
|
||||
if (attributes is null || attributes.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return attributes
|
||||
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
|
||||
.ToImmutableSortedDictionary(
|
||||
kv => kv.Key.Trim(),
|
||||
kv => kv.Value.Trim(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ReachabilityGateMappings
|
||||
{
|
||||
public static string ToGateTypeString(GateType type) => type switch
|
||||
{
|
||||
GateType.AuthRequired => "auth",
|
||||
GateType.FeatureFlag => "feature_flag",
|
||||
GateType.AdminOnly => "admin_only",
|
||||
GateType.NonDefaultConfig => "non_default_config",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user