feat: Add PathViewer and RiskDriftCard components with templates and styles

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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