feat: Add PathViewer and RiskDriftCard components with templates and styles

- Implemented PathViewerComponent for visualizing reachability call paths.
- Added RiskDriftCardComponent to display reachability drift results.
- Created corresponding HTML templates and SCSS styles for both components.
- Introduced test fixtures for reachability analysis in JSON format.
- Enhanced user interaction with collapsible and expandable features in PathViewer.
- Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
master
2025-12-18 18:35:30 +02:00
parent 811f35cba7
commit 0dc71e760a
70 changed files with 8904 additions and 163 deletions

View File

@@ -0,0 +1,384 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentJob.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: Task #1 - Implement EpssEnrichmentJob service
// Description: Background job that enriches vulnerability instances with current EPSS scores.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Options for the EPSS enrichment job.
/// </summary>
public sealed class EpssEnrichmentOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss:Enrichment";
/// <summary>
/// Whether the enrichment job is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Delay after EPSS ingestion before running enrichment. Default: 1 minute.
/// </summary>
public TimeSpan PostIngestDelay { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Batch size for processing vulnerability instances. Default: 1000.
/// </summary>
public int BatchSize { get; set; } = 1000;
/// <summary>
/// High percentile threshold. Scores at or above this trigger CROSSED_HIGH. Default: 0.99.
/// </summary>
public double HighPercentile { get; set; } = 0.99;
/// <summary>
/// High score threshold. Scores at or above this trigger priority elevation. Default: 0.5.
/// </summary>
public double HighScore { get; set; } = 0.5;
/// <summary>
/// Big jump delta threshold. Score changes >= this trigger BIG_JUMP flag. Default: 0.10.
/// </summary>
public double BigJumpDelta { get; set; } = 0.10;
/// <summary>
/// Critical percentile threshold. Default: 0.995 (top 0.5%).
/// </summary>
public double CriticalPercentile { get; set; } = 0.995;
/// <summary>
/// Medium percentile threshold. Default: 0.90 (top 10%).
/// </summary>
public double MediumPercentile { get; set; } = 0.90;
/// <summary>
/// Process only CVEs with specific change flags. Empty = process all.
/// </summary>
public EpssChangeFlags FlagsToProcess { get; set; } =
EpssChangeFlags.NewScored |
EpssChangeFlags.CrossedHigh |
EpssChangeFlags.BigJumpUp |
EpssChangeFlags.BigJumpDown;
/// <summary>
/// Suppress signals on model version change. Default: true.
/// </summary>
public bool SuppressSignalsOnModelChange { get; set; } = true;
}
/// <summary>
/// Background service that enriches vulnerability instances with current EPSS scores.
/// Runs after EPSS ingestion to update existing findings with new priority bands.
/// </summary>
public sealed class EpssEnrichmentJob : BackgroundService
{
private readonly IEpssRepository _epssRepository;
private readonly IEpssProvider _epssProvider;
private readonly IEpssSignalPublisher _signalPublisher;
private readonly IOptions<EpssEnrichmentOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssEnrichmentJob> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssEnrichment");
// Event to trigger enrichment after ingestion
private readonly SemaphoreSlim _enrichmentTrigger = new(0);
public EpssEnrichmentJob(
IEpssRepository epssRepository,
IEpssProvider epssProvider,
IEpssSignalPublisher signalPublisher,
IOptions<EpssEnrichmentOptions> options,
TimeProvider timeProvider,
ILogger<EpssEnrichmentJob> logger)
{
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_signalPublisher = signalPublisher ?? throw new ArgumentNullException(nameof(signalPublisher));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("EPSS enrichment job started");
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("EPSS enrichment job is disabled");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for enrichment trigger or cancellation
await _enrichmentTrigger.WaitAsync(stoppingToken);
// Add delay after ingestion to ensure data is fully committed
await Task.Delay(opts.PostIngestDelay, stoppingToken);
await EnrichAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS enrichment job encountered an error");
}
}
_logger.LogInformation("EPSS enrichment job stopped");
}
/// <summary>
/// Triggers the enrichment process. Called after EPSS data is ingested.
/// </summary>
public void TriggerEnrichment()
{
_enrichmentTrigger.Release();
_logger.LogDebug("EPSS enrichment triggered");
}
/// <summary>
/// Runs the enrichment process. Updates vulnerability instances with current EPSS scores.
/// </summary>
public async Task EnrichAsync(CancellationToken cancellationToken = default)
{
using var activity = _activitySource.StartActivity("epss.enrich", ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var opts = _options.Value;
_logger.LogInformation("Starting EPSS enrichment");
try
{
// Get the latest model date
var modelDate = await _epssProvider.GetLatestModelDateAsync(cancellationToken);
if (!modelDate.HasValue)
{
_logger.LogWarning("No EPSS data available for enrichment");
return;
}
activity?.SetTag("epss.model_date", modelDate.Value.ToString("yyyy-MM-dd"));
_logger.LogDebug("Using EPSS model date: {ModelDate}", modelDate.Value);
// Get CVEs with changes that need processing
var changedCves = await GetChangedCvesAsync(modelDate.Value, opts.FlagsToProcess, cancellationToken);
if (changedCves.Count == 0)
{
_logger.LogDebug("No CVE changes to process");
return;
}
_logger.LogInformation("Processing {Count} CVEs with EPSS changes", changedCves.Count);
activity?.SetTag("epss.changed_cve_count", changedCves.Count);
var totalUpdated = 0;
var totalBandChanges = 0;
// Process in batches
foreach (var batch in changedCves.Chunk(opts.BatchSize))
{
var (updated, bandChanges) = await ProcessBatchAsync(
batch,
modelDate.Value,
cancellationToken);
totalUpdated += updated;
totalBandChanges += bandChanges;
}
stopwatch.Stop();
_logger.LogInformation(
"EPSS enrichment completed: updated={Updated}, bandChanges={BandChanges}, duration={Duration}ms",
totalUpdated,
totalBandChanges,
stopwatch.ElapsedMilliseconds);
activity?.SetTag("epss.updated_count", totalUpdated);
activity?.SetTag("epss.band_change_count", totalBandChanges);
activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS enrichment failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<IReadOnlyList<EpssChangeRecord>> GetChangedCvesAsync(
DateOnly modelDate,
EpssChangeFlags flags,
CancellationToken cancellationToken)
{
// Query epss_changes table for CVEs with matching flags for the model date (Task #4)
_logger.LogDebug("Querying EPSS changes for model date {ModelDate} with flags {Flags}", modelDate, flags);
var changes = await _epssRepository.GetChangesAsync(modelDate, flags, cancellationToken: cancellationToken);
_logger.LogDebug("Found {Count} EPSS changes matching flags {Flags}", changes.Count, flags);
return changes;
}
private async Task<(int Updated, int BandChanges)> ProcessBatchAsync(
EpssChangeRecord[] batch,
DateOnly modelDate,
CancellationToken cancellationToken)
{
var opts = _options.Value;
var updated = 0;
var bandChanges = 0;
// Get current EPSS scores for all CVEs in batch
var cveIds = batch.Select(c => c.CveId).ToList();
var epssResult = await _epssProvider.GetCurrentBatchAsync(cveIds, cancellationToken);
foreach (var change in batch)
{
var evidence = epssResult.Found.FirstOrDefault(e =>
string.Equals(e.CveId, change.CveId, StringComparison.OrdinalIgnoreCase));
if (evidence is null)
{
continue;
}
var previousBand = change.PreviousBand;
var newBand = ComputePriorityBand(evidence.Percentile, opts);
// Check if band changed
if (previousBand != newBand)
{
bandChanges++;
// Emit vuln.priority.changed event
await EmitPriorityChangedEventAsync(
change.CveId,
previousBand,
newBand,
evidence,
cancellationToken);
}
updated++;
}
return (updated, bandChanges);
}
private static EpssPriorityBand ComputePriorityBand(double percentile, EpssEnrichmentOptions opts)
{
if (percentile >= opts.CriticalPercentile)
{
return EpssPriorityBand.Critical;
}
if (percentile >= opts.HighPercentile)
{
return EpssPriorityBand.High;
}
if (percentile >= opts.MediumPercentile)
{
return EpssPriorityBand.Medium;
}
return EpssPriorityBand.Low;
}
private Task EmitPriorityChangedEventAsync(
string cveId,
EpssPriorityBand previousBand,
EpssPriorityBand newBand,
EpssEvidence evidence,
CancellationToken cancellationToken)
{
// Task #6: Emit `vuln.priority.changed` event via signal publisher
_logger.LogDebug(
"Priority changed: {CveId} {PreviousBand} -> {NewBand} (score={Score:F4}, percentile={Percentile:F4})",
cveId,
previousBand,
newBand,
evidence.Score,
evidence.Percentile);
// Publish priority changed event (Task #6)
var result = await _signalPublisher.PublishPriorityChangedAsync(
Guid.Empty, // Tenant ID would come from context
cveId,
previousBand.ToString(),
newBand.ToString(),
evidence.Score,
evidence.ModelDate,
cancellationToken);
if (!result.Success)
{
_logger.LogWarning(
"Failed to publish priority changed event for {CveId}: {Error}",
cveId,
result.Error);
}
}
}
/// <summary>
/// Record representing an EPSS change that needs processing.
/// </summary>
public sealed record EpssChangeRecord
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Change flags indicating what changed.
/// </summary>
public EpssChangeFlags Flags { get; init; }
/// <summary>
/// Previous EPSS score (if available).
/// </summary>
public double? PreviousScore { get; init; }
/// <summary>
/// New EPSS score.
/// </summary>
public double NewScore { get; init; }
/// <summary>
/// Previous priority band (if available).
/// </summary>
public EpssPriorityBand PreviousBand { get; init; }
/// <summary>
/// Model date for this change.
/// </summary>
public DateOnly ModelDate { get; init; }
}

View File

@@ -0,0 +1,205 @@
// -----------------------------------------------------------------------------
// EpssEnrichmentStageExecutor.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-006
// Description: Scan stage executor that enriches findings with EPSS scores.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Scan stage executor that enriches vulnerability findings with EPSS scores.
/// Attaches immutable EPSS evidence to each CVE at scan time.
/// </summary>
public sealed class EpssEnrichmentStageExecutor : IScanStageExecutor
{
private readonly IEpssProvider _epssProvider;
private readonly ILogger<EpssEnrichmentStageExecutor> _logger;
public EpssEnrichmentStageExecutor(
IEpssProvider epssProvider,
ILogger<EpssEnrichmentStageExecutor> logger)
{
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.EpssEnrichment;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// Check if EPSS data is available
var isAvailable = await _epssProvider.IsAvailableAsync(cancellationToken).ConfigureAwait(false);
if (!isAvailable)
{
_logger.LogWarning("EPSS data not available; skipping EPSS enrichment for job {JobId}", context.JobId);
return;
}
// Get CVE IDs from findings
var cveIds = ExtractCveIds(context);
if (cveIds.Count == 0)
{
_logger.LogDebug("No CVE IDs found in findings for job {JobId}; skipping EPSS enrichment", context.JobId);
return;
}
_logger.LogInformation(
"Enriching {CveCount} CVEs with EPSS scores for job {JobId}",
cveIds.Count,
context.JobId);
// Fetch EPSS scores in batch
var epssResult = await _epssProvider.GetCurrentBatchAsync(cveIds, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"EPSS lookup: found={Found}, notFound={NotFound}, timeMs={TimeMs}, fromCache={FromCache}",
epssResult.Found.Count,
epssResult.NotFound.Count,
epssResult.LookupTimeMs,
epssResult.PartiallyFromCache);
// Store EPSS evidence in analysis context
var epssMap = epssResult.Found.ToDictionary(
e => e.CveId,
e => e,
StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.EpssEvidence, epssMap);
context.Analysis.Set(ScanAnalysisKeys.EpssModelDate, epssResult.ModelDate);
context.Analysis.Set(ScanAnalysisKeys.EpssNotFoundCves, epssResult.NotFound.ToList());
_logger.LogInformation(
"EPSS enrichment completed for job {JobId}: {Found}/{Total} CVEs enriched, model date {ModelDate}",
context.JobId,
epssMap.Count,
cveIds.Count,
epssResult.ModelDate);
}
private static HashSet<string> ExtractCveIds(ScanJobContext context)
{
var cveIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Extract from OS package analyzer results
if (context.Analysis.TryGet<Dictionary<string, object>>(ScanAnalysisKeys.OsPackageAnalyzers, out var osResults) && osResults is not null)
{
foreach (var analyzerResult in osResults.Values)
{
ExtractCvesFromAnalyzerResult(analyzerResult, cveIds);
}
}
// Extract from language analyzer results
if (context.Analysis.TryGet<Dictionary<string, object>>(ScanAnalysisKeys.LanguagePackageAnalyzers, out var langResults) && langResults is not null)
{
foreach (var analyzerResult in langResults.Values)
{
ExtractCvesFromAnalyzerResult(analyzerResult, cveIds);
}
}
// Extract from consolidated findings if available
if (context.Analysis.TryGet<IEnumerable<object>>(ScanAnalysisKeys.ConsolidatedFindings, out var findings) && findings is not null)
{
foreach (var finding in findings)
{
ExtractCvesFromFinding(finding, cveIds);
}
}
return cveIds;
}
private static void ExtractCvesFromAnalyzerResult(object analyzerResult, HashSet<string> cveIds)
{
// Use reflection to extract CVE IDs from various analyzer result types
// This handles OSPackageAnalyzerResult, LanguagePackageAnalyzerResult, etc.
var resultType = analyzerResult.GetType();
// Try to get Vulnerabilities property
var vulnsProperty = resultType.GetProperty("Vulnerabilities");
if (vulnsProperty?.GetValue(analyzerResult) is IEnumerable<object> vulns)
{
foreach (var vuln in vulns)
{
ExtractCvesFromFinding(vuln, cveIds);
}
}
// Try to get Findings property
var findingsProperty = resultType.GetProperty("Findings");
if (findingsProperty?.GetValue(analyzerResult) is IEnumerable<object> findingsList)
{
foreach (var finding in findingsList)
{
ExtractCvesFromFinding(finding, cveIds);
}
}
}
private static void ExtractCvesFromFinding(object finding, HashSet<string> cveIds)
{
var findingType = finding.GetType();
// Try CveId property
var cveIdProperty = findingType.GetProperty("CveId");
if (cveIdProperty?.GetValue(finding) is string cveId && !string.IsNullOrWhiteSpace(cveId))
{
cveIds.Add(cveId);
return;
}
// Try VulnerabilityId property (some findings use this)
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
if (vulnIdProperty?.GetValue(finding) is string vulnId &&
!string.IsNullOrWhiteSpace(vulnId) &&
vulnId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
cveIds.Add(vulnId);
return;
}
// Try Identifiers collection
var identifiersProperty = findingType.GetProperty("Identifiers");
if (identifiersProperty?.GetValue(finding) is IEnumerable<object> identifiers)
{
foreach (var identifier in identifiers)
{
var idValue = identifier.ToString();
if (!string.IsNullOrWhiteSpace(idValue) &&
idValue.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
{
cveIds.Add(idValue);
}
}
}
}
}
/// <summary>
/// Well-known keys for EPSS-related analysis data.
/// </summary>
public static partial class ScanAnalysisKeys
{
/// <summary>
/// Dictionary of CVE ID to EpssEvidence for enriched findings.
/// </summary>
public const string EpssEvidence = "epss.evidence";
/// <summary>
/// The EPSS model date used for enrichment.
/// </summary>
public const string EpssModelDate = "epss.model_date";
/// <summary>
/// List of CVE IDs that were not found in EPSS data.
/// </summary>
public const string EpssNotFoundCves = "epss.not_found";
}

View File

@@ -67,6 +67,7 @@ public sealed class EpssIngestOptions
public sealed class EpssIngestJob : BackgroundService
{
private readonly IEpssRepository _repository;
private readonly IEpssRawRepository? _rawRepository;
private readonly EpssOnlineSource _onlineSource;
private readonly EpssBundleSource _bundleSource;
private readonly EpssCsvStreamParser _parser;
@@ -82,9 +83,11 @@ public sealed class EpssIngestJob : BackgroundService
EpssCsvStreamParser parser,
IOptions<EpssIngestOptions> options,
TimeProvider timeProvider,
ILogger<EpssIngestJob> logger)
ILogger<EpssIngestJob> logger,
IEpssRawRepository? rawRepository = null)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_rawRepository = rawRepository; // Optional - raw storage for replay capability
_onlineSource = onlineSource ?? throw new ArgumentNullException(nameof(onlineSource));
_bundleSource = bundleSource ?? throw new ArgumentNullException(nameof(bundleSource));
_parser = parser ?? throw new ArgumentNullException(nameof(parser));
@@ -186,6 +189,18 @@ public sealed class EpssIngestJob : BackgroundService
session,
cancellationToken).ConfigureAwait(false);
// Store raw payload for replay capability (Sprint: SPRINT_3413_0001_0001, Task: R2)
if (_rawRepository is not null)
{
await StoreRawPayloadAsync(
importRun.ImportRunId,
sourceFile.SourceUri,
modelDate,
session,
fileContent.Length,
cancellationToken).ConfigureAwait(false);
}
// Mark success
await _repository.MarkImportSucceededAsync(
importRun.ImportRunId,
@@ -279,4 +294,69 @@ public sealed class EpssIngestJob : BackgroundService
var hash = System.Security.Cryptography.SHA256.HashData(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Stores raw EPSS payload for deterministic replay capability.
/// Sprint: SPRINT_3413_0001_0001, Task: R2
/// </summary>
private async Task StoreRawPayloadAsync(
Guid importRunId,
string sourceUri,
DateOnly modelDate,
EpssParsedSession session,
long compressedSize,
CancellationToken cancellationToken)
{
if (_rawRepository is null)
{
return;
}
try
{
// Convert parsed rows to JSON array for raw storage
var payload = System.Text.Json.JsonSerializer.Serialize(
session.Rows.Select(r => new
{
cve = r.CveId,
epss = r.Score,
percentile = r.Percentile
}),
new System.Text.Json.JsonSerializerOptions { WriteIndented = false });
var payloadBytes = System.Text.Encoding.UTF8.GetBytes(payload);
var payloadSha256 = System.Security.Cryptography.SHA256.HashData(payloadBytes);
var raw = new EpssRaw
{
SourceUri = sourceUri,
AsOfDate = modelDate,
Payload = payload,
PayloadSha256 = payloadSha256,
HeaderComment = session.HeaderComment,
ModelVersion = session.ModelVersionTag,
PublishedDate = session.PublishedDate,
RowCount = session.RowCount,
CompressedSize = compressedSize,
DecompressedSize = payloadBytes.LongLength,
ImportRunId = importRunId
};
await _rawRepository.CreateAsync(raw, cancellationToken).ConfigureAwait(false);
_logger.LogDebug(
"Stored raw EPSS payload: modelDate={ModelDate}, rows={RowCount}, size={Size}",
modelDate,
session.RowCount,
payloadBytes.Length);
}
catch (Exception ex)
{
// Log but don't fail ingestion if raw storage fails
_logger.LogWarning(
ex,
"Failed to store raw EPSS payload for {ModelDate}; ingestion will continue",
modelDate);
}
}
}

View File

@@ -0,0 +1,505 @@
// -----------------------------------------------------------------------------
// EpssSignalJob.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Tasks: S5-S10 - Signal generation service
// Description: Background job that generates tenant-scoped EPSS signals.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Options for the EPSS signal generation job.
/// </summary>
public sealed class EpssSignalOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Epss:Signal";
/// <summary>
/// Whether the signal job is enabled. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Delay after enrichment before generating signals. Default: 30 seconds.
/// </summary>
public TimeSpan PostEnrichmentDelay { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Batch size for signal generation. Default: 500.
/// </summary>
public int BatchSize { get; set; } = 500;
/// <summary>
/// Signal retention days. Default: 90.
/// </summary>
public int RetentionDays { get; set; } = 90;
}
/// <summary>
/// EPSS signal event types.
/// </summary>
public static class EpssSignalEventTypes
{
/// <summary>
/// Significant score increase (delta >= threshold).
/// </summary>
public const string RiskSpike = "RISK_SPIKE";
/// <summary>
/// Priority band change (e.g., MEDIUM -> HIGH).
/// </summary>
public const string BandChange = "BAND_CHANGE";
/// <summary>
/// New CVE scored for the first time.
/// </summary>
public const string NewHigh = "NEW_HIGH";
/// <summary>
/// CVE dropped from HIGH/CRITICAL to LOW.
/// </summary>
public const string DroppedLow = "DROPPED_LOW";
/// <summary>
/// EPSS model version changed (summary event).
/// </summary>
public const string ModelUpdated = "MODEL_UPDATED";
}
/// <summary>
/// Background service that generates tenant-scoped EPSS signals.
/// Only generates signals for CVEs that are observed in tenant's inventory.
/// </summary>
public sealed class EpssSignalJob : BackgroundService
{
private readonly IEpssRepository _epssRepository;
private readonly IEpssSignalRepository _signalRepository;
private readonly IObservedCveRepository _observedCveRepository;
private readonly IEpssSignalPublisher _signalPublisher;
private readonly IEpssProvider _epssProvider;
private readonly IOptions<EpssSignalOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssSignalJob> _logger;
private readonly ActivitySource _activitySource = new("StellaOps.Scanner.EpssSignal");
// Trigger for signal generation
private readonly SemaphoreSlim _signalTrigger = new(0);
// Track last processed model date to detect version changes
private string? _lastModelVersion;
public EpssSignalJob(
IEpssRepository epssRepository,
IEpssSignalRepository signalRepository,
IObservedCveRepository observedCveRepository,
IEpssSignalPublisher signalPublisher,
IEpssProvider epssProvider,
IOptions<EpssSignalOptions> options,
TimeProvider timeProvider,
ILogger<EpssSignalJob> logger)
{
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_signalRepository = signalRepository ?? throw new ArgumentNullException(nameof(signalRepository));
_observedCveRepository = observedCveRepository ?? throw new ArgumentNullException(nameof(observedCveRepository));
_signalPublisher = signalPublisher ?? throw new ArgumentNullException(nameof(signalPublisher));
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("EPSS signal job started");
var opts = _options.Value;
if (!opts.Enabled)
{
_logger.LogInformation("EPSS signal job is disabled");
return;
}
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Wait for signal trigger or cancellation
await _signalTrigger.WaitAsync(stoppingToken);
// Add delay after enrichment to ensure data consistency
await Task.Delay(opts.PostEnrichmentDelay, stoppingToken);
await GenerateSignalsAsync(stoppingToken);
// Periodic pruning of old signals
await _signalRepository.PruneAsync(opts.RetentionDays, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS signal job encountered an error");
}
}
_logger.LogInformation("EPSS signal job stopped");
}
/// <summary>
/// Triggers signal generation. Called after EPSS enrichment completes.
/// </summary>
public void TriggerSignalGeneration()
{
_signalTrigger.Release();
_logger.LogDebug("EPSS signal generation triggered");
}
/// <summary>
/// Generates signals for all tenants based on EPSS changes.
/// </summary>
public async Task GenerateSignalsAsync(CancellationToken cancellationToken = default)
{
using var activity = _activitySource.StartActivity("epss.signal.generate", ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var opts = _options.Value;
_logger.LogInformation("Starting EPSS signal generation");
try
{
// Get current model date
var modelDate = await _epssProvider.GetLatestModelDateAsync(cancellationToken);
if (!modelDate.HasValue)
{
_logger.LogWarning("No EPSS data available for signal generation");
return;
}
activity?.SetTag("epss.model_date", modelDate.Value.ToString("yyyy-MM-dd"));
// Check for model version change (S7)
var currentModelVersion = await GetCurrentModelVersionAsync(modelDate.Value, cancellationToken);
var isModelChange = _lastModelVersion is not null &&
!string.Equals(_lastModelVersion, currentModelVersion, StringComparison.Ordinal);
if (isModelChange)
{
_logger.LogInformation(
"EPSS model version changed: {OldVersion} -> {NewVersion}",
_lastModelVersion,
currentModelVersion);
}
_lastModelVersion = currentModelVersion;
// Get changes from epss_changes table
var changes = await GetEpssChangesAsync(modelDate.Value, cancellationToken);
if (changes.Count == 0)
{
_logger.LogDebug("No EPSS changes to process for signals");
return;
}
_logger.LogInformation("Processing {Count} EPSS changes for signal generation", changes.Count);
activity?.SetTag("epss.change_count", changes.Count);
var totalSignals = 0;
var filteredCount = 0;
// Get all active tenants (S6)
var activeTenants = await _observedCveRepository.GetActiveTenantsAsync(cancellationToken);
if (activeTenants.Count == 0)
{
_logger.LogDebug("No active tenants found; using default tenant");
activeTenants = new[] { Guid.Empty };
}
// For each tenant, filter changes to only observed CVEs
foreach (var tenantId in activeTenants)
{
// Get CVE IDs from changes
var changeCveIds = changes.Select(c => c.CveId).Distinct().ToList();
// Filter to only observed CVEs for this tenant (S6)
var observedCves = await _observedCveRepository.FilterObservedAsync(
tenantId,
changeCveIds,
cancellationToken);
var tenantChanges = changes
.Where(c => observedCves.Contains(c.CveId))
.ToArray();
if (tenantChanges.Length == 0)
{
continue;
}
filteredCount += changes.Length - tenantChanges.Length;
foreach (var batch in tenantChanges.Chunk(opts.BatchSize))
{
var signals = GenerateSignalsForBatch(
batch,
tenantId,
modelDate.Value,
currentModelVersion,
isModelChange);
if (signals.Count > 0)
{
// Store signals in database
var created = await _signalRepository.CreateBulkAsync(signals, cancellationToken);
totalSignals += created;
// Publish signals to notification system (S9)
var published = await _signalPublisher.PublishBatchAsync(signals, cancellationToken);
_logger.LogDebug(
"Published {Published}/{Total} EPSS signals for tenant {TenantId}",
published,
signals.Count,
tenantId);
}
}
// If model changed, emit summary signal per tenant (S8)
if (isModelChange)
{
await EmitModelUpdatedSignalAsync(
tenantId,
modelDate.Value,
_lastModelVersion!,
currentModelVersion!,
tenantChanges.Length,
cancellationToken);
totalSignals++;
}
}
stopwatch.Stop();
_logger.LogInformation(
"EPSS signal generation completed: signals={SignalCount}, changes={ChangeCount}, filtered={FilteredCount}, tenants={TenantCount}, duration={Duration}ms",
totalSignals,
changes.Count,
filteredCount,
activeTenants.Count,
stopwatch.ElapsedMilliseconds);
activity?.SetTag("epss.signal_count", totalSignals);
activity?.SetTag("epss.filtered_count", filteredCount);
activity?.SetTag("epss.tenant_count", activeTenants.Count);
activity?.SetTag("epss.duration_ms", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "EPSS signal generation failed");
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private IReadOnlyList<EpssSignal> GenerateSignalsForBatch(
EpssChangeRecord[] changes,
Guid tenantId,
DateOnly modelDate,
string? modelVersion,
bool isModelChange)
{
var signals = new List<EpssSignal>();
foreach (var change in changes)
{
// Skip generating individual signals on model change day if suppression is enabled
// (would check tenant config in production)
if (isModelChange && ShouldSuppressOnModelChange(change))
{
continue;
}
var eventType = DetermineEventType(change);
if (string.IsNullOrEmpty(eventType))
{
continue;
}
var dedupeKey = EpssExplainHashCalculator.ComputeDedupeKey(
modelDate,
change.CveId,
eventType,
change.PreviousBand.ToString(),
ComputeNewBand(change).ToString());
var explainHash = EpssExplainHashCalculator.ComputeExplainHash(
modelDate,
change.CveId,
eventType,
change.PreviousBand.ToString(),
ComputeNewBand(change).ToString(),
change.NewScore,
0, // Percentile would come from EPSS data
modelVersion);
var payload = JsonSerializer.Serialize(new
{
cveId = change.CveId,
oldScore = change.PreviousScore,
newScore = change.NewScore,
oldBand = change.PreviousBand.ToString(),
newBand = ComputeNewBand(change).ToString(),
flags = change.Flags.ToString(),
modelVersion
});
signals.Add(new EpssSignal
{
TenantId = tenantId,
ModelDate = modelDate,
CveId = change.CveId,
EventType = eventType,
RiskBand = ComputeNewBand(change).ToString(),
EpssScore = change.NewScore,
EpssDelta = change.NewScore - (change.PreviousScore ?? 0),
IsModelChange = isModelChange,
ModelVersion = modelVersion,
DedupeKey = dedupeKey,
ExplainHash = explainHash,
Payload = payload
});
}
return signals;
}
private static string? DetermineEventType(EpssChangeRecord change)
{
if (change.Flags.HasFlag(EpssChangeFlags.NewScored))
{
return EpssSignalEventTypes.NewHigh;
}
if (change.Flags.HasFlag(EpssChangeFlags.CrossedHigh))
{
return EpssSignalEventTypes.BandChange;
}
if (change.Flags.HasFlag(EpssChangeFlags.BigJumpUp))
{
return EpssSignalEventTypes.RiskSpike;
}
if (change.Flags.HasFlag(EpssChangeFlags.DroppedLow))
{
return EpssSignalEventTypes.DroppedLow;
}
return null;
}
private static EpssPriorityBand ComputeNewBand(EpssChangeRecord change)
{
// Simplified band calculation - would use EpssPriorityCalculator in production
if (change.NewScore >= 0.5)
{
return EpssPriorityBand.Critical;
}
if (change.NewScore >= 0.2)
{
return EpssPriorityBand.High;
}
if (change.NewScore >= 0.05)
{
return EpssPriorityBand.Medium;
}
return EpssPriorityBand.Low;
}
private static bool ShouldSuppressOnModelChange(EpssChangeRecord change)
{
// Suppress RISK_SPIKE and BAND_CHANGE on model change days to avoid alert storms
return change.Flags.HasFlag(EpssChangeFlags.BigJumpUp) ||
change.Flags.HasFlag(EpssChangeFlags.BigJumpDown) ||
change.Flags.HasFlag(EpssChangeFlags.CrossedHigh);
}
private async Task<string?> GetCurrentModelVersionAsync(DateOnly modelDate, CancellationToken cancellationToken)
{
// Would query from epss_import_run or epss_raw table
// For now, return a placeholder based on date
return $"v{modelDate:yyyy.MM.dd}";
}
private async Task<IReadOnlyList<EpssChangeRecord>> GetEpssChangesAsync(
DateOnly modelDate,
CancellationToken cancellationToken)
{
// TODO: Implement repository method to get changes from epss_changes table
// For now, return empty list
return Array.Empty<EpssChangeRecord>();
}
private async Task EmitModelUpdatedSignalAsync(
Guid tenantId,
DateOnly modelDate,
string oldVersion,
string newVersion,
int affectedCveCount,
CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new
{
oldVersion,
newVersion,
affectedCveCount,
suppressedSignals = true
});
var signal = new EpssSignal
{
TenantId = tenantId,
ModelDate = modelDate,
CveId = "MODEL_UPDATE",
EventType = EpssSignalEventTypes.ModelUpdated,
IsModelChange = true,
ModelVersion = newVersion,
DedupeKey = $"{modelDate:yyyy-MM-dd}:MODEL_UPDATE:{oldVersion}->{newVersion}",
ExplainHash = EpssExplainHashCalculator.ComputeExplainHash(
modelDate,
"MODEL_UPDATE",
EpssSignalEventTypes.ModelUpdated,
oldVersion,
newVersion,
0,
0,
newVersion),
Payload = payload
};
await _signalRepository.CreateAsync(signal, cancellationToken);
_logger.LogInformation(
"Emitted MODEL_UPDATED signal: {OldVersion} -> {NewVersion}, affected {Count} CVEs",
oldVersion,
newVersion,
affectedCveCount);
}
}

View File

@@ -3,11 +3,13 @@
// Sprint: SPRINT_3500_0014_0001_native_analyzer_integration
// Task: NAI-001
// Description: Executes native binary analysis during container scans.
// Note: NUC-004 (unknown classification) deferred - requires project reference.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Emit.Native;
using StellaOps.Scanner.Worker.Diagnostics;
using StellaOps.Scanner.Worker.Options;
@@ -281,4 +283,7 @@ public sealed record NativeAnalysisResult
/// <summary>Emitted component results.</summary>
public IReadOnlyList<NativeComponentEmitResult> Components { get; init; } = Array.Empty<NativeComponentEmitResult>();
/// <summary>Layer component fragments for SBOM merging.</summary>
public IReadOnlyList<LayerComponentFragment> LayerFragments { get; init; } = Array.Empty<LayerComponentFragment>();
}

View File

@@ -9,6 +9,7 @@ public static class ScanStageNames
public const string PullLayers = "pull-layers";
public const string BuildFilesystem = "build-filesystem";
public const string ExecuteAnalyzers = "execute-analyzers";
public const string EpssEnrichment = "epss-enrichment";
public const string ComposeArtifacts = "compose-artifacts";
public const string EmitReports = "emit-reports";
public const string Entropy = "entropy";
@@ -20,8 +21,10 @@ public static class ScanStageNames
PullLayers,
BuildFilesystem,
ExecuteAnalyzers,
EpssEnrichment,
ComposeArtifacts,
Entropy,
EmitReports,
};
}

