Refactor code structure for improved readability and maintainability; optimize performance in key functions.

This commit is contained in:
master
2025-12-22 19:06:31 +02:00
parent dfaa2079aa
commit 4602ccc3a3
1444 changed files with 109919 additions and 8058 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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