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:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user