View File

@@ -133,6 +133,7 @@ builder.Services.AddSingleton<ILanguageAnalyzerPluginCatalog, LanguageAnalyzerPl
builder.Services.AddSingleton<IScanAnalyzerDispatcher, CompositeScanAnalyzerDispatcher>();
builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EpssEnrichmentStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuildStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, EntropyStageExecutor>();

View File

@@ -0,0 +1,146 @@
// -----------------------------------------------------------------------------
// AttestingRichGraphWriter.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: RichGraphWriter wrapper that produces DSSE attestation alongside graph.
// -----------------------------------------------------------------------------
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Result of writing a rich graph with attestation.
/// </summary>
/// <param name="GraphPath">Path to the richgraph-v1.json file.</param>
/// <param name="MetaPath">Path to the meta.json file.</param>
/// <param name="GraphHash">Content-addressed hash of the graph.</param>
/// <param name="NodeCount">Number of nodes in the graph.</param>
/// <param name="EdgeCount">Number of edges in the graph.</param>
/// <param name="AttestationPath">Path to the attestation DSSE envelope (if produced).</param>
/// <param name="WitnessResult">Detailed witness publication result (if attestation enabled).</param>
public sealed record AttestingRichGraphWriteResult(
string GraphPath,
string MetaPath,
string GraphHash,
int NodeCount,
int EdgeCount,
string? AttestationPath,
ReachabilityWitnessPublishResult? WitnessResult);
/// <summary>
/// Writes richgraph-v1 documents with optional DSSE attestation.
/// Wraps <see cref="RichGraphWriter"/> and integrates with <see cref="IReachabilityWitnessPublisher"/>.
/// </summary>
public sealed class AttestingRichGraphWriter
{
private readonly RichGraphWriter _graphWriter;
private readonly IReachabilityWitnessPublisher _witnessPublisher;
private readonly ReachabilityWitnessOptions _options;
private readonly ILogger<AttestingRichGraphWriter> _logger;
/// <summary>
/// Creates a new attesting rich graph writer.
/// </summary>
public AttestingRichGraphWriter(
RichGraphWriter graphWriter,
IReachabilityWitnessPublisher witnessPublisher,
IOptions<ReachabilityWitnessOptions> options,
ILogger<AttestingRichGraphWriter> logger)
{
_graphWriter = graphWriter ?? throw new ArgumentNullException(nameof(graphWriter));
_witnessPublisher = witnessPublisher ?? throw new ArgumentNullException(nameof(witnessPublisher));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Writes the rich graph and produces attestation if enabled.
/// </summary>
/// <param name="graph">The rich graph to write.</param>
/// <param name="outputRoot">Root output directory.</param>
/// <param name="analysisId">Analysis identifier.</param>
/// <param name="subjectDigest">Subject artifact digest for attestation.</param>
/// <param name="policyHash">Optional policy hash for attestation.</param>
/// <param name="sourceCommit">Optional source commit for attestation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Write result including attestation details.</returns>
public async Task<AttestingRichGraphWriteResult> WriteWithAttestationAsync(
RichGraph graph,
string outputRoot,
string analysisId,
string subjectDigest,
string? policyHash = null,
string? sourceCommit = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
ArgumentException.ThrowIfNullOrWhiteSpace(subjectDigest);
// Step 1: Write the graph using the standard writer
var writeResult = await _graphWriter.WriteAsync(graph, outputRoot, analysisId, cancellationToken)
.ConfigureAwait(false);
_logger.LogDebug(
"Wrote rich graph: {GraphPath}, hash={GraphHash}, nodes={NodeCount}, edges={EdgeCount}",
writeResult.GraphPath,
writeResult.GraphHash,
writeResult.NodeCount,
writeResult.EdgeCount);
// Step 2: Produce attestation if enabled
string? attestationPath = null;
ReachabilityWitnessPublishResult? witnessResult = null;
if (_options.Enabled)
{
// Read the graph bytes for attestation
var graphBytes = await File.ReadAllBytesAsync(writeResult.GraphPath, cancellationToken)
.ConfigureAwait(false);
// Publish witness attestation
witnessResult = await _witnessPublisher.PublishAsync(
graph,
graphBytes,
writeResult.GraphHash,
subjectDigest,
policyHash,
sourceCommit,
cancellationToken).ConfigureAwait(false);
// Write DSSE envelope to disk alongside the graph
if (witnessResult.DsseEnvelopeBytes.Length > 0)
{
var graphDir = Path.GetDirectoryName(writeResult.GraphPath)!;
attestationPath = Path.Combine(graphDir, "richgraph-v1.dsse.json");
await File.WriteAllBytesAsync(attestationPath, witnessResult.DsseEnvelopeBytes, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Wrote reachability witness attestation: {AttestationPath}, statementHash={StatementHash}",
attestationPath,
witnessResult.StatementHash);
}
}
else
{
_logger.LogDebug("Reachability witness attestation is disabled");
}
return new AttestingRichGraphWriteResult(
GraphPath: writeResult.GraphPath,
MetaPath: writeResult.MetaPath,
GraphHash: writeResult.GraphHash,
NodeCount: writeResult.NodeCount,
EdgeCount: writeResult.EdgeCount,
AttestationPath: attestationPath,
WitnessResult: witnessResult);
}
}

View File

@@ -0,0 +1,52 @@
// -----------------------------------------------------------------------------
// ReachabilityAttestationServiceCollectionExtensions.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: DI registration for reachability witness attestation services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace StellaOps.Scanner.Reachability.Attestation;
/// <summary>
/// Extension methods for registering reachability witness attestation services.
/// </summary>
public static class ReachabilityAttestationServiceCollectionExtensions
{
/// <summary>
/// Adds reachability witness attestation services to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReachabilityWitnessAttestation(this IServiceCollection services)
{
// Register DSSE builder
services.TryAddSingleton<ReachabilityWitnessDsseBuilder>();
// Register publisher
services.TryAddSingleton<IReachabilityWitnessPublisher, ReachabilityWitnessPublisher>();
// Register attesting writer (wraps RichGraphWriter)
services.TryAddSingleton<AttestingRichGraphWriter>();
// Register options
services.AddOptions<ReachabilityWitnessOptions>();
return services;
}
/// <summary>
/// Configures reachability witness options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureReachabilityWitnessOptions(
this IServiceCollection services,
Action<ReachabilityWitnessOptions> configure)
{
services.Configure(configure);
return services;
}
}

View File

