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>();
|
||||
|
||||
Reference in New Issue
Block a user