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:
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
505
src/Scanner/StellaOps.Scanner.Worker/Processing/EpssSignalJob.cs
Normal file
505
src/Scanner/StellaOps.Scanner.Worker/Processing/EpssSignalJob.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user