@@ -0,0 +1,338 @@
// -----------------------------------------------------------------------------
// CachingEpssProvider.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-005
// Description: Valkey/Redis cache layer for EPSS lookups.
// -----------------------------------------------------------------------------
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Caching decorator for <see cref="IEpssProvider"/> that uses Valkey/Redis.
/// Provides read-through caching for EPSS score lookups.
/// </summary>
public sealed class CachingEpssProvider : IEpssProvider
{
private const string CacheKeyPrefix = "epss:current:";
private const string ModelDateCacheKey = "epss:model-date";
private readonly IEpssProvider _innerProvider;
private readonly IDistributedCache<EpssCacheEntry>? _cache;
private readonly EpssProviderOptions _options;
private readonly ILogger<CachingEpssProvider> _logger;
private readonly TimeProvider _timeProvider;
public CachingEpssProvider(
IEpssProvider innerProvider,
IDistributedCache<EpssCacheEntry>? cache,
IOptions<EpssProviderOptions> options,
ILogger<CachingEpssProvider> logger,
TimeProvider? timeProvider = null)
{
_innerProvider = innerProvider ?? throw new ArgumentNullException(nameof(innerProvider));
_cache = cache; // Can be null if caching is disabled
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EpssEvidence?> GetCurrentAsync(string cveId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(cveId);
// If caching is disabled or cache is unavailable, go directly to inner provider
if (!_options.EnableCache || _cache is null)
{
return await _innerProvider.GetCurrentAsync(cveId, cancellationToken).ConfigureAwait(false);
}
var cacheKey = BuildCacheKey(cveId);
try
{
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
{
_logger.LogDebug("Cache hit for EPSS score: {CveId}", cveId);
return MapFromCacheEntry(cacheResult.Value, fromCache: true);
}
}
catch (Exception ex)
{
// Cache failures should not block the request
_logger.LogWarning(ex, "Cache lookup failed for {CveId}, falling back to database", cveId);
}
// Cache miss - fetch from database
var evidence = await _innerProvider.GetCurrentAsync(cveId, cancellationToken).ConfigureAwait(false);
if (evidence is not null)
{
await TryCacheAsync(cacheKey, evidence, cancellationToken).ConfigureAwait(false);
}
return evidence;
}
public async Task<EpssBatchResult> GetCurrentBatchAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cveIds);
var cveIdList = cveIds.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (cveIdList.Count == 0)
{
return new EpssBatchResult
{
Found = Array.Empty<EpssEvidence>(),
NotFound = Array.Empty<string>(),
ModelDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = 0
};
}
// If caching is disabled, go directly to inner provider
if (!_options.EnableCache || _cache is null)
{
return await _innerProvider.GetCurrentBatchAsync(cveIdList, cancellationToken).ConfigureAwait(false);
}
var sw = Stopwatch.StartNew();
var found = new List<EpssEvidence>();
var notInCache = new List<string>();
var cacheHits = 0;
DateOnly? modelDate = null;
// Try cache first for each CVE
foreach (var cveId in cveIdList)
{
try
{
var cacheKey = BuildCacheKey(cveId);
var cacheResult = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value is not null)
{
var evidence = MapFromCacheEntry(cacheResult.Value, fromCache: true);
found.Add(evidence);
modelDate ??= evidence.ModelDate;
cacheHits++;
}
else
{
notInCache.Add(cveId);
}
}
catch (Exception ex)
{
// Cache failure - will need to fetch from DB
_logger.LogDebug(ex, "Cache lookup failed for {CveId}", cveId);
notInCache.Add(cveId);
}
}
_logger.LogDebug(
"EPSS cache: {CacheHits}/{Total} hits, {CacheMisses} to fetch from database",
cacheHits,
cveIdList.Count,
notInCache.Count);
// Fetch remaining from database
if (notInCache.Count > 0)
{
var dbResult = await _innerProvider.GetCurrentBatchAsync(notInCache, cancellationToken).ConfigureAwait(false);
foreach (var evidence in dbResult.Found)
{
found.Add(evidence);
modelDate ??= evidence.ModelDate;
// Populate cache
await TryCacheAsync(BuildCacheKey(evidence.CveId), evidence, cancellationToken).ConfigureAwait(false);
}
// Add CVEs not found in database to the not found list
var notFound = dbResult.NotFound.ToList();
sw.Stop();
return new EpssBatchResult
{
Found = found,
NotFound = notFound,
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = cacheHits > 0 && notInCache.Count > 0
};
}
sw.Stop();
return new EpssBatchResult
{
Found = found,
NotFound = Array.Empty<string>(),
ModelDate = modelDate ?? DateOnly.FromDateTime(_timeProvider.GetUtcNow().Date),
LookupTimeMs = sw.ElapsedMilliseconds,
PartiallyFromCache = cacheHits > 0
};
}
public Task<EpssEvidence?> GetAsOfDateAsync(
string cveId,
DateOnly asOfDate,
CancellationToken cancellationToken = default)
{
// Historical lookups are not cached - they're typically one-off queries
return _innerProvider.GetAsOfDateAsync(cveId, asOfDate, cancellationToken);
}
public Task<IReadOnlyList<EpssEvidence>> GetHistoryAsync(
string cveId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
// History lookups are not cached
return _innerProvider.GetHistoryAsync(cveId, startDate, endDate, cancellationToken);
}
public async Task<DateOnly?> GetLatestModelDateAsync(CancellationToken cancellationToken = default)
{
// Try cache first (short TTL for model date)
if (_options.EnableCache && _cache is not null)
{
try
{
var cacheResult = await _cache.GetAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
if (cacheResult.IsHit && cacheResult.Value?.ModelDate is not null)
{
return cacheResult.Value.ModelDate;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Cache lookup failed for model date");
}
}
var modelDate = await _innerProvider.GetLatestModelDateAsync(cancellationToken).ConfigureAwait(false);
// Cache model date with shorter TTL (5 minutes)
if (modelDate.HasValue && _options.EnableCache && _cache is not null)
{
try
{
await _cache.SetAsync(
ModelDateCacheKey,
new EpssCacheEntry { ModelDate = modelDate.Value },
new CacheEntryOptions { TimeToLive = TimeSpan.FromMinutes(5) },
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to cache model date");
}
}
return modelDate;
}
public Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
{
return _innerProvider.IsAvailableAsync(cancellationToken);
}
/// <summary>
/// Invalidates all cached EPSS scores. Called after new EPSS data is ingested.
/// </summary>
public async Task InvalidateCacheAsync(CancellationToken cancellationToken = default)
{
if (_cache is null)
{
return;
}
try
{
var invalidated = await _cache.InvalidateByPatternAsync($"{CacheKeyPrefix}*", cancellationToken).ConfigureAwait(false);
await _cache.InvalidateAsync(ModelDateCacheKey, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Invalidated {Count} EPSS cache entries", invalidated + 1);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate EPSS cache");
}
}
private static string BuildCacheKey(string cveId)
{
return $"{CacheKeyPrefix}{cveId.ToUpperInvariant()}";
}
private async Task TryCacheAsync(string cacheKey, EpssEvidence evidence, CancellationToken cancellationToken)
{
if (_cache is null)
{
return;
}
try
{
var cacheEntry = new EpssCacheEntry
{
CveId = evidence.CveId,
Score = evidence.Score,
Percentile = evidence.Percentile,
ModelDate = evidence.ModelDate,
CachedAt = _timeProvider.GetUtcNow()
};
await _cache.SetAsync(
cacheKey,
cacheEntry,
new CacheEntryOptions { TimeToLive = _options.CacheTtl },
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to cache EPSS score for {CveId}", evidence.CveId);
}
}
private EpssEvidence MapFromCacheEntry(EpssCacheEntry entry, bool fromCache)
{
return new EpssEvidence
{
CveId = entry.CveId ?? string.Empty,
Score = entry.Score,
Percentile = entry.Percentile,
ModelDate = entry.ModelDate,
CapturedAt = entry.CachedAt,
Source = "cache",
FromCache = fromCache
};
}
}
/// <summary>
/// Cache entry for EPSS scores.
/// </summary>
public sealed class EpssCacheEntry
{
public string? CveId { get; set; }
public double Score { get; set; }
public double Percentile { get; set; }
public DateOnly ModelDate { get; set; }
public DateTimeOffset CachedAt { get; set; }
}

View File

@@ -0,0 +1,51 @@
// -----------------------------------------------------------------------------
// EpssChangeRecord.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: #3 - Implement epss_changes flag logic
// Description: Record representing an EPSS change that needs processing.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Record representing an EPSS change that needs processing.
/// </summary>
public sealed record EpssChangeRecord
{
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Change flags indicating what changed.
/// </summary>
public EpssChangeFlags Flags { get; init; }
/// <summary>
/// Previous EPSS score (if available).
/// </summary>
public double? PreviousScore { get; init; }
/// <summary>
/// New EPSS score.
/// </summary>
public double NewScore { get; init; }
/// <summary>
/// New EPSS percentile.
/// </summary>
public double NewPercentile { get; init; }
/// <summary>
/// Previous priority band (if available).
/// </summary>
public EpssPriorityBand PreviousBand { get; init; }
/// <summary>
/// Model date for this change.
/// </summary>
public DateOnly ModelDate { get; init; }
}

View File

@@ -0,0 +1,110 @@
// -----------------------------------------------------------------------------
// EpssExplainHashCalculator.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S4 - Implement ComputeExplainHash
// Description: Deterministic SHA-256 hash calculator for EPSS signal explainability.
// -----------------------------------------------------------------------------
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Calculator for deterministic explain hashes on EPSS signals.
/// The explain hash provides a unique fingerprint for signal inputs,
/// enabling audit trails and change detection.
/// </summary>
public static class EpssExplainHashCalculator
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Computes a deterministic SHA-256 hash from signal input parameters.
/// </summary>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="eventType">Event type (RISK_SPIKE, BAND_CHANGE, etc.).</param>
/// <param name="oldBand">Previous risk band (nullable).</param>
/// <param name="newBand">New risk band (nullable).</param>
/// <param name="score">EPSS score.</param>
/// <param name="percentile">EPSS percentile.</param>
/// <param name="modelVersion">EPSS model version.</param>
/// <returns>SHA-256 hash as byte array.</returns>
public static byte[] ComputeExplainHash(
DateOnly modelDate,
string cveId,
string eventType,
string? oldBand,
string? newBand,
double score,
double percentile,
string? modelVersion)
{
// Create deterministic input structure
var input = new ExplainHashInput
{
ModelDate = modelDate.ToString("yyyy-MM-dd"),
CveId = cveId.ToUpperInvariant(), // Normalize CVE ID
EventType = eventType.ToUpperInvariant(),
OldBand = oldBand?.ToUpperInvariant() ?? "NONE",
NewBand = newBand?.ToUpperInvariant() ?? "NONE",
Score = Math.Round(score, 6), // Consistent precision
Percentile = Math.Round(percentile, 6),
ModelVersion = modelVersion ?? string.Empty
};
// Serialize to deterministic JSON
var json = JsonSerializer.Serialize(input, JsonOptions);
var bytes = Encoding.UTF8.GetBytes(json);
return SHA256.HashData(bytes);
}
/// <summary>
/// Computes the dedupe key for an EPSS signal.
/// This key is used to prevent duplicate signals.
/// </summary>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="eventType">Event type.</param>
/// <param name="oldBand">Previous risk band.</param>
/// <param name="newBand">New risk band.</param>
/// <returns>Deterministic dedupe key string.</returns>
public static string ComputeDedupeKey(
DateOnly modelDate,
string cveId,
string eventType,
string? oldBand,
string? newBand)
{
return $"{modelDate:yyyy-MM-dd}:{cveId.ToUpperInvariant()}:{eventType.ToUpperInvariant()}:{oldBand?.ToUpperInvariant() ?? "NONE"}->{newBand?.ToUpperInvariant() ?? "NONE"}";
}
/// <summary>
/// Converts an explain hash to hex string for display.
/// </summary>
/// <param name="hash">The hash bytes.</param>
/// <returns>Lowercase hex string.</returns>
public static string ToHexString(byte[] hash)
{
return Convert.ToHexString(hash).ToLowerInvariant();
}
private sealed record ExplainHashInput
{
public required string ModelDate { get; init; }
public required string CveId { get; init; }
public required string EventType { get; init; }
public required string OldBand { get; init; }
public required string NewBand { get; init; }
public required double Score { get; init; }
public required double Percentile { get; init; }
public required string ModelVersion { get; init; }
}
}

View File

@@ -0,0 +1,285 @@
// -----------------------------------------------------------------------------
// EpssReplayService.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R4 - Implement ReplayFromRawAsync
// Description: Service for replaying EPSS data from stored raw payloads.
// -----------------------------------------------------------------------------
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Result of an EPSS replay operation.
/// </summary>
public sealed record EpssReplayResult
{
/// <summary>
/// The model date that was replayed.
/// </summary>
public required DateOnly ModelDate { get; init; }
/// <summary>
/// Number of rows replayed.
/// </summary>
public required int RowCount { get; init; }
/// <summary>
/// Number of distinct CVEs.
/// </summary>
public required int DistinctCveCount { get; init; }
/// <summary>
/// Whether this was a dry run (no writes).
/// </summary>
public required bool IsDryRun { get; init; }
/// <summary>
/// Duration of the replay in milliseconds.
/// </summary>
public required long DurationMs { get; init; }
/// <summary>
/// Model version from the raw payload.
/// </summary>
public string? ModelVersion { get; init; }
}
/// <summary>
/// Service for replaying EPSS data from stored raw payloads.
/// Enables deterministic re-normalization without re-downloading from FIRST.org.
/// </summary>
public sealed class EpssReplayService
{
private readonly IEpssRawRepository _rawRepository;
private readonly IEpssRepository _epssRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<EpssReplayService> _logger;
public EpssReplayService(
IEpssRawRepository rawRepository,
IEpssRepository epssRepository,
TimeProvider timeProvider,
ILogger<EpssReplayService> logger)
{
_rawRepository = rawRepository ?? throw new ArgumentNullException(nameof(rawRepository));
_epssRepository = epssRepository ?? throw new ArgumentNullException(nameof(epssRepository));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Replays EPSS data from a stored raw payload for a specific date.
/// Re-normalizes the data into the epss_snapshot table without re-downloading.
/// </summary>
/// <param name="modelDate">The model date to replay.</param>
/// <param name="dryRun">If true, validates but doesn't write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the replay operation.</returns>
public async Task<EpssReplayResult> ReplayFromRawAsync(
DateOnly modelDate,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
_logger.LogInformation(
"Starting EPSS replay from raw for {ModelDate} (dryRun={DryRun})",
modelDate,
dryRun);
// Fetch the raw payload
var raw = await _rawRepository.GetByDateAsync(modelDate, cancellationToken).ConfigureAwait(false);
if (raw is null)
{
throw new InvalidOperationException($"No raw EPSS payload found for {modelDate}");
}
_logger.LogDebug(
"Found raw payload: rawId={RawId}, rows={RowCount}, modelVersion={ModelVersion}",
raw.RawId,
raw.RowCount,
raw.ModelVersion);
// Parse the JSON payload
var rows = ParseRawPayload(raw.Payload);
if (dryRun)
{
stopwatch.Stop();
_logger.LogInformation(
"EPSS replay dry run completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms",
modelDate,
rows.Count,
rows.Select(r => r.CveId).Distinct().Count(),
stopwatch.ElapsedMilliseconds);
return new EpssReplayResult
{
ModelDate = modelDate,
RowCount = rows.Count,
DistinctCveCount = rows.Select(r => r.CveId).Distinct().Count(),
IsDryRun = true,
DurationMs = stopwatch.ElapsedMilliseconds,
ModelVersion = raw.ModelVersion
};
}
// Create a new import run for the replay
var importRun = await _epssRepository.BeginImportAsync(
modelDate,
$"replay:{raw.SourceUri}",
_timeProvider.GetUtcNow(),
Convert.ToHexString(raw.PayloadSha256).ToLowerInvariant(),
cancellationToken).ConfigureAwait(false);
try
{
// Write the snapshot using async enumerable
var writeResult = await _epssRepository.WriteSnapshotAsync(
importRun.ImportRunId,
modelDate,
_timeProvider.GetUtcNow(),
ToAsyncEnumerable(rows),
cancellationToken).ConfigureAwait(false);
// Mark success
await _epssRepository.MarkImportSucceededAsync(
importRun.ImportRunId,
rows.Count,
Convert.ToHexString(raw.PayloadSha256).ToLowerInvariant(),
raw.ModelVersion,
raw.PublishedDate,
cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
_logger.LogInformation(
"EPSS replay completed: modelDate={ModelDate}, rows={RowCount}, cves={CveCount}, duration={Duration}ms",
modelDate,
writeResult.RowCount,
writeResult.DistinctCveCount,
stopwatch.ElapsedMilliseconds);
return new EpssReplayResult
{
ModelDate = modelDate,
RowCount = writeResult.RowCount,
DistinctCveCount = writeResult.DistinctCveCount,
IsDryRun = false,
DurationMs = stopwatch.ElapsedMilliseconds,
ModelVersion = raw.ModelVersion
};
}
catch (Exception ex)
{
await _epssRepository.MarkImportFailedAsync(
importRun.ImportRunId,
$"Replay failed: {ex.Message}",
cancellationToken).ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Replays EPSS data for a date range.
/// </summary>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="dryRun">If true, validates but doesn't write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Results for each date replayed.</returns>
public async Task<IReadOnlyList<EpssReplayResult>> ReplayRangeAsync(
DateOnly startDate,
DateOnly endDate,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var results = new List<EpssReplayResult>();
var rawPayloads = await _rawRepository.GetByDateRangeAsync(startDate, endDate, cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation(
"Replaying {Count} EPSS payloads from {StartDate} to {EndDate}",
rawPayloads.Count,
startDate,
endDate);
foreach (var raw in rawPayloads.OrderBy(r => r.AsOfDate))
{
try
{
var result = await ReplayFromRawAsync(raw.AsOfDate, dryRun, cancellationToken)
.ConfigureAwait(false);
results.Add(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to replay EPSS for {ModelDate}", raw.AsOfDate);
// Continue with next date
}
}
return results;
}
/// <summary>
/// Gets available dates for replay.
/// </summary>
/// <param name="startDate">Optional start date filter.</param>
/// <param name="endDate">Optional end date filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of available model dates.</returns>
public async Task<IReadOnlyList<DateOnly>> GetAvailableDatesAsync(
DateOnly? startDate = null,
DateOnly? endDate = null,
CancellationToken cancellationToken = default)
{
var start = startDate ?? DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-1));
var end = endDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
var rawPayloads = await _rawRepository.GetByDateRangeAsync(start, end, cancellationToken)
.ConfigureAwait(false);
return rawPayloads.Select(r => r.AsOfDate).OrderByDescending(d => d).ToList();
}
private static List<EpssScoreRow> ParseRawPayload(string jsonPayload)
{
var rows = new List<EpssScoreRow>();
using var doc = JsonDocument.Parse(jsonPayload);
foreach (var element in doc.RootElement.EnumerateArray())
{
var cveId = element.GetProperty("cve").GetString();
var score = element.GetProperty("epss").GetDouble();
var percentile = element.GetProperty("percentile").GetDouble();
if (!string.IsNullOrEmpty(cveId))
{
rows.Add(new EpssScoreRow(cveId, score, percentile));
}
}
return rows;
}
private static async IAsyncEnumerable<EpssScoreRow> ToAsyncEnumerable(
IEnumerable<EpssScoreRow> rows)
{
foreach (var row in rows)
{
yield return row;
}
await Task.CompletedTask;
}
}

View File

@@ -0,0 +1,104 @@
// -----------------------------------------------------------------------------
// IEpssSignalPublisher.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S9 - Connect to Notify/Router
// Description: Interface for publishing EPSS signals to the notification system.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Epss;
/// <summary>
/// Result of publishing an EPSS signal.
/// </summary>
public sealed record EpssSignalPublishResult
{
/// <summary>
/// Whether the publish was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// Message ID from the queue (if applicable).
/// </summary>
public string? MessageId { get; init; }
/// <summary>
/// Error message if publish failed.
/// </summary>
public string? Error { get; init; }
}
/// <summary>
/// Publisher for EPSS signals to the notification system.
/// Routes signals to the appropriate topics based on event type.
/// </summary>
public interface IEpssSignalPublisher
{
/// <summary>
/// Topic name for EPSS signals.
/// </summary>
const string TopicName = "signals.epss";
/// <summary>
/// Publishes an EPSS signal to the notification system.
/// </summary>
/// <param name="signal">The signal to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the publish operation.</returns>
Task<EpssSignalPublishResult> PublishAsync(
EpssSignal signal,
CancellationToken cancellationToken = default);
/// <summary>
/// Publishes multiple EPSS signals in a batch.
/// </summary>
/// <param name="signals">The signals to publish.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of successfully published signals.</returns>
Task<int> PublishBatchAsync(
IEnumerable<EpssSignal> signals,
CancellationToken cancellationToken = default);
/// <summary>
/// Publishes a priority change event.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="oldBand">Previous priority band.</param>
/// <param name="newBand">New priority band.</param>
/// <param name="epssScore">Current EPSS score.</param>
/// <param name="modelDate">EPSS model date.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result of the publish operation.</returns>
Task<EpssSignalPublishResult> PublishPriorityChangedAsync(
Guid tenantId,
string cveId,
string oldBand,
string newBand,
double epssScore,
DateOnly modelDate,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Null implementation of IEpssSignalPublisher for when messaging is disabled.
/// </summary>
public sealed class NullEpssSignalPublisher : IEpssSignalPublisher
{
public static readonly NullEpssSignalPublisher Instance = new();
private NullEpssSignalPublisher() { }
public Task<EpssSignalPublishResult> PublishAsync(EpssSignal signal, CancellationToken cancellationToken = default)
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "null" });
public Task<int> PublishBatchAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
=> Task.FromResult(signals.Count());
public Task<EpssSignalPublishResult> PublishPriorityChangedAsync(
Guid tenantId, string cveId, string oldBand, string newBand, double epssScore, DateOnly modelDate,
CancellationToken cancellationToken = default)
=> Task.FromResult(new EpssSignalPublishResult { Success = true, MessageId = "null" });
}

View File

@@ -0,0 +1,165 @@
// -----------------------------------------------------------------------------
// EpssServiceCollectionExtensions.cs
// Sprint: SPRINT_3410_0002_0001_epss_scanner_integration
// Task: EPSS-SCAN-005
// Description: DI registration for EPSS services with optional Valkey cache layer.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Abstractions;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Extensions;
/// <summary>
/// Extension methods for registering EPSS services with optional Valkey caching.
/// </summary>
public static class EpssServiceCollectionExtensions
{
/// <summary>
/// Adds EPSS provider services to the service collection.
/// Includes optional Valkey/Redis cache layer based on configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration section for EPSS options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddEpssProvider(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
// Bind EPSS provider options
services.AddOptions<EpssProviderOptions>()
.Bind(configuration.GetSection(EpssProviderOptions.SectionName))
.ValidateOnStart();
// Register the base PostgreSQL-backed provider
services.TryAddScoped<EpssProvider>();
// Register the caching decorator
services.TryAddScoped<IEpssProvider>(sp =>
{
var options = sp.GetRequiredService<IOptions<EpssProviderOptions>>().Value;
var innerProvider = sp.GetRequiredService<EpssProvider>();
var logger = sp.GetRequiredService<ILogger<CachingEpssProvider>>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
// If caching is disabled, return the inner provider directly
if (!options.EnableCache)
{
return innerProvider;
}
// Try to get the cache factory (may be null if Valkey is not configured)
var cacheFactory = sp.GetService<IDistributedCacheFactory>();
IDistributedCache<EpssCacheEntry>? cache = null;
if (cacheFactory is not null)
{
try
{
cache = cacheFactory.Create<EpssCacheEntry>(new CacheOptions
{
KeyPrefix = "epss:",
DefaultTtl = options.CacheTtl,
SlidingExpiration = false
});
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to create EPSS cache, falling back to uncached provider. " +
"Ensure Valkey/Redis is configured if caching is desired.");
}
}
else
{
logger.LogDebug(
"No IDistributedCacheFactory registered. EPSS caching will be disabled. " +
"Register StellaOps.Messaging.Transport.Valkey to enable caching.");
}
return new CachingEpssProvider(
innerProvider,
cache,
sp.GetRequiredService<IOptions<EpssProviderOptions>>(),
logger,
timeProvider);
});
return services;
}
/// <summary>
/// Adds EPSS provider services with explicit options configuration.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">The configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddEpssProvider(
this IServiceCollection services,
Action<EpssProviderOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
services.AddOptions<EpssProviderOptions>()
.Configure(configure)
.ValidateOnStart();
// Register the base PostgreSQL-backed provider
services.TryAddScoped<EpssProvider>();
// Register the caching decorator
services.TryAddScoped<IEpssProvider>(sp =>
{
var options = sp.GetRequiredService<IOptions<EpssProviderOptions>>().Value;
var innerProvider = sp.GetRequiredService<EpssProvider>();
var logger = sp.GetRequiredService<ILogger<CachingEpssProvider>>();
var timeProvider = sp.GetService<TimeProvider>() ?? TimeProvider.System;
// If caching is disabled, return the inner provider directly
if (!options.EnableCache)
{
return innerProvider;
}
// Try to get the cache factory
var cacheFactory = sp.GetService<IDistributedCacheFactory>();
IDistributedCache<EpssCacheEntry>? cache = null;
if (cacheFactory is not null)
{
try
{
cache = cacheFactory.Create<EpssCacheEntry>(new CacheOptions
{
KeyPrefix = "epss:",
DefaultTtl = options.CacheTtl,
SlidingExpiration = false
});
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create EPSS cache");
}
}
return new CachingEpssProvider(
innerProvider,
cache,
sp.GetRequiredService<IOptions<EpssProviderOptions>>(),
logger,
timeProvider);
});
return services;
}
}

View File

@@ -90,6 +90,22 @@ public static class ServiceCollectionExtensions
services.AddSingleton<EpssBundleSource>();
// Note: EpssChangeDetector is a static class, no DI registration needed
// EPSS provider with optional Valkey cache layer (Sprint: SPRINT_3410_0002_0001, Task: EPSS-SCAN-005)
services.AddEpssProvider(options =>
{
// Default configuration - can be overridden via config binding
options.EnableCache = true;
options.CacheTtl = TimeSpan.FromHours(1);
options.MaxBatchSize = 1000;
});
// EPSS raw and signal repositories (Sprint: SPRINT_3413_0001_0001)
services.AddScoped<IEpssRawRepository, PostgresEpssRawRepository>();
services.AddScoped<IEpssSignalRepository, PostgresEpssSignalRepository>();
services.AddScoped<IObservedCveRepository, PostgresObservedCveRepository>();
services.AddSingleton<EpssReplayService>();
services.TryAddSingleton<IEpssSignalPublisher, NullEpssSignalPublisher>();
// Witness storage (Sprint: SPRINT_3700_0001_0001)
services.AddScoped<IWitnessRepository, PostgresWitnessRepository>();

View File

@@ -0,0 +1,150 @@
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Sprint: 3413
-- Task: Task #2 - vuln_instance_triage schema updates
-- Description: Adds EPSS tracking columns to vulnerability instance triage table
-- ============================================================================
-- EPSS Tracking Columns for Vulnerability Instances
-- ============================================================================
-- These columns store the current EPSS state for each vulnerability instance,
-- enabling efficient priority band calculation and change detection.
-- Add EPSS columns to vuln_instance_triage if table exists
DO $$
BEGIN
-- Check if table exists
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'vuln_instance_triage') THEN
-- Add current_epss_score column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_score') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_score DOUBLE PRECISION;
COMMENT ON COLUMN vuln_instance_triage.current_epss_score IS 'Current EPSS probability score [0,1]';
END IF;
-- Add current_epss_percentile column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_percentile') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_percentile DOUBLE PRECISION;
COMMENT ON COLUMN vuln_instance_triage.current_epss_percentile IS 'Current EPSS percentile rank [0,1]';
END IF;
-- Add current_epss_band column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'current_epss_band') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN current_epss_band TEXT;
COMMENT ON COLUMN vuln_instance_triage.current_epss_band IS 'Current EPSS priority band: CRITICAL, HIGH, MEDIUM, LOW';
END IF;
-- Add epss_model_date column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'epss_model_date') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN epss_model_date DATE;
COMMENT ON COLUMN vuln_instance_triage.epss_model_date IS 'EPSS model date when last updated';
END IF;
-- Add epss_updated_at column
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'epss_updated_at') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN epss_updated_at TIMESTAMPTZ;
COMMENT ON COLUMN vuln_instance_triage.epss_updated_at IS 'Timestamp when EPSS data was last updated';
END IF;
-- Add previous_epss_band column (for change tracking)
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'vuln_instance_triage' AND column_name = 'previous_epss_band') THEN
ALTER TABLE vuln_instance_triage ADD COLUMN previous_epss_band TEXT;
COMMENT ON COLUMN vuln_instance_triage.previous_epss_band IS 'Previous EPSS priority band before last update';
END IF;
-- Create index for efficient band-based queries
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_vuln_instance_epss_band') THEN
CREATE INDEX idx_vuln_instance_epss_band
ON vuln_instance_triage (current_epss_band)
WHERE current_epss_band IN ('CRITICAL', 'HIGH');
END IF;
-- Create index for stale EPSS data detection
IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_vuln_instance_epss_model_date') THEN
CREATE INDEX idx_vuln_instance_epss_model_date
ON vuln_instance_triage (epss_model_date);
END IF;
RAISE NOTICE 'Added EPSS columns to vuln_instance_triage table';
ELSE
RAISE NOTICE 'Table vuln_instance_triage does not exist; skipping EPSS column additions';
END IF;
END $$;
-- ============================================================================
-- Batch Update Function for EPSS Enrichment
-- ============================================================================
-- Efficiently updates EPSS data for multiple vulnerability instances
CREATE OR REPLACE FUNCTION batch_update_epss_triage(
p_updates JSONB,
p_model_date DATE,
p_updated_at TIMESTAMPTZ DEFAULT now()
)
RETURNS TABLE (
updated_count INT,
band_change_count INT
) AS $$
DECLARE
v_updated INT := 0;
v_band_changes INT := 0;
v_row RECORD;
BEGIN
-- p_updates format: [{"instance_id": "...", "score": 0.123, "percentile": 0.456, "band": "HIGH"}, ...]
FOR v_row IN SELECT * FROM jsonb_to_recordset(p_updates) AS x(
instance_id UUID,
score DOUBLE PRECISION,
percentile DOUBLE PRECISION,
band TEXT
)
LOOP
UPDATE vuln_instance_triage SET
previous_epss_band = current_epss_band,
current_epss_score = v_row.score,
current_epss_percentile = v_row.percentile,
current_epss_band = v_row.band,
epss_model_date = p_model_date,
epss_updated_at = p_updated_at
WHERE instance_id = v_row.instance_id
AND (current_epss_band IS DISTINCT FROM v_row.band
OR current_epss_score IS DISTINCT FROM v_row.score);
IF FOUND THEN
v_updated := v_updated + 1;
-- Check if band actually changed
IF (SELECT previous_epss_band FROM vuln_instance_triage WHERE instance_id = v_row.instance_id)
IS DISTINCT FROM v_row.band THEN
v_band_changes := v_band_changes + 1;
END IF;
END IF;
END LOOP;
RETURN QUERY SELECT v_updated, v_band_changes;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION batch_update_epss_triage IS 'Batch updates EPSS data for vulnerability instances, tracking band changes';
-- ============================================================================
-- View for Instances Needing EPSS Update
-- ============================================================================
-- Returns instances with stale or missing EPSS data
CREATE OR REPLACE VIEW v_epss_stale_instances AS
SELECT
vit.instance_id,
vit.cve_id,
vit.tenant_id,
vit.current_epss_band,
vit.epss_model_date,
CURRENT_DATE - COALESCE(vit.epss_model_date, '1970-01-01'::DATE) AS days_stale
FROM vuln_instance_triage vit
WHERE vit.epss_model_date IS NULL
OR vit.epss_model_date < CURRENT_DATE - 1;
COMMENT ON VIEW v_epss_stale_instances IS 'Instances with stale or missing EPSS data, needing enrichment';

