Add Canonical JSON serialization library with tests and documentation

- Implemented CanonJson class for deterministic JSON serialization and hashing.
- Added unit tests for CanonJson functionality, covering various scenarios including key sorting, handling of nested objects, arrays, and special characters.
- Created project files for the Canonical JSON library and its tests, including necessary package references.
- Added README.md for library usage and API reference.
- Introduced RabbitMqIntegrationFactAttribute for conditional RabbitMQ integration tests.
This commit is contained in:
master
2025-12-19 15:35:00 +02:00
parent 43882078a4
commit 951a38d561
192 changed files with 27550 additions and 2611 deletions

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// GraphDeltaComputer.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-006)
// Description: Implementation of graph delta computation.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Computes deltas between call graph versions for incremental reachability.
/// </summary>
public sealed class GraphDeltaComputer : IGraphDeltaComputer
{
private readonly IGraphSnapshotStore? _snapshotStore;
private readonly ILogger<GraphDeltaComputer> _logger;
public GraphDeltaComputer(
ILogger<GraphDeltaComputer> logger,
IGraphSnapshotStore? snapshotStore = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_snapshotStore = snapshotStore;
}
/// <inheritdoc />
public Task<GraphDelta> ComputeDeltaAsync(
IGraphSnapshot previousGraph,
IGraphSnapshot currentGraph,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(previousGraph);
ArgumentNullException.ThrowIfNull(currentGraph);
// If hashes match, no changes
if (previousGraph.Hash == currentGraph.Hash)
{
_logger.LogDebug("Graph hashes match, no delta");
return Task.FromResult(GraphDelta.Empty);
}
// Compute node deltas
var addedNodes = currentGraph.NodeKeys.Except(previousGraph.NodeKeys).ToHashSet();
var removedNodes = previousGraph.NodeKeys.Except(currentGraph.NodeKeys).ToHashSet();
// Compute edge deltas
var previousEdgeSet = previousGraph.Edges.ToHashSet();
var currentEdgeSet = currentGraph.Edges.ToHashSet();
var addedEdges = currentGraph.Edges.Where(e => !previousEdgeSet.Contains(e)).ToList();
var removedEdges = previousGraph.Edges.Where(e => !currentEdgeSet.Contains(e)).ToList();
// Compute affected method keys
var affected = new HashSet<string>();
affected.UnionWith(addedNodes);
affected.UnionWith(removedNodes);
foreach (var edge in addedEdges)
{
affected.Add(edge.CallerKey);
affected.Add(edge.CalleeKey);
}
foreach (var edge in removedEdges)
{
affected.Add(edge.CallerKey);
affected.Add(edge.CalleeKey);
}
var delta = new GraphDelta
{
AddedNodes = addedNodes,
RemovedNodes = removedNodes,
AddedEdges = addedEdges,
RemovedEdges = removedEdges,
AffectedMethodKeys = affected,
PreviousHash = previousGraph.Hash,
CurrentHash = currentGraph.Hash
};
_logger.LogInformation(
"Computed graph delta: +{AddedNodes} nodes, -{RemovedNodes} nodes, +{AddedEdges} edges, -{RemovedEdges} edges, {Affected} affected",
addedNodes.Count, removedNodes.Count, addedEdges.Count, removedEdges.Count, affected.Count);
return Task.FromResult(delta);
}
/// <inheritdoc />
public async Task<GraphDelta> ComputeDeltaFromHashesAsync(
string serviceId,
string previousHash,
string currentHash,
CancellationToken cancellationToken = default)
{
if (previousHash == currentHash)
{
return GraphDelta.Empty;
}
if (_snapshotStore is null)
{
// Without snapshot store, we must do full recompute
_logger.LogWarning(
"No snapshot store available, forcing full recompute for {ServiceId}",
serviceId);
return GraphDelta.FullRecompute(previousHash, currentHash);
}
// Try to load snapshots
var previousSnapshot = await _snapshotStore.GetSnapshotAsync(serviceId, previousHash, cancellationToken);
var currentSnapshot = await _snapshotStore.GetSnapshotAsync(serviceId, currentHash, cancellationToken);
if (previousSnapshot is null || currentSnapshot is null)
{
_logger.LogWarning(
"Could not load snapshots for delta computation, forcing full recompute");
return GraphDelta.FullRecompute(previousHash, currentHash);
}
return await ComputeDeltaAsync(previousSnapshot, currentSnapshot, cancellationToken);
}
}
/// <summary>
/// Store for graph snapshots used in delta computation.
/// </summary>
public interface IGraphSnapshotStore
{
/// <summary>
/// Gets a graph snapshot by service and hash.
/// </summary>
Task<IGraphSnapshot?> GetSnapshotAsync(
string serviceId,
string graphHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores a graph snapshot.
/// </summary>
Task StoreSnapshotAsync(
string serviceId,
IGraphSnapshot snapshot,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,136 @@
// -----------------------------------------------------------------------------
// IGraphDeltaComputer.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-005)
// Description: Interface for computing graph deltas between versions.
// -----------------------------------------------------------------------------
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Computes the difference between two call graphs.
/// Used to identify which (entry, sink) pairs need recomputation.
/// </summary>
public interface IGraphDeltaComputer
{
/// <summary>
/// Computes the delta between two call graphs.
/// </summary>
/// <param name="previousGraph">Previous graph state.</param>
/// <param name="currentGraph">Current graph state.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Delta result with added/removed nodes and edges.</returns>
Task<GraphDelta> ComputeDeltaAsync(
IGraphSnapshot previousGraph,
IGraphSnapshot currentGraph,
CancellationToken cancellationToken = default);
/// <summary>
/// Computes delta from graph hashes if snapshots aren't available.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="previousHash">Previous graph hash.</param>
/// <param name="currentHash">Current graph hash.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Delta result.</returns>
Task<GraphDelta> ComputeDeltaFromHashesAsync(
string serviceId,
string previousHash,
string currentHash,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Snapshot of a call graph for delta computation.
/// </summary>
public interface IGraphSnapshot
{
/// <summary>
/// Graph hash for identity.
/// </summary>
string Hash { get; }
/// <summary>
/// All node (method) keys in the graph.
/// </summary>
IReadOnlySet<string> NodeKeys { get; }
/// <summary>
/// All edges in the graph (caller -> callee).
/// </summary>
IReadOnlyList<GraphEdge> Edges { get; }
/// <summary>
/// Entry point method keys.
/// </summary>
IReadOnlySet<string> EntryPoints { get; }
}
/// <summary>
/// An edge in the call graph.
/// </summary>
public readonly record struct GraphEdge(string CallerKey, string CalleeKey);
/// <summary>
/// Result of computing graph delta.
/// </summary>
public sealed record GraphDelta
{
/// <summary>
/// Whether there are any changes.
/// </summary>
public bool HasChanges => AddedNodes.Count > 0 || RemovedNodes.Count > 0 ||
AddedEdges.Count > 0 || RemovedEdges.Count > 0;
/// <summary>
/// Nodes added in current graph (ΔV+).
/// </summary>
public IReadOnlySet<string> AddedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Nodes removed from previous graph (ΔV-).
/// </summary>
public IReadOnlySet<string> RemovedNodes { get; init; } = new HashSet<string>();
/// <summary>
/// Edges added in current graph (ΔE+).
/// </summary>
public IReadOnlyList<GraphEdge> AddedEdges { get; init; } = [];
/// <summary>
/// Edges removed from previous graph (ΔE-).
/// </summary>
public IReadOnlyList<GraphEdge> RemovedEdges { get; init; } = [];
/// <summary>
/// All affected method keys (union of added, removed, and edge endpoints).
/// </summary>
public IReadOnlySet<string> AffectedMethodKeys { get; init; } = new HashSet<string>();
/// <summary>
/// Previous graph hash.
/// </summary>
public string? PreviousHash { get; init; }
/// <summary>
/// Current graph hash.
/// </summary>
public string? CurrentHash { get; init; }
/// <summary>
/// Creates an empty delta (no changes).
/// </summary>
public static GraphDelta Empty => new();
/// <summary>
/// Creates a full recompute delta (graph hash mismatch, must recompute all).
/// </summary>
public static GraphDelta FullRecompute(string? previousHash, string currentHash) => new()
{
PreviousHash = previousHash,
CurrentHash = currentHash
};
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// IReachabilityCache.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-003)
// Description: Interface for reachability result caching.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Interface for caching reachability analysis results.
/// Enables incremental recomputation by caching (entry, sink) pairs.
/// </summary>
public interface IReachabilityCache
{
/// <summary>
/// Gets cached reachability results for a service.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="graphHash">Hash of the current call graph.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cached result if valid, null otherwise.</returns>
Task<CachedReachabilityResult?> GetAsync(
string serviceId,
string graphHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Stores reachability results in cache.
/// </summary>
/// <param name="entry">Cache entry to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task SetAsync(
ReachabilityCacheEntry entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets reachable set for a specific (entry, sink) pair.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="entryMethodKey">Entry point method key.</param>
/// <param name="sinkMethodKey">Sink method key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cached reachable result if available.</returns>
Task<ReachablePairResult?> GetReachablePairAsync(
string serviceId,
string entryMethodKey,
string sinkMethodKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates cache entries affected by graph changes.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="affectedMethodKeys">Method keys that changed.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of invalidated entries.</returns>
Task<int> InvalidateAsync(
string serviceId,
IEnumerable<string> affectedMethodKeys,
CancellationToken cancellationToken = default);
/// <summary>
/// Invalidates all cache entries for a service.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task InvalidateAllAsync(
string serviceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets cache statistics for a service.
/// </summary>
/// <param name="serviceId">Service identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Cache statistics.</returns>
Task<CacheStatistics> GetStatisticsAsync(
string serviceId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Cached reachability analysis result.
/// </summary>
public sealed record CachedReachabilityResult
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Graph hash when results were computed.
/// </summary>
public required string GraphHash { get; init; }
/// <summary>
/// When the cache was populated.
/// </summary>
public DateTimeOffset CachedAt { get; init; }
/// <summary>
/// Time-to-live remaining.
/// </summary>
public TimeSpan? TimeToLive { get; init; }
/// <summary>
/// Cached reachable pairs.
/// </summary>
public IReadOnlyList<ReachablePairResult> ReachablePairs { get; init; } = [];
/// <summary>
/// Total entry points analyzed.
/// </summary>
public int EntryPointCount { get; init; }
/// <summary>
/// Total sinks analyzed.
/// </summary>
public int SinkCount { get; init; }
}
/// <summary>
/// Result for a single (entry, sink) reachability pair.
/// </summary>
public sealed record ReachablePairResult
{
/// <summary>
/// Entry point method key.
/// </summary>
public required string EntryMethodKey { get; init; }
/// <summary>
/// Sink method key.
/// </summary>
public required string SinkMethodKey { get; init; }
/// <summary>
/// Whether the sink is reachable from the entry.
/// </summary>
public bool IsReachable { get; init; }
/// <summary>
/// Shortest path length if reachable.
/// </summary>
public int? PathLength { get; init; }
/// <summary>
/// Confidence score.
/// </summary>
public double Confidence { get; init; }
/// <summary>
/// When this pair was last computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
}
/// <summary>
/// Entry for storing in the reachability cache.
/// </summary>
public sealed record ReachabilityCacheEntry
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Graph hash for cache key.
/// </summary>
public required string GraphHash { get; init; }
/// <summary>
/// SBOM hash for versioning.
/// </summary>
public string? SbomHash { get; init; }
/// <summary>
/// Reachable pairs to cache.
/// </summary>
public required IReadOnlyList<ReachablePairResult> ReachablePairs { get; init; }
/// <summary>
/// Entry points analyzed.
/// </summary>
public int EntryPointCount { get; init; }
/// <summary>
/// Sinks analyzed.
/// </summary>
public int SinkCount { get; init; }
/// <summary>
/// Time-to-live for this cache entry.
/// </summary>
public TimeSpan? TimeToLive { get; init; }
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CacheStatistics
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Number of cached pairs.
/// </summary>
public int CachedPairCount { get; init; }
/// <summary>
/// Total cache hits.
/// </summary>
public long HitCount { get; init; }
/// <summary>
/// Total cache misses.
/// </summary>
public long MissCount { get; init; }
/// <summary>
/// Cache hit ratio.
/// </summary>
public double HitRatio => HitCount + MissCount > 0
? (double)HitCount / (HitCount + MissCount)
: 0.0;
/// <summary>
/// Last cache population time.
/// </summary>
public DateTimeOffset? LastPopulatedAt { get; init; }
/// <summary>
/// Last invalidation time.
/// </summary>
public DateTimeOffset? LastInvalidatedAt { get; init; }
/// <summary>
/// Current graph hash.
/// </summary>
public string? CurrentGraphHash { get; init; }
}

View File

@@ -0,0 +1,201 @@
// -----------------------------------------------------------------------------
// ImpactSetCalculator.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-007)
// Description: Calculates which entry/sink pairs are affected by graph changes.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Calculates the impact set: which (entry, sink) pairs need recomputation
/// based on graph delta.
/// </summary>
public interface IImpactSetCalculator
{
/// <summary>
/// Calculates which entry/sink pairs are affected by graph changes.
/// </summary>
/// <param name="delta">Graph delta.</param>
/// <param name="graph">Current graph snapshot.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Impact set with affected pairs.</returns>
Task<ImpactSet> CalculateImpactAsync(
GraphDelta delta,
IGraphSnapshot graph,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Set of (entry, sink) pairs affected by graph changes.
/// </summary>
public sealed record ImpactSet
{
/// <summary>
/// Whether full recomputation is required.
/// </summary>
public bool RequiresFullRecompute { get; init; }
/// <summary>
/// Entry points that need reanalysis.
/// </summary>
public IReadOnlySet<string> AffectedEntryPoints { get; init; } = new HashSet<string>();
/// <summary>
/// Sinks that may have changed reachability.
/// </summary>
public IReadOnlySet<string> AffectedSinks { get; init; } = new HashSet<string>();
/// <summary>
/// Specific (entry, sink) pairs that need recomputation.
/// </summary>
public IReadOnlyList<(string EntryKey, string SinkKey)> AffectedPairs { get; init; } = [];
/// <summary>
/// Estimated savings ratio compared to full recompute.
/// </summary>
public double SavingsRatio { get; init; }
/// <summary>
/// Creates an impact set requiring full recomputation.
/// </summary>
public static ImpactSet FullRecompute() => new() { RequiresFullRecompute = true };
/// <summary>
/// Creates an empty impact set (no changes needed).
/// </summary>
public static ImpactSet Empty() => new() { SavingsRatio = 1.0 };
}
/// <summary>
/// Default implementation of impact set calculator.
/// Uses BFS to find ancestors of changed nodes to determine affected entries.
/// </summary>
public sealed class ImpactSetCalculator : IImpactSetCalculator
{
private readonly ILogger<ImpactSetCalculator> _logger;
private readonly int _maxAffectedRatioForIncremental;
public ImpactSetCalculator(
ILogger<ImpactSetCalculator> logger,
int maxAffectedRatioForIncremental = 30)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_maxAffectedRatioForIncremental = maxAffectedRatioForIncremental;
}
/// <inheritdoc />
public Task<ImpactSet> CalculateImpactAsync(
GraphDelta delta,
IGraphSnapshot graph,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(delta);
ArgumentNullException.ThrowIfNull(graph);
if (!delta.HasChanges)
{
_logger.LogDebug("No graph changes, empty impact set");
return Task.FromResult(ImpactSet.Empty());
}
// Build reverse adjacency for ancestor lookup
var reverseAdj = BuildReverseAdjacency(graph.Edges);
// Find all ancestors of affected method keys
var affectedAncestors = new HashSet<string>();
foreach (var methodKey in delta.AffectedMethodKeys)
{
cancellationToken.ThrowIfCancellationRequested();
var ancestors = FindAncestors(methodKey, reverseAdj);
affectedAncestors.UnionWith(ancestors);
}
// Intersect with entry points to find affected entries
var affectedEntries = graph.EntryPoints
.Where(e => affectedAncestors.Contains(e) || delta.AffectedMethodKeys.Contains(e))
.ToHashSet();
// Check if too many entries affected (fall back to full recompute)
var affectedRatio = graph.EntryPoints.Count > 0
? (double)affectedEntries.Count / graph.EntryPoints.Count * 100
: 0;
if (affectedRatio > _maxAffectedRatioForIncremental)
{
_logger.LogInformation(
"Affected ratio {Ratio:F1}% exceeds threshold {Threshold}%, forcing full recompute",
affectedRatio, _maxAffectedRatioForIncremental);
return Task.FromResult(ImpactSet.FullRecompute());
}
// Determine affected sinks (any sink reachable from affected methods)
var affectedSinks = delta.AffectedMethodKeys.ToHashSet();
var savingsRatio = graph.EntryPoints.Count > 0
? 1.0 - ((double)affectedEntries.Count / graph.EntryPoints.Count)
: 1.0;
var impact = new ImpactSet
{
RequiresFullRecompute = false,
AffectedEntryPoints = affectedEntries,
AffectedSinks = affectedSinks,
SavingsRatio = savingsRatio
};
_logger.LogInformation(
"Impact set calculated: {AffectedEntries} entries, {AffectedSinks} potential sinks, {Savings:P1} savings",
affectedEntries.Count, affectedSinks.Count, savingsRatio);
return Task.FromResult(impact);
}
private static Dictionary<string, List<string>> BuildReverseAdjacency(IReadOnlyList<GraphEdge> edges)
{
var reverseAdj = new Dictionary<string, List<string>>();
foreach (var edge in edges)
{
if (!reverseAdj.TryGetValue(edge.CalleeKey, out var callers))
{
callers = new List<string>();
reverseAdj[edge.CalleeKey] = callers;
}
callers.Add(edge.CallerKey);
}
return reverseAdj;
}
private static HashSet<string> FindAncestors(string startNode, Dictionary<string, List<string>> reverseAdj)
{
var ancestors = new HashSet<string>();
var queue = new Queue<string>();
queue.Enqueue(startNode);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!reverseAdj.TryGetValue(current, out var callers))
continue;
foreach (var caller in callers)
{
if (ancestors.Add(caller))
{
queue.Enqueue(caller);
}
}
}
return ancestors;
}
}

View File

@@ -0,0 +1,467 @@
// -----------------------------------------------------------------------------
// IncrementalReachabilityService.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-012)
// Description: Orchestrates incremental reachability analysis with caching.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Service for performing incremental reachability analysis with caching.
/// Orchestrates cache lookup, delta computation, selective recompute, and state flip detection.
/// </summary>
public interface IIncrementalReachabilityService
{
/// <summary>
/// Performs incremental reachability analysis.
/// </summary>
/// <param name="request">Analysis request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Incremental analysis result.</returns>
Task<IncrementalReachabilityResult> AnalyzeAsync(
IncrementalReachabilityRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for incremental reachability analysis.
/// </summary>
public sealed record IncrementalReachabilityRequest
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Current call graph snapshot.
/// </summary>
public required IGraphSnapshot CurrentGraph { get; init; }
/// <summary>
/// Sink method keys to analyze.
/// </summary>
public required IReadOnlyList<string> Sinks { get; init; }
/// <summary>
/// Whether to detect state flips.
/// </summary>
public bool DetectStateFlips { get; init; } = true;
/// <summary>
/// Whether to update cache with new results.
/// </summary>
public bool UpdateCache { get; init; } = true;
/// <summary>
/// Maximum BFS depth for reachability analysis.
/// </summary>
public int MaxDepth { get; init; } = 50;
}
/// <summary>
/// Result of incremental reachability analysis.
/// </summary>
public sealed record IncrementalReachabilityResult
{
/// <summary>
/// Service identifier.
/// </summary>
public required string ServiceId { get; init; }
/// <summary>
/// Reachability results for each (entry, sink) pair.
/// </summary>
public IReadOnlyList<ReachablePairResult> Results { get; init; } = [];
/// <summary>
/// State flip detection result.
/// </summary>
public StateFlipResult? StateFlips { get; init; }
/// <summary>
/// Whether results came from cache.
/// </summary>
public bool FromCache { get; init; }
/// <summary>
/// Whether incremental analysis was used.
/// </summary>
public bool WasIncremental { get; init; }
/// <summary>
/// Savings ratio from incremental analysis (0.0 = full recompute, 1.0 = all cached).
/// </summary>
public double SavingsRatio { get; init; }
/// <summary>
/// Analysis duration.
/// </summary>
public TimeSpan Duration { get; init; }
/// <summary>
/// Graph hash used for caching.
/// </summary>
public string? GraphHash { get; init; }
}
/// <summary>
/// Default implementation of incremental reachability service.
/// </summary>
public sealed class IncrementalReachabilityService : IIncrementalReachabilityService
{
private readonly IReachabilityCache _cache;
private readonly IGraphDeltaComputer _deltaComputer;
private readonly IImpactSetCalculator _impactCalculator;
private readonly IStateFlipDetector _stateFlipDetector;
private readonly ILogger<IncrementalReachabilityService> _logger;
public IncrementalReachabilityService(
IReachabilityCache cache,
IGraphDeltaComputer deltaComputer,
IImpactSetCalculator impactCalculator,
IStateFlipDetector stateFlipDetector,
ILogger<IncrementalReachabilityService> logger)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_deltaComputer = deltaComputer ?? throw new ArgumentNullException(nameof(deltaComputer));
_impactCalculator = impactCalculator ?? throw new ArgumentNullException(nameof(impactCalculator));
_stateFlipDetector = stateFlipDetector ?? throw new ArgumentNullException(nameof(stateFlipDetector));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<IncrementalReachabilityResult> AnalyzeAsync(
IncrementalReachabilityRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var graphHash = request.CurrentGraph.Hash;
_logger.LogInformation(
"Starting incremental reachability analysis for {ServiceId}, graph {Hash}",
request.ServiceId, graphHash);
// Step 1: Check cache for exact match
var cached = await _cache.GetAsync(request.ServiceId, graphHash, cancellationToken);
if (cached is not null)
{
IncrementalReachabilityMetrics.CacheHits.Add(1);
_logger.LogInformation("Cache hit for {ServiceId}, returning cached results", request.ServiceId);
sw.Stop();
return new IncrementalReachabilityResult
{
ServiceId = request.ServiceId,
Results = cached.ReachablePairs,
FromCache = true,
WasIncremental = false,
SavingsRatio = 1.0,
Duration = sw.Elapsed,
GraphHash = graphHash
};
}
IncrementalReachabilityMetrics.CacheMisses.Add(1);
// Step 2: Get previous cache to compute delta
var stats = await _cache.GetStatisticsAsync(request.ServiceId, cancellationToken);
var previousHash = stats.CurrentGraphHash;
GraphDelta delta;
ImpactSet impact;
IReadOnlyList<ReachablePairResult> previousResults = [];
if (previousHash is not null && previousHash != graphHash)
{
// Compute delta
delta = await _deltaComputer.ComputeDeltaFromHashesAsync(
request.ServiceId, previousHash, graphHash, cancellationToken);
impact = await _impactCalculator.CalculateImpactAsync(
delta, request.CurrentGraph, cancellationToken);
// Get previous results for state flip detection
var previousCached = await _cache.GetAsync(request.ServiceId, previousHash, cancellationToken);
previousResults = previousCached?.ReachablePairs ?? [];
}
else
{
// No previous cache, full compute
delta = GraphDelta.FullRecompute(previousHash, graphHash);
impact = ImpactSet.FullRecompute();
}
// Step 3: Compute reachability (full or incremental)
IReadOnlyList<ReachablePairResult> results;
if (impact.RequiresFullRecompute)
{
IncrementalReachabilityMetrics.FullRecomputes.Add(1);
results = ComputeFullReachability(request);
}
else
{
IncrementalReachabilityMetrics.IncrementalComputes.Add(1);
results = await ComputeIncrementalReachabilityAsync(
request, impact, previousResults, cancellationToken);
}
// Step 4: Detect state flips
StateFlipResult? stateFlips = null;
if (request.DetectStateFlips && previousResults.Count > 0)
{
stateFlips = await _stateFlipDetector.DetectFlipsAsync(
previousResults, results, cancellationToken);
}
// Step 5: Update cache
if (request.UpdateCache)
{
var entry = new ReachabilityCacheEntry
{
ServiceId = request.ServiceId,
GraphHash = graphHash,
ReachablePairs = results,
EntryPointCount = request.CurrentGraph.EntryPoints.Count,
SinkCount = request.Sinks.Count,
TimeToLive = TimeSpan.FromHours(24)
};
await _cache.SetAsync(entry, cancellationToken);
}
sw.Stop();
IncrementalReachabilityMetrics.AnalysisDurationMs.Record(sw.ElapsedMilliseconds);
_logger.LogInformation(
"Incremental analysis complete for {ServiceId}: {ResultCount} pairs, {Savings:P1} savings, {Duration}ms",
request.ServiceId, results.Count, impact.SavingsRatio, sw.ElapsedMilliseconds);
return new IncrementalReachabilityResult
{
ServiceId = request.ServiceId,
Results = results,
StateFlips = stateFlips,
FromCache = false,
WasIncremental = !impact.RequiresFullRecompute,
SavingsRatio = impact.SavingsRatio,
Duration = sw.Elapsed,
GraphHash = graphHash
};
}
private List<ReachablePairResult> ComputeFullReachability(IncrementalReachabilityRequest request)
{
var results = new List<ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
// Build forward adjacency for BFS
var adj = new Dictionary<string, List<string>>();
foreach (var edge in request.CurrentGraph.Edges)
{
if (!adj.TryGetValue(edge.CallerKey, out var callees))
{
callees = new List<string>();
adj[edge.CallerKey] = callees;
}
callees.Add(edge.CalleeKey);
}
var sinkSet = request.Sinks.ToHashSet();
foreach (var entry in request.CurrentGraph.EntryPoints)
{
// BFS from entry to find reachable sinks
var reachableSinks = BfsToSinks(entry, sinkSet, adj, request.MaxDepth);
foreach (var (sink, pathLength) in reachableSinks)
{
results.Add(new ReachablePairResult
{
EntryMethodKey = entry,
SinkMethodKey = sink,
IsReachable = true,
PathLength = pathLength,
Confidence = 1.0,
ComputedAt = now
});
}
// Add unreachable pairs for sinks not reached
foreach (var sink in sinkSet.Except(reachableSinks.Keys))
{
results.Add(new ReachablePairResult
{
EntryMethodKey = entry,
SinkMethodKey = sink,
IsReachable = false,
Confidence = 1.0,
ComputedAt = now
});
}
}
return results;
}
private async Task<IReadOnlyList<ReachablePairResult>> ComputeIncrementalReachabilityAsync(
IncrementalReachabilityRequest request,
ImpactSet impact,
IReadOnlyList<ReachablePairResult> previousResults,
CancellationToken cancellationToken)
{
var results = new Dictionary<(string, string), ReachablePairResult>();
var now = DateTimeOffset.UtcNow;
// Copy unaffected results from previous
foreach (var prev in previousResults)
{
var key = (prev.EntryMethodKey, prev.SinkMethodKey);
if (!impact.AffectedEntryPoints.Contains(prev.EntryMethodKey))
{
// Entry not affected, keep previous result
results[key] = prev;
}
}
// Recompute only affected entries
var adj = new Dictionary<string, List<string>>();
foreach (var edge in request.CurrentGraph.Edges)
{
if (!adj.TryGetValue(edge.CallerKey, out var callees))
{
callees = new List<string>();
adj[edge.CallerKey] = callees;
}
callees.Add(edge.CalleeKey);
}
var sinkSet = request.Sinks.ToHashSet();
foreach (var entry in impact.AffectedEntryPoints)
{
cancellationToken.ThrowIfCancellationRequested();
if (!request.CurrentGraph.EntryPoints.Contains(entry))
continue; // Entry no longer exists
var reachableSinks = BfsToSinks(entry, sinkSet, adj, request.MaxDepth);
foreach (var (sink, pathLength) in reachableSinks)
{
var key = (entry, sink);
results[key] = new ReachablePairResult
{
EntryMethodKey = entry,
SinkMethodKey = sink,
IsReachable = true,
PathLength = pathLength,
Confidence = 1.0,
ComputedAt = now
};
}
foreach (var sink in sinkSet.Except(reachableSinks.Keys))
{
var key = (entry, sink);
results[key] = new ReachablePairResult
{
EntryMethodKey = entry,
SinkMethodKey = sink,
IsReachable = false,
Confidence = 1.0,
ComputedAt = now
};
}
}
return results.Values.ToList();
}
private static Dictionary<string, int> BfsToSinks(
string startNode,
HashSet<string> sinks,
Dictionary<string, List<string>> adj,
int maxDepth)
{
var reached = new Dictionary<string, int>();
var visited = new HashSet<string>();
var queue = new Queue<(string Node, int Depth)>();
queue.Enqueue((startNode, 0));
visited.Add(startNode);
while (queue.Count > 0)
{
var (current, depth) = queue.Dequeue();
if (depth > maxDepth)
break;
if (sinks.Contains(current))
{
reached[current] = depth;
}
if (!adj.TryGetValue(current, out var callees))
continue;
foreach (var callee in callees)
{
if (visited.Add(callee))
{
queue.Enqueue((callee, depth + 1));
}
}
}
return reached;
}
}
/// <summary>
/// Metrics for incremental reachability service.
/// </summary>
internal static class IncrementalReachabilityMetrics
{
private static readonly string MeterName = "StellaOps.Scanner.Reachability.Cache";
public static readonly System.Diagnostics.Metrics.Counter<long> CacheHits =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.reachability_cache.hits",
description: "Number of cache hits");
public static readonly System.Diagnostics.Metrics.Counter<long> CacheMisses =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.reachability_cache.misses",
description: "Number of cache misses");
public static readonly System.Diagnostics.Metrics.Counter<long> FullRecomputes =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.reachability_cache.full_recomputes",
description: "Number of full recomputes");
public static readonly System.Diagnostics.Metrics.Counter<long> IncrementalComputes =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.reachability_cache.incremental_computes",
description: "Number of incremental computes");
public static readonly System.Diagnostics.Metrics.Histogram<long> AnalysisDurationMs =
new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram<long>(
"stellaops.reachability_cache.analysis_duration_ms",
unit: "ms",
description: "Analysis duration in milliseconds");
}

View File

@@ -0,0 +1,391 @@
// -----------------------------------------------------------------------------
// PostgresReachabilityCache.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-004)
// Description: PostgreSQL implementation of IReachabilityCache.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// PostgreSQL implementation of the reachability cache.
/// </summary>
public sealed class PostgresReachabilityCache : IReachabilityCache
{
private readonly string _connectionString;
private readonly ILogger<PostgresReachabilityCache> _logger;
public PostgresReachabilityCache(
string connectionString,
ILogger<PostgresReachabilityCache> logger)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<CachedReachabilityResult?> GetAsync(
string serviceId,
string graphHash,
CancellationToken cancellationToken = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
// Get cache entry
const string entrySql = """
SELECT id, cached_at, expires_at, entry_point_count, sink_count
FROM reach_cache_entries
WHERE service_id = @serviceId AND graph_hash = @graphHash
AND (expires_at IS NULL OR expires_at > NOW())
""";
await using var entryCmd = new NpgsqlCommand(entrySql, conn);
entryCmd.Parameters.AddWithValue("@serviceId", serviceId);
entryCmd.Parameters.AddWithValue("@graphHash", graphHash);
await using var entryReader = await entryCmd.ExecuteReaderAsync(cancellationToken);
if (!await entryReader.ReadAsync(cancellationToken))
{
return null; // Cache miss
}
var entryId = entryReader.GetGuid(0);
var cachedAt = entryReader.GetDateTime(1);
var expiresAt = entryReader.IsDBNull(2) ? (DateTimeOffset?)null : entryReader.GetDateTime(2);
var entryPointCount = entryReader.GetInt32(3);
var sinkCount = entryReader.GetInt32(4);
await entryReader.CloseAsync();
// Get cached pairs
const string pairsSql = """
SELECT entry_method_key, sink_method_key, is_reachable, path_length, confidence, computed_at
FROM reach_cache_pairs
WHERE cache_entry_id = @entryId
""";
await using var pairsCmd = new NpgsqlCommand(pairsSql, conn);
pairsCmd.Parameters.AddWithValue("@entryId", entryId);
var pairs = new List<ReachablePairResult>();
await using var pairsReader = await pairsCmd.ExecuteReaderAsync(cancellationToken);
while (await pairsReader.ReadAsync(cancellationToken))
{
pairs.Add(new ReachablePairResult
{
EntryMethodKey = pairsReader.GetString(0),
SinkMethodKey = pairsReader.GetString(1),
IsReachable = pairsReader.GetBoolean(2),
PathLength = pairsReader.IsDBNull(3) ? null : pairsReader.GetInt32(3),
Confidence = pairsReader.GetDouble(4),
ComputedAt = pairsReader.GetDateTime(5)
});
}
// Update stats
await UpdateStatsAsync(conn, serviceId, isHit: true, cancellationToken: cancellationToken);
_logger.LogDebug("Cache hit for {ServiceId}, {PairCount} pairs", serviceId, pairs.Count);
return new CachedReachabilityResult
{
ServiceId = serviceId,
GraphHash = graphHash,
CachedAt = cachedAt,
TimeToLive = expiresAt.HasValue ? expiresAt.Value - DateTimeOffset.UtcNow : null,
ReachablePairs = pairs,
EntryPointCount = entryPointCount,
SinkCount = sinkCount
};
}
/// <inheritdoc />
public async Task SetAsync(
ReachabilityCacheEntry entry,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
await using var tx = await conn.BeginTransactionAsync(cancellationToken);
try
{
// Delete existing entry for this service/hash
const string deleteSql = """
DELETE FROM reach_cache_entries
WHERE service_id = @serviceId AND graph_hash = @graphHash
""";
await using var deleteCmd = new NpgsqlCommand(deleteSql, conn, tx);
deleteCmd.Parameters.AddWithValue("@serviceId", entry.ServiceId);
deleteCmd.Parameters.AddWithValue("@graphHash", entry.GraphHash);
await deleteCmd.ExecuteNonQueryAsync(cancellationToken);
// Insert new cache entry
var reachableCount = 0;
var unreachableCount = 0;
foreach (var pair in entry.ReachablePairs)
{
if (pair.IsReachable) reachableCount++;
else unreachableCount++;
}
var expiresAt = entry.TimeToLive.HasValue
? (object)DateTimeOffset.UtcNow.Add(entry.TimeToLive.Value)
: DBNull.Value;
const string insertEntrySql = """
INSERT INTO reach_cache_entries
(service_id, graph_hash, sbom_hash, entry_point_count, sink_count,
pair_count, reachable_count, unreachable_count, expires_at)
VALUES
(@serviceId, @graphHash, @sbomHash, @entryPointCount, @sinkCount,
@pairCount, @reachableCount, @unreachableCount, @expiresAt)
RETURNING id
""";
await using var insertCmd = new NpgsqlCommand(insertEntrySql, conn, tx);
insertCmd.Parameters.AddWithValue("@serviceId", entry.ServiceId);
insertCmd.Parameters.AddWithValue("@graphHash", entry.GraphHash);
insertCmd.Parameters.AddWithValue("@sbomHash", entry.SbomHash ?? (object)DBNull.Value);
insertCmd.Parameters.AddWithValue("@entryPointCount", entry.EntryPointCount);
insertCmd.Parameters.AddWithValue("@sinkCount", entry.SinkCount);
insertCmd.Parameters.AddWithValue("@pairCount", entry.ReachablePairs.Count);
insertCmd.Parameters.AddWithValue("@reachableCount", reachableCount);
insertCmd.Parameters.AddWithValue("@unreachableCount", unreachableCount);
insertCmd.Parameters.AddWithValue("@expiresAt", expiresAt);
var entryId = (Guid)(await insertCmd.ExecuteScalarAsync(cancellationToken))!;
// Insert pairs in batches
if (entry.ReachablePairs.Count > 0)
{
await InsertPairsBatchAsync(conn, tx, entryId, entry.ReachablePairs, cancellationToken);
}
await tx.CommitAsync(cancellationToken);
// Update stats
await UpdateStatsAsync(conn, entry.ServiceId, isHit: false, entry.GraphHash, cancellationToken);
_logger.LogInformation(
"Cached {PairCount} pairs for {ServiceId}, graph {Hash}",
entry.ReachablePairs.Count, entry.ServiceId, entry.GraphHash);
}
catch
{
await tx.RollbackAsync(cancellationToken);
throw;
}
}
/// <inheritdoc />
public async Task<ReachablePairResult?> GetReachablePairAsync(
string serviceId,
string entryMethodKey,
string sinkMethodKey,
CancellationToken cancellationToken = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
const string sql = """
SELECT p.is_reachable, p.path_length, p.confidence, p.computed_at
FROM reach_cache_pairs p
JOIN reach_cache_entries e ON p.cache_entry_id = e.id
WHERE e.service_id = @serviceId
AND p.entry_method_key = @entryKey
AND p.sink_method_key = @sinkKey
AND (e.expires_at IS NULL OR e.expires_at > NOW())
ORDER BY e.cached_at DESC
LIMIT 1
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serviceId", serviceId);
cmd.Parameters.AddWithValue("@entryKey", entryMethodKey);
cmd.Parameters.AddWithValue("@sinkKey", sinkMethodKey);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return new ReachablePairResult
{
EntryMethodKey = entryMethodKey,
SinkMethodKey = sinkMethodKey,
IsReachable = reader.GetBoolean(0),
PathLength = reader.IsDBNull(1) ? null : reader.GetInt32(1),
Confidence = reader.GetDouble(2),
ComputedAt = reader.GetDateTime(3)
};
}
/// <inheritdoc />
public async Task<int> InvalidateAsync(
string serviceId,
IEnumerable<string> affectedMethodKeys,
CancellationToken cancellationToken = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
// For now, invalidate entire cache for service
// More granular invalidation would require additional indices
const string sql = """
DELETE FROM reach_cache_entries
WHERE service_id = @serviceId
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serviceId", serviceId);
var deleted = await cmd.ExecuteNonQueryAsync(cancellationToken);
if (deleted > 0)
{
await UpdateInvalidationTimeAsync(conn, serviceId, cancellationToken);
_logger.LogInformation("Invalidated {Count} cache entries for {ServiceId}", deleted, serviceId);
}
return deleted;
}
/// <inheritdoc />
public async Task InvalidateAllAsync(
string serviceId,
CancellationToken cancellationToken = default)
{
await InvalidateAsync(serviceId, Array.Empty<string>(), cancellationToken);
}
/// <inheritdoc />
public async Task<CacheStatistics> GetStatisticsAsync(
string serviceId,
CancellationToken cancellationToken = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(cancellationToken);
const string sql = """
SELECT total_hits, total_misses, full_recomputes, incremental_computes,
current_graph_hash, last_populated_at, last_invalidated_at
FROM reach_cache_stats
WHERE service_id = @serviceId
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serviceId", serviceId);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return new CacheStatistics { ServiceId = serviceId };
}
// Get cached pair count
await reader.CloseAsync();
const string countSql = """
SELECT COALESCE(SUM(pair_count), 0)
FROM reach_cache_entries
WHERE service_id = @serviceId AND (expires_at IS NULL OR expires_at > NOW())
""";
await using var countCmd = new NpgsqlCommand(countSql, conn);
countCmd.Parameters.AddWithValue("@serviceId", serviceId);
var pairCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync(cancellationToken));
return new CacheStatistics
{
ServiceId = serviceId,
CachedPairCount = pairCount,
HitCount = reader.GetInt64(0),
MissCount = reader.GetInt64(1),
LastPopulatedAt = reader.IsDBNull(5) ? null : reader.GetDateTime(5),
LastInvalidatedAt = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
CurrentGraphHash = reader.IsDBNull(4) ? null : reader.GetString(4)
};
}
private async Task InsertPairsBatchAsync(
NpgsqlConnection conn,
NpgsqlTransaction tx,
Guid entryId,
IReadOnlyList<ReachablePairResult> pairs,
CancellationToken cancellationToken)
{
await using var writer = await conn.BeginBinaryImportAsync(
"COPY reach_cache_pairs (cache_entry_id, entry_method_key, sink_method_key, is_reachable, path_length, confidence, computed_at) FROM STDIN (FORMAT BINARY)",
cancellationToken);
foreach (var pair in pairs)
{
await writer.StartRowAsync(cancellationToken);
await writer.WriteAsync(entryId, NpgsqlTypes.NpgsqlDbType.Uuid, cancellationToken);
await writer.WriteAsync(pair.EntryMethodKey, NpgsqlTypes.NpgsqlDbType.Text, cancellationToken);
await writer.WriteAsync(pair.SinkMethodKey, NpgsqlTypes.NpgsqlDbType.Text, cancellationToken);
await writer.WriteAsync(pair.IsReachable, NpgsqlTypes.NpgsqlDbType.Boolean, cancellationToken);
if (pair.PathLength.HasValue)
await writer.WriteAsync(pair.PathLength.Value, NpgsqlTypes.NpgsqlDbType.Integer, cancellationToken);
else
await writer.WriteNullAsync(cancellationToken);
await writer.WriteAsync(pair.Confidence, NpgsqlTypes.NpgsqlDbType.Double, cancellationToken);
await writer.WriteAsync(pair.ComputedAt.UtcDateTime, NpgsqlTypes.NpgsqlDbType.TimestampTz, cancellationToken);
}
await writer.CompleteAsync(cancellationToken);
}
private static async Task UpdateStatsAsync(
NpgsqlConnection conn,
string serviceId,
bool isHit,
string? graphHash = null,
CancellationToken cancellationToken = default)
{
const string sql = "SELECT update_reach_cache_stats(@serviceId, @isHit, NULL, @graphHash)";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serviceId", serviceId);
cmd.Parameters.AddWithValue("@isHit", isHit);
cmd.Parameters.AddWithValue("@graphHash", graphHash ?? (object)DBNull.Value);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
private static async Task UpdateInvalidationTimeAsync(
NpgsqlConnection conn,
string serviceId,
CancellationToken cancellationToken)
{
const string sql = """
UPDATE reach_cache_stats
SET last_invalidated_at = NOW(), updated_at = NOW()
WHERE service_id = @serviceId
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("@serviceId", serviceId);
await cmd.ExecuteNonQueryAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,264 @@
// -----------------------------------------------------------------------------
// StateFlipDetector.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-011)
// Description: Detects reachability state changes between scans.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Cache;
/// <summary>
/// Detects state flips: transitions between reachable and unreachable states.
/// Used for PR gates and change tracking.
/// </summary>
public interface IStateFlipDetector
{
/// <summary>
/// Detects state flips between previous and current reachability results.
/// </summary>
/// <param name="previous">Previous scan results.</param>
/// <param name="current">Current scan results.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>State flip detection result.</returns>
Task<StateFlipResult> DetectFlipsAsync(
IReadOnlyList<ReachablePairResult> previous,
IReadOnlyList<ReachablePairResult> current,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of state flip detection.
/// </summary>
public sealed record StateFlipResult
{
/// <summary>
/// Whether any state flips occurred.
/// </summary>
public bool HasFlips => NewlyReachable.Count > 0 || NewlyUnreachable.Count > 0;
/// <summary>
/// Pairs that became reachable (were unreachable, now reachable).
/// This represents NEW RISK.
/// </summary>
public IReadOnlyList<StateFlip> NewlyReachable { get; init; } = [];
/// <summary>
/// Pairs that became unreachable (were reachable, now unreachable).
/// This represents MITIGATED risk.
/// </summary>
public IReadOnlyList<StateFlip> NewlyUnreachable { get; init; } = [];
/// <summary>
/// Count of new risks introduced.
/// </summary>
public int NewRiskCount => NewlyReachable.Count;
/// <summary>
/// Count of mitigated risks.
/// </summary>
public int MitigatedCount => NewlyUnreachable.Count;
/// <summary>
/// Net change in reachable vulnerability paths.
/// Positive = more risk, Negative = less risk.
/// </summary>
public int NetChange => NewlyReachable.Count - NewlyUnreachable.Count;
/// <summary>
/// Summary for PR annotation.
/// </summary>
public string Summary => HasFlips
? $"Reachability changed: +{NewRiskCount} new paths, -{MitigatedCount} removed paths"
: "No reachability changes";
/// <summary>
/// Whether this should block a PR (new reachable paths introduced).
/// </summary>
public bool ShouldBlockPr => NewlyReachable.Count > 0;
/// <summary>
/// Creates an empty result (no flips).
/// </summary>
public static StateFlipResult Empty => new();
}
/// <summary>
/// A single state flip event.
/// </summary>
public sealed record StateFlip
{
/// <summary>
/// Entry point method key.
/// </summary>
public required string EntryMethodKey { get; init; }
/// <summary>
/// Sink method key.
/// </summary>
public required string SinkMethodKey { get; init; }
/// <summary>
/// Previous state (reachable = true, unreachable = false).
/// </summary>
public bool WasReachable { get; init; }
/// <summary>
/// New state.
/// </summary>
public bool IsReachable { get; init; }
/// <summary>
/// Type of flip.
/// </summary>
public StateFlipType FlipType => IsReachable ? StateFlipType.BecameReachable : StateFlipType.BecameUnreachable;
/// <summary>
/// Associated CVE if applicable.
/// </summary>
public string? CveId { get; init; }
/// <summary>
/// Package name if applicable.
/// </summary>
public string? PackageName { get; init; }
}
/// <summary>
/// Type of state flip.
/// </summary>
public enum StateFlipType
{
/// <summary>
/// Was unreachable, now reachable (NEW RISK).
/// </summary>
BecameReachable,
/// <summary>
/// Was reachable, now unreachable (MITIGATED).
/// </summary>
BecameUnreachable
}
/// <summary>
/// Default implementation of state flip detector.
/// </summary>
public sealed class StateFlipDetector : IStateFlipDetector
{
private readonly ILogger<StateFlipDetector> _logger;
public StateFlipDetector(ILogger<StateFlipDetector> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public Task<StateFlipResult> DetectFlipsAsync(
IReadOnlyList<ReachablePairResult> previous,
IReadOnlyList<ReachablePairResult> current,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(previous);
ArgumentNullException.ThrowIfNull(current);
// Build lookup for previous state
var previousState = previous.ToDictionary(
p => (p.EntryMethodKey, p.SinkMethodKey),
p => p.IsReachable);
// Build lookup for current state
var currentState = current.ToDictionary(
p => (p.EntryMethodKey, p.SinkMethodKey),
p => p.IsReachable);
var newlyReachable = new List<StateFlip>();
var newlyUnreachable = new List<StateFlip>();
// Check all current pairs for flips
foreach (var pair in current)
{
cancellationToken.ThrowIfCancellationRequested();
var key = (pair.EntryMethodKey, pair.SinkMethodKey);
if (previousState.TryGetValue(key, out var wasReachable))
{
if (!wasReachable && pair.IsReachable)
{
// Was unreachable, now reachable = NEW RISK
newlyReachable.Add(new StateFlip
{
EntryMethodKey = pair.EntryMethodKey,
SinkMethodKey = pair.SinkMethodKey,
WasReachable = false,
IsReachable = true
});
}
else if (wasReachable && !pair.IsReachable)
{
// Was reachable, now unreachable = MITIGATED
newlyUnreachable.Add(new StateFlip
{
EntryMethodKey = pair.EntryMethodKey,
SinkMethodKey = pair.SinkMethodKey,
WasReachable = true,
IsReachable = false
});
}
}
else if (pair.IsReachable)
{
// New pair that is reachable = NEW RISK
newlyReachable.Add(new StateFlip
{
EntryMethodKey = pair.EntryMethodKey,
SinkMethodKey = pair.SinkMethodKey,
WasReachable = false,
IsReachable = true
});
}
}
// Check for pairs that existed previously but no longer exist (removed code = mitigated)
foreach (var prevPair in previous.Where(p => p.IsReachable))
{
var key = (prevPair.EntryMethodKey, prevPair.SinkMethodKey);
if (!currentState.ContainsKey(key))
{
// Pair no longer exists and was reachable = MITIGATED
newlyUnreachable.Add(new StateFlip
{
EntryMethodKey = prevPair.EntryMethodKey,
SinkMethodKey = prevPair.SinkMethodKey,
WasReachable = true,
IsReachable = false
});
}
}
var result = new StateFlipResult
{
NewlyReachable = newlyReachable,
NewlyUnreachable = newlyUnreachable
};
if (result.HasFlips)
{
_logger.LogInformation(
"State flips detected: +{NewRisk} new reachable, -{Mitigated} unreachable (net: {Net})",
result.NewRiskCount, result.MitigatedCount, result.NetChange);
}
else
{
_logger.LogDebug("No state flips detected");
}
return Task.FromResult(result);
}
}

View File

@@ -4,6 +4,11 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.ProofSpine\StellaOps.Scanner.ProofSpine.csproj" />
@@ -11,6 +16,7 @@
<ProjectReference Include="..\StellaOps.Scanner.SmartDiff\StellaOps.Scanner.SmartDiff.csproj" />
<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="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,238 @@
// -----------------------------------------------------------------------------
// ISurfaceQueryService.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-001)
// Description: Interface for querying vulnerability surfaces during scans.
// -----------------------------------------------------------------------------
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Surfaces;
/// <summary>
/// Service for querying vulnerability surfaces to resolve trigger methods for reachability analysis.
/// </summary>
public interface ISurfaceQueryService
{
/// <summary>
/// Queries the vulnerability surface for a specific CVE and package.
/// </summary>
/// <param name="request">Query request with CVE and package details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Query result with trigger methods or fallback indicators.</returns>
Task<SurfaceQueryResult> QueryAsync(
SurfaceQueryRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Bulk query for multiple CVE/package combinations.
/// </summary>
/// <param name="requests">Collection of query requests.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary of results keyed by query key.</returns>
Task<IReadOnlyDictionary<string, SurfaceQueryResult>> QueryBulkAsync(
IEnumerable<SurfaceQueryRequest> requests,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a surface exists for the given CVE and package.
/// </summary>
/// <param name="cveId">CVE identifier.</param>
/// <param name="ecosystem">Package ecosystem.</param>
/// <param name="packageName">Package name.</param>
/// <param name="version">Package version.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if surface exists.</returns>
Task<bool> ExistsAsync(
string cveId,
string ecosystem,
string packageName,
string version,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request to query a vulnerability surface.
/// </summary>
public sealed record SurfaceQueryRequest
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package ecosystem (nuget, npm, maven, pypi).
/// </summary>
public required string Ecosystem { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Vulnerable package version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Whether to include internal paths in the result.
/// </summary>
public bool IncludePaths { get; init; }
/// <summary>
/// Maximum number of triggers to return.
/// </summary>
public int MaxTriggers { get; init; } = 100;
/// <summary>
/// Gets a unique key for caching/batching.
/// </summary>
public string QueryKey => $"{CveId}|{Ecosystem}|{PackageName}|{Version}";
}
/// <summary>
/// Result of a vulnerability surface query.
/// </summary>
public sealed record SurfaceQueryResult
{
/// <summary>
/// Whether a surface was found.
/// </summary>
public bool SurfaceFound { get; init; }
/// <summary>
/// The source of sink methods for reachability analysis.
/// </summary>
public SinkSource Source { get; init; }
/// <summary>
/// Surface ID if found.
/// </summary>
public Guid? SurfaceId { get; init; }
/// <summary>
/// Trigger method keys (public API entry points).
/// </summary>
public IReadOnlyList<TriggerMethodInfo> Triggers { get; init; } = [];
/// <summary>
/// Sink method keys (changed vulnerability methods).
/// </summary>
public IReadOnlyList<string> Sinks { get; init; } = [];
/// <summary>
/// Error message if query failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// When the surface was computed.
/// </summary>
public DateTimeOffset? ComputedAt { get; init; }
/// <summary>
/// Creates a result indicating surface was found.
/// </summary>
public static SurfaceQueryResult Found(
Guid surfaceId,
IReadOnlyList<TriggerMethodInfo> triggers,
IReadOnlyList<string> sinks,
DateTimeOffset computedAt)
{
return new SurfaceQueryResult
{
SurfaceFound = true,
Source = SinkSource.Surface,
SurfaceId = surfaceId,
Triggers = triggers,
Sinks = sinks,
ComputedAt = computedAt
};
}
/// <summary>
/// Creates a result indicating fallback to package API.
/// </summary>
public static SurfaceQueryResult FallbackToPackageApi(string reason)
{
return new SurfaceQueryResult
{
SurfaceFound = false,
Source = SinkSource.PackageApi,
Error = reason
};
}
/// <summary>
/// Creates a result indicating no surface data available.
/// </summary>
public static SurfaceQueryResult NotFound(string cveId, string packageName)
{
return new SurfaceQueryResult
{
SurfaceFound = false,
Source = SinkSource.FallbackAll,
Error = $"No surface found for {cveId} in {packageName}"
};
}
}
/// <summary>
/// Information about a trigger method.
/// </summary>
public sealed record TriggerMethodInfo
{
/// <summary>
/// Fully qualified method key.
/// </summary>
public required string MethodKey { get; init; }
/// <summary>
/// Simple method name.
/// </summary>
public required string MethodName { get; init; }
/// <summary>
/// Declaring type.
/// </summary>
public required string DeclaringType { get; init; }
/// <summary>
/// Number of sinks reachable from this trigger.
/// </summary>
public int SinkCount { get; init; }
/// <summary>
/// Shortest path length to any sink.
/// </summary>
public int ShortestPathLength { get; init; }
/// <summary>
/// Whether this trigger is an interface method.
/// </summary>
public bool IsInterfaceTrigger { get; init; }
}
/// <summary>
/// Source of sink methods for reachability analysis.
/// </summary>
public enum SinkSource
{
/// <summary>
/// Sinks from computed vulnerability surface (highest precision).
/// </summary>
Surface,
/// <summary>
/// Sinks from package public API (medium precision).
/// </summary>
PackageApi,
/// <summary>
/// Fallback: all methods in package (lowest precision).
/// </summary>
FallbackAll
}

View File

@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------------
// ISurfaceRepository.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002)
// Description: Repository interface for vulnerability surface data access.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Scanner.Reachability.Surfaces;
/// <summary>
/// Repository for accessing vulnerability surface data.
/// </summary>
public interface ISurfaceRepository
{
/// <summary>
/// Gets a vulnerability surface by CVE and package.
/// </summary>
Task<SurfaceInfo?> GetSurfaceAsync(
string cveId,
string ecosystem,
string packageName,
string version,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets trigger methods for a surface.
/// </summary>
Task<IReadOnlyList<TriggerMethodInfo>> GetTriggersAsync(
Guid surfaceId,
int maxCount,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets sink method keys for a surface.
/// </summary>
Task<IReadOnlyList<string>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a surface exists.
/// </summary>
Task<bool> ExistsAsync(
string cveId,
string ecosystem,
string packageName,
string version,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Information about a vulnerability surface.
/// </summary>
public sealed record SurfaceInfo
{
/// <summary>
/// Surface ID.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package ecosystem.
/// </summary>
public required string Ecosystem { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Vulnerable version.
/// </summary>
public required string VulnVersion { get; init; }
/// <summary>
/// Fixed version.
/// </summary>
public string? FixedVersion { get; init; }
/// <summary>
/// When the surface was computed.
/// </summary>
public DateTimeOffset ComputedAt { get; init; }
/// <summary>
/// Number of changed methods (sinks).
/// </summary>
public int ChangedMethodCount { get; init; }
/// <summary>
/// Number of trigger methods.
/// </summary>
public int TriggerCount { get; init; }
}

View File

@@ -0,0 +1,97 @@
// -----------------------------------------------------------------------------
// ReachabilityConfidenceTier.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-004)
// Description: Confidence tiers for reachability analysis results.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Confidence tier for reachability analysis results.
/// Higher tiers indicate more precise and actionable findings.
/// </summary>
public enum ReachabilityConfidenceTier
{
/// <summary>
/// Confirmed reachable: Surface + trigger method reachable.
/// Path from entrypoint to specific trigger method that reaches vulnerable code.
/// Highest confidence - "You WILL hit the vulnerable code via this path."
/// </summary>
Confirmed = 100,
/// <summary>
/// Likely reachable: No surface but package API is called.
/// Path to public API of vulnerable package exists.
/// Medium confidence - "You call the package; vulnerability MAY be triggered."
/// </summary>
Likely = 75,
/// <summary>
/// Present: Package is in dependency tree but no call graph data.
/// Dependency exists but reachability cannot be determined.
/// Lower confidence - "Package is present; impact unknown."
/// </summary>
Present = 50,
/// <summary>
/// Unreachable: No path to vulnerable code found.
/// Surface analyzed, no triggers reached from entrypoints.
/// Evidence for not_affected VEX status.
/// </summary>
Unreachable = 25,
/// <summary>
/// Unknown: Insufficient data to determine reachability.
/// </summary>
Unknown = 0
}
/// <summary>
/// Extension methods for ReachabilityConfidenceTier.
/// </summary>
public static class ReachabilityConfidenceTierExtensions
{
/// <summary>
/// Gets human-readable description of the confidence tier.
/// </summary>
public static string GetDescription(this ReachabilityConfidenceTier tier) => tier switch
{
ReachabilityConfidenceTier.Confirmed => "Confirmed reachable via trigger method",
ReachabilityConfidenceTier.Likely => "Likely reachable via package API",
ReachabilityConfidenceTier.Present => "Package present but reachability undetermined",
ReachabilityConfidenceTier.Unreachable => "No path to vulnerable code found",
ReachabilityConfidenceTier.Unknown => "Insufficient data for analysis",
_ => "Unknown confidence tier"
};
/// <summary>
/// Gets the VEX status recommendation for this tier.
/// </summary>
public static string GetVexRecommendation(this ReachabilityConfidenceTier tier) => tier switch
{
ReachabilityConfidenceTier.Confirmed => "affected",
ReachabilityConfidenceTier.Likely => "under_investigation",
ReachabilityConfidenceTier.Present => "under_investigation",
ReachabilityConfidenceTier.Unreachable => "not_affected",
ReachabilityConfidenceTier.Unknown => "under_investigation",
_ => "under_investigation"
};
/// <summary>
/// Checks if this tier indicates potential impact.
/// </summary>
public static bool IndicatesImpact(this ReachabilityConfidenceTier tier) =>
tier is ReachabilityConfidenceTier.Confirmed or ReachabilityConfidenceTier.Likely;
/// <summary>
/// Checks if this tier can provide evidence for not_affected.
/// </summary>
public static bool CanBeNotAffected(this ReachabilityConfidenceTier tier) =>
tier is ReachabilityConfidenceTier.Unreachable;
/// <summary>
/// Gets a confidence score (0.0 - 1.0) for this tier.
/// </summary>
public static double GetConfidenceScore(this ReachabilityConfidenceTier tier) =>
(int)tier / 100.0;
}

View File

@@ -0,0 +1,473 @@
// -----------------------------------------------------------------------------
// SurfaceAwareReachabilityAnalyzer.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-005, REACH-006, REACH-009)
// Description: Reachability analyzer that uses vulnerability surfaces for precise sink resolution.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Surfaces;
/// <summary>
/// Reachability analyzer that integrates with vulnerability surfaces
/// for precise trigger-based sink resolution.
/// </summary>
public sealed class SurfaceAwareReachabilityAnalyzer : ISurfaceAwareReachabilityAnalyzer
{
private readonly ISurfaceQueryService _surfaceQuery;
private readonly IReachabilityGraphService _graphService;
private readonly ILogger<SurfaceAwareReachabilityAnalyzer> _logger;
public SurfaceAwareReachabilityAnalyzer(
ISurfaceQueryService surfaceQuery,
IReachabilityGraphService graphService,
ILogger<SurfaceAwareReachabilityAnalyzer> logger)
{
_surfaceQuery = surfaceQuery ?? throw new ArgumentNullException(nameof(surfaceQuery));
_graphService = graphService ?? throw new ArgumentNullException(nameof(graphService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<SurfaceAwareReachabilityResult> AnalyzeAsync(
SurfaceAwareReachabilityRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var findings = new List<SurfaceReachabilityFinding>();
// Query surfaces for all vulnerabilities
var surfaceRequests = request.Vulnerabilities
.Select(v => new SurfaceQueryRequest
{
CveId = v.CveId,
Ecosystem = v.Ecosystem,
PackageName = v.PackageName,
Version = v.Version,
IncludePaths = true
})
.ToList();
var surfaceResults = await _surfaceQuery.QueryBulkAsync(surfaceRequests, cancellationToken);
foreach (var vuln in request.Vulnerabilities)
{
cancellationToken.ThrowIfCancellationRequested();
var queryKey = $"{vuln.CveId}|{vuln.Ecosystem}|{vuln.PackageName}|{vuln.Version}";
if (!surfaceResults.TryGetValue(queryKey, out var surface))
{
// No surface result - should not happen but handle gracefully
findings.Add(CreateUnknownFinding(vuln, "No surface query result"));
continue;
}
var finding = await AnalyzeVulnerabilityAsync(vuln, surface, request.CallGraph, cancellationToken);
findings.Add(finding);
}
sw.Stop();
// Compute summary statistics
var confirmedCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Confirmed);
var likelyCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Likely);
var unreachableCount = findings.Count(f => f.ConfidenceTier == ReachabilityConfidenceTier.Unreachable);
_logger.LogInformation(
"Surface-aware reachability analysis complete: {Total} vulns, {Confirmed} confirmed, {Likely} likely, {Unreachable} unreachable in {Duration}ms",
findings.Count, confirmedCount, likelyCount, unreachableCount, sw.ElapsedMilliseconds);
return new SurfaceAwareReachabilityResult
{
Findings = findings,
TotalVulnerabilities = findings.Count,
ConfirmedReachable = confirmedCount,
LikelyReachable = likelyCount,
Unreachable = unreachableCount,
AnalysisDuration = sw.Elapsed
};
}
private async Task<SurfaceReachabilityFinding> AnalyzeVulnerabilityAsync(
VulnerabilityInfo vuln,
SurfaceQueryResult surface,
ICallGraphAccessor? callGraph,
CancellationToken cancellationToken)
{
// Determine sink source and resolve sinks
IReadOnlyList<string> sinks;
SinkSource sinkSource;
if (surface.SurfaceFound && surface.Triggers.Count > 0)
{
// Use trigger methods as sinks (highest precision)
sinks = surface.Triggers.Select(t => t.MethodKey).ToList();
sinkSource = SinkSource.Surface;
_logger.LogDebug(
"{CveId}/{PackageName}: Using {TriggerCount} trigger methods from surface",
vuln.CveId, vuln.PackageName, sinks.Count);
}
else if (surface.Source == SinkSource.PackageApi)
{
// Fallback to package API methods
sinks = await ResolvePackageApiMethodsAsync(vuln, cancellationToken);
sinkSource = SinkSource.PackageApi;
_logger.LogDebug(
"{CveId}/{PackageName}: Using {SinkCount} package API methods as fallback",
vuln.CveId, vuln.PackageName, sinks.Count);
}
else
{
// Ultimate fallback - no sink resolution possible
return CreatePresentFinding(vuln, surface);
}
// If no call graph, we can't determine reachability
if (callGraph is null)
{
return CreatePresentFinding(vuln, surface);
}
// Perform reachability analysis from entrypoints to sinks
var reachablePaths = await _graphService.FindPathsToSinksAsync(
callGraph,
sinks,
cancellationToken);
if (reachablePaths.Count == 0)
{
// No paths found - unreachable
return new SurfaceReachabilityFinding
{
CveId = vuln.CveId,
PackageName = vuln.PackageName,
Version = vuln.Version,
ConfidenceTier = ReachabilityConfidenceTier.Unreachable,
SinkSource = sinkSource,
SurfaceId = surface.SurfaceId,
Message = "No execution path to vulnerable code found",
ReachableTriggers = [],
Witnesses = []
};
}
// Paths found - determine confidence tier
var tier = sinkSource == SinkSource.Surface
? ReachabilityConfidenceTier.Confirmed
: ReachabilityConfidenceTier.Likely;
var reachableTriggers = reachablePaths
.Select(p => p.SinkMethodKey)
.Distinct()
.ToList();
return new SurfaceReachabilityFinding
{
CveId = vuln.CveId,
PackageName = vuln.PackageName,
Version = vuln.Version,
ConfidenceTier = tier,
SinkSource = sinkSource,
SurfaceId = surface.SurfaceId,
Message = $"{tier.GetDescription()}: {reachablePaths.Count} paths to {reachableTriggers.Count} triggers",
ReachableTriggers = reachableTriggers,
Witnesses = reachablePaths.Select(p => new PathWitness
{
EntrypointMethodKey = p.EntrypointMethodKey,
SinkMethodKey = p.SinkMethodKey,
PathLength = p.PathLength,
PathMethodKeys = p.PathMethodKeys
}).ToList()
};
}
private async Task<IReadOnlyList<string>> ResolvePackageApiMethodsAsync(
VulnerabilityInfo vuln,
CancellationToken cancellationToken)
{
// TODO: Implement package API method resolution
// This would query the package's public API methods as fallback sinks
await Task.CompletedTask;
return [];
}
private static SurfaceReachabilityFinding CreatePresentFinding(
VulnerabilityInfo vuln,
SurfaceQueryResult surface)
{
return new SurfaceReachabilityFinding
{
CveId = vuln.CveId,
PackageName = vuln.PackageName,
Version = vuln.Version,
ConfidenceTier = ReachabilityConfidenceTier.Present,
SinkSource = surface.Source,
SurfaceId = surface.SurfaceId,
Message = "Package present; reachability undetermined",
ReachableTriggers = [],
Witnesses = []
};
}
private static SurfaceReachabilityFinding CreateUnknownFinding(
VulnerabilityInfo vuln,
string reason)
{
return new SurfaceReachabilityFinding
{
CveId = vuln.CveId,
PackageName = vuln.PackageName,
Version = vuln.Version,
ConfidenceTier = ReachabilityConfidenceTier.Unknown,
SinkSource = SinkSource.FallbackAll,
Message = reason,
ReachableTriggers = [],
Witnesses = []
};
}
}
/// <summary>
/// Interface for surface-aware reachability analysis.
/// </summary>
public interface ISurfaceAwareReachabilityAnalyzer
{
/// <summary>
/// Analyzes reachability for vulnerabilities using surface data.
/// </summary>
Task<SurfaceAwareReachabilityResult> AnalyzeAsync(
SurfaceAwareReachabilityRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for surface-aware reachability analysis.
/// </summary>
public sealed record SurfaceAwareReachabilityRequest
{
/// <summary>
/// Vulnerabilities to analyze.
/// </summary>
public required IReadOnlyList<VulnerabilityInfo> Vulnerabilities { get; init; }
/// <summary>
/// Call graph accessor for the analyzed codebase.
/// </summary>
public ICallGraphAccessor? CallGraph { get; init; }
/// <summary>
/// Maximum depth for path finding.
/// </summary>
public int MaxPathDepth { get; init; } = 20;
}
/// <summary>
/// Result of surface-aware reachability analysis.
/// </summary>
public sealed record SurfaceAwareReachabilityResult
{
/// <summary>
/// Individual findings for each vulnerability.
/// </summary>
public IReadOnlyList<SurfaceReachabilityFinding> Findings { get; init; } = [];
/// <summary>
/// Total vulnerabilities analyzed.
/// </summary>
public int TotalVulnerabilities { get; init; }
/// <summary>
/// Count of confirmed reachable vulnerabilities.
/// </summary>
public int ConfirmedReachable { get; init; }
/// <summary>
/// Count of likely reachable vulnerabilities.
/// </summary>
public int LikelyReachable { get; init; }
/// <summary>
/// Count of unreachable vulnerabilities.
/// </summary>
public int Unreachable { get; init; }
/// <summary>
/// Analysis duration.
/// </summary>
public TimeSpan AnalysisDuration { get; init; }
}
/// <summary>
/// Reachability finding for a single vulnerability.
/// </summary>
public sealed record SurfaceReachabilityFinding
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Package version.
/// </summary>
public required string Version { get; init; }
/// <summary>
/// Confidence tier for this finding.
/// </summary>
public ReachabilityConfidenceTier ConfidenceTier { get; init; }
/// <summary>
/// Source of sink methods used.
/// </summary>
public SinkSource SinkSource { get; init; }
/// <summary>
/// Surface ID if available.
/// </summary>
public Guid? SurfaceId { get; init; }
/// <summary>
/// Human-readable message.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Trigger methods that are reachable.
/// </summary>
public IReadOnlyList<string> ReachableTriggers { get; init; } = [];
/// <summary>
/// Path witnesses from entrypoints to triggers.
/// </summary>
public IReadOnlyList<PathWitness> Witnesses { get; init; } = [];
}
/// <summary>
/// Vulnerability information for analysis.
/// </summary>
public sealed record VulnerabilityInfo
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Package ecosystem.
/// </summary>
public required string Ecosystem { get; init; }
/// <summary>
/// Package name.
/// </summary>
public required string PackageName { get; init; }
/// <summary>
/// Package version.
/// </summary>
public required string Version { get; init; }
}
/// <summary>
/// Path witness from entrypoint to sink.
/// </summary>
public sealed record PathWitness
{
/// <summary>
/// Entrypoint method key.
/// </summary>
public required string EntrypointMethodKey { get; init; }
/// <summary>
/// Sink (trigger) method key.
/// </summary>
public required string SinkMethodKey { get; init; }
/// <summary>
/// Number of hops in path.
/// </summary>
public int PathLength { get; init; }
/// <summary>
/// Ordered method keys in path.
/// </summary>
public IReadOnlyList<string> PathMethodKeys { get; init; } = [];
}
/// <summary>
/// Interface for call graph access.
/// </summary>
public interface ICallGraphAccessor
{
/// <summary>
/// Gets entrypoint method keys.
/// </summary>
IReadOnlyList<string> GetEntrypoints();
/// <summary>
/// Gets callees of a method.
/// </summary>
IReadOnlyList<string> GetCallees(string methodKey);
/// <summary>
/// Checks if a method exists.
/// </summary>
bool ContainsMethod(string methodKey);
}
/// <summary>
/// Interface for reachability graph operations.
/// </summary>
public interface IReachabilityGraphService
{
/// <summary>
/// Finds paths from entrypoints to any of the specified sinks.
/// </summary>
Task<IReadOnlyList<ReachablePath>> FindPathsToSinksAsync(
ICallGraphAccessor callGraph,
IReadOnlyList<string> sinkMethodKeys,
CancellationToken cancellationToken = default);
}
/// <summary>
/// A reachable path from entrypoint to sink.
/// </summary>
public sealed record ReachablePath
{
/// <summary>
/// Entrypoint method key.
/// </summary>
public required string EntrypointMethodKey { get; init; }
/// <summary>
/// Sink method key.
/// </summary>
public required string SinkMethodKey { get; init; }
/// <summary>
/// Path length.
/// </summary>
public int PathLength { get; init; }
/// <summary>
/// Ordered method keys in path.
/// </summary>
public IReadOnlyList<string> PathMethodKeys { get; init; } = [];
}

View File

@@ -0,0 +1,275 @@
// -----------------------------------------------------------------------------
// SurfaceQueryService.cs
// Sprint: SPRINT_3700_0004_0001_reachability_integration (REACH-002, REACH-003, REACH-007)
// Description: Implementation of vulnerability surface query service.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Reachability.Surfaces;
/// <summary>
/// Implementation of the vulnerability surface query service.
/// Queries the database for pre-computed vulnerability surfaces.
/// </summary>
public sealed class SurfaceQueryService : ISurfaceQueryService
{
private readonly ISurfaceRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<SurfaceQueryService> _logger;
private readonly SurfaceQueryOptions _options;
private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(15);
public SurfaceQueryService(
ISurfaceRepository repository,
IMemoryCache cache,
ILogger<SurfaceQueryService> logger,
SurfaceQueryOptions? options = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? new SurfaceQueryOptions();
}
/// <inheritdoc />
public async Task<SurfaceQueryResult> QueryAsync(
SurfaceQueryRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
var cacheKey = $"surface:{request.QueryKey}";
// Check cache first
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(cacheKey, out var cached))
{
SurfaceQueryMetrics.CacheHits.Add(1);
return cached!;
}
SurfaceQueryMetrics.CacheMisses.Add(1);
var sw = Stopwatch.StartNew();
try
{
// Query repository
var surface = await _repository.GetSurfaceAsync(
request.CveId,
request.Ecosystem,
request.PackageName,
request.Version,
cancellationToken);
SurfaceQueryResult result;
if (surface is not null)
{
// Surface found - get triggers
var triggers = await _repository.GetTriggersAsync(
surface.Id,
request.MaxTriggers,
cancellationToken);
var sinks = await _repository.GetSinksAsync(surface.Id, cancellationToken);
result = SurfaceQueryResult.Found(
surface.Id,
triggers,
sinks,
surface.ComputedAt);
SurfaceQueryMetrics.SurfaceHits.Add(1);
_logger.LogDebug(
"Surface found for {CveId}/{PackageName}: {TriggerCount} triggers, {SinkCount} sinks",
request.CveId, request.PackageName, triggers.Count, sinks.Count);
}
else
{
// Surface not found - apply fallback cascade
result = ApplyFallbackCascade(request);
SurfaceQueryMetrics.SurfaceMisses.Add(1);
}
sw.Stop();
SurfaceQueryMetrics.QueryDurationMs.Record(sw.ElapsedMilliseconds);
// Cache result
if (_options.EnableCaching)
{
var cacheOptions = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _options.CacheDuration ?? DefaultCacheDuration
};
_cache.Set(cacheKey, result, cacheOptions);
}
return result;
}
catch (Exception ex)
{
sw.Stop();
SurfaceQueryMetrics.QueryErrors.Add(1);
_logger.LogWarning(ex, "Failed to query surface for {CveId}/{PackageName}", request.CveId, request.PackageName);
return SurfaceQueryResult.FallbackToPackageApi($"Query failed: {ex.Message}");
}
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<string, SurfaceQueryResult>> QueryBulkAsync(
IEnumerable<SurfaceQueryRequest> requests,
CancellationToken cancellationToken = default)
{
var requestList = requests.ToList();
var results = new Dictionary<string, SurfaceQueryResult>(requestList.Count);
// Split into cached and uncached
var uncachedRequests = new List<SurfaceQueryRequest>();
foreach (var request in requestList)
{
var cacheKey = $"surface:{request.QueryKey}";
if (_options.EnableCaching && _cache.TryGetValue<SurfaceQueryResult>(cacheKey, out var cached))
{
results[request.QueryKey] = cached!;
SurfaceQueryMetrics.CacheHits.Add(1);
}
else
{
uncachedRequests.Add(request);
SurfaceQueryMetrics.CacheMisses.Add(1);
}
}
// Query remaining in parallel batches
if (uncachedRequests.Count > 0)
{
var batchSize = _options.BulkQueryBatchSize;
var batches = uncachedRequests
.Select((r, i) => new { Request = r, Index = i })
.GroupBy(x => x.Index / batchSize)
.Select(g => g.Select(x => x.Request).ToList());
foreach (var batch in batches)
{
var tasks = batch.Select(r => QueryAsync(r, cancellationToken));
var batchResults = await Task.WhenAll(tasks);
for (var i = 0; i < batch.Count; i++)
{
results[batch[i].QueryKey] = batchResults[i];
}
}
}
return results;
}
/// <inheritdoc />
public async Task<bool> ExistsAsync(
string cveId,
string ecosystem,
string packageName,
string version,
CancellationToken cancellationToken = default)
{
var cacheKey = $"surface_exists:{cveId}|{ecosystem}|{packageName}|{version}";
if (_options.EnableCaching && _cache.TryGetValue<bool>(cacheKey, out var exists))
{
return exists;
}
var result = await _repository.ExistsAsync(cveId, ecosystem, packageName, version, cancellationToken);
if (_options.EnableCaching)
{
_cache.Set(cacheKey, result, TimeSpan.FromMinutes(5));
}
return result;
}
private SurfaceQueryResult ApplyFallbackCascade(SurfaceQueryRequest request)
{
_logger.LogDebug(
"No surface for {CveId}/{PackageName} v{Version}, applying fallback cascade",
request.CveId, request.PackageName, request.Version);
// Fallback cascade:
// 1. If we have package API info, use that
// 2. Otherwise, fall back to "all methods" mode
// For now, return FallbackAll - in future we can add PackageApi lookup
return SurfaceQueryResult.NotFound(request.CveId, request.PackageName);
}
}
/// <summary>
/// Options for surface query service.
/// </summary>
public sealed record SurfaceQueryOptions
{
/// <summary>
/// Whether to enable in-memory caching.
/// </summary>
public bool EnableCaching { get; init; } = true;
/// <summary>
/// Cache duration for surface results.
/// </summary>
public TimeSpan? CacheDuration { get; init; }
/// <summary>
/// Batch size for bulk queries.
/// </summary>
public int BulkQueryBatchSize { get; init; } = 10;
}
/// <summary>
/// Metrics for surface query service.
/// </summary>
internal static class SurfaceQueryMetrics
{
private static readonly string MeterName = "StellaOps.Scanner.Reachability.Surfaces";
public static readonly System.Diagnostics.Metrics.Counter<long> CacheHits =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.cache_hits",
description: "Number of surface query cache hits");
public static readonly System.Diagnostics.Metrics.Counter<long> CacheMisses =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.cache_misses",
description: "Number of surface query cache misses");
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceHits =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.surface_hits",
description: "Number of surfaces found");
public static readonly System.Diagnostics.Metrics.Counter<long> SurfaceMisses =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.surface_misses",
description: "Number of surfaces not found");
public static readonly System.Diagnostics.Metrics.Counter<long> QueryErrors =
new System.Diagnostics.Metrics.Meter(MeterName).CreateCounter<long>(
"stellaops.surface_query.errors",
description: "Number of query errors");
public static readonly System.Diagnostics.Metrics.Histogram<long> QueryDurationMs =
new System.Diagnostics.Metrics.Meter(MeterName).CreateHistogram<long>(
"stellaops.surface_query.duration_ms",
unit: "ms",
description: "Surface query duration in milliseconds");
}

View File

@@ -1,3 +1,5 @@
using System.Collections.Immutable;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
@@ -20,6 +22,18 @@ public interface IPathWitnessBuilder
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All generated witnesses.</returns>
IAsyncEnumerable<PathWitness> BuildAllAsync(BatchWitnessRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Creates path witnesses from pre-computed ReachabilityAnalyzer output.
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
/// This method uses deterministic paths from the analyzer instead of computing its own.
/// </summary>
/// <param name="request">The analyzer-based witness request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>All generated witnesses from the analyzer paths.</returns>
IAsyncEnumerable<PathWitness> BuildFromAnalyzerAsync(
AnalyzerWitnessRequest request,
CancellationToken cancellationToken = default);
}
/// <summary>
@@ -173,3 +187,92 @@ public sealed record BatchWitnessRequest
/// </summary>
public string? BuildId { get; init; }
}
/// <summary>
/// Request to build witnesses from pre-computed ReachabilityAnalyzer output.
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
/// </summary>
public sealed record AnalyzerWitnessRequest
{
/// <summary>
/// The SBOM digest for artifact context.
/// </summary>
public required string SbomDigest { get; init; }
/// <summary>
/// Package URL of the vulnerable component.
/// </summary>
public required string ComponentPurl { get; init; }
/// <summary>
/// Vulnerability ID (e.g., "CVE-2024-12345").
/// </summary>
public required string VulnId { get; init; }
/// <summary>
/// Vulnerability source (e.g., "NVD").
/// </summary>
public required string VulnSource { get; init; }
/// <summary>
/// Affected version range.
/// </summary>
public required string AffectedRange { get; init; }
/// <summary>
/// Sink taxonomy type for all sinks in the paths.
/// </summary>
public required string SinkType { get; init; }
/// <summary>
/// Graph digest from the analyzer result.
/// </summary>
public required string GraphDigest { get; init; }
/// <summary>
/// Pre-computed paths from ReachabilityAnalyzer.
/// Each path contains (EntrypointId, SinkId, NodeIds ordered from entrypoint to sink).
/// </summary>
public required IReadOnlyList<AnalyzerPathData> Paths { get; init; }
/// <summary>
/// Node metadata lookup for resolving node details.
/// Key is node ID, value contains name, file, line info.
/// </summary>
public required IReadOnlyDictionary<string, AnalyzerNodeData> NodeMetadata { get; init; }
/// <summary>
/// Optional attack surface digest.
/// </summary>
public string? SurfaceDigest { get; init; }
/// <summary>
/// Optional analysis config digest.
/// </summary>
public string? AnalysisConfigDigest { get; init; }
/// <summary>
/// Optional build ID.
/// </summary>
public string? BuildId { get; init; }
}
/// <summary>
/// Lightweight representation of a reachability path from the analyzer.
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
/// </summary>
public sealed record AnalyzerPathData(
string EntrypointId,
string SinkId,
ImmutableArray<string> NodeIds);
/// <summary>
/// Lightweight node metadata for witness generation.
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
/// </summary>
public sealed record AnalyzerNodeData(
string Name,
string? FilePath,
int? Line,
string? EntrypointKind);

View File

@@ -0,0 +1,28 @@
using StellaOps.Attestor.Envelope;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Service for creating and verifying DSSE-signed path witness envelopes.
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
/// </summary>
public interface IWitnessDsseSigner
{
/// <summary>
/// Signs a path witness and creates a DSSE envelope.
/// </summary>
/// <param name="witness">The path witness to sign.</param>
/// <param name="signingKey">The key to use for signing (must have private material).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the DSSE envelope or error.</returns>
WitnessDsseResult SignWitness(PathWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a DSSE-signed witness envelope.
/// </summary>
/// <param name="envelope">The DSSE envelope containing the signed witness.</param>
/// <param name="publicKey">The public key to verify against.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the verified witness or error.</returns>
WitnessVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default);
}

View File

@@ -164,6 +164,111 @@ public sealed class PathWitnessBuilder : IPathWitnessBuilder
}
}
/// <inheritdoc />
/// <summary>
/// Creates path witnesses from pre-computed ReachabilityAnalyzer output.
/// Sprint: SPRINT_3700_0001_0001 (WIT-008)
/// </summary>
public async IAsyncEnumerable<PathWitness> BuildFromAnalyzerAsync(
AnalyzerWitnessRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Paths.Count == 0)
{
yield break;
}
var nodeMetadata = request.NodeMetadata;
foreach (var analyzerPath in request.Paths)
{
cancellationToken.ThrowIfCancellationRequested();
// Convert analyzer NodeIds to PathSteps with metadata
var pathSteps = new List<PathStep>();
foreach (var nodeId in analyzerPath.NodeIds)
{
if (nodeMetadata.TryGetValue(nodeId, out var node))
{
pathSteps.Add(new PathStep
{
Symbol = node.Name,
SymbolId = nodeId,
File = node.FilePath,
Line = node.Line
});
}
else
{
// Node not found, add with just the ID
pathSteps.Add(new PathStep
{
Symbol = nodeId,
SymbolId = nodeId,
File = null,
Line = null
});
}
}
// Get entrypoint metadata
nodeMetadata.TryGetValue(analyzerPath.EntrypointId, out var entrypointNode);
var entrypointKind = entrypointNode?.EntrypointKind ?? "unknown";
var entrypointName = entrypointNode?.Name ?? analyzerPath.EntrypointId;
// Get sink metadata
nodeMetadata.TryGetValue(analyzerPath.SinkId, out var sinkNode);
var sinkSymbol = sinkNode?.Name ?? analyzerPath.SinkId;
// Build the witness
var witness = new PathWitness
{
WitnessId = string.Empty, // Will be set after hashing
Artifact = new WitnessArtifact
{
SbomDigest = request.SbomDigest,
ComponentPurl = request.ComponentPurl
},
Vuln = new WitnessVuln
{
Id = request.VulnId,
Source = request.VulnSource,
AffectedRange = request.AffectedRange
},
Entrypoint = new WitnessEntrypoint
{
Kind = entrypointKind,
Name = entrypointName,
SymbolId = analyzerPath.EntrypointId
},
Path = pathSteps,
Sink = new WitnessSink
{
Symbol = sinkSymbol,
SymbolId = analyzerPath.SinkId,
SinkType = request.SinkType
},
Gates = null, // Gate detection not applied for analyzer-based paths yet
Evidence = new WitnessEvidence
{
CallgraphDigest = request.GraphDigest,
SurfaceDigest = request.SurfaceDigest,
AnalysisConfigDigest = request.AnalysisConfigDigest,
BuildId = request.BuildId
},
ObservedAt = _timeProvider.GetUtcNow()
};
// Compute witness ID from canonical content
var witnessId = ComputeWitnessId(witness);
witness = witness with { WitnessId = witnessId };
yield return witness;
}
}
/// <summary>
/// Finds the shortest path from source to target using BFS.
/// </summary>

View File

@@ -0,0 +1,179 @@
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Generates signed DSSE envelopes for path witnesses.
/// Sprint: SPRINT_3700_0001_0001 (WIT-009)
/// Combines PathWitnessBuilder with WitnessDsseSigner for end-to-end witness attestation.
/// </summary>
public sealed class SignedWitnessGenerator : ISignedWitnessGenerator
{
private readonly IPathWitnessBuilder _builder;
private readonly IWitnessDsseSigner _signer;
/// <summary>
/// Creates a new SignedWitnessGenerator.
/// </summary>
public SignedWitnessGenerator(IPathWitnessBuilder builder, IWitnessDsseSigner signer)
{
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
}
/// <inheritdoc />
public async Task<SignedWitnessResult?> GenerateSignedWitnessAsync(
PathWitnessRequest request,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(signingKey);
// Build the witness
var witness = await _builder.BuildAsync(request, cancellationToken).ConfigureAwait(false);
if (witness is null)
{
return null;
}
// Sign it
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
if (!signResult.IsSuccess)
{
return new SignedWitnessResult
{
IsSuccess = false,
Error = signResult.Error
};
}
return new SignedWitnessResult
{
IsSuccess = true,
Witness = witness,
Envelope = signResult.Envelope,
PayloadBytes = signResult.PayloadBytes
};
}
/// <inheritdoc />
public async IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesAsync(
BatchWitnessRequest request,
EnvelopeKey signingKey,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(signingKey);
await foreach (var witness in _builder.BuildAllAsync(request, cancellationToken).ConfigureAwait(false))
{
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
yield return signResult.IsSuccess
? new SignedWitnessResult
{
IsSuccess = true,
Witness = witness,
Envelope = signResult.Envelope,
PayloadBytes = signResult.PayloadBytes
}
: new SignedWitnessResult
{
IsSuccess = false,
Error = signResult.Error
};
}
}
/// <inheritdoc />
public async IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesFromAnalyzerAsync(
AnalyzerWitnessRequest request,
EnvelopeKey signingKey,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(signingKey);
await foreach (var witness in _builder.BuildFromAnalyzerAsync(request, cancellationToken).ConfigureAwait(false))
{
var signResult = _signer.SignWitness(witness, signingKey, cancellationToken);
yield return signResult.IsSuccess
? new SignedWitnessResult
{
IsSuccess = true,
Witness = witness,
Envelope = signResult.Envelope,
PayloadBytes = signResult.PayloadBytes
}
: new SignedWitnessResult
{
IsSuccess = false,
Error = signResult.Error
};
}
}
}
/// <summary>
/// Interface for generating signed DSSE envelopes for path witnesses.
/// </summary>
public interface ISignedWitnessGenerator
{
/// <summary>
/// Generates a signed witness from a single request.
/// </summary>
Task<SignedWitnessResult?> GenerateSignedWitnessAsync(
PathWitnessRequest request,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Generates signed witnesses from a batch request.
/// </summary>
IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesAsync(
BatchWitnessRequest request,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default);
/// <summary>
/// Generates signed witnesses from pre-computed analyzer paths.
/// </summary>
IAsyncEnumerable<SignedWitnessResult> GenerateSignedWitnessesFromAnalyzerAsync(
AnalyzerWitnessRequest request,
EnvelopeKey signingKey,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of generating a signed witness.
/// </summary>
public sealed record SignedWitnessResult
{
/// <summary>
/// Whether the signing succeeded.
/// </summary>
public bool IsSuccess { get; init; }
/// <summary>
/// The generated witness (if successful).
/// </summary>
public PathWitness? Witness { get; init; }
/// <summary>
/// The DSSE envelope containing the signed witness (if successful).
/// </summary>
public DsseEnvelope? Envelope { get; init; }
/// <summary>
/// The canonical JSON payload bytes (if successful).
/// </summary>
public byte[]? PayloadBytes { get; init; }
/// <summary>
/// Error message (if failed).
/// </summary>
public string? Error { get; init; }
}

View File

@@ -0,0 +1,207 @@
using System.Text;
using System.Text.Json;
using StellaOps.Attestor.Envelope;
namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Service for creating and verifying DSSE-signed path witness envelopes.
/// Sprint: SPRINT_3700_0001_0001 (WIT-007D)
/// </summary>
public sealed class WitnessDsseSigner : IWitnessDsseSigner
{
private readonly EnvelopeSignatureService _signatureService;
private static readonly JsonSerializerOptions CanonicalJsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Creates a new WitnessDsseSigner with the specified signature service.
/// </summary>
public WitnessDsseSigner(EnvelopeSignatureService signatureService)
{
_signatureService = signatureService ?? throw new ArgumentNullException(nameof(signatureService));
}
/// <summary>
/// Creates a new WitnessDsseSigner with a default signature service.
/// </summary>
public WitnessDsseSigner() : this(new EnvelopeSignatureService())
{
}
/// <inheritdoc />
public WitnessDsseResult SignWitness(PathWitness witness, EnvelopeKey signingKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(witness);
ArgumentNullException.ThrowIfNull(signingKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Serialize witness to canonical JSON bytes
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(witness, CanonicalJsonOptions);
// Build the PAE (Pre-Authentication Encoding) for DSSE
var pae = BuildPae(WitnessSchema.DssePayloadType, payloadBytes);
// Sign the PAE
var signResult = _signatureService.Sign(pae, signingKey, cancellationToken);
if (!signResult.IsSuccess)
{
return WitnessDsseResult.Failure($"Signing failed: {signResult.Error?.Message}");
}
var signature = signResult.Value;
// Create the DSSE envelope
var dsseSignature = new DsseSignature(
signature: Convert.ToBase64String(signature.Value.Span),
keyId: signature.KeyId);
var envelope = new DsseEnvelope(
payloadType: WitnessSchema.DssePayloadType,
payload: payloadBytes,
signatures: [dsseSignature]);
return WitnessDsseResult.Success(envelope, payloadBytes);
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
return WitnessDsseResult.Failure($"Failed to create DSSE envelope: {ex.Message}");
}
}
/// <inheritdoc />
public WitnessVerifyResult VerifyWitness(DsseEnvelope envelope, EnvelopeKey publicKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(envelope);
ArgumentNullException.ThrowIfNull(publicKey);
cancellationToken.ThrowIfCancellationRequested();
try
{
// Verify payload type
if (!string.Equals(envelope.PayloadType, WitnessSchema.DssePayloadType, StringComparison.Ordinal))
{
return WitnessVerifyResult.Failure($"Invalid payload type: expected '{WitnessSchema.DssePayloadType}', got '{envelope.PayloadType}'");
}
// Deserialize the witness from payload
var witness = JsonSerializer.Deserialize<PathWitness>(envelope.Payload.Span, CanonicalJsonOptions);
if (witness is null)
{
return WitnessVerifyResult.Failure("Failed to deserialize witness from payload");
}
// Verify schema version
if (!string.Equals(witness.WitnessSchema, WitnessSchema.Version, StringComparison.Ordinal))
{
return WitnessVerifyResult.Failure($"Unsupported witness schema: {witness.WitnessSchema}");
}
// Find signature matching the public key
var matchingSignature = envelope.Signatures.FirstOrDefault(
s => string.Equals(s.KeyId, publicKey.KeyId, StringComparison.Ordinal));
if (matchingSignature is null)
{
return WitnessVerifyResult.Failure($"No signature found for key ID: {publicKey.KeyId}");
}
// Build PAE and verify signature
var pae = BuildPae(envelope.PayloadType, envelope.Payload.ToArray());
var signatureBytes = Convert.FromBase64String(matchingSignature.Signature);
var envelopeSignature = new EnvelopeSignature(publicKey.KeyId, publicKey.AlgorithmId, signatureBytes);
var verifyResult = _signatureService.Verify(pae, envelopeSignature, publicKey, cancellationToken);
if (!verifyResult.IsSuccess)
{
return WitnessVerifyResult.Failure($"Signature verification failed: {verifyResult.Error?.Message}");
}
return WitnessVerifyResult.Success(witness, matchingSignature.KeyId);
}
catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException)
{
return WitnessVerifyResult.Failure($"Verification failed: {ex.Message}");
}
}
/// <summary>
/// Builds the DSSE Pre-Authentication Encoding (PAE) for a payload.
/// PAE = "DSSEv1" SP len(type) SP type SP len(payload) SP payload
/// </summary>
private static byte[] BuildPae(string payloadType, byte[] payload)
{
var typeBytes = Encoding.UTF8.GetBytes(payloadType);
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true);
// Write "DSSEv1 "
writer.Write(Encoding.UTF8.GetBytes("DSSEv1 "));
// Write len(type) as little-endian 8-byte integer followed by space
WriteLengthAndSpace(writer, typeBytes.Length);
// Write type followed by space
writer.Write(typeBytes);
writer.Write((byte)' ');
// Write len(payload) as little-endian 8-byte integer followed by space
WriteLengthAndSpace(writer, payload.Length);
// Write payload
writer.Write(payload);
writer.Flush();
return stream.ToArray();
}
private static void WriteLengthAndSpace(BinaryWriter writer, int length)
{
// Write length as ASCII decimal string
writer.Write(Encoding.UTF8.GetBytes(length.ToString()));
writer.Write((byte)' ');
}
}
/// <summary>
/// Result of DSSE signing a witness.
/// </summary>
public sealed record WitnessDsseResult
{
public bool IsSuccess { get; init; }
public DsseEnvelope? Envelope { get; init; }
public byte[]? PayloadBytes { get; init; }
public string? Error { get; init; }
public static WitnessDsseResult Success(DsseEnvelope envelope, byte[] payloadBytes)
=> new() { IsSuccess = true, Envelope = envelope, PayloadBytes = payloadBytes };
public static WitnessDsseResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}
/// <summary>
/// Result of verifying a DSSE-signed witness.
/// </summary>
public sealed record WitnessVerifyResult
{
public bool IsSuccess { get; init; }
public PathWitness? Witness { get; init; }
public string? VerifiedKeyId { get; init; }
public string? Error { get; init; }
public static WitnessVerifyResult Success(PathWitness witness, string keyId)
=> new() { IsSuccess = true, Witness = witness, VerifiedKeyId = keyId };
public static WitnessVerifyResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}

View File

@@ -2,6 +2,7 @@ namespace StellaOps.Scanner.Reachability.Witnesses;
/// <summary>
/// Constants for the stellaops.witness.v1 schema.
/// Sprint: SPRINT_3700_0001_0001 (WIT-007C)
/// </summary>
public static class WitnessSchema
{
@@ -16,7 +17,29 @@ public static class WitnessSchema
public const string WitnessIdPrefix = "wit:";
/// <summary>
/// Default DSSE payload type for witnesses.
/// Default DSSE payload type for path witnesses.
/// Used when creating DSSE envelopes for path witness attestations.
/// </summary>
public const string DssePayloadType = "application/vnd.stellaops.witness.v1+json";
/// <summary>
/// DSSE predicate type URI for path witnesses (in-toto style).
/// Matches PredicateTypes.StellaOpsPathWitness in Signer.Core.
/// </summary>
public const string PredicateType = "stella.ops/pathWitness@v1";
/// <summary>
/// Witness type for reachability path witnesses.
/// </summary>
public const string WitnessTypeReachabilityPath = "reachability_path";
/// <summary>
/// Witness type for gate proof witnesses.
/// </summary>
public const string WitnessTypeGateProof = "gate_proof";
/// <summary>
/// JSON schema URI for witness validation.
/// </summary>
public const string JsonSchemaUri = "https://stellaops.org/schemas/witness-v1.json";
}