work work hard work

This commit is contained in:
StellaOps Bot
2025-12-18 00:47:24 +02:00
parent dee252940b
commit b4235c134c
189 changed files with 9627 additions and 3258 deletions

View File

@@ -270,7 +270,7 @@ internal static class SmartDiffEndpoints
return new MaterialChangeDto
{
VulnId = change.FindingKey.VulnId,
Purl = change.FindingKey.Purl,
Purl = change.FindingKey.ComponentPurl,
HasMaterialChange = change.HasMaterialChange,
PriorityScore = change.PriorityScore,
PreviousStateHash = change.PreviousStateHash,
@@ -284,7 +284,7 @@ internal static class SmartDiffEndpoints
PreviousValue = c.PreviousValue,
CurrentValue = c.CurrentValue,
Weight = c.Weight,
SubType = c.SubType
SubType = null
}).ToImmutableArray()
};
}
@@ -295,7 +295,7 @@ internal static class SmartDiffEndpoints
{
CandidateId = candidate.CandidateId,
VulnId = candidate.FindingKey.VulnId,
Purl = candidate.FindingKey.Purl,
Purl = candidate.FindingKey.ComponentPurl,
ImageDigest = candidate.ImageDigest,
SuggestedStatus = candidate.SuggestedStatus.ToString().ToLowerInvariant(),
Justification = MapJustificationToString(candidate.Justification),
@@ -344,7 +344,7 @@ public sealed class MaterialChangeDto
public required string VulnId { get; init; }
public required string Purl { get; init; }
public bool HasMaterialChange { get; init; }
public int PriorityScore { get; init; }
public double PriorityScore { get; init; }
public required string PreviousStateHash { get; init; }
public required string CurrentStateHash { get; init; }
public required ImmutableArray<DetectedChangeDto> Changes { get; init; }

View File

@@ -4,11 +4,15 @@ using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy;
using StellaOps.Scanner.Core.Utility;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
@@ -19,7 +23,12 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
private const string DefaultTenant = "default";
private const string Source = "scanner.webservice";
private static readonly Guid TenantNamespace = new("ac8f2b54-72ea-43fa-9c3b-6a87ebd2d48a");
private static readonly Guid ExecutionNamespace = new("f0b1f40c-0f04-447b-a102-50de3ff79a33");
private static readonly Guid ManifestNamespace = new("d9c8858c-e2a4-47d6-bf0f-1e76d2865bea");
private readonly IPlatformEventPublisher _publisher;
private readonly IClassificationChangeTracker _classificationChangeTracker;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReportEventDispatcher> _logger;
private readonly string[] _apiBaseSegments;
@@ -32,11 +41,13 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
public ReportEventDispatcher(
IPlatformEventPublisher publisher,
IClassificationChangeTracker classificationChangeTracker,
IOptions<ScannerWebServiceOptions> options,
TimeProvider timeProvider,
ILogger<ReportEventDispatcher> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_classificationChangeTracker = classificationChangeTracker ?? throw new ArgumentNullException(nameof(classificationChangeTracker));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
@@ -109,6 +120,8 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
await PublishSafelyAsync(reportEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
await TrackFnDriftSafelyAsync(request, preview, document, tenant, occurredAt, cancellationToken).ConfigureAwait(false);
var scanCompletedEvent = new OrchestratorEvent
{
EventId = Guid.NewGuid(),
@@ -130,6 +143,200 @@ internal sealed class ReportEventDispatcher : IReportEventDispatcher
await PublishSafelyAsync(scanCompletedEvent, document.ReportId, cancellationToken).ConfigureAwait(false);
}
private async Task TrackFnDriftSafelyAsync(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
string tenant,
DateTimeOffset occurredAt,
CancellationToken cancellationToken)
{
if (preview.Diffs.IsDefaultOrEmpty)
{
return;
}
try
{
var changes = BuildClassificationChanges(request, preview, document, tenant, occurredAt);
if (changes.Count == 0)
{
return;
}
await _classificationChangeTracker.TrackChangesAsync(changes, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to record FN-drift classification changes for report {ReportId}.", document.ReportId);
}
}
private static IReadOnlyList<ClassificationChange> BuildClassificationChanges(
ReportRequestDto request,
PolicyPreviewResponse preview,
ReportDocumentDto document,
string tenant,
DateTimeOffset occurredAt)
{
var findings = request.Findings ?? Array.Empty<PolicyPreviewFindingDto>();
if (findings.Count == 0)
{
return Array.Empty<ClassificationChange>();
}
var findingsById = findings
.Where(finding => !string.IsNullOrWhiteSpace(finding.Id))
.ToDictionary(finding => finding.Id!, StringComparer.Ordinal);
var tenantId = ResolveTenantId(tenant);
var executionId = ResolveExecutionId(tenantId, document.ReportId);
var manifestId = ResolveManifestId(tenantId, document);
var artifactDigest = string.IsNullOrWhiteSpace(document.ImageDigest) ? request.ImageDigest ?? string.Empty : document.ImageDigest;
var changes = new List<ClassificationChange>();
foreach (var diff in preview.Diffs)
{
var projected = diff.Projected;
if (projected is null || string.IsNullOrWhiteSpace(projected.FindingId))
{
continue;
}
if (!findingsById.TryGetValue(projected.FindingId, out var finding))
{
continue;
}
if (string.IsNullOrWhiteSpace(finding.Cve) || string.IsNullOrWhiteSpace(finding.Purl))
{
continue;
}
var previousStatus = MapVerdictStatus(diff.Baseline.Status);
var newStatus = MapVerdictStatus(projected.Status);
if (previousStatus == ClassificationStatus.Affected && newStatus == ClassificationStatus.Unaffected)
{
newStatus = ClassificationStatus.Fixed;
}
changes.Add(new ClassificationChange
{
ArtifactDigest = artifactDigest,
VulnId = finding.Cve!,
PackagePurl = finding.Purl!,
TenantId = tenantId,
ManifestId = manifestId,
ExecutionId = executionId,
PreviousStatus = previousStatus,
NewStatus = newStatus,
Cause = DetermineCause(diff),
CauseDetail = BuildCauseDetail(diff, finding),
ChangedAt = occurredAt
});
}
return changes;
}
private static Guid ResolveTenantId(string tenant)
{
if (Guid.TryParse(tenant, out var tenantId))
{
return tenantId;
}
var normalized = tenant.Trim().ToLowerInvariant();
return ScannerIdentifiers.CreateDeterministicGuid(TenantNamespace, Encoding.UTF8.GetBytes(normalized));
}
private static Guid ResolveExecutionId(Guid tenantId, string reportId)
{
var payload = $"{tenantId:D}:{reportId}".Trim().ToLowerInvariant();
return ScannerIdentifiers.CreateDeterministicGuid(ExecutionNamespace, Encoding.UTF8.GetBytes(payload));
}
private static Guid ResolveManifestId(Guid tenantId, ReportDocumentDto document)
{
var manifestDigest = document.Surface?.ManifestDigest;
var payloadSource = string.IsNullOrWhiteSpace(manifestDigest)
? document.ImageDigest
: manifestDigest;
var payload = $"{tenantId:D}:{payloadSource}".Trim().ToLowerInvariant();
return ScannerIdentifiers.CreateDeterministicGuid(ManifestNamespace, Encoding.UTF8.GetBytes(payload));
}
private static ClassificationStatus MapVerdictStatus(PolicyVerdictStatus status) => status switch
{
PolicyVerdictStatus.Blocked or PolicyVerdictStatus.Escalated => ClassificationStatus.Affected,
PolicyVerdictStatus.Warned or PolicyVerdictStatus.Deferred or PolicyVerdictStatus.RequiresVex => ClassificationStatus.Unknown,
_ => ClassificationStatus.Unaffected
};
private static DriftCause DetermineCause(PolicyVerdictDiff diff)
{
if (!string.Equals(diff.Baseline.RuleName, diff.Projected.RuleName, StringComparison.Ordinal)
|| !string.Equals(diff.Baseline.RuleAction, diff.Projected.RuleAction, StringComparison.Ordinal))
{
return DriftCause.RuleDelta;
}
if (!string.Equals(diff.Baseline.Reachability, diff.Projected.Reachability, StringComparison.Ordinal))
{
return DriftCause.ReachabilityDelta;
}
if (!string.Equals(diff.Baseline.SourceTrust, diff.Projected.SourceTrust, StringComparison.Ordinal))
{
return DriftCause.FeedDelta;
}
if (diff.Baseline.Quiet != diff.Projected.Quiet
|| !string.Equals(diff.Baseline.QuietedBy, diff.Projected.QuietedBy, StringComparison.Ordinal))
{
return DriftCause.LatticeDelta;
}
return DriftCause.Other;
}
private static IReadOnlyDictionary<string, string>? BuildCauseDetail(PolicyVerdictDiff diff, PolicyPreviewFindingDto finding)
{
var details = new SortedDictionary<string, string>(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(diff.Projected.RuleName))
{
details["ruleName"] = diff.Projected.RuleName!;
}
if (!string.IsNullOrWhiteSpace(diff.Projected.RuleAction))
{
details["ruleAction"] = diff.Projected.RuleAction!;
}
if (!string.IsNullOrWhiteSpace(diff.Projected.Reachability))
{
details["reachability"] = diff.Projected.Reachability!;
}
if (!string.IsNullOrWhiteSpace(diff.Projected.SourceTrust))
{
details["sourceTrust"] = diff.Projected.SourceTrust!;
}
if (!string.IsNullOrWhiteSpace(finding.Source))
{
details["findingSource"] = finding.Source!;
}
return details.Count == 0 ? null : details;
}
private async Task PublishSafelyAsync(OrchestratorEvent @event, string reportId, CancellationToken cancellationToken)
{
try

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using StellaOps.Scanner.Storage.Services;
using StellaOps.Scanner.Worker.Options;
namespace StellaOps.Scanner.Worker.Diagnostics;
@@ -61,6 +62,7 @@ public static class TelemetryExtensions
metrics
.AddMeter(
ScannerWorkerInstrumentation.MeterName,
FnDriftMetricsExporter.MeterName,
"StellaOps.Scanner.Analyzers.Lang.Node",
"StellaOps.Scanner.Analyzers.Lang.Go")
.AddRuntimeInstrumentation()

View File

@@ -27,6 +27,7 @@ using StellaOps.Scanner.Worker.Determinism;
using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Storage.Extensions;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Storage.Services;
using Reachability = StellaOps.Scanner.Worker.Processing.Reachability;
var builder = Host.CreateApplicationBuilder(args);
@@ -98,6 +99,7 @@ var connectionString = storageSection.GetValue<string>("Postgres:ConnectionStrin
if (!string.IsNullOrWhiteSpace(connectionString))
{
builder.Services.AddScannerStorage(storageSection);
builder.Services.AddHostedService<FnDriftMetricsExporter>();
builder.Services.AddSingleton<IConfigureOptions<ScannerStorageOptions>, ScannerStorageSurfaceSecretConfigurator>();
builder.Services.AddSingleton<ISurfaceManifestPublisher, SurfaceManifestPublisher>();
builder.Services.AddSingleton<IScanStageExecutor, SurfaceManifestStageExecutor>();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"] =
[

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,45 @@
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Node;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class BenchmarkIntegrationTests
{
[Theory]
[InlineData("unsafe-eval", true)]
[InlineData("guarded-eval", false)]
public async Task NodeTraceExtractor_AlignsWithBenchmarkReachability(string caseName, bool expectSinkReachable)
{
var repoRoot = FindRepoRoot();
var caseDir = Path.Combine(repoRoot, "bench", "reachability-benchmark", "cases", "js", caseName);
var extractor = new NodeCallGraphExtractor();
var snapshot = await extractor.ExtractAsync(new CallGraphExtractionRequest(
ScanId: $"bench-{caseName}",
Language: "node",
TargetPath: caseDir));
var analyzer = new ReachabilityAnalyzer();
var result = analyzer.Analyze(snapshot);
Assert.Equal(expectSinkReachable, result.ReachableSinkIds.Length > 0);
}
private static string FindRepoRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (Directory.Exists(Path.Combine(directory.FullName, "bench", "reachability-benchmark")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new InvalidOperationException("Unable to locate repository root for benchmark integration tests.");
}
}

View File

@@ -0,0 +1,42 @@
using StellaOps.Scanner.CallGraph.Caching;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class CircuitBreakerStateTests
{
[Fact]
public void RecordFailure_TripsOpen_AfterThreshold()
{
var config = new CircuitBreakerConfig
{
FailureThreshold = 2,
TimeoutSeconds = 60,
HalfOpenTimeout = 10
};
var cb = new CircuitBreakerState(config);
Assert.Equal(CircuitState.Closed, cb.State);
cb.RecordFailure();
Assert.Equal(CircuitState.Closed, cb.State);
cb.RecordFailure();
Assert.Equal(CircuitState.Open, cb.State);
Assert.True(cb.IsOpen);
}
[Fact]
public void RecordSuccess_ResetsToClosed()
{
var config = new CircuitBreakerConfig { FailureThreshold = 1, TimeoutSeconds = 60, HalfOpenTimeout = 10 };
var cb = new CircuitBreakerState(config);
cb.RecordFailure();
Assert.True(cb.IsOpen);
cb.RecordSuccess();
Assert.Equal(CircuitState.Closed, cb.State);
Assert.False(cb.IsOpen);
}
}

View File

@@ -0,0 +1,166 @@
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.DotNet;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class DotNetCallGraphExtractorTests
{
[Fact]
public async Task ExtractAsync_SimpleProject_ProducesEntrypointAndSink()
{
await using var temp = await TempDirectory.CreateAsync();
var csprojPath = Path.Combine(temp.Path, "App.csproj");
await File.WriteAllTextAsync(csprojPath, """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
""");
await File.WriteAllTextAsync(Path.Combine(temp.Path, "Program.cs"), """
using System;
public sealed class HttpGetAttribute : Attribute { }
namespace System.Diagnostics
{
public static class Process
{
public static void Start(string cmd) { }
}
}
public sealed class FooController
{
[HttpGet]
public void Get()
{
Helper();
}
private void Helper()
{
System.Diagnostics.Process.Start("cmd.exe");
}
}
""");
var fixedTime = DateTimeOffset.Parse("2025-12-17T00:00:00Z");
var extractor = new DotNetCallGraphExtractor(new FixedTimeProvider(fixedTime));
var snapshot = await extractor.ExtractAsync(new CallGraphExtractionRequest(
ScanId: "scan-001",
Language: "dotnet",
TargetPath: csprojPath));
Assert.Equal("scan-001", snapshot.ScanId);
Assert.Equal("dotnet", snapshot.Language);
Assert.False(string.IsNullOrWhiteSpace(snapshot.GraphDigest));
Assert.NotEmpty(snapshot.Nodes);
Assert.NotEmpty(snapshot.Edges);
Assert.Contains(snapshot.Nodes, n => n.IsEntrypoint && n.EntrypointType == EntrypointType.HttpHandler);
Assert.Contains(snapshot.Nodes, n => n.IsSink);
Assert.NotEmpty(snapshot.SinkIds);
Assert.NotEmpty(snapshot.EntrypointIds);
}
[Fact]
public async Task ExtractAsync_IsDeterministic_ForSameInputs()
{
await using var temp = await TempDirectory.CreateAsync();
var csprojPath = Path.Combine(temp.Path, "App.csproj");
await File.WriteAllTextAsync(csprojPath, """
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
""");
await File.WriteAllTextAsync(Path.Combine(temp.Path, "Program.cs"), """
public static class Program
{
public static void Main()
{
A();
}
private static void A()
{
B();
}
private static void B()
{
}
}
""");
var extractor = new DotNetCallGraphExtractor();
var request = new CallGraphExtractionRequest("scan-001", "dotnet", csprojPath);
var first = await extractor.ExtractAsync(request);
var second = await extractor.ExtractAsync(request);
Assert.Equal(first.GraphDigest, second.GraphDigest);
Assert.Equal(first.Nodes.Select(n => n.NodeId), second.Nodes.Select(n => n.NodeId));
Assert.Equal(first.Edges.Select(e => (e.SourceId, e.TargetId, e.CallKind)), second.Edges.Select(e => (e.SourceId, e.TargetId, e.CallKind)));
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _instant;
public FixedTimeProvider(DateTimeOffset instant)
{
_instant = instant;
}
public override DateTimeOffset GetUtcNow() => _instant;
}
private sealed class TempDirectory : IAsyncDisposable
{
public string Path { get; }
private TempDirectory(string path)
{
Path = path;
}
public static Task<TempDirectory> CreateAsync()
{
var root = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"stella_callgraph_{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return Task.FromResult(new TempDirectory(root));
}
public ValueTask DisposeAsync()
{
try
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
catch
{
// best effort cleanup
}
return ValueTask.CompletedTask;
}
}
}

View File

@@ -0,0 +1,67 @@
using System.Collections.Immutable;
using StellaOps.Scanner.CallGraph;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
public class ReachabilityAnalyzerTests
{
[Fact]
public void Analyze_WhenSinkReachable_ReturnsShortestPath()
{
var entry = CallGraphNodeIds.Compute("dotnet:test:entry");
var mid = CallGraphNodeIds.Compute("dotnet:test:mid");
var sink = CallGraphNodeIds.Compute("dotnet:test:sink");
var snapshot = new CallGraphSnapshot(
ScanId: "scan-1",
GraphDigest: "sha256:placeholder",
Language: "dotnet",
ExtractedAt: DateTimeOffset.UtcNow,
Nodes:
[
new CallGraphNode(entry, "Entry", "file.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null),
new CallGraphNode(mid, "Mid", "file.cs", 2, "app", Visibility.Public, false, null, false, null),
new CallGraphNode(sink, "Sink", "file.cs", 3, "System", Visibility.Public, false, null, true, StellaOps.Scanner.Reachability.SinkCategory.CmdExec),
],
Edges:
[
new CallGraphEdge(entry, mid, CallKind.Direct),
new CallGraphEdge(mid, sink, CallKind.Direct),
],
EntrypointIds: [entry],
SinkIds: [sink]);
var analyzer = new ReachabilityAnalyzer();
var result = analyzer.Analyze(snapshot);
Assert.Contains(sink, result.ReachableSinkIds);
Assert.Single(result.Paths);
Assert.Equal(entry, result.Paths[0].EntrypointId);
Assert.Equal(sink, result.Paths[0].SinkId);
Assert.Equal(ImmutableArray.Create(entry, mid, sink), result.Paths[0].NodeIds);
}
[Fact]
public void Analyze_WhenNoEntrypoints_ReturnsEmpty()
{
var snapshot = new CallGraphSnapshot(
ScanId: "scan-1",
GraphDigest: "sha256:placeholder",
Language: "dotnet",
ExtractedAt: DateTimeOffset.UtcNow,
Nodes: ImmutableArray<CallGraphNode>.Empty,
Edges: ImmutableArray<CallGraphEdge>.Empty,
EntrypointIds: ImmutableArray<string>.Empty,
SinkIds: ImmutableArray<string>.Empty);
var analyzer = new ReachabilityAnalyzer();
var result = analyzer.Analyze(snapshot);
Assert.Empty(result.ReachableNodeIds);
Assert.Empty(result.ReachableSinkIds);
Assert.Empty(result.Paths);
Assert.False(string.IsNullOrWhiteSpace(result.ResultDigest));
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Scanner.CallGraph\\StellaOps.Scanner.CallGraph.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Messaging.Testing\\StellaOps.Messaging.Testing.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Testing.Fixtures;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.CallGraph.Caching;
using Xunit;
namespace StellaOps.Scanner.CallGraph.Tests;
[Collection(nameof(ValkeyFixtureCollection))]
public class ValkeyCallGraphCacheServiceTests : IAsyncLifetime
{
private readonly ValkeyFixture _fixture;
private ValkeyCallGraphCacheService _cache = null!;
public ValkeyCallGraphCacheServiceTests(ValkeyFixture fixture)
{
_fixture = fixture;
}
public Task InitializeAsync()
{
var options = Options.Create(new CallGraphCacheConfig
{
Enabled = true,
ConnectionString = _fixture.ConnectionString,
KeyPrefix = "test:callgraph:",
TtlSeconds = 60,
EnableGzip = true,
CircuitBreaker = new CircuitBreakerConfig { FailureThreshold = 3, TimeoutSeconds = 30, HalfOpenTimeout = 10 }
});
_cache = new ValkeyCallGraphCacheService(options, NullLogger<ValkeyCallGraphCacheService>.Instance);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _cache.DisposeAsync();
}
[Fact]
public async Task SetThenGet_CallGraph_RoundTrips()
{
var nodeId = CallGraphNodeIds.Compute("dotnet:test:entry");
var snapshot = new CallGraphSnapshot(
ScanId: "scan-cache-1",
GraphDigest: "sha256:cg",
Language: "dotnet",
ExtractedAt: DateTimeOffset.UtcNow,
Nodes: [new CallGraphNode(nodeId, "Entry", "file.cs", 1, "app", Visibility.Public, true, EntrypointType.HttpHandler, false, null)],
Edges: [],
EntrypointIds: [nodeId],
SinkIds: []);
await _cache.SetCallGraphAsync(snapshot);
var loaded = await _cache.TryGetCallGraphAsync("scan-cache-1", "dotnet");
Assert.NotNull(loaded);
Assert.Equal(snapshot.ScanId, loaded!.ScanId);
Assert.Equal(snapshot.Language, loaded.Language);
Assert.Equal(snapshot.GraphDigest, loaded.GraphDigest);
}
[Fact]
public async Task SetThenGet_ReachabilityResult_RoundTrips()
{
var result = new ReachabilityAnalysisResult(
ScanId: "scan-cache-2",
GraphDigest: "sha256:cg",
Language: "dotnet",
ComputedAt: DateTimeOffset.UtcNow,
ReachableNodeIds: [],
ReachableSinkIds: [],
Paths: [],
ResultDigest: "sha256:r");
await _cache.SetReachabilityResultAsync(result);
var loaded = await _cache.TryGetReachabilityResultAsync("scan-cache-2", "dotnet");
Assert.NotNull(loaded);
Assert.Equal(result.ResultDigest, loaded!.ResultDigest);
}
}

View File

@@ -1,61 +1,52 @@
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.Storage.Services;
using Xunit;
namespace StellaOps.Scanner.Storage.Tests;
/// <summary>
/// Unit tests for ClassificationChangeTracker.
/// SPRINT_3404_0001_0001 - Task #11, #12
/// Unit tests for <see cref="ClassificationChangeTracker" />.
/// </summary>
public sealed class ClassificationChangeTrackerTests
{
private readonly Mock<IClassificationHistoryRepository> _repositoryMock;
private readonly FakeClassificationHistoryRepository _repository;
private readonly ClassificationChangeTracker _tracker;
private readonly FakeTimeProvider _timeProvider;
public ClassificationChangeTrackerTests()
{
_repositoryMock = new Mock<IClassificationHistoryRepository>();
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
_repository = new FakeClassificationHistoryRepository();
_tracker = new ClassificationChangeTracker(
_repositoryMock.Object,
_repository,
NullLogger<ClassificationChangeTracker>.Instance,
_timeProvider);
new FakeTimeProvider(DateTimeOffset.Parse("2025-12-17T00:00:00Z")));
}
[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);
Assert.Single(_repository.InsertedChanges);
Assert.Same(change, _repository.InsertedChanges[0]);
}
[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);
Assert.Empty(_repository.InsertedChanges);
}
[Fact]
public async Task TrackChangesAsync_FiltersNoOpChanges()
{
// Arrange
var changes = new[]
{
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected),
@@ -63,97 +54,70 @@ public sealed class ClassificationChangeTrackerTests
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);
Assert.Single(_repository.InsertedBatches);
Assert.Equal(2, _repository.InsertedBatches[0].Count);
}
[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);
Assert.Empty(_repository.InsertedBatches);
}
[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>());
_repository.SetExecutionChanges(tenantId, prevExecId, Array.Empty<ClassificationChange>());
_repository.SetExecutionChanges(tenantId, currExecId, new[]
{
CreateChange(ClassificationStatus.New, ClassificationStatus.Affected, artifact, "CVE-2024-0001"),
});
_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);
@@ -162,30 +126,22 @@ public sealed class ClassificationChangeTrackerTests
[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"),
});
_repository.SetExecutionChanges(tenantId, prevExecId, new[]
{
CreateChange(ClassificationStatus.New, ClassificationStatus.Unknown, artifact, "CVE-2024-0001"),
});
_repository.SetExecutionChanges(tenantId, currExecId, new[]
{
CreateChange(ClassificationStatus.Unknown, ClassificationStatus.Affected, 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);
@@ -196,8 +152,7 @@ public sealed class ClassificationChangeTrackerTests
ClassificationStatus next,
string artifact = "sha256:test",
string vulnId = "CVE-2024-0001")
{
return new ClassificationChange
=> new()
{
ArtifactDigest = artifact,
VulnId = vulnId,
@@ -209,29 +164,66 @@ public sealed class ClassificationChangeTrackerTests
NewStatus = next,
Cause = DriftCause.FeedDelta,
};
private 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);
}
private sealed class FakeClassificationHistoryRepository : IClassificationHistoryRepository
{
private readonly Dictionary<(Guid tenantId, Guid executionId), IReadOnlyList<ClassificationChange>> _byExecution = new();
public List<ClassificationChange> InsertedChanges { get; } = new();
public List<List<ClassificationChange>> InsertedBatches { get; } = new();
public void SetExecutionChanges(Guid tenantId, Guid executionId, IReadOnlyList<ClassificationChange> changes)
=> _byExecution[(tenantId, executionId)] = changes;
public Task InsertAsync(ClassificationChange change, CancellationToken cancellationToken = default)
{
InsertedChanges.Add(change);
return Task.CompletedTask;
}
public Task InsertBatchAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
{
InsertedBatches.Add(changes.ToList());
return Task.CompletedTask;
}
public Task<IReadOnlyList<ClassificationChange>> GetByExecutionAsync(
Guid tenantId,
Guid executionId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(_byExecution.TryGetValue((tenantId, executionId), out var changes)
? changes
: Array.Empty<ClassificationChange>());
}
public Task<IReadOnlyList<ClassificationChange>> GetChangesAsync(Guid tenantId, DateTimeOffset since, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ClassificationChange>> GetByArtifactAsync(string artifactDigest, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<ClassificationChange>> GetByVulnIdAsync(string vulnId, Guid? tenantId = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<FnDriftStats>> GetDriftStatsAsync(Guid tenantId, DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<FnDrift30dSummary?> GetDrift30dSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task RefreshDriftStatsAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
/// <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);
}

View File

@@ -5,6 +5,8 @@
// Description: Unit tests for scan metrics repository operations
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Repositories;
using Xunit;
@@ -16,6 +18,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
{
private readonly ScannerPostgresFixture _fixture;
private IScanMetricsRepository _repository = null!;
private NpgsqlDataSource _dataSource = null!;
public ScanMetricsRepositoryTests(ScannerPostgresFixture fixture)
{
@@ -24,11 +27,20 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
public async Task InitializeAsync()
{
await _fixture.ResetAsync();
_repository = new PostgresScanMetricsRepository(_fixture.CreateConnection);
await _fixture.TruncateAllTablesAsync();
// Migration 004 creates scan metrics objects under the hard-coded `scanner` schema.
// Clear those tables explicitly for test isolation.
await _fixture.ExecuteSqlAsync("TRUNCATE TABLE scanner.execution_phases, scanner.scan_metrics CASCADE;");
_dataSource = NpgsqlDataSource.Create(_fixture.ConnectionString);
_repository = new PostgresScanMetricsRepository(_dataSource, NullLogger<PostgresScanMetricsRepository>.Instance);
}
public Task DisposeAsync() => Task.CompletedTask;
public async Task DisposeAsync()
{
await _dataSource.DisposeAsync();
}
[Fact]
public async Task SaveAsync_InsertsNewMetrics()
@@ -59,7 +71,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
new ExecutionPhase
{
MetricsId = metrics.MetricsId,
PhaseName = "pull",
PhaseName = ScanPhaseNames.Ingest,
PhaseOrder = 1,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-10),
FinishedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
@@ -68,7 +80,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
new ExecutionPhase
{
MetricsId = metrics.MetricsId,
PhaseName = "analyze",
PhaseName = ScanPhaseNames.Analyze,
PhaseOrder = 2,
StartedAt = DateTimeOffset.UtcNow.AddSeconds(-5),
FinishedAt = DateTimeOffset.UtcNow,
@@ -80,10 +92,10 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
await _repository.SavePhasesAsync(phases, CancellationToken.None);
// Assert
var retrieved = await _repository.GetPhasesByMetricsIdAsync(metrics.MetricsId, CancellationToken.None);
var retrieved = await _repository.GetPhasesAsync(metrics.MetricsId, CancellationToken.None);
Assert.Equal(2, retrieved.Count);
Assert.Contains(retrieved, p => p.PhaseName == "pull");
Assert.Contains(retrieved, p => p.PhaseName == "analyze");
Assert.Contains(retrieved, p => p.PhaseName == ScanPhaseNames.Ingest);
Assert.Contains(retrieved, p => p.PhaseName == ScanPhaseNames.Analyze);
}
[Fact]
@@ -97,7 +109,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
}
[Fact]
public async Task GetTteByTenantAsync_ReturnsMetricsForTenant()
public async Task GetRecentAsync_ReturnsMetricsForTenant()
{
// Arrange
var tenantId = Guid.NewGuid();
@@ -110,7 +122,7 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
await _repository.SaveAsync(metricsOther, CancellationToken.None);
// Act
var result = await _repository.GetTteByTenantAsync(tenantId, limit: 10, CancellationToken.None);
var result = await _repository.GetRecentAsync(tenantId, limit: 10, includeReplays: true, cancellationToken: CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
@@ -118,33 +130,35 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
}
[Fact]
public async Task GetTteBySurfaceAsync_ReturnsMetricsForSurface()
public async Task GetByArtifactAsync_ReturnsMetricsForArtifact()
{
// Arrange
var surfaceId = Guid.NewGuid();
var metrics1 = CreateTestMetrics(surfaceId: surfaceId);
var metrics2 = CreateTestMetrics(surfaceId: surfaceId);
var artifactDigest = $"sha256:{Guid.NewGuid():N}";
var metrics1 = CreateTestMetrics(artifactDigest: artifactDigest);
var metrics2 = CreateTestMetrics(artifactDigest: artifactDigest);
var other = CreateTestMetrics();
await _repository.SaveAsync(metrics1, CancellationToken.None);
await _repository.SaveAsync(metrics2, CancellationToken.None);
await _repository.SaveAsync(other, CancellationToken.None);
// Act
var result = await _repository.GetTteBySurfaceAsync(surfaceId, limit: 10, CancellationToken.None);
var result = await _repository.GetByArtifactAsync(artifactDigest, CancellationToken.None);
// Assert
Assert.Equal(2, result.Count);
Assert.All(result, m => Assert.Equal(surfaceId, m.SurfaceId));
Assert.All(result, m => Assert.Equal(artifactDigest, m.ArtifactDigest));
}
[Fact]
public async Task GetP50TteAsync_CalculatesMedianCorrectly()
public async Task GetTtePercentileAsync_CalculatesMedianCorrectly()
{
// Arrange
var tenantId = Guid.NewGuid();
var baseTime = DateTimeOffset.UtcNow;
// Create metrics with different durations: 100ms, 200ms, 300ms, 400ms, 500ms
for (int i = 1; i <= 5; i++)
// Create metrics with different durations: 100ms, 200ms, 300ms, 400ms, 500ms.
for (var i = 1; i <= 5; i++)
{
var metrics = new ScanMetrics
{
@@ -152,22 +166,26 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
ScanId = Guid.NewGuid(),
TenantId = tenantId,
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
ArtifactType = "oci_image",
ArtifactType = ArtifactTypes.OciImage,
FindingsSha256 = $"sha256:{Guid.NewGuid():N}",
StartedAt = baseTime.AddMilliseconds(-(i * 100)),
FinishedAt = baseTime,
Phases = new ScanPhaseTimings
{
PullMs = i * 20,
IngestMs = i * 20,
AnalyzeMs = i * 30,
DecideMs = i * 50
}
ReachabilityMs = 0,
VexMs = 0,
SignMs = 0,
PublishMs = 0
},
ScannerVersion = "1.0.0"
};
await _repository.SaveAsync(metrics, CancellationToken.None);
}
// Act
var p50 = await _repository.GetP50TteAsync(tenantId, since: baseTime.AddHours(-1), CancellationToken.None);
var p50 = await _repository.GetTtePercentileAsync(tenantId, percentile: 0.50m, since: baseTime.AddHours(-1), cancellationToken: CancellationToken.None);
// Assert
Assert.NotNull(p50);
@@ -178,15 +196,15 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
public async Task SaveAsync_PreservesPhaseTimings()
{
// Arrange
var metrics = CreateTestMetrics();
metrics.Phases = new ScanPhaseTimings
var metrics = CreateTestMetrics(phases: new ScanPhaseTimings
{
PullMs = 100,
IngestMs = 100,
AnalyzeMs = 200,
DecideMs = 150,
AttestMs = 50,
ReachabilityMs = 300
};
ReachabilityMs = 300,
VexMs = 150,
SignMs = 50,
PublishMs = 25
});
// Act
await _repository.SaveAsync(metrics, CancellationToken.None);
@@ -194,20 +212,19 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
// Assert
var retrieved = await _repository.GetByScanIdAsync(metrics.ScanId, CancellationToken.None);
Assert.NotNull(retrieved);
Assert.Equal(100, retrieved.Phases.PullMs);
Assert.Equal(100, retrieved.Phases.IngestMs);
Assert.Equal(200, retrieved.Phases.AnalyzeMs);
Assert.Equal(150, retrieved.Phases.DecideMs);
Assert.Equal(50, retrieved.Phases.AttestMs);
Assert.Equal(300, retrieved.Phases.ReachabilityMs);
Assert.Equal(150, retrieved.Phases.VexMs);
Assert.Equal(50, retrieved.Phases.SignMs);
Assert.Equal(25, retrieved.Phases.PublishMs);
}
[Fact]
public async Task SaveAsync_HandlesReplayScans()
{
// Arrange
var metrics = CreateTestMetrics();
metrics.IsReplay = true;
metrics.ReplayManifestHash = "sha256:replay123";
var metrics = CreateTestMetrics(isReplay: true, replayManifestHash: "sha256:replay123");
// Act
await _repository.SaveAsync(metrics, CancellationToken.None);
@@ -219,7 +236,13 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
Assert.Equal("sha256:replay123", retrieved.ReplayManifestHash);
}
private static ScanMetrics CreateTestMetrics(Guid? tenantId = null, Guid? surfaceId = null)
private static ScanMetrics CreateTestMetrics(
Guid? tenantId = null,
Guid? surfaceId = null,
string? artifactDigest = null,
ScanPhaseTimings? phases = null,
bool isReplay = false,
string? replayManifestHash = null)
{
return new ScanMetrics
{
@@ -227,12 +250,15 @@ public sealed class ScanMetricsRepositoryTests : IAsyncLifetime
ScanId = Guid.NewGuid(),
TenantId = tenantId ?? Guid.NewGuid(),
SurfaceId = surfaceId,
ArtifactDigest = $"sha256:{Guid.NewGuid():N}",
ArtifactType = "oci_image",
ArtifactDigest = artifactDigest ?? $"sha256:{Guid.NewGuid():N}",
ArtifactType = ArtifactTypes.OciImage,
ReplayManifestHash = replayManifestHash,
FindingsSha256 = $"sha256:{Guid.NewGuid():N}",
StartedAt = DateTimeOffset.UtcNow.AddMinutes(-1),
FinishedAt = DateTimeOffset.UtcNow,
Phases = new ScanPhaseTimings()
Phases = phases ?? ScanPhaseTimings.Empty,
ScannerVersion = "1.0.0",
IsReplay = isReplay
};
}
}

View File

@@ -77,7 +77,7 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
// Assert
Assert.NotNull(retrieved);
Assert.Equal(snapshot.FindingKey.VulnId, retrieved.FindingKey.VulnId);
Assert.Equal(snapshot.FindingKey.Purl, retrieved.FindingKey.Purl);
Assert.Equal(snapshot.FindingKey.ComponentPurl, retrieved.FindingKey.ComponentPurl);
Assert.Equal(snapshot.Reachable, retrieved.Reachable);
Assert.Equal(snapshot.VexStatus, retrieved.VexStatus);
Assert.Equal(snapshot.Kev, retrieved.Kev);
@@ -89,11 +89,11 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
// Arrange
var findingKey = new FindingKey("CVE-2024-5678", "pkg:pypi/requests@2.28.0");
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-001",
var snapshot1 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-001",
capturedAt: DateTimeOffset.UtcNow.AddHours(-2));
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-002",
var snapshot2 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-002",
capturedAt: DateTimeOffset.UtcNow.AddHours(-1));
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.Purl, "scan-003",
var snapshot3 = CreateTestSnapshot(findingKey.VulnId, findingKey.ComponentPurl, "scan-003",
capturedAt: DateTimeOffset.UtcNow);
// Act
@@ -251,8 +251,8 @@ public class SmartDiffRepositoryIntegrationTests : IAsyncLifetime
{
// Arrange
var findingKey = new FindingKey("CVE-2024-HIST", "pkg:npm/history@1.0.0");
var change1 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 100);
var change2 = CreateTestChange(findingKey.VulnId, findingKey.Purl, hasMaterialChange: true, priority: 200);
var change1 = CreateTestChange(findingKey.VulnId, findingKey.ComponentPurl, hasMaterialChange: true, priority: 100);
var change2 = CreateTestChange(findingKey.VulnId, findingKey.ComponentPurl, hasMaterialChange: true, priority: 200);
await _changeRepo.StoreChangeAsync(change1, "scan-h1");
await _changeRepo.StoreChangeAsync(change2, "scan-h2");

View File

@@ -13,6 +13,8 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Policy;
using StellaOps.Scanner.Storage.Models;
using StellaOps.Scanner.Storage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Options;
using StellaOps.Scanner.WebService.Services;
@@ -30,7 +32,8 @@ public sealed class ReportEventDispatcherTests
public async Task PublishAsync_EmitsReportReadyAndScanCompleted()
{
var publisher = new RecordingEventPublisher();
var dispatcher = new ReportEventDispatcher(publisher, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = CancellationToken.None;
var request = new ReportRequestDto
@@ -165,6 +168,143 @@ public sealed class ReportEventDispatcherTests
Assert.Equal("blocked", scanPayload.Report.Verdict);
}
[Fact]
public async Task PublishAsync_RecordsFnDriftClassificationChanges()
{
var publisher = new RecordingEventPublisher();
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = CancellationToken.None;
var request = new ReportRequestDto
{
ImageDigest = "sha256:feedface",
Findings = new[]
{
new PolicyPreviewFindingDto
{
Id = "finding-1",
Severity = "Critical",
Repository = "acme/edge/api",
Cve = "CVE-2024-9999",
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3",
Tags = new[] { "reachability:runtime" }
}
}
};
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
var projected = new PolicyVerdict(
"finding-1",
PolicyVerdictStatus.Blocked,
Score: 47.5,
ConfigVersion: "1.0",
SourceTrust: "NVD",
Reachability: "runtime");
var preview = new PolicyPreviewResponse(
Success: true,
PolicyDigest: "digest-123",
RevisionId: "rev-42",
Issues: ImmutableArray<PolicyIssue>.Empty,
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
ChangedCount: 1);
var document = new ReportDocumentDto
{
ReportId = "report-abc",
ImageDigest = "sha256:feedface",
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
Verdict = "blocked",
Policy = new ReportPolicyDto
{
RevisionId = "rev-42",
Digest = "digest-123"
},
Summary = new ReportSummaryDto
{
Total = 1,
Blocked = 1,
Warned = 0,
Ignored = 0,
Quieted = 0
}
};
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
var change = Assert.Single(tracker.Changes);
Assert.Equal("sha256:feedface", change.ArtifactDigest);
Assert.Equal("CVE-2024-9999", change.VulnId);
Assert.Equal("pkg:nuget/Acme.Edge.Api@1.2.3", change.PackagePurl);
Assert.Equal(ClassificationStatus.Unaffected, change.PreviousStatus);
Assert.Equal(ClassificationStatus.Affected, change.NewStatus);
Assert.Equal(DriftCause.ReachabilityDelta, change.Cause);
Assert.Equal(document.GeneratedAt, change.ChangedAt);
Assert.NotEqual(Guid.Empty, change.TenantId);
Assert.NotEqual(Guid.Empty, change.ExecutionId);
Assert.NotEqual(Guid.Empty, change.ManifestId);
}
[Fact]
public async Task PublishAsync_DoesNotFailWhenFnDriftTrackingThrows()
{
var publisher = new RecordingEventPublisher();
var tracker = new RecordingClassificationChangeTracker
{
ThrowOnTrack = true
};
var dispatcher = new ReportEventDispatcher(publisher, tracker, Microsoft.Extensions.Options.Options.Create(new ScannerWebServiceOptions()), TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = CancellationToken.None;
var request = new ReportRequestDto
{
ImageDigest = "sha256:feedface",
Findings = new[]
{
new PolicyPreviewFindingDto
{
Id = "finding-1",
Severity = "Critical",
Repository = "acme/edge/api",
Cve = "CVE-2024-9999",
Purl = "pkg:nuget/Acme.Edge.Api@1.2.3"
}
}
};
var baseline = new PolicyVerdict("finding-1", PolicyVerdictStatus.Pass, ConfigVersion: "1.0");
var projected = new PolicyVerdict("finding-1", PolicyVerdictStatus.Blocked, ConfigVersion: "1.0");
var preview = new PolicyPreviewResponse(
Success: true,
PolicyDigest: "digest-123",
RevisionId: "rev-42",
Issues: ImmutableArray<PolicyIssue>.Empty,
Diffs: ImmutableArray.Create(new PolicyVerdictDiff(baseline, projected)),
ChangedCount: 1);
var document = new ReportDocumentDto
{
ReportId = "report-abc",
ImageDigest = "sha256:feedface",
GeneratedAt = DateTimeOffset.Parse("2025-10-19T12:34:56Z"),
Verdict = "blocked",
Policy = new ReportPolicyDto(),
Summary = new ReportSummaryDto()
};
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(StellaOpsClaimTypes.Tenant, "tenant-alpha") }));
await dispatcher.PublishAsync(request, preview, document, envelope: null, context, cancellationToken);
Assert.Equal(2, publisher.Events.Count);
}
[Fact]
public async Task PublishAsync_HonoursConfiguredConsoleAndApiSegments()
{
@@ -186,7 +326,8 @@ public sealed class ReportEventDispatcherTests
});
var publisher = new RecordingEventPublisher();
var dispatcher = new ReportEventDispatcher(publisher, options, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var tracker = new RecordingClassificationChangeTracker();
var dispatcher = new ReportEventDispatcher(publisher, tracker, options, TimeProvider.System, NullLogger<ReportEventDispatcher>.Instance);
var cancellationToken = CancellationToken.None;
var request = new ReportRequestDto
@@ -295,4 +436,40 @@ public sealed class ReportEventDispatcherTests
return Task.CompletedTask;
}
}
private sealed class RecordingClassificationChangeTracker : IClassificationChangeTracker
{
public List<ClassificationChange> Changes { get; } = new();
public bool ThrowOnTrack { get; init; }
public Task TrackChangeAsync(ClassificationChange change, CancellationToken cancellationToken = default)
{
if (ThrowOnTrack)
{
throw new InvalidOperationException("Tracking failure");
}
Changes.Add(change);
return Task.CompletedTask;
}
public Task TrackChangesAsync(IEnumerable<ClassificationChange> changes, CancellationToken cancellationToken = default)
{
if (ThrowOnTrack)
{
throw new InvalidOperationException("Tracking failure");
}
Changes.AddRange(changes);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ClassificationChange>> ComputeDeltaAsync(
Guid tenantId,
string artifactDigest,
Guid previousExecutionId,
Guid currentExecutionId,
CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<ClassificationChange>>(Array.Empty<ClassificationChange>());
}
}