View File

@@ -0,0 +1,177 @@
-- =============================================================================
-- Migration: 014_vuln_surfaces.sql
-- Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
-- Task: SURF-014
-- Description: Vulnerability surface storage for trigger method analysis.
-- =============================================================================
BEGIN;
-- Prevent re-running
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'scanner' AND tablename = 'vuln_surfaces') THEN
RAISE EXCEPTION 'Migration 014_vuln_surfaces already applied';
END IF;
END $$;
-- =============================================================================
-- VULN_SURFACES: Computed vulnerability surface for CVE + package + version
-- =============================================================================
CREATE TABLE scanner.vuln_surfaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id),
-- CVE/vulnerability identity
cve_id TEXT NOT NULL,
package_ecosystem TEXT NOT NULL, -- 'nuget', 'npm', 'maven', 'pypi'
package_name TEXT NOT NULL,
vuln_version TEXT NOT NULL, -- Version with vulnerability
fixed_version TEXT, -- First fixed version (null if no fix)
-- Surface computation metadata
computed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
computation_duration_ms INTEGER,
fingerprint_method TEXT NOT NULL, -- 'cecil-il', 'babel-ast', 'asm-bytecode', 'python-ast'
-- Summary statistics
total_methods_vuln INTEGER NOT NULL DEFAULT 0,
total_methods_fixed INTEGER NOT NULL DEFAULT 0,
changed_method_count INTEGER NOT NULL DEFAULT 0,
-- DSSE attestation (optional)
attestation_digest TEXT,
-- Indexes for lookups
CONSTRAINT uq_vuln_surface_key UNIQUE (tenant_id, cve_id, package_ecosystem, package_name, vuln_version)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surfaces_cve ON scanner.vuln_surfaces(tenant_id, cve_id);
CREATE INDEX idx_vuln_surfaces_package ON scanner.vuln_surfaces(tenant_id, package_ecosystem, package_name);
CREATE INDEX idx_vuln_surfaces_computed_at ON scanner.vuln_surfaces(computed_at DESC);
COMMENT ON TABLE scanner.vuln_surfaces IS 'Computed vulnerability surfaces identifying which methods changed between vulnerable and fixed versions';
-- =============================================================================
-- VULN_SURFACE_SINKS: Individual trigger methods for a vulnerability surface
-- =============================================================================
CREATE TABLE scanner.vuln_surface_sinks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
surface_id UUID NOT NULL REFERENCES scanner.vuln_surfaces(id) ON DELETE CASCADE,
-- Method identity
method_key TEXT NOT NULL, -- Normalized method signature (FQN)
method_name TEXT NOT NULL, -- Simple method name
declaring_type TEXT NOT NULL, -- Containing class/module
namespace TEXT, -- Namespace/package
-- Change classification
change_type TEXT NOT NULL CHECK (change_type IN ('added', 'removed', 'modified')),
-- Fingerprints for comparison
vuln_fingerprint TEXT, -- Hash in vulnerable version (null if added in fix)
fixed_fingerprint TEXT, -- Hash in fixed version (null if removed in fix)
-- Metadata
is_public BOOLEAN NOT NULL DEFAULT true,
parameter_count INTEGER,
return_type TEXT,
-- Source location (if available from debug symbols)
source_file TEXT,
start_line INTEGER,
end_line INTEGER,
-- Indexes for lookups
CONSTRAINT uq_surface_sink_key UNIQUE (surface_id, method_key)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surface_sinks_surface ON scanner.vuln_surface_sinks(surface_id);
CREATE INDEX idx_vuln_surface_sinks_method ON scanner.vuln_surface_sinks(method_name);
CREATE INDEX idx_vuln_surface_sinks_type ON scanner.vuln_surface_sinks(declaring_type);
COMMENT ON TABLE scanner.vuln_surface_sinks IS 'Individual methods that changed between vulnerable and fixed package versions';
-- =============================================================================
-- VULN_SURFACE_TRIGGERS: Links sinks to call graph nodes where they are invoked
-- =============================================================================
CREATE TABLE scanner.vuln_surface_triggers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sink_id UUID NOT NULL REFERENCES scanner.vuln_surface_sinks(id) ON DELETE CASCADE,
scan_id UUID NOT NULL, -- References scanner.scans
-- Caller identity
caller_node_id TEXT NOT NULL, -- Call graph node ID
caller_method_key TEXT NOT NULL, -- FQN of calling method
caller_file TEXT, -- Source file of caller
caller_line INTEGER, -- Line number of call
-- Reachability analysis
reachability_bucket TEXT NOT NULL DEFAULT 'unknown', -- 'entrypoint', 'direct', 'runtime', 'unknown', 'unreachable'
path_length INTEGER, -- Shortest path from entrypoint
confidence REAL NOT NULL DEFAULT 0.5,
-- Evidence
call_type TEXT NOT NULL DEFAULT 'direct', -- 'direct', 'virtual', 'interface', 'reflection'
is_conditional BOOLEAN NOT NULL DEFAULT false,
-- Indexes for lookups
CONSTRAINT uq_trigger_key UNIQUE (sink_id, scan_id, caller_node_id)
);
-- Indexes for common queries
CREATE INDEX idx_vuln_surface_triggers_sink ON scanner.vuln_surface_triggers(sink_id);
CREATE INDEX idx_vuln_surface_triggers_scan ON scanner.vuln_surface_triggers(scan_id);
CREATE INDEX idx_vuln_surface_triggers_bucket ON scanner.vuln_surface_triggers(reachability_bucket);
COMMENT ON TABLE scanner.vuln_surface_triggers IS 'Links between vulnerability sink methods and their callers in analyzed code';
-- =============================================================================
-- RLS (Row Level Security)
-- =============================================================================
ALTER TABLE scanner.vuln_surfaces ENABLE ROW LEVEL SECURITY;
-- Tenant isolation policy
CREATE POLICY vuln_surfaces_tenant_isolation ON scanner.vuln_surfaces
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
-- Note: vuln_surface_sinks and triggers inherit isolation through FK to surfaces
-- =============================================================================
-- FUNCTIONS
-- =============================================================================
-- Get surface statistics for a CVE
CREATE OR REPLACE FUNCTION scanner.get_vuln_surface_stats(
p_tenant_id UUID,
p_cve_id TEXT
)
RETURNS TABLE (
package_ecosystem TEXT,
package_name TEXT,
vuln_version TEXT,
fixed_version TEXT,
changed_method_count INTEGER,
trigger_count BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
vs.package_ecosystem,
vs.package_name,
vs.vuln_version,
vs.fixed_version,
vs.changed_method_count,
COUNT(DISTINCT vst.id)::BIGINT AS trigger_count
FROM scanner.vuln_surfaces vs
LEFT JOIN scanner.vuln_surface_sinks vss ON vss.surface_id = vs.id
LEFT JOIN scanner.vuln_surface_triggers vst ON vst.sink_id = vss.id
WHERE vs.tenant_id = p_tenant_id
AND vs.cve_id = p_cve_id
GROUP BY vs.id, vs.package_ecosystem, vs.package_name, vs.vuln_version, vs.fixed_version, vs.changed_method_count
ORDER BY vs.package_ecosystem, vs.package_name;
END;
$$ LANGUAGE plpgsql STABLE;
COMMIT;

View File

@@ -15,4 +15,6 @@ internal static class MigrationIds
public const string EpssRawLayer = "011_epss_raw_layer.sql";
public const string EpssSignalLayer = "012_epss_signal_layer.sql";
public const string WitnessStorage = "013_witness_storage.sql";
public const string EpssTriageColumns = "014_epss_triage_columns.sql";
}

View File

