work work hard work
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
public sealed class ReachabilityAnalyzer
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly int _maxDepth;
|
||||
|
||||
public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_maxDepth = maxDepth <= 0 ? 256 : maxDepth;
|
||||
}
|
||||
|
||||
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var trimmed = snapshot.Trimmed();
|
||||
|
||||
var adjacency = BuildAdjacency(trimmed);
|
||||
|
||||
var entrypoints = trimmed.EntrypointIds;
|
||||
if (entrypoints.IsDefaultOrEmpty)
|
||||
{
|
||||
return EmptyResult(trimmed);
|
||||
}
|
||||
|
||||
var origins = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var parents = new Dictionary<string, string?>(StringComparer.Ordinal);
|
||||
var depths = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
|
||||
foreach (var entry in entrypoints.OrderBy(e => e, StringComparer.Ordinal))
|
||||
{
|
||||
origins[entry] = entry;
|
||||
parents[entry] = null;
|
||||
depths[entry] = 0;
|
||||
queue.Enqueue(entry);
|
||||
}
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!depths.TryGetValue(current, out var depth))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth >= _maxDepth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var next in neighbors)
|
||||
{
|
||||
if (origins.ContainsKey(next))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
origins[next] = origins[current];
|
||||
parents[next] = current;
|
||||
depths[next] = depth + 1;
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
|
||||
var reachableNodes = origins.Keys.OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray();
|
||||
var reachableSinks = trimmed.SinkIds
|
||||
.Where(origins.ContainsKey)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var paths = BuildPaths(reachableSinks, origins, parents);
|
||||
|
||||
var computedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new ReachabilityAnalysisResult(
|
||||
ScanId: trimmed.ScanId,
|
||||
GraphDigest: trimmed.GraphDigest,
|
||||
Language: trimmed.Language,
|
||||
ComputedAt: computedAt,
|
||||
ReachableNodeIds: reachableNodes,
|
||||
ReachableSinkIds: reachableSinks,
|
||||
Paths: paths,
|
||||
ResultDigest: string.Empty);
|
||||
|
||||
var resultDigest = CallGraphDigests.ComputeResultDigest(provisional);
|
||||
return provisional with { ResultDigest = resultDigest };
|
||||
}
|
||||
|
||||
private static Dictionary<string, ImmutableArray<string>> BuildAdjacency(CallGraphSnapshot snapshot)
|
||||
{
|
||||
var map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
||||
foreach (var edge in snapshot.Edges)
|
||||
{
|
||||
if (!map.TryGetValue(edge.SourceId, out var list))
|
||||
{
|
||||
list = new List<string>();
|
||||
map[edge.SourceId] = list;
|
||||
}
|
||||
list.Add(edge.TargetId);
|
||||
}
|
||||
|
||||
return map.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => kvp.Value
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(v => v, StringComparer.Ordinal)
|
||||
.ToImmutableArray(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static ReachabilityAnalysisResult EmptyResult(CallGraphSnapshot snapshot)
|
||||
{
|
||||
var computedAt = TimeProvider.System.GetUtcNow();
|
||||
var provisional = new ReachabilityAnalysisResult(
|
||||
ScanId: snapshot.ScanId,
|
||||
GraphDigest: snapshot.GraphDigest,
|
||||
Language: snapshot.Language,
|
||||
ComputedAt: computedAt,
|
||||
ReachableNodeIds: ImmutableArray<string>.Empty,
|
||||
ReachableSinkIds: ImmutableArray<string>.Empty,
|
||||
Paths: ImmutableArray<ReachabilityPath>.Empty,
|
||||
ResultDigest: string.Empty);
|
||||
|
||||
return provisional with { ResultDigest = CallGraphDigests.ComputeResultDigest(provisional) };
|
||||
}
|
||||
|
||||
private static ImmutableArray<ReachabilityPath> BuildPaths(
|
||||
ImmutableArray<string> reachableSinks,
|
||||
Dictionary<string, string> origins,
|
||||
Dictionary<string, string?> parents)
|
||||
{
|
||||
var paths = new List<ReachabilityPath>(reachableSinks.Length);
|
||||
foreach (var sinkId in reachableSinks)
|
||||
{
|
||||
if (!origins.TryGetValue(sinkId, out var origin))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeIds = ReconstructPathNodeIds(sinkId, parents);
|
||||
paths.Add(new ReachabilityPath(origin, sinkId, nodeIds));
|
||||
}
|
||||
|
||||
return paths
|
||||
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> ReconstructPathNodeIds(string sinkId, Dictionary<string, string?> parents)
|
||||
{
|
||||
var stack = new Stack<string>();
|
||||
var cursor = sinkId;
|
||||
while (true)
|
||||
{
|
||||
stack.Push(cursor);
|
||||
if (!parents.TryGetValue(cursor, out var parent) || parent is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>(stack.Count);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
builder.Add(stack.Pop());
|
||||
}
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Caching;
|
||||
|
||||
public sealed class CallGraphCacheConfig
|
||||
{
|
||||
[ConfigurationKeyName("enabled")]
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
[ConfigurationKeyName("connection_string")]
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
[ConfigurationKeyName("key_prefix")]
|
||||
public string KeyPrefix { get; set; } = "callgraph:";
|
||||
|
||||
[ConfigurationKeyName("ttl_seconds")]
|
||||
public int TtlSeconds { get; set; } = 3600;
|
||||
|
||||
[ConfigurationKeyName("gzip")]
|
||||
public bool EnableGzip { get; set; } = true;
|
||||
|
||||
[ConfigurationKeyName("circuit_breaker")]
|
||||
public CircuitBreakerConfig CircuitBreaker { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Caching;
|
||||
|
||||
public sealed class CircuitBreakerConfig
|
||||
{
|
||||
[ConfigurationKeyName("failure_threshold")]
|
||||
public int FailureThreshold { get; set; } = 5;
|
||||
|
||||
[ConfigurationKeyName("timeout_seconds")]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
[ConfigurationKeyName("half_open_timeout")]
|
||||
public int HalfOpenTimeout { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
namespace StellaOps.Scanner.CallGraph.Caching;
|
||||
|
||||
public enum CircuitState
|
||||
{
|
||||
Closed,
|
||||
Open,
|
||||
HalfOpen
|
||||
}
|
||||
|
||||
public sealed class CircuitBreakerState
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly int _failureThreshold;
|
||||
private readonly TimeSpan _openTimeout;
|
||||
private readonly TimeSpan _halfOpenTimeout;
|
||||
|
||||
private CircuitState _state = CircuitState.Closed;
|
||||
private int _failureCount;
|
||||
private DateTimeOffset _openedAt;
|
||||
|
||||
public CircuitBreakerState(CircuitBreakerConfig config, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_failureThreshold = Math.Max(1, config.FailureThreshold);
|
||||
_openTimeout = TimeSpan.FromSeconds(Math.Max(1, config.TimeoutSeconds));
|
||||
_halfOpenTimeout = TimeSpan.FromSeconds(Math.Max(1, config.HalfOpenTimeout));
|
||||
}
|
||||
|
||||
public CircuitState State
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
UpdateState();
|
||||
return _state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
UpdateState();
|
||||
return _state == CircuitState.Open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsHalfOpen
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
UpdateState();
|
||||
return _state == CircuitState.HalfOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSuccess()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_state is CircuitState.HalfOpen or CircuitState.Open)
|
||||
{
|
||||
_state = CircuitState.Closed;
|
||||
}
|
||||
|
||||
_failureCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordFailure()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_state == CircuitState.HalfOpen)
|
||||
{
|
||||
_state = CircuitState.Open;
|
||||
_openedAt = now;
|
||||
_failureCount = _failureThreshold;
|
||||
return;
|
||||
}
|
||||
|
||||
_failureCount++;
|
||||
if (_failureCount >= _failureThreshold)
|
||||
{
|
||||
_state = CircuitState.Open;
|
||||
_openedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_state = CircuitState.Closed;
|
||||
_failureCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateState()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (_state == CircuitState.Open)
|
||||
{
|
||||
if (now - _openedAt >= _openTimeout)
|
||||
{
|
||||
_state = CircuitState.HalfOpen;
|
||||
}
|
||||
}
|
||||
else if (_state == CircuitState.HalfOpen)
|
||||
{
|
||||
if (now - _openedAt >= _openTimeout + _halfOpenTimeout)
|
||||
{
|
||||
_state = CircuitState.Open;
|
||||
_openedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace StellaOps.Scanner.CallGraph.Caching;
|
||||
|
||||
public interface ICallGraphCacheService
|
||||
{
|
||||
ValueTask<CallGraphSnapshot?> TryGetCallGraphAsync(string scanId, string language, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetCallGraphAsync(CallGraphSnapshot snapshot, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
|
||||
|
||||
ValueTask<ReachabilityAnalysisResult?> TryGetReachabilityResultAsync(string scanId, string language, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetReachabilityResultAsync(ReachabilityAnalysisResult result, TimeSpan? ttl = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Caching;
|
||||
|
||||
public sealed class ValkeyCallGraphCacheService : ICallGraphCacheService, IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly CallGraphCacheConfig _options;
|
||||
private readonly ILogger<ValkeyCallGraphCacheService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CircuitBreakerState _circuitBreaker;
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
|
||||
|
||||
private IConnectionMultiplexer? _connection;
|
||||
|
||||
public ValkeyCallGraphCacheService(
|
||||
IOptions<CallGraphCacheConfig> options,
|
||||
ILogger<ValkeyCallGraphCacheService> logger,
|
||||
TimeProvider? timeProvider = null,
|
||||
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
|
||||
{
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_connectionFactory = connectionFactory ?? (config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
|
||||
_circuitBreaker = new CircuitBreakerState(_options.CircuitBreaker, _timeProvider);
|
||||
}
|
||||
|
||||
public async ValueTask<CallGraphSnapshot?> TryGetCallGraphAsync(string scanId, string language, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = BuildKey(scanId, language, kind: "graph");
|
||||
var payload = await TryGetBytesAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = _options.EnableGzip ? Inflate(payload) : payload;
|
||||
return JsonSerializer.Deserialize<CallGraphSnapshot>(bytes, JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached call graph for {ScanId}/{Language}", scanId, language);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetCallGraphAsync(CallGraphSnapshot snapshot, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(snapshot.ScanId, snapshot.Language, kind: "graph");
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(snapshot.Trimmed(), JsonOptions);
|
||||
var payload = _options.EnableGzip ? Deflate(bytes) : bytes;
|
||||
|
||||
await SetBytesAsync(key, payload, ttl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask<ReachabilityAnalysisResult?> TryGetReachabilityResultAsync(string scanId, string language, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var key = BuildKey(scanId, language, kind: "reachability");
|
||||
var payload = await TryGetBytesAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (payload is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = _options.EnableGzip ? Inflate(payload) : payload;
|
||||
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(bytes, JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidDataException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize cached reachability result for {ScanId}/{Language}", scanId, language);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetReachabilityResultAsync(ReachabilityAnalysisResult result, TimeSpan? ttl = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = BuildKey(result.ScanId, result.Language, kind: "reachability");
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(result.Trimmed(), JsonOptions);
|
||||
var payload = _options.EnableGzip ? Deflate(bytes) : bytes;
|
||||
|
||||
await SetBytesAsync(key, payload, ttl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_connection is IAsyncDisposable asyncDisposable)
|
||||
{
|
||||
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
_connection?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEnabled()
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_circuitBreaker.IsOpen)
|
||||
{
|
||||
_logger.LogWarning("Call graph cache circuit breaker is open; bypassing Valkey.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(_options.ConnectionString);
|
||||
}
|
||||
|
||||
private async ValueTask<byte[]?> TryGetBytesAsync(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
var value = await db.StringGetAsync(key).ConfigureAwait(false);
|
||||
_circuitBreaker.RecordSuccess();
|
||||
|
||||
return value.IsNull ? null : (byte[]?)value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Valkey cache GET failed for key {Key}", key);
|
||||
_circuitBreaker.RecordFailure();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetBytesAsync(string key, byte[] payload, TimeSpan? ttl, CancellationToken cancellationToken)
|
||||
{
|
||||
var effectiveTtl = ttl ?? TimeSpan.FromSeconds(Math.Max(1, _options.TtlSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
|
||||
await db.StringSetAsync(key, payload, expiry: effectiveTtl).ConfigureAwait(false);
|
||||
_circuitBreaker.RecordSuccess();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Valkey cache SET failed for key {Key}", key);
|
||||
_circuitBreaker.RecordFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection.GetDatabase();
|
||||
}
|
||||
|
||||
private async Task<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_connection is not null)
|
||||
{
|
||||
return _connection;
|
||||
}
|
||||
|
||||
var config = ConfigurationOptions.Parse(_options.ConnectionString);
|
||||
_connection = await _connectionFactory(config).ConfigureAwait(false);
|
||||
return _connection;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildKey(string scanId, string language, string kind)
|
||||
=> $"{_options.KeyPrefix}{kind}:{scanId.Trim()}:{language.Trim().ToLowerInvariant()}";
|
||||
|
||||
private static byte[] Deflate(byte[] payload)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
|
||||
{
|
||||
gzip.Write(payload, 0, payload.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] Inflate(byte[] payload)
|
||||
{
|
||||
using var input = new MemoryStream(payload);
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.CallGraph.Caching;
|
||||
using StellaOps.Scanner.CallGraph.DotNet;
|
||||
using StellaOps.Scanner.CallGraph.Node;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.DependencyInjection;
|
||||
|
||||
public static class CallGraphServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCallGraphServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
services.Configure<CallGraphCacheConfig>(configuration.GetSection("CallGraph:Cache"));
|
||||
|
||||
services.AddSingleton<ICallGraphExtractor, DotNetCallGraphExtractor>();
|
||||
services.AddSingleton<ICallGraphExtractor, NodeCallGraphExtractor>();
|
||||
|
||||
services.AddSingleton<ReachabilityAnalyzer>();
|
||||
services.AddSingleton<ICallGraphCacheService, ValkeyCallGraphCacheService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Build.Locator;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.MSBuild;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.DotNet;
|
||||
|
||||
public sealed class DotNetCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DotNetCallGraphExtractor(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Language => "dotnet";
|
||||
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var resolvedTarget = ResolveTargetPath(request.TargetPath);
|
||||
if (resolvedTarget is null)
|
||||
{
|
||||
throw new FileNotFoundException($"Unable to locate a .sln or .csproj at '{request.TargetPath}'.");
|
||||
}
|
||||
|
||||
var analysisRoot = Path.GetDirectoryName(resolvedTarget) ?? Directory.GetCurrentDirectory();
|
||||
EnsureMsBuildRegistered();
|
||||
|
||||
using var workspace = MSBuildWorkspace.Create();
|
||||
workspace.WorkspaceFailed += (_, _) => { };
|
||||
|
||||
var solution = resolvedTarget.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
|
||||
? await workspace.OpenSolutionAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)
|
||||
: (await workspace.OpenProjectAsync(resolvedTarget, cancellationToken).ConfigureAwait(false)).Solution;
|
||||
|
||||
var nodesById = new Dictionary<string, CallGraphNode>(StringComparer.Ordinal);
|
||||
var edges = new HashSet<CallGraphEdge>(CallGraphEdgeComparer.Instance);
|
||||
|
||||
foreach (var project in solution.Projects.OrderBy(p => p.FilePath ?? p.Name, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
foreach (var document in project.Documents.OrderBy(d => d.FilePath ?? d.Name, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (root is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (model is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var methodSyntax in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
|
||||
{
|
||||
var methodSymbol = model.GetDeclaredSymbol(methodSyntax, cancellationToken);
|
||||
if (methodSymbol is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var methodNode = CreateMethodNode(analysisRoot, methodSymbol, methodSyntax);
|
||||
nodesById[methodNode.NodeId] = methodNode;
|
||||
|
||||
foreach (var invocation in methodSyntax.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var invoked = model.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol;
|
||||
if (invoked is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetNode = CreateInvokedNode(analysisRoot, invoked);
|
||||
nodesById.TryAdd(targetNode.NodeId, targetNode);
|
||||
|
||||
edges.Add(new CallGraphEdge(
|
||||
SourceId: methodNode.NodeId,
|
||||
TargetId: targetNode.NodeId,
|
||||
CallKind: ClassifyCallKind(invoked),
|
||||
CallSite: FormatCallSite(analysisRoot, invocation)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nodes = nodesById.Values
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypoints = nodes
|
||||
.Where(n => n.IsEntrypoint)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinks = nodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var orderedEdges = edges
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: request.ScanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: nodes,
|
||||
Edges: orderedEdges,
|
||||
EntrypointIds: entrypoints,
|
||||
SinkIds: sinks);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private static void EnsureMsBuildRegistered()
|
||||
{
|
||||
if (MSBuildLocator.IsRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
MSBuildLocator.RegisterDefaults();
|
||||
}
|
||||
|
||||
private static string? ResolveTargetPath(string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Path.GetFullPath(targetPath);
|
||||
if (File.Exists(path) && (path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var sln = Directory.EnumerateFiles(path, "*.sln", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (sln is not null)
|
||||
{
|
||||
return sln;
|
||||
}
|
||||
|
||||
var csproj = Directory.EnumerateFiles(path, "*.csproj", SearchOption.AllDirectories)
|
||||
.OrderBy(p => p, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
return csproj;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static CallKind ClassifyCallKind(IMethodSymbol invoked)
|
||||
{
|
||||
if (invoked.MethodKind == MethodKind.DelegateInvoke)
|
||||
{
|
||||
return CallKind.Delegate;
|
||||
}
|
||||
|
||||
if (invoked.IsVirtual || invoked.IsAbstract || invoked.IsOverride)
|
||||
{
|
||||
return CallKind.Virtual;
|
||||
}
|
||||
|
||||
return CallKind.Direct;
|
||||
}
|
||||
|
||||
private static CallGraphNode CreateMethodNode(string analysisRoot, IMethodSymbol method, MethodDeclarationSyntax syntax)
|
||||
{
|
||||
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));
|
||||
var (file, line) = GetSourceLocation(analysisRoot, syntax.GetLocation());
|
||||
|
||||
var (isEntrypoint, entryType) = EntrypointClassifier.IsEntrypoint(method);
|
||||
|
||||
return new CallGraphNode(
|
||||
NodeId: id,
|
||||
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: method.ContainingAssembly?.Name ?? "unknown",
|
||||
Visibility: MapVisibility(method.DeclaredAccessibility),
|
||||
IsEntrypoint: isEntrypoint,
|
||||
EntrypointType: entryType,
|
||||
IsSink: false,
|
||||
SinkCategory: null);
|
||||
}
|
||||
|
||||
private static CallGraphNode CreateInvokedNode(string analysisRoot, IMethodSymbol method)
|
||||
{
|
||||
var id = CallGraphNodeIds.Compute(GetStableSymbolId(method));
|
||||
var definitionLocation = method.Locations.FirstOrDefault(l => l.IsInSource) ?? Location.None;
|
||||
var (file, line) = GetSourceLocation(analysisRoot, definitionLocation);
|
||||
|
||||
var sink = SinkRegistry.MatchSink("dotnet", method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
|
||||
|
||||
return new CallGraphNode(
|
||||
NodeId: id,
|
||||
Symbol: method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: method.ContainingAssembly?.Name ?? "unknown",
|
||||
Visibility: MapVisibility(method.DeclaredAccessibility),
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: sink is not null,
|
||||
SinkCategory: sink?.Category);
|
||||
}
|
||||
|
||||
private static Visibility MapVisibility(Accessibility accessibility)
|
||||
{
|
||||
return accessibility switch
|
||||
{
|
||||
Accessibility.Public => Visibility.Public,
|
||||
Accessibility.Internal => Visibility.Internal,
|
||||
Accessibility.Protected => Visibility.Protected,
|
||||
_ => Visibility.Private
|
||||
};
|
||||
}
|
||||
|
||||
private static (string File, int Line) GetSourceLocation(string analysisRoot, Location location)
|
||||
{
|
||||
if (location is null || !location.IsInSource || location.SourceTree is null)
|
||||
{
|
||||
return (string.Empty, 0);
|
||||
}
|
||||
|
||||
var span = location.GetLineSpan();
|
||||
var relative = Path.GetRelativePath(analysisRoot, span.Path ?? string.Empty);
|
||||
if (relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
relative = Path.GetFileName(span.Path ?? string.Empty);
|
||||
}
|
||||
|
||||
var file = relative.Replace('\\', '/');
|
||||
var line = span.StartLinePosition.Line + 1;
|
||||
return (file, line);
|
||||
}
|
||||
|
||||
private static string? FormatCallSite(string analysisRoot, InvocationExpressionSyntax invocation)
|
||||
{
|
||||
var location = invocation.GetLocation();
|
||||
if (location is null || !location.IsInSource || location.SourceTree is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var span = location.GetLineSpan();
|
||||
var relative = Path.GetRelativePath(analysisRoot, span.Path ?? string.Empty);
|
||||
if (relative.StartsWith("..", StringComparison.Ordinal))
|
||||
{
|
||||
relative = Path.GetFileName(span.Path ?? string.Empty);
|
||||
}
|
||||
|
||||
var file = relative.Replace('\\', '/');
|
||||
var line = span.StartLinePosition.Line + 1;
|
||||
if (string.IsNullOrWhiteSpace(file) || line <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return $"{file}:{line}";
|
||||
}
|
||||
|
||||
private static string GetStableSymbolId(IMethodSymbol method)
|
||||
{
|
||||
var docId = method.GetDocumentationCommentId();
|
||||
if (!string.IsNullOrWhiteSpace(docId))
|
||||
{
|
||||
return $"dotnet:{method.ContainingAssembly?.Name}:{docId}";
|
||||
}
|
||||
|
||||
return $"dotnet:{method.ContainingAssembly?.Name}:{method.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}";
|
||||
}
|
||||
|
||||
private sealed class CallGraphEdgeComparer : IEqualityComparer<CallGraphEdge>
|
||||
{
|
||||
public static readonly CallGraphEdgeComparer Instance = new();
|
||||
|
||||
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
|
||||
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
|
||||
&& x.CallKind == y.CallKind
|
||||
&& string.Equals(x.CallSite ?? string.Empty, y.CallSite ?? string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public int GetHashCode(CallGraphEdge obj)
|
||||
{
|
||||
return HashCode.Combine(
|
||||
obj.SourceId,
|
||||
obj.TargetId,
|
||||
obj.CallKind,
|
||||
obj.CallSite ?? string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class EntrypointClassifier
|
||||
{
|
||||
private static readonly HashSet<string> HttpMethodAttributes = new(StringComparer.Ordinal)
|
||||
{
|
||||
"HttpGetAttribute",
|
||||
"HttpPostAttribute",
|
||||
"HttpPutAttribute",
|
||||
"HttpDeleteAttribute",
|
||||
"HttpPatchAttribute",
|
||||
"RouteAttribute"
|
||||
};
|
||||
|
||||
public static (bool IsEntrypoint, EntrypointType? Type) IsEntrypoint(IMethodSymbol method)
|
||||
{
|
||||
if (method is null)
|
||||
{
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
// Main()
|
||||
if (method.IsStatic && method.Name == "Main" && method.ContainingType is not null)
|
||||
{
|
||||
return (true, EntrypointType.CliCommand);
|
||||
}
|
||||
|
||||
// ASP.NET attributes
|
||||
foreach (var attribute in method.GetAttributes())
|
||||
{
|
||||
var name = attribute.AttributeClass?.Name;
|
||||
if (name is not null && HttpMethodAttributes.Contains(name))
|
||||
{
|
||||
return (true, EntrypointType.HttpHandler);
|
||||
}
|
||||
}
|
||||
|
||||
// Hosted services
|
||||
if (method.ContainingType is not null)
|
||||
{
|
||||
var type = method.ContainingType;
|
||||
if (type.AllInterfaces.Any(i => i.ToDisplayString() == "Microsoft.Extensions.Hosting.IHostedService")
|
||||
|| DerivesFrom(type, "Microsoft.Extensions.Hosting.BackgroundService"))
|
||||
{
|
||||
if (method.Name is "StartAsync" or "ExecuteAsync")
|
||||
{
|
||||
return (true, EntrypointType.BackgroundJob);
|
||||
}
|
||||
}
|
||||
|
||||
// gRPC base type
|
||||
if (DerivesFrom(type, "Grpc.Core.BindableService") || DerivesFrom(type, "Grpc.AspNetCore.Server.BindableService"))
|
||||
{
|
||||
if (method.DeclaredAccessibility == Accessibility.Public)
|
||||
{
|
||||
return (true, EntrypointType.GrpcMethod);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private static bool DerivesFrom(INamedTypeSymbol type, string fullName)
|
||||
{
|
||||
var current = type.BaseType;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current.ToDisplayString() == fullName)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
current = current.BaseType;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
public sealed record CallGraphExtractionRequest(
|
||||
string ScanId,
|
||||
string Language,
|
||||
string TargetPath);
|
||||
|
||||
public interface ICallGraphExtractor
|
||||
{
|
||||
string Language { get; }
|
||||
|
||||
Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph.Node;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder Node.js call graph extractor.
|
||||
/// Babel integration is planned; this implementation is intentionally minimal.
|
||||
/// </summary>
|
||||
public sealed class NodeCallGraphExtractor : ICallGraphExtractor
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public NodeCallGraphExtractor(TimeProvider? timeProvider = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public string Language => "node";
|
||||
|
||||
public async Task<CallGraphSnapshot> ExtractAsync(CallGraphExtractionRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!string.Equals(request.Language, Language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Expected language '{Language}', got '{request.Language}'.", nameof(request));
|
||||
}
|
||||
|
||||
var tracePath = ResolveTracePath(request.TargetPath);
|
||||
if (tracePath is not null && File.Exists(tracePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = File.OpenRead(tracePath);
|
||||
var trace = await JsonSerializer.DeserializeAsync<TraceDocument>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (trace is not null)
|
||||
{
|
||||
return BuildFromTrace(request.ScanId, trace);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
// fall through to empty snapshot
|
||||
}
|
||||
}
|
||||
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: request.ScanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: ImmutableArray<CallGraphNode>.Empty,
|
||||
Edges: ImmutableArray<CallGraphEdge>.Empty,
|
||||
EntrypointIds: ImmutableArray<string>.Empty,
|
||||
SinkIds: ImmutableArray<string>.Empty);
|
||||
|
||||
var digest = CallGraphDigests.ComputeGraphDigest(provisional);
|
||||
return provisional with { GraphDigest = digest };
|
||||
}
|
||||
|
||||
private CallGraphSnapshot BuildFromTrace(string scanId, TraceDocument trace)
|
||||
{
|
||||
var extractedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var nodes = new List<CallGraphNode>();
|
||||
var edges = new List<CallGraphEdge>();
|
||||
|
||||
var entrySymbol = trace.Entry?.Trim() ?? "unknown_entry";
|
||||
var entryId = CallGraphNodeIds.Compute($"node:entry:{entrySymbol}");
|
||||
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: entryId,
|
||||
Symbol: entrySymbol,
|
||||
File: string.Empty,
|
||||
Line: 0,
|
||||
Package: "app",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: true,
|
||||
EntrypointType: EntrypointType.HttpHandler,
|
||||
IsSink: false,
|
||||
SinkCategory: null));
|
||||
|
||||
var path = trace.Path ?? Array.Empty<string>();
|
||||
var previousId = entryId;
|
||||
foreach (var raw in path)
|
||||
{
|
||||
var symbol = raw?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(symbol))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var nodeId = CallGraphNodeIds.Compute($"node:{symbol}");
|
||||
var (file, line) = ParseFileLine(symbol);
|
||||
var sink = SinkRegistry.MatchSink("node", symbol);
|
||||
|
||||
nodes.Add(new CallGraphNode(
|
||||
NodeId: nodeId,
|
||||
Symbol: symbol,
|
||||
File: file,
|
||||
Line: line,
|
||||
Package: "app",
|
||||
Visibility: Visibility.Public,
|
||||
IsEntrypoint: false,
|
||||
EntrypointType: null,
|
||||
IsSink: sink is not null,
|
||||
SinkCategory: sink?.Category));
|
||||
|
||||
edges.Add(new CallGraphEdge(previousId, nodeId, CallKind.Direct));
|
||||
previousId = nodeId;
|
||||
}
|
||||
|
||||
var distinctNodes = nodes
|
||||
.GroupBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.Select(g => g.First())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var distinctEdges = edges
|
||||
.Distinct(CallGraphEdgeStructuralComparer.Instance)
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinkIds = distinctNodes
|
||||
.Where(n => n.IsSink)
|
||||
.Select(n => n.NodeId)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var provisional = new CallGraphSnapshot(
|
||||
ScanId: scanId,
|
||||
GraphDigest: string.Empty,
|
||||
Language: Language,
|
||||
ExtractedAt: extractedAt,
|
||||
Nodes: distinctNodes,
|
||||
Edges: distinctEdges,
|
||||
EntrypointIds: ImmutableArray.Create(entryId),
|
||||
SinkIds: sinkIds);
|
||||
|
||||
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
|
||||
}
|
||||
|
||||
private static (string File, int Line) ParseFileLine(string symbol)
|
||||
{
|
||||
// Common benchmark shape: "app.js:handleRequest" or "app.js::createServer"
|
||||
var idx = symbol.IndexOf(".js", StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
return (string.Empty, 0);
|
||||
}
|
||||
|
||||
var end = idx + 3;
|
||||
var file = symbol[..end].Replace('\\', '/');
|
||||
return (file, 0);
|
||||
}
|
||||
|
||||
private static string? ResolveTracePath(string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var path = Path.GetFullPath(targetPath);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
var candidate = Path.Combine(path, "outputs", "traces", "traces.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed record TraceDocument(string Entry, string[] Path, string Sink, string Notes);
|
||||
|
||||
private sealed class CallGraphEdgeStructuralComparer : IEqualityComparer<CallGraphEdge>
|
||||
{
|
||||
public static readonly CallGraphEdgeStructuralComparer Instance = new();
|
||||
|
||||
public bool Equals(CallGraphEdge? x, CallGraphEdge? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.Equals(x.SourceId, y.SourceId, StringComparison.Ordinal)
|
||||
&& string.Equals(x.TargetId, y.TargetId, StringComparison.Ordinal)
|
||||
&& x.CallKind == y.CallKind;
|
||||
}
|
||||
|
||||
public int GetHashCode(CallGraphEdge obj)
|
||||
=> HashCode.Combine(obj.SourceId, obj.TargetId, obj.CallKind);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
|
||||
namespace StellaOps.Scanner.CallGraph;
|
||||
|
||||
public sealed record CallGraphSnapshot(
|
||||
[property: JsonPropertyName("scanId")] string ScanId,
|
||||
[property: JsonPropertyName("graphDigest")] string GraphDigest,
|
||||
[property: JsonPropertyName("language")] string Language,
|
||||
[property: JsonPropertyName("extractedAt")] DateTimeOffset ExtractedAt,
|
||||
[property: JsonPropertyName("nodes")] ImmutableArray<CallGraphNode> Nodes,
|
||||
[property: JsonPropertyName("edges")] ImmutableArray<CallGraphEdge> Edges,
|
||||
[property: JsonPropertyName("entrypointIds")] ImmutableArray<string> EntrypointIds,
|
||||
[property: JsonPropertyName("sinkIds")] ImmutableArray<string> SinkIds)
|
||||
{
|
||||
public CallGraphSnapshot Trimmed()
|
||||
{
|
||||
var nodes = (Nodes.IsDefault ? ImmutableArray<CallGraphNode>.Empty : Nodes)
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n.NodeId))
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.NodeId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var edges = (Edges.IsDefault ? ImmutableArray<CallGraphEdge>.Empty : Edges)
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.SourceId) && !string.IsNullOrWhiteSpace(e.TargetId))
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.SourceId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TargetId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallKind.ToString(), StringComparer.Ordinal)
|
||||
.ThenBy(e => e.CallSite ?? string.Empty, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var entrypoints = (EntrypointIds.IsDefault ? ImmutableArray<string>.Empty : EntrypointIds)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var sinks = (SinkIds.IsDefault ? ImmutableArray<string>.Empty : SinkIds)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return this with
|
||||
{
|
||||
ScanId = ScanId?.Trim() ?? string.Empty,
|
||||
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
|
||||
Language = Language?.Trim() ?? string.Empty,
|
||||
Nodes = nodes,
|
||||
Edges = edges,
|
||||
EntrypointIds = entrypoints,
|
||||
SinkIds = sinks
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record CallGraphNode(
|
||||
[property: JsonPropertyName("nodeId")] string NodeId,
|
||||
[property: JsonPropertyName("symbol")] string Symbol,
|
||||
[property: JsonPropertyName("file")] string File,
|
||||
[property: JsonPropertyName("line")] int Line,
|
||||
[property: JsonPropertyName("package")] string Package,
|
||||
[property: JsonPropertyName("visibility")] Visibility Visibility,
|
||||
[property: JsonPropertyName("isEntrypoint")] bool IsEntrypoint,
|
||||
[property: JsonPropertyName("entrypointType")] EntrypointType? EntrypointType,
|
||||
[property: JsonPropertyName("isSink")] bool IsSink,
|
||||
[property: JsonPropertyName("sinkCategory")] SinkCategory? SinkCategory)
|
||||
{
|
||||
public CallGraphNode Trimmed()
|
||||
=> this with
|
||||
{
|
||||
NodeId = NodeId?.Trim() ?? string.Empty,
|
||||
Symbol = Symbol?.Trim() ?? string.Empty,
|
||||
File = File?.Trim() ?? string.Empty,
|
||||
Package = Package?.Trim() ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record CallGraphEdge(
|
||||
[property: JsonPropertyName("sourceId")] string SourceId,
|
||||
[property: JsonPropertyName("targetId")] string TargetId,
|
||||
[property: JsonPropertyName("callKind")] CallKind CallKind,
|
||||
[property: JsonPropertyName("callSite")] string? CallSite = null)
|
||||
{
|
||||
public CallGraphEdge Trimmed()
|
||||
=> this with
|
||||
{
|
||||
SourceId = SourceId?.Trim() ?? string.Empty,
|
||||
TargetId = TargetId?.Trim() ?? string.Empty,
|
||||
CallSite = string.IsNullOrWhiteSpace(CallSite) ? null : CallSite.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<Visibility>))]
|
||||
public enum Visibility
|
||||
{
|
||||
Public,
|
||||
Internal,
|
||||
Protected,
|
||||
Private
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<CallKind>))]
|
||||
public enum CallKind
|
||||
{
|
||||
Direct,
|
||||
Virtual,
|
||||
Delegate,
|
||||
Reflection,
|
||||
Dynamic
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<EntrypointType>))]
|
||||
public enum EntrypointType
|
||||
{
|
||||
HttpHandler,
|
||||
GrpcMethod,
|
||||
CliCommand,
|
||||
BackgroundJob,
|
||||
ScheduledJob,
|
||||
MessageHandler,
|
||||
EventSubscriber,
|
||||
WebSocketHandler,
|
||||
Unknown
|
||||
}
|
||||
|
||||
public static class CallGraphDigests
|
||||
{
|
||||
private static readonly JsonWriterOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
public static string ComputeGraphDigest(CallGraphSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var trimmed = snapshot.Trimmed();
|
||||
|
||||
using var buffer = new MemoryStream(capacity: 64 * 1024);
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
|
||||
{
|
||||
WriteDigestPayload(writer, trimmed);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.ToArray());
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string ComputeResultDigest(ReachabilityAnalysisResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var trimmed = result.Trimmed();
|
||||
|
||||
using var buffer = new MemoryStream(capacity: 64 * 1024);
|
||||
using (var writer = new Utf8JsonWriter(buffer, CanonicalJsonOptions))
|
||||
{
|
||||
WriteDigestPayload(writer, trimmed);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.ToArray());
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static void WriteDigestPayload(Utf8JsonWriter writer, CallGraphSnapshot snapshot)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("schema", "stellaops.callgraph@v1");
|
||||
writer.WriteString("language", snapshot.Language);
|
||||
|
||||
writer.WritePropertyName("nodes");
|
||||
writer.WriteStartArray();
|
||||
foreach (var node in snapshot.Nodes)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("nodeId", node.NodeId);
|
||||
writer.WriteString("symbol", node.Symbol);
|
||||
writer.WriteString("file", node.File);
|
||||
writer.WriteNumber("line", node.Line);
|
||||
writer.WriteString("package", node.Package);
|
||||
writer.WriteString("visibility", node.Visibility.ToString());
|
||||
writer.WriteBoolean("isEntrypoint", node.IsEntrypoint);
|
||||
if (node.EntrypointType is not null)
|
||||
{
|
||||
writer.WriteString("entrypointType", node.EntrypointType.Value.ToString());
|
||||
}
|
||||
writer.WriteBoolean("isSink", node.IsSink);
|
||||
if (node.SinkCategory is not null)
|
||||
{
|
||||
writer.WriteString("sinkCategory", node.SinkCategory.Value.ToString());
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("edges");
|
||||
writer.WriteStartArray();
|
||||
foreach (var edge in snapshot.Edges)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("sourceId", edge.SourceId);
|
||||
writer.WriteString("targetId", edge.TargetId);
|
||||
writer.WriteString("callKind", edge.CallKind.ToString());
|
||||
if (!string.IsNullOrWhiteSpace(edge.CallSite))
|
||||
{
|
||||
writer.WriteString("callSite", edge.CallSite);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("entrypointIds");
|
||||
writer.WriteStartArray();
|
||||
foreach (var id in snapshot.EntrypointIds)
|
||||
{
|
||||
writer.WriteStringValue(id);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("sinkIds");
|
||||
writer.WriteStartArray();
|
||||
foreach (var id in snapshot.SinkIds)
|
||||
{
|
||||
writer.WriteStringValue(id);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteDigestPayload(Utf8JsonWriter writer, ReachabilityAnalysisResult result)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("schema", "stellaops.reachability@v1");
|
||||
writer.WriteString("graphDigest", result.GraphDigest);
|
||||
writer.WriteString("language", result.Language);
|
||||
|
||||
writer.WritePropertyName("reachableNodeIds");
|
||||
writer.WriteStartArray();
|
||||
foreach (var id in result.ReachableNodeIds)
|
||||
{
|
||||
writer.WriteStringValue(id);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("reachableSinkIds");
|
||||
writer.WriteStartArray();
|
||||
foreach (var id in result.ReachableSinkIds)
|
||||
{
|
||||
writer.WriteStringValue(id);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("paths");
|
||||
writer.WriteStartArray();
|
||||
foreach (var path in result.Paths)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("entrypointId", path.EntrypointId);
|
||||
writer.WriteString("sinkId", path.SinkId);
|
||||
writer.WritePropertyName("nodeIds");
|
||||
writer.WriteStartArray();
|
||||
foreach (var nodeId in path.NodeIds)
|
||||
{
|
||||
writer.WriteStringValue(nodeId);
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReachabilityPath(
|
||||
[property: JsonPropertyName("entrypointId")] string EntrypointId,
|
||||
[property: JsonPropertyName("sinkId")] string SinkId,
|
||||
[property: JsonPropertyName("nodeIds")] ImmutableArray<string> NodeIds)
|
||||
{
|
||||
public ReachabilityPath Trimmed()
|
||||
{
|
||||
var nodes = (NodeIds.IsDefault ? ImmutableArray<string>.Empty : NodeIds)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.ToImmutableArray();
|
||||
|
||||
return this with
|
||||
{
|
||||
EntrypointId = EntrypointId?.Trim() ?? string.Empty,
|
||||
SinkId = SinkId?.Trim() ?? string.Empty,
|
||||
NodeIds = nodes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReachabilityAnalysisResult(
|
||||
[property: JsonPropertyName("scanId")] string ScanId,
|
||||
[property: JsonPropertyName("graphDigest")] string GraphDigest,
|
||||
[property: JsonPropertyName("language")] string Language,
|
||||
[property: JsonPropertyName("computedAt")] DateTimeOffset ComputedAt,
|
||||
[property: JsonPropertyName("reachableNodeIds")] ImmutableArray<string> ReachableNodeIds,
|
||||
[property: JsonPropertyName("reachableSinkIds")] ImmutableArray<string> ReachableSinkIds,
|
||||
[property: JsonPropertyName("paths")] ImmutableArray<ReachabilityPath> Paths,
|
||||
[property: JsonPropertyName("resultDigest")] string ResultDigest)
|
||||
{
|
||||
public ReachabilityAnalysisResult Trimmed()
|
||||
{
|
||||
var reachableNodes = (ReachableNodeIds.IsDefault ? ImmutableArray<string>.Empty : ReachableNodeIds)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var reachableSinks = (ReachableSinkIds.IsDefault ? ImmutableArray<string>.Empty : ReachableSinkIds)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var paths = (Paths.IsDefault ? ImmutableArray<ReachabilityPath>.Empty : Paths)
|
||||
.Select(p => p.Trimmed())
|
||||
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
|
||||
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return this with
|
||||
{
|
||||
ScanId = ScanId?.Trim() ?? string.Empty,
|
||||
GraphDigest = GraphDigest?.Trim() ?? string.Empty,
|
||||
Language = Language?.Trim() ?? string.Empty,
|
||||
ResultDigest = ResultDigest?.Trim() ?? string.Empty,
|
||||
ReachableNodeIds = reachableNodes,
|
||||
ReachableSinkIds = reachableSinks,
|
||||
Paths = paths
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class CallGraphNodeIds
|
||||
{
|
||||
public static string Compute(string stableSymbolId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stableSymbolId))
|
||||
{
|
||||
throw new ArgumentException("Symbol id must be provided.", nameof(stableSymbolId));
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(stableSymbolId.Trim()));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
public static string StableSymbolId(string language, string symbol)
|
||||
=> $"{language.Trim().ToLowerInvariant()}:{symbol.Trim()}";
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Build.Locator" Version="1.10.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.14.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Reachability\\StellaOps.Scanner.Reachability.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Replay.Core;
|
||||
|
||||
namespace StellaOps.Scanner.Core;
|
||||
|
||||
@@ -60,15 +61,14 @@ public sealed record ScanManifest(
|
||||
/// <summary>
|
||||
/// Serialize to canonical JSON (for hashing).
|
||||
/// </summary>
|
||||
public string ToCanonicalJson() => JsonSerializer.Serialize(this, CanonicalJsonOptions);
|
||||
public string ToCanonicalJson() => CanonicalJson.Serialize(this);
|
||||
|
||||
/// <summary>
|
||||
/// Compute the SHA-256 hash of the canonical JSON representation.
|
||||
/// </summary>
|
||||
public string ComputeHash()
|
||||
{
|
||||
var json = ToCanonicalJson();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
|
||||
var bytes = CanonicalJson.SerializeToUtf8Bytes(this);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Scanner.ProofSpine;
|
||||
|
||||
namespace StellaOps.Scanner.Core;
|
||||
@@ -88,11 +90,18 @@ public sealed record ManifestVerificationResult(
|
||||
public sealed class ScanManifestSigner : IScanManifestSigner
|
||||
{
|
||||
private readonly IDsseSigningService _dsseSigningService;
|
||||
private readonly ICryptoProfile _cryptoProfile;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private const string PredicateType = "scanmanifest.stella/v1";
|
||||
|
||||
public ScanManifestSigner(IDsseSigningService dsseSigningService)
|
||||
public ScanManifestSigner(
|
||||
IDsseSigningService dsseSigningService,
|
||||
ICryptoProfile cryptoProfile,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_dsseSigningService = dsseSigningService ?? throw new ArgumentNullException(nameof(dsseSigningService));
|
||||
_cryptoProfile = cryptoProfile ?? throw new ArgumentNullException(nameof(cryptoProfile));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -101,20 +110,20 @@ public sealed class ScanManifestSigner : IScanManifestSigner
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var manifestHash = manifest.ComputeHash();
|
||||
var manifestJson = manifest.ToCanonicalJson();
|
||||
var manifestBytes = System.Text.Encoding.UTF8.GetBytes(manifestJson);
|
||||
|
||||
// Create DSSE envelope
|
||||
var envelope = await _dsseSigningService.SignAsync(
|
||||
payload: manifest,
|
||||
payloadType: PredicateType,
|
||||
payload: manifestBytes,
|
||||
cancellationToken);
|
||||
cryptoProfile: _cryptoProfile,
|
||||
cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SignedScanManifest(
|
||||
Manifest: manifest,
|
||||
ManifestHash: manifestHash,
|
||||
Envelope: envelope,
|
||||
SignedAt: DateTimeOffset.UtcNow);
|
||||
SignedAt: _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -125,31 +134,93 @@ public sealed class ScanManifestSigner : IScanManifestSigner
|
||||
try
|
||||
{
|
||||
// Verify DSSE signature
|
||||
var verifyResult = await _dsseSigningService.VerifyAsync(signedManifest.Envelope, cancellationToken);
|
||||
if (!verifyResult)
|
||||
var verifyResult = await _dsseSigningService.VerifyAsync(signedManifest.Envelope, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verifyResult.IsValid)
|
||||
{
|
||||
return ManifestVerificationResult.Failure("DSSE signature verification failed");
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: verifyResult.FailureReason ?? "DSSE signature verification failed");
|
||||
}
|
||||
|
||||
// Verify payload type
|
||||
if (signedManifest.Envelope.PayloadType != PredicateType)
|
||||
{
|
||||
return ManifestVerificationResult.Failure($"Unexpected payload type: {signedManifest.Envelope.PayloadType}");
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: $"Unexpected payload type: {signedManifest.Envelope.PayloadType}");
|
||||
}
|
||||
|
||||
// Verify manifest hash
|
||||
var computedHash = signedManifest.Manifest.ComputeHash();
|
||||
if (computedHash != signedManifest.ManifestHash)
|
||||
{
|
||||
return ManifestVerificationResult.Failure("Manifest hash mismatch");
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: "Manifest hash mismatch");
|
||||
}
|
||||
|
||||
var keyId = signedManifest.Envelope.Signatures.FirstOrDefault()?.Keyid;
|
||||
return ManifestVerificationResult.Success(signedManifest.Manifest, keyId);
|
||||
if (!TryDecodeBase64(signedManifest.Envelope.Payload, out var payloadBytes))
|
||||
{
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: "Envelope payload is not valid base64");
|
||||
}
|
||||
|
||||
var canonicalBytes = CanonicalJson.SerializeToUtf8Bytes(signedManifest.Manifest);
|
||||
if (!CryptographicOperations.FixedTimeEquals(payloadBytes, canonicalBytes))
|
||||
{
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: "Envelope payload does not match manifest payload");
|
||||
}
|
||||
|
||||
var keyId = signedManifest.Envelope.Signatures.FirstOrDefault()?.KeyId;
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: true,
|
||||
Manifest: signedManifest.Manifest,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: null,
|
||||
KeyId: keyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ManifestVerificationResult.Failure($"Verification error: {ex.Message}");
|
||||
return new ManifestVerificationResult(
|
||||
IsValid: false,
|
||||
Manifest: null,
|
||||
VerifiedAt: _timeProvider.GetUtcNow(),
|
||||
ErrorMessage: $"Verification error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeBase64(string? value, out byte[] bytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(value);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
bytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,7 @@
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Scanner.ProofSpine/StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="../../../Policy/__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -97,14 +97,14 @@ public static class GatePatterns
|
||||
[
|
||||
new GatePattern(@"@feature_flag", "Feature flag decorator", 0.90),
|
||||
new GatePattern(@"ldclient\.variation", "LaunchDarkly Python", 0.95),
|
||||
new GatePattern(@"os\.environ\.get\(['\"]FEATURE_", "Env feature flag", 0.70),
|
||||
new GatePattern(@"os\.environ\.get\(['""]FEATURE_", "Env feature flag", 0.70),
|
||||
new GatePattern(@"waffle\.flag_is_active", "Django Waffle", 0.90)
|
||||
],
|
||||
["go"] =
|
||||
[
|
||||
new GatePattern(@"unleash\.IsEnabled", "Unleash Go SDK", 0.95),
|
||||
new GatePattern(@"ldclient\.BoolVariation", "LaunchDarkly Go", 0.95),
|
||||
new GatePattern(@"os\.Getenv\(\"FEATURE_", "Env feature flag", 0.70)
|
||||
new GatePattern(@"os\.Getenv\(""FEATURE_", "Env feature flag", 0.70)
|
||||
],
|
||||
["ruby"] =
|
||||
[
|
||||
|
||||
@@ -74,7 +74,11 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<EntryTraceRepository>();
|
||||
services.AddScoped<RubyPackageInventoryRepository>();
|
||||
services.AddScoped<BunPackageInventoryRepository>();
|
||||
services.TryAddSingleton<IClassificationHistoryRepository, ClassificationHistoryRepository>();
|
||||
services.TryAddSingleton<IClassificationChangeTracker, ClassificationChangeTracker>();
|
||||
services.AddScoped<IProofSpineRepository, PostgresProofSpineRepository>();
|
||||
services.AddScoped<ICallGraphSnapshotRepository, PostgresCallGraphSnapshotRepository>();
|
||||
services.AddScoped<IReachabilityResultRepository, PostgresReachabilityResultRepository>();
|
||||
services.AddSingleton<IEntryTraceResultStore, EntryTraceResultStore>();
|
||||
services.AddSingleton<IRubyPackageInventoryStore, RubyPackageInventoryStore>();
|
||||
services.AddSingleton<IBunPackageInventoryStore, BunPackageInventoryStore>();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration: 0059_scans_table
|
||||
-- Sprint: SPRINT_3500_0002_0001_score_proofs_foundations (prereq)
|
||||
-- Description: Minimal `scans` table required by score replay/proof bundle tables.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
scan_id UUID PRIMARY KEY,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_scans_created_at
|
||||
ON scans(created_at_utc DESC);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Migration: 0065_unknowns_table
|
||||
-- Sprint: SPRINT_3600_0002_0001 (foundation prerequisite)
|
||||
-- Description: Minimal `unknowns` table required for containment/ranking follow-up migrations.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unknowns (
|
||||
unknown_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
artifact_digest TEXT NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
package_purl TEXT NOT NULL,
|
||||
score DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_unknowns_tenant_artifact
|
||||
ON unknowns(tenant_id, artifact_digest);
|
||||
CREATE INDEX IF NOT EXISTS ix_unknowns_created_at
|
||||
ON unknowns(created_at_utc DESC);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration: 0075_scan_findings_table
|
||||
-- Sprint: Advisory-derived (EPSS Integration prerequisite)
|
||||
-- Description: Minimal `scan_findings` table required for EPSS-at-scan evidence columns.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_findings (
|
||||
finding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scan_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
vuln_id TEXT NOT NULL,
|
||||
package_purl TEXT NOT NULL,
|
||||
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_scan_findings_scan_id
|
||||
ON scan_findings(scan_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_scan_findings_tenant_vuln
|
||||
ON scan_findings(tenant_id, vuln_id);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Call graph snapshots + reachability analysis results
|
||||
-- Sprint: SPRINT_3600_0002_0001_call_graph_infrastructure
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS scanner;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: scanner.call_graph_snapshots
|
||||
-- Purpose: Cache call graph snapshots per scan/language for reachability drift.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS scanner.call_graph_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
scan_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
graph_digest TEXT NOT NULL,
|
||||
|
||||
extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
node_count INT NOT NULL,
|
||||
edge_count INT NOT NULL,
|
||||
entrypoint_count INT NOT NULL,
|
||||
sink_count INT NOT NULL,
|
||||
|
||||
snapshot_json JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT call_graph_snapshot_unique_per_scan UNIQUE (tenant_id, scan_id, language, graph_digest)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_tenant_scan
|
||||
ON scanner.call_graph_snapshots (tenant_id, scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_graph_digest
|
||||
ON scanner.call_graph_snapshots (graph_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_call_graph_snapshots_extracted_at
|
||||
ON scanner.call_graph_snapshots USING BRIN (extracted_at);
|
||||
|
||||
ALTER TABLE scanner.call_graph_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots;
|
||||
CREATE POLICY call_graph_snapshots_tenant_isolation ON scanner.call_graph_snapshots
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE scanner.call_graph_snapshots IS 'Call graph snapshots per scan/language for reachability drift detection.';
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Table: scanner.reachability_results
|
||||
-- Purpose: Cache reachability BFS results (reachable sinks + shortest paths).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS scanner.reachability_results (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
scan_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
graph_digest TEXT NOT NULL,
|
||||
result_digest TEXT NOT NULL,
|
||||
|
||||
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
reachable_node_count INT NOT NULL,
|
||||
reachable_sink_count INT NOT NULL,
|
||||
|
||||
result_json JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT reachability_result_unique_per_scan UNIQUE (tenant_id, scan_id, language, graph_digest, result_digest)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_tenant_scan
|
||||
ON scanner.reachability_results (tenant_id, scan_id, language);
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_graph_digest
|
||||
ON scanner.reachability_results (graph_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_reachability_results_computed_at
|
||||
ON scanner.reachability_results USING BRIN (computed_at);
|
||||
|
||||
ALTER TABLE scanner.reachability_results ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS reachability_results_tenant_isolation ON scanner.reachability_results;
|
||||
CREATE POLICY reachability_results_tenant_isolation ON scanner.reachability_results
|
||||
USING (tenant_id = scanner.current_tenant_id());
|
||||
|
||||
COMMENT ON TABLE scanner.reachability_results IS 'Reachability analysis results per scan/language with shortest paths.';
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
-- Migration: 009_smart_diff_tables_search_path
|
||||
-- Sprint: SPRINT_3500_0003_0001_smart_diff_detection
|
||||
-- Task: SDIFF-DET-016 (follow-up)
|
||||
-- Description: Ensure Smart-Diff tables/types live in the active schema (search_path) and align tenant context key with DataSourceBase (`app.tenant_id`).
|
||||
|
||||
-- =============================================================================
|
||||
-- Enums for Smart-Diff (created in the active schema)
|
||||
-- =============================================================================
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE vex_status_type AS ENUM (
|
||||
'unknown',
|
||||
'affected',
|
||||
'not_affected',
|
||||
'fixed',
|
||||
'under_investigation'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE policy_decision_type AS ENUM (
|
||||
'allow',
|
||||
'warn',
|
||||
'block'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE detection_rule AS ENUM (
|
||||
'R1_ReachabilityFlip',
|
||||
'R2_VexFlip',
|
||||
'R3_RangeBoundary',
|
||||
'R4_IntelligenceFlip'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE material_change_type AS ENUM (
|
||||
'reachability_flip',
|
||||
'vex_flip',
|
||||
'range_boundary',
|
||||
'kev_added',
|
||||
'kev_removed',
|
||||
'epss_threshold',
|
||||
'policy_flip'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE risk_direction AS ENUM (
|
||||
'increased',
|
||||
'decreased',
|
||||
'neutral'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE vex_justification AS ENUM (
|
||||
'component_not_present',
|
||||
'vulnerable_code_not_present',
|
||||
'vulnerable_code_not_in_execute_path',
|
||||
'vulnerable_code_cannot_be_controlled_by_adversary',
|
||||
'inline_mitigations_already_exist'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE vex_review_action AS ENUM (
|
||||
'accept',
|
||||
'reject',
|
||||
'defer'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Table: risk_state_snapshots
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS risk_state_snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
vuln_id TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
|
||||
scan_id TEXT NOT NULL,
|
||||
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
reachable BOOLEAN,
|
||||
lattice_state TEXT,
|
||||
vex_status vex_status_type NOT NULL DEFAULT 'unknown',
|
||||
in_affected_range BOOLEAN,
|
||||
|
||||
kev BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
epss_score NUMERIC(5, 4),
|
||||
|
||||
policy_flags TEXT[] DEFAULT '{}',
|
||||
policy_decision policy_decision_type,
|
||||
|
||||
state_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT risk_state_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_state_tenant_finding
|
||||
ON risk_state_snapshots (tenant_id, vuln_id, purl);
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_state_scan
|
||||
ON risk_state_snapshots (scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_state_captured_at
|
||||
ON risk_state_snapshots USING BRIN (captured_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_risk_state_hash
|
||||
ON risk_state_snapshots (state_hash);
|
||||
|
||||
-- =============================================================================
|
||||
-- Table: material_risk_changes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS material_risk_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
vuln_id TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
|
||||
scan_id TEXT NOT NULL,
|
||||
|
||||
has_material_change BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
priority_score NUMERIC(6, 4) NOT NULL DEFAULT 0,
|
||||
|
||||
previous_state_hash TEXT NOT NULL,
|
||||
current_state_hash TEXT NOT NULL,
|
||||
|
||||
changes JSONB NOT NULL DEFAULT '[]',
|
||||
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT material_change_unique_per_scan UNIQUE (tenant_id, scan_id, vuln_id, purl)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_material_changes_tenant_scan
|
||||
ON material_risk_changes (tenant_id, scan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_material_changes_priority
|
||||
ON material_risk_changes (priority_score DESC)
|
||||
WHERE has_material_change = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_material_changes_detected_at
|
||||
ON material_risk_changes USING BRIN (detected_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_material_changes_changes_gin
|
||||
ON material_risk_changes USING GIN (changes);
|
||||
|
||||
-- =============================================================================
|
||||
-- Table: vex_candidates
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vex_candidates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
candidate_id TEXT NOT NULL UNIQUE,
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
vuln_id TEXT NOT NULL,
|
||||
purl TEXT NOT NULL,
|
||||
|
||||
image_digest TEXT NOT NULL,
|
||||
|
||||
suggested_status vex_status_type NOT NULL,
|
||||
justification vex_justification NOT NULL,
|
||||
rationale TEXT NOT NULL,
|
||||
|
||||
evidence_links JSONB NOT NULL DEFAULT '[]',
|
||||
|
||||
confidence NUMERIC(4, 3) NOT NULL,
|
||||
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
|
||||
requires_review BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
review_action vex_review_action,
|
||||
reviewed_by TEXT,
|
||||
reviewed_at TIMESTAMPTZ,
|
||||
review_comment TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_candidates_tenant_image
|
||||
ON vex_candidates (tenant_id, image_digest);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_candidates_pending_review
|
||||
ON vex_candidates (tenant_id, requires_review, confidence DESC)
|
||||
WHERE requires_review = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_candidates_expires
|
||||
ON vex_candidates (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_candidates_candidate_id
|
||||
ON vex_candidates (candidate_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vex_candidates_evidence_gin
|
||||
ON vex_candidates USING GIN (evidence_links);
|
||||
|
||||
-- =============================================================================
|
||||
-- RLS Policies (tenant isolation via app.tenant_id)
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE risk_state_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE material_risk_changes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE vex_candidates ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id()
|
||||
RETURNS UUID AS $$
|
||||
BEGIN
|
||||
RETURN NULLIF(current_setting('app.tenant_id', TRUE), '')::UUID;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
DROP POLICY IF EXISTS risk_state_tenant_isolation ON risk_state_snapshots;
|
||||
CREATE POLICY risk_state_tenant_isolation ON risk_state_snapshots
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
DROP POLICY IF EXISTS material_changes_tenant_isolation ON material_risk_changes;
|
||||
CREATE POLICY material_changes_tenant_isolation ON material_risk_changes
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
DROP POLICY IF EXISTS vex_candidates_tenant_isolation ON vex_candidates;
|
||||
CREATE POLICY vex_candidates_tenant_isolation ON vex_candidates
|
||||
FOR ALL
|
||||
USING (tenant_id = current_tenant_id())
|
||||
WITH CHECK (tenant_id = current_tenant_id());
|
||||
|
||||
-- =============================================================================
|
||||
-- Helper Functions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_material_changes_for_scan(
|
||||
p_scan_id TEXT,
|
||||
p_min_priority NUMERIC DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
vuln_id TEXT,
|
||||
purl TEXT,
|
||||
priority_score NUMERIC,
|
||||
changes JSONB
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
mc.vuln_id,
|
||||
mc.purl,
|
||||
mc.priority_score,
|
||||
mc.changes
|
||||
FROM material_risk_changes mc
|
||||
WHERE mc.scan_id = p_scan_id
|
||||
AND mc.has_material_change = TRUE
|
||||
AND (p_min_priority IS NULL OR mc.priority_score >= p_min_priority)
|
||||
ORDER BY mc.priority_score DESC;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_pending_vex_candidates(
|
||||
p_image_digest TEXT DEFAULT NULL,
|
||||
p_min_confidence NUMERIC DEFAULT 0.7,
|
||||
p_limit INT DEFAULT 50
|
||||
)
|
||||
RETURNS TABLE (
|
||||
candidate_id TEXT,
|
||||
vuln_id TEXT,
|
||||
purl TEXT,
|
||||
image_digest TEXT,
|
||||
suggested_status vex_status_type,
|
||||
justification vex_justification,
|
||||
rationale TEXT,
|
||||
confidence NUMERIC,
|
||||
evidence_links JSONB
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
vc.candidate_id,
|
||||
vc.vuln_id,
|
||||
vc.purl,
|
||||
vc.image_digest,
|
||||
vc.suggested_status,
|
||||
vc.justification,
|
||||
vc.rationale,
|
||||
vc.confidence,
|
||||
vc.evidence_links
|
||||
FROM vex_candidates vc
|
||||
WHERE vc.requires_review = TRUE
|
||||
AND vc.expires_at > NOW()
|
||||
AND vc.confidence >= p_min_confidence
|
||||
AND (p_image_digest IS NULL OR vc.image_digest = p_image_digest)
|
||||
ORDER BY vc.confidence DESC
|
||||
LIMIT p_limit;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
|
||||
COMMENT ON TABLE risk_state_snapshots IS
|
||||
'Point-in-time risk state snapshots for Smart-Diff change detection';
|
||||
COMMENT ON TABLE material_risk_changes IS
|
||||
'Detected material risk changes between scans (R1-R4 rules)';
|
||||
COMMENT ON TABLE vex_candidates IS
|
||||
'Auto-generated VEX candidates based on absent vulnerable APIs';
|
||||
|
||||
COMMENT ON COLUMN risk_state_snapshots.state_hash IS
|
||||
'SHA-256 of normalized state for deterministic change detection';
|
||||
COMMENT ON COLUMN material_risk_changes.changes IS
|
||||
'JSONB array of DetectedChange records';
|
||||
COMMENT ON COLUMN vex_candidates.evidence_links IS
|
||||
'JSONB array of EvidenceLink records with type, uri, digest';
|
||||
|
||||
@@ -10,4 +10,5 @@ internal static class MigrationIds
|
||||
public const string ScoreReplayTables = "006_score_replay_tables.sql";
|
||||
public const string UnknownsRankingContainment = "007_unknowns_ranking_containment.sql";
|
||||
public const string EpssIntegration = "008_epss_integration.sql";
|
||||
public const string CallGraphTables = "009_call_graph_tables.sql";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresCallGraphSnapshotRepository : ICallGraphSnapshotRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresCallGraphSnapshotRepository> _logger;
|
||||
|
||||
public PostgresCallGraphSnapshotRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresCallGraphSnapshotRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
var trimmed = snapshot.Trimmed();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.call_graph_snapshots (
|
||||
tenant_id,
|
||||
scan_id,
|
||||
language,
|
||||
graph_digest,
|
||||
extracted_at,
|
||||
node_count,
|
||||
edge_count,
|
||||
entrypoint_count,
|
||||
sink_count,
|
||||
snapshot_json
|
||||
) VALUES (
|
||||
@TenantId,
|
||||
@ScanId,
|
||||
@Language,
|
||||
@GraphDigest,
|
||||
@ExtractedAt,
|
||||
@NodeCount,
|
||||
@EdgeCount,
|
||||
@EntrypointCount,
|
||||
@SinkCount,
|
||||
@SnapshotJson::jsonb
|
||||
)
|
||||
ON CONFLICT (tenant_id, scan_id, language, graph_digest) DO UPDATE SET
|
||||
extracted_at = EXCLUDED.extracted_at,
|
||||
node_count = EXCLUDED.node_count,
|
||||
edge_count = EXCLUDED.edge_count,
|
||||
entrypoint_count = EXCLUDED.entrypoint_count,
|
||||
sink_count = EXCLUDED.sink_count,
|
||||
snapshot_json = EXCLUDED.snapshot_json
|
||||
""";
|
||||
|
||||
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
|
||||
var tenantId = GetCurrentTenantId();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ScanId = trimmed.ScanId,
|
||||
Language = trimmed.Language,
|
||||
GraphDigest = trimmed.GraphDigest,
|
||||
ExtractedAt = trimmed.ExtractedAt.UtcDateTime,
|
||||
NodeCount = trimmed.Nodes.Length,
|
||||
EdgeCount = trimmed.Edges.Length,
|
||||
EntrypointCount = trimmed.EntrypointIds.Length,
|
||||
SinkCount = trimmed.SinkIds.Length,
|
||||
SnapshotJson = json
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored call graph snapshot scan={ScanId} lang={Language} nodes={Nodes} edges={Edges}",
|
||||
trimmed.ScanId,
|
||||
trimmed.Language,
|
||||
trimmed.Nodes.Length,
|
||||
trimmed.Edges.Length);
|
||||
}
|
||||
|
||||
public async Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
const string sql = """
|
||||
SELECT snapshot_json
|
||||
FROM scanner.call_graph_snapshots
|
||||
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
|
||||
ORDER BY extracted_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = GetCurrentTenantId(),
|
||||
ScanId = scanId,
|
||||
Language = language
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<CallGraphSnapshot>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,15 @@ namespace StellaOps.Scanner.Storage.Postgres;
|
||||
/// </summary>
|
||||
public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresMaterialRiskChangeRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string MaterialRiskChangesTable => $"{SchemaName}.material_risk_changes";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -30,49 +37,58 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
|
||||
public async Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await InsertChangeAsync(connection, change, scanId, ct);
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await InsertChangeAsync(connection, change, scanId.Trim(), ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changes);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
if (changes.Count == 0)
|
||||
return;
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var change in changes)
|
||||
{
|
||||
await InsertChangeAsync(connection, change, scanId, ct, transaction);
|
||||
await InsertChangeAsync(connection, change, scanId.Trim(), ct, transaction).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Stored {Count} material risk changes for scan {ScanId}", changes.Count, scanId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store material risk changes for scan {ScanId}", scanId);
|
||||
await transaction.RollbackAsync(ct);
|
||||
await transaction.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, has_material_change, priority_score,
|
||||
previous_state_hash, current_state_hash, changes
|
||||
FROM scanner.material_risk_changes
|
||||
WHERE scan_id = @ScanId
|
||||
FROM {MaterialRiskChangesTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND scan_id = @ScanId
|
||||
ORDER BY priority_score DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { ScanId = scanId });
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new { TenantId, ScanId = scanId.Trim() });
|
||||
|
||||
return rows.Select(r => r.ToResult()).ToList();
|
||||
}
|
||||
@@ -82,21 +98,27 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
int limit = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
ArgumentNullException.ThrowIfNull(findingKey);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, has_material_change, priority_score,
|
||||
previous_state_hash, current_state_hash, changes
|
||||
FROM scanner.material_risk_changes
|
||||
WHERE vuln_id = @VulnId AND purl = @Purl
|
||||
FROM {MaterialRiskChangesTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND vuln_id = @VulnId
|
||||
AND purl = @Purl
|
||||
ORDER BY detected_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(sql, new
|
||||
{
|
||||
TenantId,
|
||||
VulnId = findingKey.VulnId,
|
||||
Purl = findingKey.Purl,
|
||||
Purl = findingKey.ComponentPurl,
|
||||
Limit = limit
|
||||
});
|
||||
|
||||
@@ -107,6 +129,8 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
MaterialRiskChangeQuery query,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var conditions = new List<string> { "has_material_change = TRUE" };
|
||||
var parameters = new DynamicParameters();
|
||||
|
||||
@@ -134,17 +158,20 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
parameters.Add("MinPriority", query.MinPriorityScore.Value);
|
||||
}
|
||||
|
||||
conditions.Add("tenant_id = @TenantId");
|
||||
parameters.Add("TenantId", TenantId);
|
||||
|
||||
var whereClause = string.Join(" AND ", conditions);
|
||||
|
||||
// Count query
|
||||
var countSql = $"SELECT COUNT(*) FROM scanner.material_risk_changes WHERE {whereClause}";
|
||||
var countSql = $"SELECT COUNT(*) FROM {MaterialRiskChangesTable} WHERE {whereClause}";
|
||||
|
||||
// Data query
|
||||
var dataSql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, has_material_change, priority_score,
|
||||
previous_state_hash, current_state_hash, changes
|
||||
FROM scanner.material_risk_changes
|
||||
FROM {MaterialRiskChangesTable}
|
||||
WHERE {whereClause}
|
||||
ORDER BY priority_score DESC
|
||||
OFFSET @Offset LIMIT @Limit
|
||||
@@ -153,7 +180,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
parameters.Add("Offset", query.Offset);
|
||||
parameters.Add("Limit", query.Limit);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
|
||||
var totalCount = await connection.ExecuteScalarAsync<int>(countSql, parameters);
|
||||
var rows = await connection.QueryAsync<MaterialRiskChangeRow>(dataSql, parameters);
|
||||
@@ -167,15 +194,19 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
Limit: query.Limit);
|
||||
}
|
||||
|
||||
private static async Task InsertChangeAsync(
|
||||
private async Task InsertChangeAsync(
|
||||
NpgsqlConnection connection,
|
||||
MaterialRiskChangeResult change,
|
||||
string scanId,
|
||||
CancellationToken ct,
|
||||
NpgsqlTransaction? transaction = null)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scanner.material_risk_changes (
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(change);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {MaterialRiskChangesTable} (
|
||||
tenant_id, vuln_id, purl, scan_id,
|
||||
has_material_change, priority_score,
|
||||
previous_state_hash, current_state_hash, changes
|
||||
@@ -192,14 +223,13 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
changes = EXCLUDED.changes
|
||||
""";
|
||||
|
||||
var tenantId = GetCurrentTenantId();
|
||||
var changesJson = JsonSerializer.Serialize(change.Changes, JsonOptions);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantId,
|
||||
VulnId = change.FindingKey.VulnId,
|
||||
Purl = change.FindingKey.Purl,
|
||||
Purl = change.FindingKey.ComponentPurl,
|
||||
ScanId = scanId,
|
||||
HasMaterialChange = change.HasMaterialChange,
|
||||
PriorityScore = change.PriorityScore,
|
||||
@@ -209,11 +239,6 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
}, transaction: transaction, cancellationToken: ct));
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row mapping class for Dapper.
|
||||
/// </summary>
|
||||
@@ -236,7 +261,7 @@ public sealed class PostgresMaterialRiskChangeRepository : IMaterialRiskChangeRe
|
||||
FindingKey: new FindingKey(vuln_id, purl),
|
||||
HasMaterialChange: has_material_change,
|
||||
Changes: [.. detectedChanges],
|
||||
PriorityScore: (int)priority_score,
|
||||
PriorityScore: (double)priority_score,
|
||||
PreviousStateHash: previous_state_hash,
|
||||
CurrentStateHash: current_state_hash);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
public sealed class PostgresReachabilityResultRepository : IReachabilityResultRepository
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresReachabilityResultRepository> _logger;
|
||||
|
||||
public PostgresReachabilityResultRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresReachabilityResultRepository> logger)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
var trimmed = result.Trimmed();
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO scanner.reachability_results (
|
||||
tenant_id,
|
||||
scan_id,
|
||||
language,
|
||||
graph_digest,
|
||||
result_digest,
|
||||
computed_at,
|
||||
reachable_node_count,
|
||||
reachable_sink_count,
|
||||
result_json
|
||||
) VALUES (
|
||||
@TenantId,
|
||||
@ScanId,
|
||||
@Language,
|
||||
@GraphDigest,
|
||||
@ResultDigest,
|
||||
@ComputedAt,
|
||||
@ReachableNodeCount,
|
||||
@ReachableSinkCount,
|
||||
@ResultJson::jsonb
|
||||
)
|
||||
ON CONFLICT (tenant_id, scan_id, language, graph_digest, result_digest) DO UPDATE SET
|
||||
computed_at = EXCLUDED.computed_at,
|
||||
reachable_node_count = EXCLUDED.reachable_node_count,
|
||||
reachable_sink_count = EXCLUDED.reachable_sink_count,
|
||||
result_json = EXCLUDED.result_json
|
||||
""";
|
||||
|
||||
var json = JsonSerializer.Serialize(trimmed, JsonOptions);
|
||||
var tenantId = GetCurrentTenantId();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ScanId = trimmed.ScanId,
|
||||
Language = trimmed.Language,
|
||||
GraphDigest = trimmed.GraphDigest,
|
||||
ResultDigest = trimmed.ResultDigest,
|
||||
ComputedAt = trimmed.ComputedAt.UtcDateTime,
|
||||
ReachableNodeCount = trimmed.ReachableNodeIds.Length,
|
||||
ReachableSinkCount = trimmed.ReachableSinkIds.Length,
|
||||
ResultJson = json
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Stored reachability result scan={ScanId} lang={Language} sinks={Sinks}",
|
||||
trimmed.ScanId,
|
||||
trimmed.Language,
|
||||
trimmed.ReachableSinkIds.Length);
|
||||
}
|
||||
|
||||
public async Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
const string sql = """
|
||||
SELECT result_json
|
||||
FROM scanner.reachability_results
|
||||
WHERE tenant_id = @TenantId AND scan_id = @ScanId AND language = @Language
|
||||
ORDER BY computed_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
var json = await connection.ExecuteScalarAsync<string?>(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = GetCurrentTenantId(),
|
||||
ScanId = scanId,
|
||||
Language = language
|
||||
}, cancellationToken: ct)).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<ReachabilityAnalysisResult>(json, JsonOptions);
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Data;
|
||||
using System.Text.Json;
|
||||
using Dapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
@@ -9,14 +7,20 @@ using StellaOps.Scanner.SmartDiff.Detection;
|
||||
namespace StellaOps.Scanner.Storage.Postgres;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of IRiskStateRepository.
|
||||
/// PostgreSQL implementation of <see cref="IRiskStateRepository"/>.
|
||||
/// Per Sprint 3500.3 - Smart-Diff Detection Rules.
|
||||
/// </summary>
|
||||
public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresRiskStateRepository> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string RiskStateSnapshotsTable => $"{SchemaName}.risk_state_snapshots";
|
||||
|
||||
public PostgresRiskStateRepository(
|
||||
ScannerDataSource dataSource,
|
||||
ILogger<PostgresRiskStateRepository> logger)
|
||||
@@ -27,52 +31,63 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
|
||||
public async Task StoreSnapshotAsync(RiskStateSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await InsertSnapshotAsync(connection, snapshot, ct);
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await InsertSnapshotAsync(connection, snapshot, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task StoreSnapshotsAsync(IReadOnlyList<RiskStateSnapshot> snapshots, CancellationToken ct = default)
|
||||
{
|
||||
if (snapshots.Count == 0)
|
||||
return;
|
||||
ArgumentNullException.ThrowIfNull(snapshots);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
if (snapshots.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var snapshot in snapshots)
|
||||
{
|
||||
await InsertSnapshotAsync(connection, snapshot, ct, transaction);
|
||||
await InsertSnapshotAsync(connection, snapshot, ct, transaction).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
await transaction.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RiskStateSnapshot?> GetLatestSnapshotAsync(FindingKey findingKey, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
ArgumentNullException.ThrowIfNull(findingKey);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, scan_id, captured_at,
|
||||
reachable, lattice_state, vex_status::TEXT, in_affected_range,
|
||||
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
|
||||
FROM scanner.risk_state_snapshots
|
||||
WHERE vuln_id = @VulnId AND purl = @Purl
|
||||
FROM {RiskStateSnapshotsTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND vuln_id = @VulnId
|
||||
AND purl = @Purl
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<RiskStateRow>(sql, new
|
||||
{
|
||||
TenantId,
|
||||
VulnId = findingKey.VulnId,
|
||||
Purl = findingKey.Purl
|
||||
Purl = findingKey.ComponentPurl
|
||||
});
|
||||
|
||||
return row?.ToSnapshot();
|
||||
@@ -80,18 +95,21 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
|
||||
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsForScanAsync(string scanId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(scanId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, scan_id, captured_at,
|
||||
reachable, lattice_state, vex_status::TEXT, in_affected_range,
|
||||
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
|
||||
FROM scanner.risk_state_snapshots
|
||||
WHERE scan_id = @ScanId
|
||||
FROM {RiskStateSnapshotsTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND scan_id = @ScanId
|
||||
ORDER BY vuln_id, purl
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { ScanId = scanId });
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, ScanId = scanId.Trim() });
|
||||
|
||||
return rows.Select(r => r.ToSnapshot()).ToList();
|
||||
}
|
||||
@@ -101,22 +119,28 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
int limit = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
ArgumentNullException.ThrowIfNull(findingKey);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, scan_id, captured_at,
|
||||
reachable, lattice_state, vex_status::TEXT, in_affected_range,
|
||||
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
|
||||
FROM scanner.risk_state_snapshots
|
||||
WHERE vuln_id = @VulnId AND purl = @Purl
|
||||
FROM {RiskStateSnapshotsTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND vuln_id = @VulnId
|
||||
AND purl = @Purl
|
||||
ORDER BY captured_at DESC
|
||||
LIMIT @Limit
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<RiskStateRow>(sql, new
|
||||
{
|
||||
TenantId,
|
||||
VulnId = findingKey.VulnId,
|
||||
Purl = findingKey.Purl,
|
||||
Purl = findingKey.ComponentPurl,
|
||||
Limit = limit
|
||||
});
|
||||
|
||||
@@ -125,37 +149,42 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
|
||||
public async Task<IReadOnlyList<RiskStateSnapshot>> GetSnapshotsByHashAsync(string stateHash, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stateHash);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
vuln_id, purl, scan_id, captured_at,
|
||||
reachable, lattice_state, vex_status::TEXT, in_affected_range,
|
||||
kev, epss_score, policy_flags, policy_decision::TEXT, state_hash
|
||||
FROM scanner.risk_state_snapshots
|
||||
WHERE state_hash = @StateHash
|
||||
FROM {RiskStateSnapshotsTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND state_hash = @StateHash
|
||||
ORDER BY captured_at DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { StateHash = stateHash });
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<RiskStateRow>(sql, new { TenantId, StateHash = stateHash.Trim() });
|
||||
|
||||
return rows.Select(r => r.ToSnapshot()).ToList();
|
||||
}
|
||||
|
||||
private static async Task InsertSnapshotAsync(
|
||||
private async Task InsertSnapshotAsync(
|
||||
NpgsqlConnection connection,
|
||||
RiskStateSnapshot snapshot,
|
||||
CancellationToken ct,
|
||||
NpgsqlTransaction? transaction = null)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scanner.risk_state_snapshots (
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {RiskStateSnapshotsTable} (
|
||||
tenant_id, vuln_id, purl, scan_id, captured_at,
|
||||
reachable, lattice_state, vex_status, in_affected_range,
|
||||
kev, epss_score, policy_flags, policy_decision, state_hash
|
||||
) VALUES (
|
||||
@TenantId, @VulnId, @Purl, @ScanId, @CapturedAt,
|
||||
@Reachable, @LatticeState, @VexStatus::scanner.vex_status_type, @InAffectedRange,
|
||||
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::scanner.policy_decision_type, @StateHash
|
||||
@Reachable, @LatticeState, @VexStatus::vex_status_type, @InAffectedRange,
|
||||
@Kev, @EpssScore, @PolicyFlags, @PolicyDecision::policy_decision_type, @StateHash
|
||||
)
|
||||
ON CONFLICT (tenant_id, scan_id, vuln_id, purl) DO UPDATE SET
|
||||
reachable = EXCLUDED.reachable,
|
||||
@@ -169,32 +198,27 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
state_hash = EXCLUDED.state_hash
|
||||
""";
|
||||
|
||||
var tenantId = GetCurrentTenantId();
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
{
|
||||
TenantId = tenantId,
|
||||
VulnId = snapshot.FindingKey.VulnId,
|
||||
Purl = snapshot.FindingKey.Purl,
|
||||
ScanId = snapshot.ScanId,
|
||||
CapturedAt = snapshot.CapturedAt,
|
||||
Reachable = snapshot.Reachable,
|
||||
LatticeState = snapshot.LatticeState,
|
||||
VexStatus = snapshot.VexStatus.ToString().ToLowerInvariant(),
|
||||
InAffectedRange = snapshot.InAffectedRange,
|
||||
Kev = snapshot.Kev,
|
||||
EpssScore = snapshot.EpssScore,
|
||||
PolicyFlags = snapshot.PolicyFlags.ToArray(),
|
||||
PolicyDecision = snapshot.PolicyDecision?.ToString().ToLowerInvariant(),
|
||||
StateHash = snapshot.ComputeStateHash()
|
||||
}, transaction: transaction, cancellationToken: ct));
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
// In production, this would come from the current context
|
||||
// For now, return a default tenant ID
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
await connection.ExecuteAsync(new CommandDefinition(
|
||||
sql,
|
||||
new
|
||||
{
|
||||
TenantId,
|
||||
VulnId = snapshot.FindingKey.VulnId,
|
||||
Purl = snapshot.FindingKey.ComponentPurl,
|
||||
ScanId = snapshot.ScanId,
|
||||
CapturedAt = snapshot.CapturedAt,
|
||||
Reachable = snapshot.Reachable,
|
||||
LatticeState = snapshot.LatticeState,
|
||||
VexStatus = snapshot.VexStatus.ToString().ToLowerInvariant(),
|
||||
InAffectedRange = snapshot.InAffectedRange,
|
||||
Kev = snapshot.Kev,
|
||||
EpssScore = snapshot.EpssScore,
|
||||
PolicyFlags = snapshot.PolicyFlags.ToArray(),
|
||||
PolicyDecision = snapshot.PolicyDecision?.ToString().ToLowerInvariant(),
|
||||
StateHash = snapshot.ComputeStateHash()
|
||||
},
|
||||
transaction: transaction,
|
||||
cancellationToken: ct)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -214,7 +238,6 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
public decimal? epss_score { get; set; }
|
||||
public string[]? policy_flags { get; set; }
|
||||
public string? policy_decision { get; set; }
|
||||
public string state_hash { get; set; } = "";
|
||||
|
||||
public RiskStateSnapshot ToSnapshot()
|
||||
{
|
||||
@@ -247,7 +270,9 @@ public sealed class PostgresRiskStateRepository : IRiskStateRepository
|
||||
private static PolicyDecisionType? ParsePolicyDecision(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ToLowerInvariant() switch
|
||||
{
|
||||
|
||||
@@ -13,8 +13,15 @@ namespace StellaOps.Scanner.Storage.Postgres;
|
||||
/// </summary>
|
||||
public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
{
|
||||
private const string TenantContext = "00000000-0000-0000-0000-000000000001";
|
||||
private static readonly Guid TenantId = Guid.Parse(TenantContext);
|
||||
|
||||
private readonly ScannerDataSource _dataSource;
|
||||
private readonly ILogger<PostgresVexCandidateStore> _logger;
|
||||
|
||||
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
|
||||
private string VexCandidatesTable => $"{SchemaName}.vex_candidates";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
@@ -30,83 +37,96 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
|
||||
public async Task StoreCandidatesAsync(IReadOnlyList<VexCandidate> candidates, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(candidates);
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return;
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
await InsertCandidateAsync(connection, candidate, ct, transaction);
|
||||
await InsertCandidateAsync(connection, candidate, ct, transaction).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Stored {Count} VEX candidates", candidates.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store VEX candidates");
|
||||
await transaction.RollbackAsync(ct);
|
||||
await transaction.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<VexCandidate>> GetCandidatesAsync(string imageDigest, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
candidate_id, vuln_id, purl, image_digest,
|
||||
suggested_status::TEXT, justification::TEXT, rationale,
|
||||
evidence_links, confidence, generated_at, expires_at,
|
||||
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
|
||||
FROM scanner.vex_candidates
|
||||
WHERE image_digest = @ImageDigest
|
||||
FROM {VexCandidatesTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND image_digest = @ImageDigest
|
||||
ORDER BY confidence DESC
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { ImageDigest = imageDigest });
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var rows = await connection.QueryAsync<VexCandidateRow>(sql, new { TenantId, ImageDigest = imageDigest.Trim() });
|
||||
|
||||
return rows.Select(r => r.ToCandidate()).ToList();
|
||||
}
|
||||
|
||||
public async Task<VexCandidate?> GetCandidateAsync(string candidateId, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
|
||||
|
||||
var sql = $"""
|
||||
SELECT
|
||||
candidate_id, vuln_id, purl, image_digest,
|
||||
suggested_status::TEXT, justification::TEXT, rationale,
|
||||
evidence_links, confidence, generated_at, expires_at,
|
||||
requires_review, review_action::TEXT, reviewed_by, reviewed_at, review_comment
|
||||
FROM scanner.vex_candidates
|
||||
WHERE candidate_id = @CandidateId
|
||||
FROM {VexCandidatesTable}
|
||||
WHERE tenant_id = @TenantId
|
||||
AND candidate_id = @CandidateId
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { CandidateId = candidateId });
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var row = await connection.QuerySingleOrDefaultAsync<VexCandidateRow>(sql, new { TenantId, CandidateId = candidateId.Trim() });
|
||||
|
||||
return row?.ToCandidate();
|
||||
}
|
||||
|
||||
public async Task<bool> ReviewCandidateAsync(string candidateId, VexCandidateReview review, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE scanner.vex_candidates SET
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(candidateId);
|
||||
ArgumentNullException.ThrowIfNull(review);
|
||||
|
||||
var sql = $"""
|
||||
UPDATE {VexCandidatesTable} SET
|
||||
requires_review = FALSE,
|
||||
review_action = @ReviewAction::scanner.vex_review_action,
|
||||
review_action = @ReviewAction::vex_review_action,
|
||||
reviewed_by = @ReviewedBy,
|
||||
reviewed_at = @ReviewedAt,
|
||||
review_comment = @ReviewComment
|
||||
WHERE candidate_id = @CandidateId
|
||||
WHERE tenant_id = @TenantId
|
||||
AND candidate_id = @CandidateId
|
||||
""";
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(TenantContext, ct).ConfigureAwait(false);
|
||||
var affected = await connection.ExecuteAsync(sql, new
|
||||
{
|
||||
CandidateId = candidateId,
|
||||
TenantId,
|
||||
CandidateId = candidateId.Trim(),
|
||||
ReviewAction = review.Action.ToString().ToLowerInvariant(),
|
||||
ReviewedBy = review.Reviewer,
|
||||
ReviewedAt = review.ReviewedAt,
|
||||
@@ -122,20 +142,23 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
private static async Task InsertCandidateAsync(
|
||||
private async Task InsertCandidateAsync(
|
||||
NpgsqlConnection connection,
|
||||
VexCandidate candidate,
|
||||
CancellationToken ct,
|
||||
NpgsqlTransaction? transaction = null)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO scanner.vex_candidates (
|
||||
ArgumentNullException.ThrowIfNull(connection);
|
||||
ArgumentNullException.ThrowIfNull(candidate);
|
||||
|
||||
var sql = $"""
|
||||
INSERT INTO {VexCandidatesTable} (
|
||||
tenant_id, candidate_id, vuln_id, purl, image_digest,
|
||||
suggested_status, justification, rationale,
|
||||
evidence_links, confidence, generated_at, expires_at, requires_review
|
||||
) VALUES (
|
||||
@TenantId, @CandidateId, @VulnId, @Purl, @ImageDigest,
|
||||
@SuggestedStatus::scanner.vex_status_type, @Justification::scanner.vex_justification, @Rationale,
|
||||
@SuggestedStatus::vex_status_type, @Justification::vex_justification, @Rationale,
|
||||
@EvidenceLinks::jsonb, @Confidence, @GeneratedAt, @ExpiresAt, @RequiresReview
|
||||
)
|
||||
ON CONFLICT (candidate_id) DO UPDATE SET
|
||||
@@ -147,7 +170,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
expires_at = EXCLUDED.expires_at
|
||||
""";
|
||||
|
||||
var tenantId = GetCurrentTenantId();
|
||||
var tenantId = TenantId;
|
||||
var evidenceLinksJson = JsonSerializer.Serialize(candidate.EvidenceLinks, JsonOptions);
|
||||
|
||||
await connection.ExecuteAsync(new CommandDefinition(sql, new
|
||||
@@ -155,7 +178,7 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
TenantId = tenantId,
|
||||
CandidateId = candidate.CandidateId,
|
||||
VulnId = candidate.FindingKey.VulnId,
|
||||
Purl = candidate.FindingKey.Purl,
|
||||
Purl = candidate.FindingKey.ComponentPurl,
|
||||
ImageDigest = candidate.ImageDigest,
|
||||
SuggestedStatus = MapVexStatus(candidate.SuggestedStatus),
|
||||
Justification = MapJustification(candidate.Justification),
|
||||
@@ -193,12 +216,6 @@ public sealed class PostgresVexCandidateStore : IVexCandidateStore
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid GetCurrentTenantId()
|
||||
{
|
||||
// In production, this would come from the current context
|
||||
return Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Row mapping class for Dapper.
|
||||
/// </summary>
|
||||
|
||||
@@ -150,21 +150,34 @@ public sealed class ClassificationHistoryRepository : RepositoryBase<ScannerData
|
||||
DateOnly toDate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
|
||||
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
|
||||
engine_count, other_count
|
||||
FROM {DriftStatsView}
|
||||
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
|
||||
ORDER BY day_bucket DESC
|
||||
""";
|
||||
var sql = tenantId == Guid.Empty
|
||||
? $"""
|
||||
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
|
||||
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
|
||||
engine_count, other_count
|
||||
FROM {DriftStatsView}
|
||||
WHERE day_bucket >= @from_date AND day_bucket <= @to_date
|
||||
ORDER BY day_bucket DESC
|
||||
"""
|
||||
: $"""
|
||||
SELECT day_bucket, tenant_id, cause, total_reclassified, fn_count, fn_drift_percent,
|
||||
feed_delta_count, rule_delta_count, lattice_delta_count, reachability_delta_count,
|
||||
engine_count, other_count
|
||||
FROM {DriftStatsView}
|
||||
WHERE tenant_id = @tenant_id AND day_bucket >= @from_date AND day_bucket <= @to_date
|
||||
ORDER BY day_bucket DESC
|
||||
""";
|
||||
|
||||
return QueryAsync(
|
||||
Tenant,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
if (tenantId != Guid.Empty)
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
}
|
||||
|
||||
AddParameter(cmd, "from_date", fromDate);
|
||||
AddParameter(cmd, "to_date", toDate);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public interface ICallGraphSnapshotRepository
|
||||
{
|
||||
Task StoreAsync(CallGraphSnapshot snapshot, CancellationToken ct = default);
|
||||
|
||||
Task<CallGraphSnapshot?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using StellaOps.Scanner.CallGraph;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
public interface IReachabilityResultRepository
|
||||
{
|
||||
Task StoreAsync(ReachabilityAnalysisResult result, CancellationToken ct = default);
|
||||
|
||||
Task<ReachabilityAnalysisResult?> TryGetLatestAsync(string scanId, string language, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
|
||||
namespace StellaOps.Scanner.Core.Drift;
|
||||
namespace StellaOps.Scanner.Storage.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates FN-Drift rate with stratification.
|
||||
@@ -142,6 +142,8 @@ public sealed class FnDriftMetricsExporter : BackgroundService
|
||||
|
||||
private async Task RefreshMetricsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _repository.RefreshDriftStatsAsync(cancellationToken);
|
||||
|
||||
// Get 30-day summary for all tenants (aggregated)
|
||||
// In production, this would iterate over active tenants
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.305.6" />
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
@@ -20,8 +21,10 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.EntryTrace\\StellaOps.Scanner.EntryTrace.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.Core\\StellaOps.Scanner.Core.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.ProofSpine\\StellaOps.Scanner.ProofSpine.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
using StellaOps.Scanner.Storage.Models;
|
||||
using StellaOps.Scanner.Storage.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.Storage.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ClassificationChangeTracker.
|
||||
/// SPRINT_3404_0001_0001 - Task #11, #12
|
||||
/// </summary>
|
||||
public sealed class ClassificationChangeTrackerTests
|
||||
{
|
||||
private readonly Mock<IClassificationHistoryRepository> _repositoryMock;
|
||||
private readonly ClassificationChangeTracker _tracker;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ClassificationChangeTrackerTests()
|
||||
{
|
||||
_repositoryMock = new Mock<IClassificationHistoryRepository>();
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_tracker = new ClassificationChangeTracker(
|
||||
_repositoryMock.Object,
|
||||
NullLogger<ClassificationChangeTracker>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangeAsync_ActualChange_InsertsToRepository()
|
||||
{
|
||||
// Arrange
|
||||
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
|
||||
|
||||
// Act
|
||||
await _tracker.TrackChangeAsync(change);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(r => r.InsertAsync(change, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangeAsync_NoOpChange_SkipsInsert()
|
||||
{
|
||||
// Arrange - same status
|
||||
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected);
|
||||
|
||||
// Act
|
||||
await _tracker.TrackChangeAsync(change);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(r => r.InsertAsync(It.IsAny<ClassificationChange>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangesAsync_FiltersNoOpChanges()
|
||||
{
|
||||
// Arrange
|
||||
var changes = new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected),
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected), // No-op
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed),
|
||||
};
|
||||
|
||||
// Act
|
||||
await _tracker.TrackChangesAsync(changes);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(r => r.InsertBatchAsync(
|
||||
It.Is<IEnumerable<ClassificationChange>>(c => c.Count() == 2),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackChangesAsync_EmptyAfterFilter_DoesNotInsert()
|
||||
{
|
||||
// Arrange - all no-ops
|
||||
var changes = new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Affected, ClassificationStatus.Affected),
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Unknown),
|
||||
};
|
||||
|
||||
// Act
|
||||
await _tracker.TrackChangesAsync(changes);
|
||||
|
||||
// Assert
|
||||
_repositoryMock.Verify(r => r.InsertBatchAsync(It.IsAny<IEnumerable<ClassificationChange>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_UnknownToAffected_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var change = CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected);
|
||||
|
||||
// Assert
|
||||
Assert.True(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_UnaffectedToAffected_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var change = CreateChange(ClassificationStatus.Unaffected, ClassificationStatus.Affected);
|
||||
|
||||
// Assert
|
||||
Assert.True(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_AffectedToFixed_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var change = CreateChange(ClassificationStatus.Affected, ClassificationStatus.Fixed);
|
||||
|
||||
// Assert
|
||||
Assert.False(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFnTransition_NewToAffected_ReturnsFalse()
|
||||
{
|
||||
// Arrange - new finding, not a reclassification
|
||||
var change = CreateChange(ClassificationStatus.New, ClassificationStatus.Affected);
|
||||
|
||||
// Assert
|
||||
Assert.False(change.IsFnTransition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_NewFinding_RecordsAsNewStatus()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var artifact = "sha256:abc123";
|
||||
var prevExecId = Guid.NewGuid();
|
||||
var currExecId = Guid.NewGuid();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByExecutionAsync(tenantId, prevExecId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ClassificationChange>());
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByExecutionAsync(tenantId, currExecId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.New, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
|
||||
// Act
|
||||
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
|
||||
|
||||
// Assert
|
||||
Assert.Single(delta);
|
||||
Assert.Equal(ClassificationStatus.New, delta[0].PreviousStatus);
|
||||
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeDeltaAsync_StatusChange_RecordsDelta()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid();
|
||||
var artifact = "sha256:abc123";
|
||||
var prevExecId = Guid.NewGuid();
|
||||
var currExecId = Guid.NewGuid();
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByExecutionAsync(tenantId, prevExecId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.New, ClassificationStatus.Unknown, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
|
||||
_repositoryMock
|
||||
.Setup(r => r.GetByExecutionAsync(tenantId, currExecId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
|
||||
});
|
||||
|
||||
// Act
|
||||
var delta = await _tracker.ComputeDeltaAsync(tenantId, artifact, prevExecId, currExecId);
|
||||
|
||||
// Assert
|
||||
Assert.Single(delta);
|
||||
Assert.Equal(ClassificationStatus.Unknown, delta[0].PreviousStatus);
|
||||
Assert.Equal(ClassificationStatus.Affected, delta[0].NewStatus);
|
||||
}
|
||||
|
||||
private static ClassificationChange CreateChange(
|
||||
ClassificationStatus previous,
|
||||
ClassificationStatus next,
|
||||
string artifact = "sha256:test",
|
||||
string vulnId = "CVE-2024-0001")
|
||||
{
|
||||
return new ClassificationChange
|
||||
{
|
||||
ArtifactDigest = artifact,
|
||||
VulnId = vulnId,
|
||||
PackagePurl = "pkg:npm/test@1.0.0",
|
||||
TenantId = Guid.NewGuid(),
|
||||
ManifestId = Guid.NewGuid(),
|
||||
ExecutionId = Guid.NewGuid(),
|
||||
PreviousStatus = previous,
|
||||
NewStatus = next,
|
||||
Cause = DriftCause.FeedDelta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake time provider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mock interface for testing.
|
||||
/// </summary>
|
||||
public interface IClassificationHistoryRepository
|
||||
{
|
||||
Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default);
|
||||
Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(Guid tenantId, Guid executionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user