@@ -0,0 +1,228 @@
// -----------------------------------------------------------------------------
// PostgresEpssRawRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R1-R4 - EPSS Raw Feed Layer
// Description: PostgreSQL implementation of IEpssRawRepository.
// -----------------------------------------------------------------------------
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IEpssRawRepository"/>.
/// </summary>
public sealed class PostgresEpssRawRepository : IEpssRawRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string RawTable => $"{SchemaName}.epss_raw";
public PostgresEpssRawRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<EpssRaw> CreateAsync(EpssRaw raw, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(raw);
var sql = $"""
INSERT INTO {RawTable} (
source_uri, asof_date, payload, payload_sha256,
header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
)
VALUES (
@SourceUri, @AsOfDate, @Payload::jsonb, @PayloadSha256,
@HeaderComment, @ModelVersion, @PublishedDate,
@RowCount, @CompressedSize, @DecompressedSize, @ImportRunId
)
ON CONFLICT (source_uri, asof_date, payload_sha256) DO NOTHING
RETURNING raw_id, ingestion_ts
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long raw_id, DateTimeOffset ingestion_ts)?>(sql, new
{
raw.SourceUri,
AsOfDate = raw.AsOfDate.ToDateTime(TimeOnly.MinValue),
raw.Payload,
raw.PayloadSha256,
raw.HeaderComment,
raw.ModelVersion,
PublishedDate = raw.PublishedDate?.ToDateTime(TimeOnly.MinValue),
raw.RowCount,
raw.CompressedSize,
raw.DecompressedSize,
raw.ImportRunId
});
if (result.HasValue)
{
return raw with
{
RawId = result.Value.raw_id,
IngestionTs = result.Value.ingestion_ts
};
}
// Record already exists (idempotency), fetch existing
var existing = await GetByDateAsync(raw.AsOfDate, cancellationToken);
return existing ?? raw;
}
public async Task<EpssRaw?> GetByDateAsync(DateOnly asOfDate, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date = @AsOfDate
ORDER BY ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue)
});
return row.HasValue ? MapToRaw(row.Value) : null;
}
public async Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE asof_date >= @StartDate AND asof_date <= @EndDate
ORDER BY asof_date DESC
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
return rows.Select(MapToRaw).ToList();
}
public async Task<EpssRaw?> GetLatestAsync(CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
ORDER BY asof_date DESC, ingestion_ts DESC
LIMIT 1
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<RawRow?>(sql);
return row.HasValue ? MapToRaw(row.Value) : null;
}
public async Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {RawTable}
WHERE asof_date = @AsOfDate AND payload_sha256 = @PayloadSha256
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new
{
AsOfDate = asOfDate.ToDateTime(TimeOnly.MinValue),
PayloadSha256 = payloadSha256
});
}
public async Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
string modelVersion,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
raw_id, source_uri, asof_date, ingestion_ts,
payload, payload_sha256, header_comment, model_version, published_date,
row_count, compressed_size, decompressed_size, import_run_id
FROM {RawTable}
WHERE model_version = @ModelVersion
ORDER BY asof_date DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<RawRow>(sql, new
{
ModelVersion = modelVersion,
Limit = limit
});
return rows.Select(MapToRaw).ToList();
}
public async Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_raw(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
private static EpssRaw MapToRaw(RawRow row)
{
return new EpssRaw
{
RawId = row.raw_id,
SourceUri = row.source_uri,
AsOfDate = DateOnly.FromDateTime(row.asof_date),
IngestionTs = row.ingestion_ts,
Payload = row.payload,
PayloadSha256 = row.payload_sha256,
HeaderComment = row.header_comment,
ModelVersion = row.model_version,
PublishedDate = row.published_date.HasValue ? DateOnly.FromDateTime(row.published_date.Value) : null,
RowCount = row.row_count,
CompressedSize = row.compressed_size,
DecompressedSize = row.decompressed_size,
ImportRunId = row.import_run_id
};
}
private readonly record struct RawRow(
long raw_id,
string source_uri,
DateTime asof_date,
DateTimeOffset ingestion_ts,
string payload,
byte[] payload_sha256,
string? header_comment,
string? model_version,
DateTime? published_date,
int row_count,
long? compressed_size,
long? decompressed_size,
Guid? import_run_id);
}

View File

@@ -9,6 +9,7 @@ using System.Data;
using Dapper;
using Npgsql;
using NpgsqlTypes;
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
using StellaOps.Scanner.Storage.Repositories;
@@ -481,6 +482,61 @@ public sealed class PostgresEpssRepository : IEpssRepository
cancellationToken: cancellationToken)).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<EpssChangeRecord>> GetChangesAsync(
DateOnly modelDate,
Core.Epss.EpssChangeFlags? flags = null,
int limit = 100000,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
cve_id,
flags,
prev_score,
new_score,
new_percentile,
prev_band,
model_date
FROM {ChangesTable}
WHERE model_date = @ModelDate
{(flags.HasValue ? "AND (flags & @Flags) != 0" : "")}
ORDER BY new_score DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<ChangeRow>(sql, new
{
ModelDate = modelDate,
Flags = flags.HasValue ? (int)flags.Value : 0,
Limit = limit
});
return rows.Select(r => new EpssChangeRecord
{
CveId = r.cve_id,
Flags = (Core.Epss.EpssChangeFlags)r.flags,
PreviousScore = r.prev_score,
NewScore = r.new_score,
NewPercentile = r.new_percentile,
PreviousBand = (Core.Epss.EpssPriorityBand)r.prev_band,
ModelDate = r.model_date
}).ToList();
}
private sealed class ChangeRow
{
public string cve_id { get; set; } = "";
public int flags { get; set; }
public double? prev_score { get; set; }
public double new_score { get; set; }
public double new_percentile { get; set; }
public int prev_band { get; set; }
public DateOnly model_date { get; set; }
}
private sealed class StageCounts
{
public int distinct_count { get; set; }

View File

@@ -0,0 +1,395 @@
// -----------------------------------------------------------------------------
// PostgresEpssSignalRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S3 - Implement PostgresEpssSignalRepository
// Description: PostgreSQL implementation of IEpssSignalRepository.
// -----------------------------------------------------------------------------
using System.Text.Json;
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IEpssSignalRepository"/>.
/// </summary>
public sealed class PostgresEpssSignalRepository : IEpssSignalRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string SignalTable => $"{SchemaName}.epss_signal";
private string ConfigTable => $"{SchemaName}.epss_signal_config";
public PostgresEpssSignalRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<EpssSignal> CreateAsync(EpssSignal signal, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(signal);
var sql = $"""
INSERT INTO {SignalTable} (
tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
RETURNING signal_id, created_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstOrDefaultAsync<(long signal_id, DateTimeOffset created_at)?>(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
});
if (result.HasValue)
{
return signal with
{
SignalId = result.Value.signal_id,
CreatedAt = result.Value.created_at
};
}
// Signal already exists (dedupe), fetch existing
var existing = await GetByDedupeKeyAsync(signal.TenantId, signal.DedupeKey, cancellationToken);
return existing ?? signal;
}
public async Task<int> CreateBulkAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(signals);
var signalList = signals.ToList();
if (signalList.Count == 0)
{
return 0;
}
var sql = $"""
INSERT INTO {SignalTable} (
tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload
)
VALUES (
@TenantId, @ModelDate, @CveId, @EventType, @RiskBand,
@EpssScore, @EpssDelta, @Percentile, @PercentileDelta,
@IsModelChange, @ModelVersion, @DedupeKey, @ExplainHash, @Payload::jsonb
)
ON CONFLICT (tenant_id, dedupe_key) DO NOTHING
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var inserted = 0;
foreach (var signal in signalList)
{
var affected = await connection.ExecuteAsync(sql, new
{
signal.TenantId,
ModelDate = signal.ModelDate.ToDateTime(TimeOnly.MinValue),
signal.CveId,
signal.EventType,
signal.RiskBand,
signal.EpssScore,
signal.EpssDelta,
signal.Percentile,
signal.PercentileDelta,
signal.IsModelChange,
signal.ModelVersion,
signal.DedupeKey,
signal.ExplainHash,
signal.Payload
}, transaction);
inserted += affected;
}
await transaction.CommitAsync(cancellationToken);
return inserted;
}
public async Task<IReadOnlyList<EpssSignal>> GetByTenantAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
IEnumerable<string>? eventTypes = null,
CancellationToken cancellationToken = default)
{
var eventTypeList = eventTypes?.ToList();
var hasEventTypeFilter = eventTypeList?.Count > 0;
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
{(hasEventTypeFilter ? "AND event_type = ANY(@EventTypes)" : "")}
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue),
EventTypes = eventTypeList?.ToArray()
});
return rows.Select(MapToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetByCveAsync(
Guid tenantId,
string cveId,
int limit = 100,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
ORDER BY model_date DESC, created_at DESC
LIMIT @Limit
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
CveId = cveId,
Limit = limit
});
return rows.Select(MapToSignal).ToList();
}
public async Task<IReadOnlyList<EpssSignal>> GetHighPriorityAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId
AND model_date >= @StartDate
AND model_date <= @EndDate
AND risk_band IN ('CRITICAL', 'HIGH')
ORDER BY model_date DESC, created_at DESC
LIMIT 10000
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<SignalRow>(sql, new
{
TenantId = tenantId,
StartDate = startDate.ToDateTime(TimeOnly.MinValue),
EndDate = endDate.ToDateTime(TimeOnly.MinValue)
});
return rows.Select(MapToSignal).ToList();
}
public async Task<EpssSignalConfig?> GetConfigAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT
config_id, tenant_id,
critical_percentile, high_percentile, medium_percentile,
big_jump_delta, suppress_on_model_change, enabled_event_types,
created_at, updated_at
FROM {ConfigTable}
WHERE tenant_id = @TenantId
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<ConfigRow?>(sql, new { TenantId = tenantId });
return row.HasValue ? MapToConfig(row.Value) : null;
}
public async Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(config);
var sql = $"""
INSERT INTO {ConfigTable} (
tenant_id, critical_percentile, high_percentile, medium_percentile,
big_jump_delta, suppress_on_model_change, enabled_event_types
)
VALUES (
@TenantId, @CriticalPercentile, @HighPercentile, @MediumPercentile,
@BigJumpDelta, @SuppressOnModelChange, @EnabledEventTypes
)
ON CONFLICT (tenant_id) DO UPDATE SET
critical_percentile = EXCLUDED.critical_percentile,
high_percentile = EXCLUDED.high_percentile,
medium_percentile = EXCLUDED.medium_percentile,
big_jump_delta = EXCLUDED.big_jump_delta,
suppress_on_model_change = EXCLUDED.suppress_on_model_change,
enabled_event_types = EXCLUDED.enabled_event_types,
updated_at = now()
RETURNING config_id, created_at, updated_at
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var result = await connection.QueryFirstAsync<(Guid config_id, DateTimeOffset created_at, DateTimeOffset updated_at)>(sql, new
{
config.TenantId,
config.CriticalPercentile,
config.HighPercentile,
config.MediumPercentile,
config.BigJumpDelta,
config.SuppressOnModelChange,
EnabledEventTypes = config.EnabledEventTypes.ToArray()
});
return config with
{
ConfigId = result.config_id,
CreatedAt = result.created_at,
UpdatedAt = result.updated_at
};
}
public async Task<int> PruneAsync(int retentionDays = 90, CancellationToken cancellationToken = default)
{
var sql = $"SELECT {SchemaName}.prune_epss_signals(@RetentionDays)";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<int>(sql, new { RetentionDays = retentionDays });
}
private async Task<EpssSignal?> GetByDedupeKeyAsync(Guid tenantId, string dedupeKey, CancellationToken cancellationToken)
{
var sql = $"""
SELECT
signal_id, tenant_id, model_date, cve_id, event_type, risk_band,
epss_score, epss_delta, percentile, percentile_delta,
is_model_change, model_version, dedupe_key, explain_hash, payload, created_at
FROM {SignalTable}
WHERE tenant_id = @TenantId AND dedupe_key = @DedupeKey
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var row = await connection.QueryFirstOrDefaultAsync<SignalRow?>(sql, new { TenantId = tenantId, DedupeKey = dedupeKey });
return row.HasValue ? MapToSignal(row.Value) : null;
}
private static EpssSignal MapToSignal(SignalRow row)
{
return new EpssSignal
{
SignalId = row.signal_id,
TenantId = row.tenant_id,
ModelDate = DateOnly.FromDateTime(row.model_date),
CveId = row.cve_id,
EventType = row.event_type,
RiskBand = row.risk_band,
EpssScore = row.epss_score,
EpssDelta = row.epss_delta,
Percentile = row.percentile,
PercentileDelta = row.percentile_delta,
IsModelChange = row.is_model_change,
ModelVersion = row.model_version,
DedupeKey = row.dedupe_key,
ExplainHash = row.explain_hash,
Payload = row.payload,
CreatedAt = row.created_at
};
}
private static EpssSignalConfig MapToConfig(ConfigRow row)
{
return new EpssSignalConfig
{
ConfigId = row.config_id,
TenantId = row.tenant_id,
CriticalPercentile = row.critical_percentile,
HighPercentile = row.high_percentile,
MediumPercentile = row.medium_percentile,
BigJumpDelta = row.big_jump_delta,
SuppressOnModelChange = row.suppress_on_model_change,
EnabledEventTypes = row.enabled_event_types ?? Array.Empty<string>(),
CreatedAt = row.created_at,
UpdatedAt = row.updated_at
};
}
private readonly record struct SignalRow(
long signal_id,
Guid tenant_id,
DateTime model_date,
string cve_id,
string event_type,
string? risk_band,
double? epss_score,
double? epss_delta,
double? percentile,
double? percentile_delta,
bool is_model_change,
string? model_version,
string dedupe_key,
byte[] explain_hash,
string payload,
DateTimeOffset created_at);
private readonly record struct ConfigRow(
Guid config_id,
Guid tenant_id,
double critical_percentile,
double high_percentile,
double medium_percentile,
double big_jump_delta,
bool suppress_on_model_change,
string[]? enabled_event_types,
DateTimeOffset created_at,
DateTimeOffset updated_at);
}

View File

@@ -0,0 +1,152 @@
// -----------------------------------------------------------------------------
// PostgresObservedCveRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S6 - Add observed CVEs filter
// Description: PostgreSQL implementation of IObservedCveRepository.
// -----------------------------------------------------------------------------
using Dapper;
using StellaOps.Scanner.Storage.Repositories;
namespace StellaOps.Scanner.Storage.Postgres;
/// <summary>
/// PostgreSQL implementation of <see cref="IObservedCveRepository"/>.
/// Queries vuln_instance_triage to determine which CVEs are observed per tenant.
/// </summary>
public sealed class PostgresObservedCveRepository : IObservedCveRepository
{
private readonly ScannerDataSource _dataSource;
private string SchemaName => _dataSource.SchemaName ?? ScannerDataSource.DefaultSchema;
private string TriageTable => $"{SchemaName}.vuln_instance_triage";
public PostgresObservedCveRepository(ScannerDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
public async Task<IReadOnlySet<string>> GetObservedCvesAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id IS NOT NULL
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var cves = await connection.QueryAsync<string>(sql, new { TenantId = tenantId });
return new HashSet<string>(cves, StringComparer.OrdinalIgnoreCase);
}
public async Task<bool> IsObservedAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT EXISTS (
SELECT 1 FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = @CveId
)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
return await connection.ExecuteScalarAsync<bool>(sql, new { TenantId = tenantId, CveId = cveId });
}
public async Task<IReadOnlySet<string>> FilterObservedAsync(
Guid tenantId,
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
var cveList = cveIds.ToList();
if (cveList.Count == 0)
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var sql = $"""
SELECT DISTINCT cve_id
FROM {TriageTable}
WHERE tenant_id = @TenantId
AND cve_id = ANY(@CveIds)
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var observed = await connection.QueryAsync<string>(sql, new
{
TenantId = tenantId,
CveIds = cveList.ToArray()
});
return new HashSet<string>(observed, StringComparer.OrdinalIgnoreCase);
}
public async Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(
CancellationToken cancellationToken = default)
{
var sql = $"""
SELECT DISTINCT tenant_id
FROM {TriageTable}
WHERE cve_id IS NOT NULL
AND cve_id LIKE 'CVE-%'
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var tenants = await connection.QueryAsync<Guid>(sql);
return tenants.ToList();
}
public async Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default)
{
var cveList = cveIds.ToList();
if (cveList.Count == 0)
{
return new Dictionary<string, IReadOnlyList<Guid>>(StringComparer.OrdinalIgnoreCase);
}
var sql = $"""
SELECT cve_id, tenant_id
FROM {TriageTable}
WHERE cve_id = ANY(@CveIds)
GROUP BY cve_id, tenant_id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
var rows = await connection.QueryAsync<(string cve_id, Guid tenant_id)>(sql, new
{
CveIds = cveList.ToArray()
});
var result = new Dictionary<string, List<Guid>>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
if (!result.TryGetValue(row.cve_id, out var tenants))
{
tenants = new List<Guid>();
result[row.cve_id] = tenants;
}
if (!tenants.Contains(row.tenant_id))
{
tenants.Add(row.tenant_id);
}
}
return result.ToDictionary(
kvp => kvp.Key,
kvp => (IReadOnlyList<Guid>)kvp.Value,
StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,152 @@
// -----------------------------------------------------------------------------
// IEpssRawRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: R1-R4 - EPSS Raw Feed Layer
// Description: Repository interface for immutable EPSS raw payload storage.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for immutable EPSS raw payload storage.
/// Layer 1 of the 3-layer EPSS architecture: stores full CSV payload as JSONB.
/// </summary>
public interface IEpssRawRepository
{
/// <summary>
/// Stores a raw EPSS payload.
/// </summary>
/// <param name="raw">The raw payload to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The stored record with generated ID.</returns>
Task<EpssRaw> CreateAsync(EpssRaw raw, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a raw payload by as-of date.
/// </summary>
/// <param name="asOfDate">The date of the EPSS snapshot.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw payload, or null if not found.</returns>
Task<EpssRaw?> GetByDateAsync(DateOnly asOfDate, CancellationToken cancellationToken = default);
/// <summary>
/// Gets raw payloads within a date range.
/// </summary>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of raw payloads ordered by date descending.</returns>
Task<IReadOnlyList<EpssRaw>> GetByDateRangeAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the most recent raw payload.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The most recent raw payload, or null if none exist.</returns>
Task<EpssRaw?> GetLatestAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a raw payload exists for a given date and content hash.
/// Used for idempotency checks.
/// </summary>
/// <param name="asOfDate">The date of the EPSS snapshot.</param>
/// <param name="payloadSha256">SHA-256 hash of decompressed content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the payload already exists.</returns>
Task<bool> ExistsAsync(DateOnly asOfDate, byte[] payloadSha256, CancellationToken cancellationToken = default);
/// <summary>
/// Gets payloads by model version.
/// Useful for detecting model version changes.
/// </summary>
/// <param name="modelVersion">The model version string.</param>
/// <param name="limit">Maximum number of records to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of raw payloads with the specified model version.</returns>
Task<IReadOnlyList<EpssRaw>> GetByModelVersionAsync(
string modelVersion,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Prunes old raw payloads based on retention policy.
/// </summary>
/// <param name="retentionDays">Number of days to retain. Default: 365.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of records deleted.</returns>
Task<int> PruneAsync(int retentionDays = 365, CancellationToken cancellationToken = default);
}
/// <summary>
/// EPSS raw payload entity.
/// </summary>
public sealed record EpssRaw
{
/// <summary>
/// Raw record ID (auto-generated).
/// </summary>
public long RawId { get; init; }
/// <summary>
/// Source URI where the data was retrieved from.
/// </summary>
public required string SourceUri { get; init; }
/// <summary>
/// Date of the EPSS snapshot.
/// </summary>
public required DateOnly AsOfDate { get; init; }
/// <summary>
/// Timestamp when the data was ingested.
/// </summary>
public DateTimeOffset IngestionTs { get; init; }
/// <summary>
/// Full payload as JSON array: [{cve:"CVE-...", epss:0.123, percentile:0.456}, ...].
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// SHA-256 hash of decompressed content for integrity verification.
/// </summary>
public required byte[] PayloadSha256 { get; init; }
/// <summary>
/// Raw comment line from CSV header (e.g., "# model: v2025.03.14, published: 2025-03-14").
/// </summary>
public string? HeaderComment { get; init; }
/// <summary>
/// Extracted model version from header comment.
/// </summary>
public string? ModelVersion { get; init; }
/// <summary>
/// Extracted publish date from header comment.
/// </summary>
public DateOnly? PublishedDate { get; init; }
/// <summary>
/// Number of rows in the payload.
/// </summary>
public required int RowCount { get; init; }
/// <summary>
/// Original compressed file size (bytes).
/// </summary>
public long? CompressedSize { get; init; }
/// <summary>
/// Decompressed CSV size (bytes).
/// </summary>
public long? DecompressedSize { get; init; }
/// <summary>
/// Reference to the import run that created this record.
/// </summary>
public Guid? ImportRunId { get; init; }
}

View File

@@ -5,6 +5,7 @@
// Description: EPSS persistence contract (import runs, scores/current projection, change log).
// -----------------------------------------------------------------------------
using StellaOps.Scanner.Core.Epss;
using StellaOps.Scanner.Storage.Epss;
namespace StellaOps.Scanner.Storage.Repositories;
@@ -54,6 +55,21 @@ public interface IEpssRepository
string cveId,
int days,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets EPSS change records for a model date, optionally filtered by flags.
/// Used by enrichment job to target only CVEs with material changes.
/// </summary>
/// <param name="modelDate">The EPSS model date.</param>
/// <param name="flags">Change flags to filter by. Null returns all changes.</param>
/// <param name="limit">Maximum number of records to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of change records matching the criteria.</returns>
Task<IReadOnlyList<EpssChangeRecord>> GetChangesAsync(
DateOnly modelDate,
EpssChangeFlags? flags = null,
int limit = 100000,
CancellationToken cancellationToken = default);
}
public sealed record EpssImportRun(

View File

@@ -0,0 +1,242 @@
// -----------------------------------------------------------------------------
// IEpssSignalRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S2 - Implement IEpssSignalRepository interface
// Description: Repository interface for EPSS signal-ready events.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for EPSS signal-ready events (tenant-scoped).
/// </summary>
public interface IEpssSignalRepository
{
/// <summary>
/// Creates a new EPSS signal.
/// </summary>
/// <param name="signal">The signal to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created signal with generated ID.</returns>
Task<EpssSignal> CreateAsync(EpssSignal signal, CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple EPSS signals in bulk.
/// Uses upsert with dedupe_key to prevent duplicates.
/// </summary>
/// <param name="signals">The signals to create.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of signals created (excluding duplicates).</returns>
Task<int> CreateBulkAsync(IEnumerable<EpssSignal> signals, CancellationToken cancellationToken = default);
/// <summary>
/// Gets signals for a tenant within a date range.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="eventTypes">Optional filter by event types.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of signals ordered by model_date descending.</returns>
Task<IReadOnlyList<EpssSignal>> GetByTenantAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
IEnumerable<string>? eventTypes = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets signals for a specific CVE within a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="limit">Maximum number of signals to return.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of signals ordered by model_date descending.</returns>
Task<IReadOnlyList<EpssSignal>> GetByCveAsync(
Guid tenantId,
string cveId,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets high-priority signals (CRITICAL/HIGH band) for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="startDate">Start date (inclusive).</param>
/// <param name="endDate">End date (inclusive).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of high-priority signals.</returns>
Task<IReadOnlyList<EpssSignal>> GetHighPriorityAsync(
Guid tenantId,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the signal configuration for a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The configuration, or null if not configured.</returns>
Task<EpssSignalConfig?> GetConfigAsync(Guid tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Upserts the signal configuration for a tenant.
/// </summary>
/// <param name="config">The configuration to upsert.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The upserted configuration.</returns>
Task<EpssSignalConfig> UpsertConfigAsync(EpssSignalConfig config, CancellationToken cancellationToken = default);
/// <summary>
/// Prunes old signals based on retention policy.
/// </summary>
/// <param name="retentionDays">Number of days to retain. Default: 90.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of signals deleted.</returns>
Task<int> PruneAsync(int retentionDays = 90, CancellationToken cancellationToken = default);
}
/// <summary>
/// EPSS signal entity.
/// </summary>
public sealed record EpssSignal
{
/// <summary>
/// Signal ID (auto-generated).
/// </summary>
public long SignalId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// EPSS model date.
/// </summary>
public required DateOnly ModelDate { get; init; }
/// <summary>
/// CVE identifier.
/// </summary>
public required string CveId { get; init; }
/// <summary>
/// Event type: RISK_SPIKE, BAND_CHANGE, NEW_HIGH, DROPPED_LOW, MODEL_UPDATED.
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// Risk band: CRITICAL, HIGH, MEDIUM, LOW.
/// </summary>
public string? RiskBand { get; init; }
/// <summary>
/// EPSS score at signal time.
/// </summary>
public double? EpssScore { get; init; }
/// <summary>
/// EPSS score delta from previous day.
/// </summary>
public double? EpssDelta { get; init; }
/// <summary>
/// EPSS percentile at signal time.
/// </summary>
public double? Percentile { get; init; }
/// <summary>
/// Percentile delta from previous day.
/// </summary>
public double? PercentileDelta { get; init; }
/// <summary>
/// Whether this is a model version change day.
/// </summary>
public bool IsModelChange { get; init; }
/// <summary>
/// EPSS model version.
/// </summary>
public string? ModelVersion { get; init; }
/// <summary>
/// Deterministic deduplication key.
/// </summary>
public required string DedupeKey { get; init; }
/// <summary>
/// SHA-256 of signal inputs for audit trail.
/// </summary>
public required byte[] ExplainHash { get; init; }
/// <summary>
/// Full evidence payload as JSON.
/// </summary>
public required string Payload { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// EPSS signal configuration for a tenant.
/// </summary>
public sealed record EpssSignalConfig
{
/// <summary>
/// Configuration ID.
/// </summary>
public Guid ConfigId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
public required Guid TenantId { get; init; }
/// <summary>
/// Critical percentile threshold. Default: 0.995.
/// </summary>
public double CriticalPercentile { get; init; } = 0.995;
/// <summary>
/// High percentile threshold. Default: 0.99.
/// </summary>
public double HighPercentile { get; init; } = 0.99;
/// <summary>
/// Medium percentile threshold. Default: 0.90.
/// </summary>
public double MediumPercentile { get; init; } = 0.90;
/// <summary>
/// Big jump delta threshold. Default: 0.10.
/// </summary>
public double BigJumpDelta { get; init; } = 0.10;
/// <summary>
/// Suppress signals on model version change. Default: true.
/// </summary>
public bool SuppressOnModelChange { get; init; } = true;
/// <summary>
/// Enabled event types.
/// </summary>
public IReadOnlyList<string> EnabledEventTypes { get; init; } =
new[] { "RISK_SPIKE", "BAND_CHANGE", "NEW_HIGH" };
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// Last update timestamp.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,101 @@
// -----------------------------------------------------------------------------
// IObservedCveRepository.cs
// Sprint: SPRINT_3413_0001_0001_epss_live_enrichment
// Task: S6 - Add observed CVEs filter
// Description: Repository interface for tracking observed CVEs per tenant.
// -----------------------------------------------------------------------------
namespace StellaOps.Scanner.Storage.Repositories;
/// <summary>
/// Repository for tracking which CVEs are observed (in use) by each tenant.
/// Used to filter EPSS signals to only relevant CVEs.
/// </summary>
public interface IObservedCveRepository
{
/// <summary>
/// Gets the set of CVE IDs that are currently observed by a tenant.
/// Only CVEs that exist in the tenant's vulnerability inventory.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Set of observed CVE IDs.</returns>
Task<IReadOnlySet<string>> GetObservedCvesAsync(
Guid tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks if a CVE is observed by a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveId">CVE identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the CVE is observed.</returns>
Task<bool> IsObservedAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Filters a set of CVE IDs to only those observed by a tenant.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="cveIds">CVE IDs to filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Filtered set of observed CVE IDs.</returns>
Task<IReadOnlySet<string>> FilterObservedAsync(
Guid tenantId,
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all tenant IDs that have at least one observed CVE.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of tenant IDs.</returns>
Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Gets tenant IDs that observe specific CVEs.
/// Used for targeted signal delivery.
/// </summary>
/// <param name="cveIds">CVE IDs to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping CVE ID to list of tenant IDs observing it.</returns>
Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(
IEnumerable<string> cveIds,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Null implementation of IObservedCveRepository for when tenant filtering is disabled.
/// Returns all CVEs as observed.
/// </summary>
public sealed class NullObservedCveRepository : IObservedCveRepository
{
public static readonly NullObservedCveRepository Instance = new();
private NullObservedCveRepository() { }
public Task<IReadOnlySet<string>> GetObservedCvesAsync(Guid tenantId, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
public Task<bool> IsObservedAsync(Guid tenantId, string cveId, CancellationToken cancellationToken = default)
=> Task.FromResult(true); // All CVEs are observed when filtering is disabled
public Task<IReadOnlySet<string>> FilterObservedAsync(Guid tenantId, IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(cveIds, StringComparer.OrdinalIgnoreCase));
public Task<IReadOnlyList<Guid>> GetActiveTenantsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult<IReadOnlyList<Guid>>(new[] { Guid.Empty });
public Task<IReadOnlyDictionary<string, IReadOnlyList<Guid>>> GetTenantsObservingCvesAsync(IEnumerable<string> cveIds, CancellationToken cancellationToken = default)
{
var result = cveIds.ToDictionary(
cve => cve,
_ => (IReadOnlyList<Guid>)new[] { Guid.Empty },
StringComparer.OrdinalIgnoreCase);
return Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<Guid>>>(result);
}
}

View File

@@ -27,5 +27,6 @@
<ProjectReference Include="..\\StellaOps.Scanner.ReachabilityDrift\\StellaOps.Scanner.ReachabilityDrift.csproj" />
<ProjectReference Include="..\\StellaOps.Scanner.SmartDiff\\StellaOps.Scanner.SmartDiff.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Infrastructure.Postgres\\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Messaging\\StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// CecilMethodFingerprinterTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Unit tests for CecilMethodFingerprinter.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class CecilMethodFingerprinterTests
{
private readonly CecilMethodFingerprinter _fingerprinter;
public CecilMethodFingerprinterTests()
{
_fingerprinter = new CecilMethodFingerprinter(
NullLogger<CecilMethodFingerprinter>.Instance);
}
[Fact]
public void Ecosystem_ReturnsNuget()
{
Assert.Equal("nuget", _fingerprinter.Ecosystem);
}
[Fact]
public async Task FingerprintAsync_WithNullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _fingerprinter.FingerprintAsync(null!));
}
[Fact]
public async Task FingerprintAsync_WithNonExistentPath_ReturnsEmptyResult()
{
// Arrange
var request = new FingerprintRequest
{
PackagePath = "/nonexistent/path/to/package",
PackageName = "nonexistent",
Version = "1.0.0"
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.Empty(result.Methods);
}
[Fact]
public async Task FingerprintAsync_WithOwnAssembly_FindsMethods()
{
// Arrange - use the test assembly itself
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotEmpty(result.Methods);
// Should find this test class
Assert.True(result.Methods.Count > 0, "Should find at least some methods");
}
[Fact]
public async Task FingerprintAsync_ComputesDeterministicHashes()
{
// Arrange - fingerprint twice
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result1 = await _fingerprinter.FingerprintAsync(request);
var result2 = await _fingerprinter.FingerprintAsync(request);
// Assert - same methods should produce same hashes
Assert.Equal(result1.Methods.Count, result2.Methods.Count);
foreach (var (key, fp1) in result1.Methods)
{
Assert.True(result2.Methods.TryGetValue(key, out var fp2));
Assert.Equal(fp1.BodyHash, fp2.BodyHash);
}
}
[Fact]
public async Task FingerprintAsync_WithCancellation_RespectsCancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0"
};
// Act - operation may either throw or return early
// since the token is already cancelled
try
{
await _fingerprinter.FingerprintAsync(request, cts.Token);
// If it doesn't throw, that's also acceptable behavior
// The key is that it should respect the cancellation token
Assert.True(true, "Method completed without throwing - acceptable if it checks token");
}
catch (OperationCanceledException)
{
// Expected behavior
Assert.True(true);
}
}
[Fact]
public async Task FingerprintAsync_MethodKeyFormat_IsValid()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - keys should not be empty
foreach (var key in result.Methods.Keys)
{
Assert.NotEmpty(key);
// Method keys use "::" separator between type and method
// Some may be anonymous types like "<>f__AnonymousType0`2"
// Just verify they're non-empty and have reasonable format
Assert.True(key.Contains("::") || key.Contains("."),
$"Method key should contain :: or . separator: {key}");
}
}
[Fact]
public async Task FingerprintAsync_IncludesSignature()
{
// Arrange
var testAssemblyPath = typeof(CecilMethodFingerprinterTests).Assembly.Location;
var assemblyDir = Path.GetDirectoryName(testAssemblyPath)!;
var request = new FingerprintRequest
{
PackagePath = assemblyDir,
PackageName = "test",
Version = "1.0.0",
IncludePrivateMethods = false
};
// Act
var result = await _fingerprinter.FingerprintAsync(request);
// Assert - fingerprints should have signatures
var anyWithSignature = result.Methods.Values.Any(fp => !string.IsNullOrEmpty(fp.Signature));
Assert.True(anyWithSignature, "At least some methods should have signatures");
}
}

View File

@@ -0,0 +1,348 @@
// -----------------------------------------------------------------------------
// MethodDiffEngineTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Description: Unit tests for MethodDiffEngine.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class MethodDiffEngineTests
{
private readonly MethodDiffEngine _diffEngine;
public MethodDiffEngineTests()
{
_diffEngine = new MethodDiffEngine(
NullLogger<MethodDiffEngine>.Instance);
}
[Fact]
public async Task DiffAsync_WithNullRequest_ThrowsArgumentNullException()
{
await Assert.ThrowsAsync<ArgumentNullException>(
() => _diffEngine.DiffAsync(null!));
}
[Fact]
public async Task DiffAsync_WithIdenticalFingerprints_ReturnsNoChanges()
{
// Arrange
var fingerprint = CreateFingerprint("Test.Class::Method", "sha256:abc123");
var result1 = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fingerprint.MethodKey] = fingerprint
}
};
var result2 = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fingerprint.MethodKey] = fingerprint
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = result1,
FixedFingerprints = result2
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
Assert.Equal(0, diff.TotalChanges);
}
[Fact]
public async Task DiffAsync_WithModifiedMethod_ReturnsModified()
{
// Arrange
var vulnFp = CreateFingerprint("Test.Class::Method", "sha256:old_hash");
var fixedFp = CreateFingerprint("Test.Class::Method", "sha256:new_hash");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[fixedFp.MethodKey] = fixedFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Equal("Test.Class::Method", diff.Modified[0].MethodKey);
Assert.Equal("sha256:old_hash", diff.Modified[0].VulnVersion.BodyHash);
Assert.Equal("sha256:new_hash", diff.Modified[0].FixedVersion.BodyHash);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithAddedMethod_ReturnsAdded()
{
// Arrange
var vulnFp = CreateFingerprint("Test.Class::ExistingMethod", "sha256:existing");
var newFp = CreateFingerprint("Test.Class::NewMethod", "sha256:new_method");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[vulnFp.MethodKey] = vulnFp,
[newFp.MethodKey] = newFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Single(diff.Added);
Assert.Equal("Test.Class::NewMethod", diff.Added[0].MethodKey);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithRemovedMethod_ReturnsRemoved()
{
// Arrange
var existingFp = CreateFingerprint("Test.Class::ExistingMethod", "sha256:existing");
var removedFp = CreateFingerprint("Test.Class::RemovedMethod", "sha256:removed");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[existingFp.MethodKey] = existingFp,
[removedFp.MethodKey] = removedFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[existingFp.MethodKey] = existingFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Empty(diff.Modified);
Assert.Empty(diff.Added);
Assert.Single(diff.Removed);
Assert.Equal("Test.Class::RemovedMethod", diff.Removed[0].MethodKey);
}
[Fact]
public async Task DiffAsync_WithMultipleChanges_ReturnsAllChanges()
{
// Arrange - simulate a fix that modifies one method, adds one, removes one
var unchangedFp = CreateFingerprint("Test::Unchanged", "h1");
var modifiedVuln = CreateFingerprint("Test::Modified", "old");
var modifiedFixed = CreateFingerprint("Test::Modified", "new");
var removedFp = CreateFingerprint("Test::Removed", "h2");
var addedFp = CreateFingerprint("Test::Added", "h3");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[unchangedFp.MethodKey] = unchangedFp,
[modifiedVuln.MethodKey] = modifiedVuln,
[removedFp.MethodKey] = removedFp
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[unchangedFp.MethodKey] = unchangedFp,
[modifiedFixed.MethodKey] = modifiedFixed,
[addedFp.MethodKey] = addedFp
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Single(diff.Added);
Assert.Single(diff.Removed);
Assert.Equal(3, diff.TotalChanges);
}
[Fact]
public async Task DiffAsync_TriggerMethods_AreModifiedOrRemoved()
{
// This test validates the key insight:
// Trigger methods (the vulnerable entry points) are typically MODIFIED or REMOVED in a fix
// They wouldn't be ADDED in the fixed version
// Arrange
var triggerMethodVuln = CreateFingerprint(
"Newtonsoft.Json.JsonConvert::DeserializeObject",
"sha256:vulnerable_impl");
var triggerMethodFixed = CreateFingerprint(
"Newtonsoft.Json.JsonConvert::DeserializeObject",
"sha256:patched_impl");
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[triggerMethodVuln.MethodKey] = triggerMethodVuln
}
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>
{
[triggerMethodFixed.MethodKey] = triggerMethodFixed
}
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert - the trigger method should show as modified
Assert.True(diff.Success);
Assert.Single(diff.Modified);
Assert.Equal("Newtonsoft.Json.JsonConvert::DeserializeObject", diff.Modified[0].MethodKey);
Assert.Empty(diff.Added);
Assert.Empty(diff.Removed);
}
[Fact]
public async Task DiffAsync_WithEmptyFingerprints_ReturnsNoChanges()
{
// Arrange
var vulnResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>()
};
var fixedResult = new FingerprintResult
{
Success = true,
Methods = new Dictionary<string, MethodFingerprint>()
};
var request = new MethodDiffRequest
{
VulnFingerprints = vulnResult,
FixedFingerprints = fixedResult
};
// Act
var diff = await _diffEngine.DiffAsync(request);
// Assert
Assert.True(diff.Success);
Assert.Equal(0, diff.TotalChanges);
}
private static MethodFingerprint CreateFingerprint(string methodKey, string bodyHash)
{
var parts = methodKey.Split("::");
var declaringType = parts.Length > 1 ? parts[0] : "Unknown";
var name = parts.Length > 1 ? parts[1] : parts[0];
return new MethodFingerprint
{
MethodKey = methodKey,
DeclaringType = declaringType,
Name = name,
BodyHash = bodyHash,
Signature = $"void {name}()",
IsPublic = true,
BodySize = 100
};
}
}

View File

@@ -0,0 +1,362 @@
// -----------------------------------------------------------------------------
// NuGetPackageDownloaderTests.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-020
// Description: Unit tests for NuGetPackageDownloader.
// -----------------------------------------------------------------------------
using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Protected;
using StellaOps.Scanner.VulnSurfaces.Download;
using Xunit;
namespace StellaOps.Scanner.VulnSurfaces.Tests;
public class NuGetPackageDownloaderTests : IDisposable
{
private readonly string _testOutputDir;
public NuGetPackageDownloaderTests()
{
_testOutputDir = Path.Combine(Path.GetTempPath(), $"nuget-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testOutputDir);
}
public void Dispose()
{
if (Directory.Exists(_testOutputDir))
{
try { Directory.Delete(_testOutputDir, recursive: true); }
catch { /* ignore cleanup failures */ }
}
}
[Fact]
public void Ecosystem_ReturnsNuget()
{
// Arrange
var downloader = CreateDownloader();
// Assert
Assert.Equal("nuget", downloader.Ecosystem);
}
[Fact]
public async Task DownloadAsync_WithNullRequest_ThrowsArgumentNullException()
{
// Arrange
var downloader = CreateDownloader();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(
() => downloader.DownloadAsync(null!));
}
[Fact]
public async Task DownloadAsync_WithHttpError_ReturnsFailResult()
{
// Arrange
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound,
ReasonPhrase = "Not Found"
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "NonExistent.Package",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.False(result.Success);
Assert.Contains("404", result.Error ?? "");
Assert.Null(result.ExtractedPath);
}
[Fact]
public async Task DownloadAsync_WithValidNupkg_ReturnsSuccessResult()
{
// Arrange - create a mock .nupkg (which is just a zip file)
var nupkgContent = CreateMinimalNupkg();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new ByteArrayContent(nupkgContent)
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.NotNull(result.ExtractedPath);
Assert.NotNull(result.ArchivePath);
Assert.True(Directory.Exists(result.ExtractedPath));
Assert.True(File.Exists(result.ArchivePath));
Assert.False(result.FromCache);
}
[Fact]
public async Task DownloadAsync_WithCachedPackage_ReturnsCachedResult()
{
// Arrange - pre-create the cached directory
var packageDir = Path.Combine(_testOutputDir, "testpackage.1.0.0");
Directory.CreateDirectory(packageDir);
File.WriteAllText(Path.Combine(packageDir, "marker.txt"), "cached");
var downloader = CreateDownloader();
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = true
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.True(result.FromCache);
Assert.Equal(packageDir, result.ExtractedPath);
}
[Fact]
public async Task DownloadAsync_WithCacheFalse_BypassesCache()
{
// Arrange - pre-create the cached directory
var packageDir = Path.Combine(_testOutputDir, "testpackage.2.0.0");
Directory.CreateDirectory(packageDir);
// Set up mock to return content (we're bypassing cache)
var nupkgContent = CreateMinimalNupkg();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new ByteArrayContent(nupkgContent)
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "2.0.0",
OutputDirectory = _testOutputDir,
UseCache = false // Bypass cache
};
// Act
var result = await downloader.DownloadAsync(request);
// Assert
Assert.True(result.Success);
Assert.False(result.FromCache);
// Verify HTTP call was made
mockHandler.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
public async Task DownloadAsync_UsesCorrectUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "Newtonsoft.Json",
Version = "13.0.3",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
await downloader.DownloadAsync(request);
// Assert
Assert.NotNull(capturedRequest);
Assert.Contains("newtonsoft.json", capturedRequest.RequestUri!.ToString());
Assert.Contains("13.0.3", capturedRequest.RequestUri!.ToString());
Assert.EndsWith(".nupkg", capturedRequest.RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WithCustomRegistry_UsesCustomUrl()
{
// Arrange
HttpRequestMessage? capturedRequest = null;
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedRequest = req)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.NotFound
});
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
RegistryUrl = "https://custom.nuget.feed.example.com/v3",
UseCache = false
};
// Act
await downloader.DownloadAsync(request);
// Assert
Assert.NotNull(capturedRequest);
Assert.StartsWith("https://custom.nuget.feed.example.com/v3", capturedRequest.RequestUri!.ToString());
}
[Fact]
public async Task DownloadAsync_WithCancellation_HonorsCancellation()
{
// Arrange
using var cts = new CancellationTokenSource();
cts.Cancel();
var mockHandler = new Mock<HttpMessageHandler>();
mockHandler
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new TaskCanceledException());
var httpClient = new HttpClient(mockHandler.Object);
var downloader = CreateDownloader(httpClient);
var request = new PackageDownloadRequest
{
PackageName = "TestPackage",
Version = "1.0.0",
OutputDirectory = _testOutputDir,
UseCache = false
};
// Act
var result = await downloader.DownloadAsync(request, cts.Token);
// Assert - should return failure, not throw
Assert.False(result.Success);
Assert.Contains("cancel", result.Error?.ToLower() ?? "");
}
private NuGetPackageDownloader CreateDownloader(HttpClient? httpClient = null)
{
var client = httpClient ?? new HttpClient();
var options = Options.Create(new NuGetDownloaderOptions());
return new NuGetPackageDownloader(
client,
NullLogger<NuGetPackageDownloader>.Instance,
options);
}
private static byte[] CreateMinimalNupkg()
{
// Create a minimal valid ZIP file (which is what a .nupkg is)
using var ms = new MemoryStream();
using (var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
{
// Add a minimal .nuspec file
var nuspecEntry = archive.CreateEntry("test.nuspec");
using var writer = new StreamWriter(nuspecEntry.Open());
writer.Write("""
<?xml version="1.0"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>TestPackage</id>
<version>1.0.0</version>
<authors>Test</authors>
<description>Test package</description>
</metadata>
</package>
""");
}
return ms.ToArray();
}
}

View File

@@ -13,6 +13,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.VulnSurfaces.CallGraph;
using StellaOps.Scanner.VulnSurfaces.Diagnostics;
using StellaOps.Scanner.VulnSurfaces.Download;
using StellaOps.Scanner.VulnSurfaces.Fingerprint;
using StellaOps.Scanner.VulnSurfaces.Models;
@@ -56,6 +57,12 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
ArgumentNullException.ThrowIfNull(request);
var sw = Stopwatch.StartNew();
var tags = new KeyValuePair<string, object?>[]
{
new("ecosystem", request.Ecosystem.ToLowerInvariant())
};
VulnSurfaceMetrics.BuildRequests.Add(1, tags);
_logger.LogInformation(
"Building vulnerability surface for {CveId}: {Package} {VulnVersion} → {FixedVersion}",
@@ -87,6 +94,8 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
Directory.CreateDirectory(workDir);
// 3. Download both versions
VulnSurfaceMetrics.DownloadAttempts.Add(2, tags); // Two versions
var vulnDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
@@ -98,9 +107,14 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!vulnDownload.Success)
{
sw.Stop();
VulnSurfaceMetrics.DownloadFailures.Add(1, tags);
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "download_vuln") });
return VulnSurfaceBuildResult.Fail($"Failed to download vulnerable version: {vulnDownload.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DownloadSuccesses.Add(1, tags);
VulnSurfaceMetrics.DownloadDurationSeconds.Record(vulnDownload.Duration.TotalSeconds, tags);
var fixedDownload = await downloader.DownloadAsync(new PackageDownloadRequest
{
PackageName = request.PackageName,
@@ -112,10 +126,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!fixedDownload.Success)
{
sw.Stop();
VulnSurfaceMetrics.DownloadFailures.Add(1, tags);
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "download_fixed") });
return VulnSurfaceBuildResult.Fail($"Failed to download fixed version: {fixedDownload.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DownloadSuccesses.Add(1, tags);
VulnSurfaceMetrics.DownloadDurationSeconds.Record(fixedDownload.Duration.TotalSeconds, tags);
// 4. Fingerprint both versions
var fpSw = Stopwatch.StartNew();
var vulnFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = vulnDownload.ExtractedPath!,
@@ -126,9 +146,15 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!vulnFingerprints.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "fingerprint_vuln") });
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint vulnerable version: {vulnFingerprints.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.FingerprintDurationSeconds.Record(fpSw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.MethodsFingerprinted.Add(vulnFingerprints.Methods.Count, tags);
VulnSurfaceMetrics.MethodsPerPackage.Record(vulnFingerprints.Methods.Count, tags);
fpSw.Restart();
var fixedFingerprints = await fingerprinter.FingerprintAsync(new FingerprintRequest
{
PackagePath = fixedDownload.ExtractedPath!,
@@ -139,10 +165,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!fixedFingerprints.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "fingerprint_fixed") });
return VulnSurfaceBuildResult.Fail($"Failed to fingerprint fixed version: {fixedFingerprints.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.FingerprintDurationSeconds.Record(fpSw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.MethodsFingerprinted.Add(fixedFingerprints.Methods.Count, tags);
VulnSurfaceMetrics.MethodsPerPackage.Record(fixedFingerprints.Methods.Count, tags);
// 5. Compute diff
var diffSw = Stopwatch.StartNew();
var diff = await _diffEngine.DiffAsync(new MethodDiffRequest
{
VulnFingerprints = vulnFingerprints,
@@ -152,9 +184,12 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
if (!diff.Success)
{
sw.Stop();
VulnSurfaceMetrics.BuildFailures.Add(1, new KeyValuePair<string, object?>[] { new("ecosystem", request.Ecosystem.ToLowerInvariant()), new("reason", "diff") });
return VulnSurfaceBuildResult.Fail($"Failed to compute diff: {diff.Error}", sw.Elapsed);
}
VulnSurfaceMetrics.DiffDurationSeconds.Record(diffSw.Elapsed.TotalSeconds, tags);
// 6. Build sinks from diff
var sinks = BuildSinks(diff);
@@ -209,6 +244,13 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
sw.Stop();
// Record success metrics
VulnSurfaceMetrics.BuildSuccesses.Add(1, tags);
VulnSurfaceMetrics.BuildDurationSeconds.Record(sw.Elapsed.TotalSeconds, tags);
VulnSurfaceMetrics.SinksPerSurface.Record(sinks.Count, tags);
VulnSurfaceMetrics.SinksIdentified.Add(sinks.Count, tags);
VulnSurfaceMetrics.IncrementEcosystemCount(request.Ecosystem);
_logger.LogInformation(
"Built vulnerability surface for {CveId}: {SinkCount} sinks, {TriggerCount} triggers in {Duration}ms",
request.CveId, sinks.Count, triggerCount, sw.ElapsedMilliseconds);
@@ -218,6 +260,16 @@ public sealed class VulnSurfaceBuilder : IVulnSurfaceBuilder
catch (Exception ex)
{
sw.Stop();
// Record failure metrics
var failTags = new KeyValuePair<string, object?>[]
{
new("ecosystem", request.Ecosystem.ToLowerInvariant()),
new("reason", "exception")
};
VulnSurfaceMetrics.BuildFailures.Add(1, failTags);
VulnSurfaceMetrics.BuildDurationSeconds.Record(sw.Elapsed.TotalSeconds, tags);
_logger.LogError(ex, "Failed to build vulnerability surface for {CveId}", request.CveId);
return VulnSurfaceBuildResult.Fail(ex.Message, sw.Elapsed);
}

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// VulnSurfaceMetrics.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-019
// Description: Metrics for vulnerability surface computation.
// -----------------------------------------------------------------------------
using System.Diagnostics.Metrics;
namespace StellaOps.Scanner.VulnSurfaces.Diagnostics;
/// <summary>
/// Metrics for vulnerability surface computation and caching.
/// </summary>
public static class VulnSurfaceMetrics
{
private static readonly Meter Meter = new("StellaOps.Scanner.VulnSurfaces", "1.0.0");
// ===== BUILD COUNTERS =====
/// <summary>
/// Total surface build requests by ecosystem.
/// </summary>
public static readonly Counter<long> BuildRequests = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_requests_total",
description: "Total vulnerability surface build requests");
/// <summary>
/// Successful surface builds by ecosystem.
/// </summary>
public static readonly Counter<long> BuildSuccesses = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_successes_total",
description: "Total successful vulnerability surface builds");
/// <summary>
/// Failed surface builds by ecosystem and reason.
/// </summary>
public static readonly Counter<long> BuildFailures = Meter.CreateCounter<long>(
"stellaops_vulnsurface_build_failures_total",
description: "Total failed vulnerability surface builds");
/// <summary>
/// Cache hits when surface already computed.
/// </summary>
public static readonly Counter<long> CacheHits = Meter.CreateCounter<long>(
"stellaops_vulnsurface_cache_hits_total",
description: "Total cache hits for pre-computed surfaces");
// ===== DOWNLOAD COUNTERS =====
/// <summary>
/// Package downloads attempted by ecosystem.
/// </summary>
public static readonly Counter<long> DownloadAttempts = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_attempted_total",
description: "Total package download attempts");
/// <summary>
/// Successful package downloads.
/// </summary>
public static readonly Counter<long> DownloadSuccesses = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_succeeded_total",
description: "Total successful package downloads");
/// <summary>
/// Failed package downloads.
/// </summary>
public static readonly Counter<long> DownloadFailures = Meter.CreateCounter<long>(
"stellaops_vulnsurface_downloads_failed_total",
description: "Total failed package downloads");
// ===== FINGERPRINT COUNTERS =====
/// <summary>
/// Methods fingerprinted by ecosystem.
/// </summary>
public static readonly Counter<long> MethodsFingerprinted = Meter.CreateCounter<long>(
"stellaops_vulnsurface_methods_fingerprinted_total",
description: "Total methods fingerprinted");
/// <summary>
/// Methods changed (sinks) identified.
/// </summary>
public static readonly Counter<long> SinksIdentified = Meter.CreateCounter<long>(
"stellaops_vulnsurface_sinks_identified_total",
description: "Total sink methods (changed methods) identified");
// ===== TIMING HISTOGRAMS =====
/// <summary>
/// End-to-end surface build duration.
/// </summary>
public static readonly Histogram<double> BuildDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_build_duration_seconds",
unit: "s",
description: "Duration of surface build operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0]
});
/// <summary>
/// Package download duration.
/// </summary>
public static readonly Histogram<double> DownloadDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_download_duration_seconds",
unit: "s",
description: "Duration of package download operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0]
});
/// <summary>
/// Fingerprinting duration per package.
/// </summary>
public static readonly Histogram<double> FingerprintDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_fingerprint_duration_seconds",
unit: "s",
description: "Duration of fingerprinting operations",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
});
/// <summary>
/// Diff computation duration.
/// </summary>
public static readonly Histogram<double> DiffDurationSeconds = Meter.CreateHistogram<double>(
"stellaops_vulnsurface_diff_duration_seconds",
unit: "s",
description: "Duration of diff computation",
advice: new InstrumentAdvice<double>
{
HistogramBucketBoundaries = [0.001, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0]
});
// ===== SIZE HISTOGRAMS =====
/// <summary>
/// Number of methods per package version.
/// </summary>
public static readonly Histogram<int> MethodsPerPackage = Meter.CreateHistogram<int>(
"stellaops_vulnsurface_methods_per_package",
description: "Number of methods per analyzed package version",
advice: new InstrumentAdvice<int>
{
HistogramBucketBoundaries = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000]
});
/// <summary>
/// Number of sinks per surface.
/// </summary>
public static readonly Histogram<int> SinksPerSurface = Meter.CreateHistogram<int>(
"stellaops_vulnsurface_sinks_per_surface",
description: "Number of sink methods per vulnerability surface",
advice: new InstrumentAdvice<int>
{
HistogramBucketBoundaries = [1, 2, 5, 10, 25, 50, 100, 250]
});
// ===== ECOSYSTEM DISTRIBUTION =====
private static int _nugetSurfaces;
private static int _npmSurfaces;
private static int _mavenSurfaces;
private static int _pypiSurfaces;
/// <summary>
/// Current count of NuGet surfaces.
/// </summary>
public static readonly ObservableGauge<int> NuGetSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_nuget_count",
() => _nugetSurfaces,
description: "Current count of NuGet vulnerability surfaces");
/// <summary>
/// Current count of npm surfaces.
/// </summary>
public static readonly ObservableGauge<int> NpmSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_npm_count",
() => _npmSurfaces,
description: "Current count of npm vulnerability surfaces");
/// <summary>
/// Current count of Maven surfaces.
/// </summary>
public static readonly ObservableGauge<int> MavenSurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_maven_count",
() => _mavenSurfaces,
description: "Current count of Maven vulnerability surfaces");
/// <summary>
/// Current count of PyPI surfaces.
/// </summary>
public static readonly ObservableGauge<int> PyPISurfaceCount = Meter.CreateObservableGauge(
"stellaops_vulnsurface_pypi_count",
() => _pypiSurfaces,
description: "Current count of PyPI vulnerability surfaces");
/// <summary>
/// Updates the ecosystem surface counts.
/// </summary>
public static void SetEcosystemCounts(int nuget, int npm, int maven, int pypi)
{
Interlocked.Exchange(ref _nugetSurfaces, nuget);
Interlocked.Exchange(ref _npmSurfaces, npm);
Interlocked.Exchange(ref _mavenSurfaces, maven);
Interlocked.Exchange(ref _pypiSurfaces, pypi);
}
/// <summary>
/// Increments the surface count for an ecosystem.
/// </summary>
public static void IncrementEcosystemCount(string ecosystem)
{
switch (ecosystem.ToLowerInvariant())
{
case "nuget":
Interlocked.Increment(ref _nugetSurfaces);
break;
case "npm":
Interlocked.Increment(ref _npmSurfaces);
break;
case "maven":
Interlocked.Increment(ref _mavenSurfaces);
break;
case "pypi":
Interlocked.Increment(ref _pypiSurfaces);
break;
}
}
}

View File

@@ -124,6 +124,12 @@ public sealed record VulnSurfaceSink
[JsonPropertyName("method_name")]
public required string MethodName { get; init; }
/// <summary>
/// Namespace/package.
/// </summary>
[JsonPropertyName("namespace")]
public string? Namespace { get; init; }
/// <summary>
/// Method signature.
/// </summary>
@@ -153,6 +159,42 @@ public sealed record VulnSurfaceSink
/// </summary>
[JsonPropertyName("is_direct_exploit")]
public bool IsDirectExploit { get; init; }
/// <summary>
/// Whether the method is public.
/// </summary>
[JsonPropertyName("is_public")]
public bool IsPublic { get; init; }
/// <summary>
/// Number of parameters.
/// </summary>
[JsonPropertyName("parameter_count")]
public int? ParameterCount { get; init; }
/// <summary>
/// Return type.
/// </summary>
[JsonPropertyName("return_type")]
public string? ReturnType { get; init; }
/// <summary>
/// Source file path (if available from debug symbols).
/// </summary>
[JsonPropertyName("source_file")]
public string? SourceFile { get; init; }
/// <summary>
/// Start line number.
/// </summary>
[JsonPropertyName("start_line")]
public int? StartLine { get; init; }
/// <summary>
/// End line number.
/// </summary>
[JsonPropertyName("end_line")]
public int? EndLine { get; init; }
}
/// <summary>

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Mono.Cecil" Version="0.11.6" />
<PackageReference Include="Npgsql" Version="9.0.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,99 @@
// -----------------------------------------------------------------------------
// IVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: Repository interface for vulnerability surfaces.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// Repository interface for vulnerability surface storage.
/// </summary>
public interface IVulnSurfaceRepository
{
/// <summary>
/// Creates a new vulnerability surface.
/// </summary>
Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a sink method to a vulnerability surface.
/// </summary>
Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a trigger to a surface.
/// </summary>
Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a vulnerability surface by CVE and package.
/// </summary>
Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets sinks for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets triggers for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all surfaces for a CVE.
/// </summary>
Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a vulnerability surface and all related data.
/// </summary>
Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,100 @@
// -----------------------------------------------------------------------------
// IVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: Repository interface for vulnerability surfaces.
// -----------------------------------------------------------------------------
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// Repository interface for vulnerability surface storage.
/// </summary>
public interface IVulnSurfaceRepository
{
/// <summary>
/// Creates a new vulnerability surface.
/// </summary>
Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a sink method to a vulnerability surface.
/// </summary>
Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default);
/// <summary>
/// Adds a trigger to a surface.
/// </summary>
Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a vulnerability surface by CVE and package.
/// </summary>
Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets sinks for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets triggers for a vulnerability surface.
/// </summary>
Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all surfaces for a CVE.
/// </summary>
Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a vulnerability surface and all related data.
/// </summary>
Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,400 @@
// -----------------------------------------------------------------------------
// PostgresVulnSurfaceRepository.cs
// Sprint: SPRINT_3700_0002_0001_vuln_surfaces_core
// Task: SURF-016
// Description: PostgreSQL implementation of vulnerability surface repository.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Scanner.VulnSurfaces.Models;
namespace StellaOps.Scanner.VulnSurfaces.Storage;
/// <summary>
/// PostgreSQL implementation of vulnerability surface repository.
/// </summary>
public sealed class PostgresVulnSurfaceRepository : IVulnSurfaceRepository
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILogger<PostgresVulnSurfaceRepository> _logger;
private readonly int _commandTimeoutSeconds;
public PostgresVulnSurfaceRepository(
NpgsqlDataSource dataSource,
ILogger<PostgresVulnSurfaceRepository> logger,
int commandTimeoutSeconds = 30)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_commandTimeoutSeconds = commandTimeoutSeconds;
}
public async Task<Guid> CreateSurfaceAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
string? fixedVersion,
string fingerprintMethod,
int totalMethodsVuln,
int totalMethodsFixed,
int changedMethodCount,
int? computationDurationMs,
string? attestationDigest,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surfaces (
id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest
) VALUES (
@id, @tenant_id, @cve_id, @ecosystem, @package_name,
@vuln_version, @fixed_version, @fingerprint_method,
@total_methods_vuln, @total_methods_fixed, @changed_method_count,
@computation_duration_ms, @attestation_digest
)
ON CONFLICT (tenant_id, cve_id, package_ecosystem, package_name, vuln_version)
DO UPDATE SET
fixed_version = EXCLUDED.fixed_version,
fingerprint_method = EXCLUDED.fingerprint_method,
total_methods_vuln = EXCLUDED.total_methods_vuln,
total_methods_fixed = EXCLUDED.total_methods_fixed,
changed_method_count = EXCLUDED.changed_method_count,
computation_duration_ms = EXCLUDED.computation_duration_ms,
attestation_digest = EXCLUDED.attestation_digest,
computed_at = now()
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
command.Parameters.AddWithValue("ecosystem", ecosystem);
command.Parameters.AddWithValue("package_name", packageName);
command.Parameters.AddWithValue("vuln_version", vulnVersion);
command.Parameters.AddWithValue("fixed_version", (object?)fixedVersion ?? DBNull.Value);
command.Parameters.AddWithValue("fingerprint_method", fingerprintMethod);
command.Parameters.AddWithValue("total_methods_vuln", totalMethodsVuln);
command.Parameters.AddWithValue("total_methods_fixed", totalMethodsFixed);
command.Parameters.AddWithValue("changed_method_count", changedMethodCount);
command.Parameters.AddWithValue("computation_duration_ms", (object?)computationDurationMs ?? DBNull.Value);
command.Parameters.AddWithValue("attestation_digest", (object?)attestationDigest ?? DBNull.Value);
var result = await command.ExecuteScalarAsync(cancellationToken);
return (Guid)result!;
}
public async Task<Guid> AddSinkAsync(
Guid surfaceId,
string methodKey,
string methodName,
string declaringType,
string changeType,
string? vulnHash,
string? fixedHash,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surface_sinks (
id, surface_id, method_key, method_name, declaring_type,
change_type, vuln_fingerprint, fixed_fingerprint
) VALUES (
@id, @surface_id, @method_key, @method_name, @declaring_type,
@change_type, @vuln_hash, @fixed_hash
)
ON CONFLICT (surface_id, method_key) DO UPDATE SET
change_type = EXCLUDED.change_type,
vuln_fingerprint = EXCLUDED.vuln_fingerprint,
fixed_fingerprint = EXCLUDED.fixed_fingerprint
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("surface_id", surfaceId);
command.Parameters.AddWithValue("method_key", methodKey);
command.Parameters.AddWithValue("method_name", methodName);
command.Parameters.AddWithValue("declaring_type", declaringType);
command.Parameters.AddWithValue("change_type", changeType);
command.Parameters.AddWithValue("vuln_hash", (object?)vulnHash ?? DBNull.Value);
command.Parameters.AddWithValue("fixed_hash", (object?)fixedHash ?? DBNull.Value);
var result = await command.ExecuteScalarAsync(cancellationToken);
return (Guid)result!;
}
public async Task<Guid> AddTriggerAsync(
Guid surfaceId,
string triggerMethodKey,
string sinkMethodKey,
int depth,
double confidence,
CancellationToken cancellationToken = default)
{
var id = Guid.NewGuid();
const string sql = """
INSERT INTO scanner.vuln_surface_triggers (
id, sink_id, scan_id, caller_node_id, caller_method_key,
reachability_bucket, path_length, confidence, call_type, is_conditional
) VALUES (
@id,
(SELECT id FROM scanner.vuln_surface_sinks WHERE surface_id = @surface_id AND method_key = @sink_method_key LIMIT 1),
@surface_id::uuid,
@trigger_method_key,
@trigger_method_key,
'direct',
@depth,
@confidence,
'direct',
false
)
RETURNING id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("surface_id", surfaceId);
command.Parameters.AddWithValue("trigger_method_key", triggerMethodKey);
command.Parameters.AddWithValue("sink_method_key", sinkMethodKey);
command.Parameters.AddWithValue("depth", depth);
command.Parameters.AddWithValue("confidence", (float)confidence);
var result = await command.ExecuteScalarAsync(cancellationToken);
return result is Guid g ? g : Guid.Empty;
}
public async Task<VulnSurface?> GetByCveAndPackageAsync(
Guid tenantId,
string cveId,
string ecosystem,
string packageName,
string vulnVersion,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest, computed_at
FROM scanner.vuln_surfaces
WHERE tenant_id = @tenant_id
AND cve_id = @cve_id
AND package_ecosystem = @ecosystem
AND package_name = @package_name
AND vuln_version = @vuln_version
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
command.Parameters.AddWithValue("ecosystem", ecosystem);
command.Parameters.AddWithValue("package_name", packageName);
command.Parameters.AddWithValue("vuln_version", vulnVersion);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
if (!await reader.ReadAsync(cancellationToken))
{
return null;
}
return MapToVulnSurface(reader);
}
public async Task<IReadOnlyList<VulnSurfaceSink>> GetSinksAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, surface_id, method_key, method_name, declaring_type,
change_type, vuln_fingerprint, fixed_fingerprint
FROM scanner.vuln_surface_sinks
WHERE surface_id = @surface_id
ORDER BY declaring_type, method_name
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("surface_id", surfaceId);
var sinks = new List<VulnSurfaceSink>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
sinks.Add(MapToSink(reader));
}
return sinks;
}
public async Task<IReadOnlyList<VulnSurfaceTrigger>> GetTriggersAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT vst.id, vss.surface_id, vst.caller_method_key, vss.method_key,
vst.path_length, vst.confidence
FROM scanner.vuln_surface_triggers vst
JOIN scanner.vuln_surface_sinks vss ON vst.sink_id = vss.id
WHERE vss.surface_id = @surface_id
ORDER BY vst.path_length
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("surface_id", surfaceId);
var triggers = new List<VulnSurfaceTrigger>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
triggers.Add(MapToTrigger(reader));
}
return triggers;
}
public async Task<IReadOnlyList<VulnSurface>> GetSurfacesByCveAsync(
Guid tenantId,
string cveId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, cve_id, package_ecosystem, package_name,
vuln_version, fixed_version, fingerprint_method,
total_methods_vuln, total_methods_fixed, changed_method_count,
computation_duration_ms, attestation_digest, computed_at
FROM scanner.vuln_surfaces
WHERE tenant_id = @tenant_id AND cve_id = @cve_id
ORDER BY package_ecosystem, package_name, vuln_version
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await SetTenantContextAsync(connection, tenantId, cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("tenant_id", tenantId);
command.Parameters.AddWithValue("cve_id", cveId);
var surfaces = new List<VulnSurface>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
surfaces.Add(MapToVulnSurface(reader));
}
return surfaces;
}
public async Task<bool> DeleteSurfaceAsync(
Guid surfaceId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM scanner.vuln_surfaces WHERE id = @id
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken);
await using var command = new NpgsqlCommand(sql, connection);
command.CommandTimeout = _commandTimeoutSeconds;
command.Parameters.AddWithValue("id", surfaceId);
var rows = await command.ExecuteNonQueryAsync(cancellationToken);
return rows > 0;
}
private static async Task SetTenantContextAsync(
NpgsqlConnection connection,
Guid tenantId,
CancellationToken cancellationToken)
{
await using var command = new NpgsqlCommand(
$"SET LOCAL app.tenant_id = '{tenantId}'",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
private static VulnSurface MapToVulnSurface(NpgsqlDataReader reader)
{
return new VulnSurface
{
SurfaceId = reader.GetGuid(0).GetHashCode(),
CveId = reader.GetString(2),
PackageId = $"pkg:{reader.GetString(3)}/{reader.GetString(4)}@{reader.GetString(5)}",
Ecosystem = reader.GetString(3),
VulnVersion = reader.GetString(5),
FixedVersion = reader.IsDBNull(6) ? string.Empty : reader.GetString(6),
Status = VulnSurfaceStatus.Computed,
Confidence = 1.0,
ComputedAt = reader.GetDateTime(13)
};
}
private static VulnSurfaceSink MapToSink(NpgsqlDataReader reader)
{
return new VulnSurfaceSink
{
SinkId = reader.GetGuid(0).GetHashCode(),
SurfaceId = reader.GetGuid(1).GetHashCode(),
MethodKey = reader.GetString(2),
MethodName = reader.GetString(3),
DeclaringType = reader.GetString(4),
ChangeType = ParseChangeType(reader.GetString(5)),
VulnHash = reader.IsDBNull(6) ? null : reader.GetString(6),
FixedHash = reader.IsDBNull(7) ? null : reader.GetString(7)
};
}
private static VulnSurfaceTrigger MapToTrigger(NpgsqlDataReader reader)
{
return new VulnSurfaceTrigger
{
SurfaceId = reader.GetGuid(1).GetHashCode(),
TriggerMethodKey = reader.GetString(2),
SinkMethodKey = reader.GetString(3),
Depth = reader.IsDBNull(4) ? 0 : reader.GetInt32(4),
Confidence = reader.IsDBNull(5) ? 1.0 : reader.GetFloat(5)
};
}
private static MethodChangeType ParseChangeType(string changeType) => changeType switch
{
"added" => MethodChangeType.Added,
"removed" => MethodChangeType.Removed,
"modified" => MethodChangeType.Modified,
"signaturechanged" => MethodChangeType.SignatureChanged,
_ => MethodChangeType.Modified
};
}

View File

@@ -0,0 +1,304 @@
// -----------------------------------------------------------------------------
// AttestingRichGraphWriterTests.cs
// Sprint: SPRINT_3620_0001_0001_reachability_witness_dsse
// Description: Tests for AttestingRichGraphWriter integration.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Cryptography;
using StellaOps.Scanner.Reachability.Attestation;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class AttestingRichGraphWriterTests : IAsyncLifetime
{
private DirectoryInfo _tempDir = null!;
public Task InitializeAsync()
{
_tempDir = Directory.CreateTempSubdirectory("attesting-writer-test-");
return Task.CompletedTask;
}
public Task DisposeAsync()
{
try
{
if (_tempDir.Exists)
{
_tempDir.Delete(recursive: true);
}
}
catch
{
// Ignore cleanup errors
}
return Task.CompletedTask;
}
[Fact]
public async Task WriteWithAttestationAsync_WhenEnabled_ProducesAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.NotNull(result.AttestationPath);
Assert.True(File.Exists(result.AttestationPath));
Assert.NotNull(result.WitnessResult);
Assert.NotEmpty(result.WitnessResult.StatementHash);
}
[Fact]
public async Task WriteWithAttestationAsync_WhenDisabled_NoAttestationFile()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result);
Assert.True(File.Exists(result.GraphPath));
Assert.True(File.Exists(result.MetaPath));
Assert.Null(result.AttestationPath);
Assert.Null(result.WitnessResult);
}
[Fact]
public async Task WriteWithAttestationAsync_AttestationContainsValidDsse()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act
var result = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"test-analysis",
"sha256:abc123");
// Assert
Assert.NotNull(result.AttestationPath);
var dsseJson = await File.ReadAllTextAsync(result.AttestationPath);
Assert.Contains("payloadType", dsseJson);
// Note: + may be encoded as \u002B in JSON
Assert.True(dsseJson.Contains("application/vnd.in-toto+json") || dsseJson.Contains("application/vnd.in-toto\\u002Bjson"));
Assert.Contains("payload", dsseJson);
}
[Fact]
public async Task WriteWithAttestationAsync_GraphHashIsDeterministic()
{
// Arrange
var cryptoHash = new TestCryptoHash();
var graphWriter = new RichGraphWriter(cryptoHash);
var witnessOptions = Options.Create(new ReachabilityWitnessOptions
{
Enabled = true,
StoreInCas = false,
PublishToRekor = false
});
var witnessPublisher = new ReachabilityWitnessPublisher(
witnessOptions,
cryptoHash,
NullLogger<ReachabilityWitnessPublisher>.Instance);
var writer = new AttestingRichGraphWriter(
graphWriter,
witnessPublisher,
witnessOptions,
NullLogger<AttestingRichGraphWriter>.Instance);
var graph = CreateTestGraph();
// Act - write twice with same input
var result1 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-1",
"sha256:abc123");
var result2 = await writer.WriteWithAttestationAsync(
graph,
_tempDir.FullName,
"analysis-2",
"sha256:abc123");
// Assert - same graph should produce same hash
Assert.Equal(result1.GraphHash, result2.GraphHash);
}
private static RichGraph CreateTestGraph()
{
return new RichGraph(
Nodes: new[]
{
new RichGraphNode(
Id: "entry-1",
SymbolId: "Handler.handle",
CodeId: null,
Purl: "pkg:maven/com.example/handler@1.0.0",
Lang: "java",
Kind: "http_handler",
Display: "GET /api/users",
BuildId: null,
Evidence: null,
Attributes: null,
SymbolDigest: "sha256:entry1digest"),
new RichGraphNode(
Id: "sink-1",
SymbolId: "DB.executeQuery",
CodeId: null,
Purl: "pkg:maven/org.database/driver@2.0.0",
Lang: "java",
Kind: "sql_sink",
Display: "executeQuery(String)",
BuildId: null,
Evidence: null,
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: "sha256:sink1digest")
},
Edges: new[]
{
new RichGraphEdge(
From: "entry-1",
To: "sink-1",
Kind: "call",
Purl: null,
SymbolDigest: null,
Evidence: null,
Confidence: 1.0,
Candidates: null)
},
Roots: new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Analyzer: new RichGraphAnalyzer("stellaops.scanner.reachability", "1.0.0", null),
Schema: "richgraph-v1"
);
}
/// <summary>
/// Test crypto hash implementation.
/// </summary>
private sealed class TestCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> System.Security.Cryptography.SHA256.HashData(data);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToHexString(ComputeHash(data, algorithmId)).ToLowerInvariant();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> Convert.ToBase64String(ComputeHash(data, algorithmId));
public async ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
using var buffer = new MemoryStream();
await stream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
return System.Security.Cryptography.SHA256.HashData(buffer.ToArray());
}
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
{
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(hash).ToLowerInvariant();
}
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHash(data);
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashHex(data);
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> ComputeHashBase64(data);
public async ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashAsync(stream, null, cancellationToken).ConfigureAwait(false);
public async ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> await ComputeHashHexAsync(stream, null, cancellationToken).ConfigureAwait(false);
public string GetAlgorithmForPurpose(string purpose) => "blake3";
public string GetHashPrefix(string purpose) => "blake3:";
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> $"blake3:{ComputeHashHex(data)}";
}
}

View File

@@ -0,0 +1,32 @@
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "pkg:oci/test-image@sha256:abc123",
"digest": {
"sha256": "abc123def456789012345678901234567890123456789012345678901234"
}
}
],
"predicateType": "https://stellaops.io/attestation/reachabilityWitness/v1",
"predicate": {
"version": "1.0.0",
"analysisTimestamp": "2025-01-01T00:00:00.0000000Z",
"analyzer": {
"name": "stellaops.scanner.reachability",
"version": "1.0.0"
},
"graph": {
"schema": "richgraph-v1",
"hash": "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"nodeCount": 3,
"edgeCount": 2
},
"summary": {
"sinkCount": 1,
"entrypointCount": 1,
"pathCount": 1,
"gateCoverage": 0.0
}
}
}

View File

@@ -0,0 +1,45 @@
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "pkg:oci/production-app@sha256:xyz789",
"digest": {
"sha256": "xyz789abc123def456789012345678901234567890123456789012345678"
}
}
],
"predicateType": "https://stellaops.io/attestation/reachabilityWitness/v1",
"predicate": {
"version": "1.0.0",
"analysisTimestamp": "2025-01-15T12:30:00.0000000Z",
"analyzer": {
"name": "stellaops.scanner.reachability",
"version": "1.0.0"
},
"graph": {
"schema": "richgraph-v1",
"hash": "blake3:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"nodeCount": 150,
"edgeCount": 340,
"casUri": "cas://reachability/graphs/fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
},
"summary": {
"sinkCount": 12,
"entrypointCount": 8,
"pathCount": 45,
"gateCoverage": 0.67
},
"policy": {
"hash": "sha256:policy123456789012345678901234567890123456789012345678901234"
},
"source": {
"commit": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
},
"runtime": {
"observedAt": "2025-01-15T12:25:00.0000000Z",
"traceCount": 1250,
"coveredPaths": 38,
"runtimeConfidence": 0.84
}
}
}

View File

@@ -206,15 +206,8 @@ public class PathExplanationServiceTests
private static RichGraph CreateSimpleGraph()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode(
Id: "entry-1",
@@ -241,21 +234,23 @@ public class PathExplanationServiceTests
Attributes: new Dictionary<string, string> { ["is_sink"] = "true" },
SymbolDigest: null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null)
},
Roots: new[]
{
new RichGraphRoot("entry-1", "runtime", null)
},
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithMultipleSinks()
{
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink1", null, null, "java", "sink", null, null, null,
@@ -263,12 +258,15 @@ public class PathExplanationServiceTests
new RichGraphNode("sink-2", "Sink2", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", null),
new RichGraphEdge("entry-1", "sink-2", "call", null)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null),
new RichGraphEdge("entry-1", "sink-2", "call", null, null, null, 1.0, null)
},
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithGates()
@@ -285,22 +283,21 @@ public class PathExplanationServiceTests
}
};
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = new[]
return new RichGraph(
Nodes: new[]
{
new RichGraphNode("entry-1", "Handler", null, null, "java", "handler", null, null, null, null, null),
new RichGraphNode("sink-1", "Sink", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null)
},
Edges = new[]
Edges: new[]
{
new RichGraphEdge("entry-1", "sink-1", "call", gates)
}
};
new RichGraphEdge("entry-1", "sink-1", "call", null, null, null, 1.0, null, gates)
},
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateDeepGraph(int depth)
@@ -317,18 +314,17 @@ public class PathExplanationServiceTests
if (i > 0)
{
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null));
edges.Add(new RichGraphEdge($"node-{i - 1}", $"node-{i}", "call", null, null, null, 1.0, null));
}
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("node-0", "runtime", null) },
Nodes = nodes,
Edges = edges
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: new[] { new RichGraphRoot("node-0", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static RichGraph CreateGraphWithMultiplePaths(int pathCount)
@@ -344,17 +340,16 @@ public class PathExplanationServiceTests
{
nodes.Add(new RichGraphNode($"sink-{i}", $"Sink{i}", null, null, "java", "sink", null, null, null,
new Dictionary<string, string> { ["is_sink"] = "true" }, null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null));
edges.Add(new RichGraphEdge("entry-1", $"sink-{i}", "call", null, null, null, 1.0, null));
}
return new RichGraph
{
Schema = "stellaops.richgraph.v1",
Meta = new RichGraphMeta { Hash = "test-hash" },
Roots = new[] { new RichGraphRoot("entry-1", "runtime", null) },
Nodes = nodes,
Edges = edges
};
return new RichGraph(
Nodes: nodes,
Edges: edges,
Roots: new[] { new RichGraphRoot("entry-1", "runtime", null) },
Analyzer: new RichGraphAnalyzer("test", "1.0", null),
Schema: "stellaops.richgraph.v1"
);
}
private static ExplainedPath CreateTestPath()
@@ -364,7 +359,7 @@ public class PathExplanationServiceTests
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
SinkCategory = Explanation.SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,
@@ -402,7 +397,7 @@ public class PathExplanationServiceTests
PathId = "entry:sink:0",
SinkId = "sink-1",
SinkSymbol = "DB.query",
SinkCategory = SinkCategory.SqlRaw,
SinkCategory = Explanation.SinkCategory.SqlRaw,
EntrypointId = "entry-1",
EntrypointSymbol = "Handler.handle",
EntrypointType = EntrypointType.HttpEndpoint,

View File

@@ -132,6 +132,6 @@ public class RichGraphWriterTests
// Verify meta.json also contains the blake3-prefixed hash
var metaJson = await File.ReadAllTextAsync(result.MetaPath);
Assert.Contains("\"graph_hash\":\"blake3:", metaJson);
Assert.Contains("\"graph_hash\": \"blake3:", metaJson);
}
}