Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AutoVexDowngradeService.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Tasks: AUTOVEX-01 to AUTOVEX-05 — Hot vulnerable symbol detection and VEX downgrade
|
||||
// Description: Detects vulnerable symbols observed in production and generates
|
||||
// automatic VEX status downgrades with runtime evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Service for detecting hot vulnerable symbols and triggering VEX downgrades.
|
||||
/// </summary>
|
||||
public interface IAutoVexDowngradeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects vulnerable symbols observed in production for the given image.
|
||||
/// </summary>
|
||||
/// <param name="imageDigest">Container image digest (sha256:xxx).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>List of detected hot vulnerable symbols.</returns>
|
||||
Task<IReadOnlyList<HotVulnerableSymbol>> DetectHotVulnerableSymbolsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Generates VEX downgrade statements for detected hot vulnerable symbols.
|
||||
/// </summary>
|
||||
/// <param name="detections">List of hot vulnerable symbol detections.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Generated VEX downgrade results.</returns>
|
||||
Task<IReadOnlyList<VexDowngradeResult>> GenerateDowngradesAsync(
|
||||
IReadOnlyList<HotVulnerableSymbol> detections,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Full pipeline: detect and generate downgrades for an image.
|
||||
/// </summary>
|
||||
Task<AutoVexDowngradeReport> ProcessImageAsync(
|
||||
string imageDigest,
|
||||
AutoVexDowngradeOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a vulnerable symbol detected in production.
|
||||
/// </summary>
|
||||
public sealed record HotVulnerableSymbol
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container image digest.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// ELF Build-ID of the binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Canonical symbol name.
|
||||
/// </summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest for correlation with FuncProof.
|
||||
/// </summary>
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Package URL from SBOM correlation.
|
||||
/// </summary>
|
||||
public string? Purl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count within the window.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU percentage attributed to this symbol.
|
||||
/// </summary>
|
||||
public required double CpuPercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation window.
|
||||
/// </summary>
|
||||
public required ObservationWindow Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top stack traces where this symbol was observed.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> TopStacks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container IDs where observed.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> ContainerIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// FuncProof reference if available.
|
||||
/// </summary>
|
||||
public FuncProofReference? FuncProofRef { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence score (0.0-1.0) based on evidence quality.
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time window for observations.
|
||||
/// </summary>
|
||||
public sealed record ObservationWindow
|
||||
{
|
||||
public required DateTimeOffset Start { get; init; }
|
||||
public required DateTimeOffset End { get; init; }
|
||||
public TimeSpan Duration => End - Start;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a FuncProof document.
|
||||
/// </summary>
|
||||
public sealed record FuncProofReference
|
||||
{
|
||||
public required string FuncProofUri { get; init; }
|
||||
public required string FuncProofDigest { get; init; }
|
||||
public bool SymbolVerified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of VEX downgrade generation.
|
||||
/// </summary>
|
||||
public sealed record VexDowngradeResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the downgrade was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source hot vulnerable symbol.
|
||||
/// </summary>
|
||||
public required HotVulnerableSymbol Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generated VEX statement (if successful).
|
||||
/// </summary>
|
||||
public VexDowngradeStatement? Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope digest (if signed).
|
||||
/// </summary>
|
||||
public string? DsseDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rekor log entry ID (if logged).
|
||||
/// </summary>
|
||||
public string? RekorEntryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX downgrade statement with runtime evidence.
|
||||
/// </summary>
|
||||
public sealed record VexDowngradeStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Statement ID.
|
||||
/// </summary>
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier (OCI image digest).
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New VEX status (typically "affected").
|
||||
/// </summary>
|
||||
public required VexDowngradeStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous VEX status (for audit trail).
|
||||
/// </summary>
|
||||
public VexClaimStatus? PreviousStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status notes explaining the downgrade.
|
||||
/// </summary>
|
||||
public required string StatusNotes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observation evidence.
|
||||
/// </summary>
|
||||
public required RuntimeObservationEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generator identifier.
|
||||
/// </summary>
|
||||
public string Generator { get; init; } = "StellaOps.AutoVex";
|
||||
|
||||
/// <summary>
|
||||
/// Generator version.
|
||||
/// </summary>
|
||||
public string GeneratorVersion { get; init; } = "1.0.0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX downgrade status values.
|
||||
/// </summary>
|
||||
public enum VexDowngradeStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Confirmed affected - vulnerable code observed in production.
|
||||
/// </summary>
|
||||
Affected,
|
||||
|
||||
/// <summary>
|
||||
/// Under investigation - vulnerable code observed but needs review.
|
||||
/// </summary>
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observation evidence for VEX downgrade.
|
||||
/// </summary>
|
||||
public sealed record RuntimeObservationEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Observed symbol name.
|
||||
/// </summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol digest for verification.
|
||||
/// </summary>
|
||||
public required string SymbolDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build-ID of the containing binary.
|
||||
/// </summary>
|
||||
public required string BuildId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation time window.
|
||||
/// </summary>
|
||||
public required ObservationWindow Window { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU percentage during observation.
|
||||
/// </summary>
|
||||
public required double CpuPercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total observation count.
|
||||
/// </summary>
|
||||
public required long ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Top 5 stack traces (collapsed format).
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> TopStacks { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Container IDs where observed.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> ContainerIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Static proof reference (FuncProof).
|
||||
/// </summary>
|
||||
public FuncProofReference? StaticProof { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for auto-VEX downgrade.
|
||||
/// </summary>
|
||||
public sealed class AutoVexDowngradeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "AutoVex:Downgrade";
|
||||
|
||||
/// <summary>
|
||||
/// Minimum observation count to trigger downgrade.
|
||||
/// Default: 10 observations.
|
||||
/// </summary>
|
||||
public int MinObservationCount { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum CPU percentage to trigger downgrade.
|
||||
/// Default: 0.1% (any measurable CPU usage).
|
||||
/// </summary>
|
||||
public double MinCpuPercentage { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Observation window duration.
|
||||
/// Default: 2 hours.
|
||||
/// </summary>
|
||||
public TimeSpan ObservationWindow { get; set; } = TimeSpan.FromHours(2);
|
||||
|
||||
/// <summary>
|
||||
/// Whether high-severity CVEs (CVSS >= 9.0) require human approval.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool RequireApprovalForHighSeverity { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether KEV (Known Exploited Vulnerabilities) require human approval.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool RequireApprovalForKev { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of top stacks to include in evidence.
|
||||
/// Default: 5.
|
||||
/// </summary>
|
||||
public int MaxTopStacks { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to sign downgrade statements with DSSE.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool SignWithDsse { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to log to Rekor transparency log.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool LogToRekor { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// TTL for "not observed" status before upgrade can occur.
|
||||
/// Default: 7 days.
|
||||
/// </summary>
|
||||
public TimeSpan NotObservedTtl { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Hysteresis period - how long must no observations occur before upgrading.
|
||||
/// Default: 24 hours.
|
||||
/// </summary>
|
||||
public TimeSpan UpgradeHysteresis { get; set; } = TimeSpan.FromHours(24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Report from auto-VEX downgrade processing.
|
||||
/// </summary>
|
||||
public sealed record AutoVexDowngradeReport
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digest processed.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Processing timestamp.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ProcessedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Options used for processing.
|
||||
/// </summary>
|
||||
public required AutoVexDowngradeOptions Options { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detected hot vulnerable symbols.
|
||||
/// </summary>
|
||||
public required ImmutableArray<HotVulnerableSymbol> Detections { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Generated downgrade results.
|
||||
/// </summary>
|
||||
public required ImmutableArray<VexDowngradeResult> Results { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Count of successful downgrades.
|
||||
/// </summary>
|
||||
public int SuccessCount => Results.Count(r => r.Success);
|
||||
|
||||
/// <summary>
|
||||
/// Count of failed downgrades.
|
||||
/// </summary>
|
||||
public int FailureCount => Results.Count(r => !r.Success);
|
||||
|
||||
/// <summary>
|
||||
/// Count requiring human approval.
|
||||
/// </summary>
|
||||
public int PendingApprovalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of auto-VEX downgrade service.
|
||||
/// </summary>
|
||||
public sealed class AutoVexDowngradeService : IAutoVexDowngradeService
|
||||
{
|
||||
private readonly ILogger<AutoVexDowngradeService> _logger;
|
||||
private readonly IHotSymbolQueryService _hotSymbolService;
|
||||
private readonly IVulnerableSymbolCorrelator _correlator;
|
||||
private readonly IVexDowngradeGenerator _generator;
|
||||
private readonly AutoVexDowngradeOptions _defaultOptions;
|
||||
|
||||
public AutoVexDowngradeService(
|
||||
ILogger<AutoVexDowngradeService> logger,
|
||||
IHotSymbolQueryService hotSymbolService,
|
||||
IVulnerableSymbolCorrelator correlator,
|
||||
IVexDowngradeGenerator generator,
|
||||
IOptions<AutoVexDowngradeOptions>? options = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_hotSymbolService = hotSymbolService ?? throw new ArgumentNullException(nameof(hotSymbolService));
|
||||
_correlator = correlator ?? throw new ArgumentNullException(nameof(correlator));
|
||||
_generator = generator ?? throw new ArgumentNullException(nameof(generator));
|
||||
_defaultOptions = options?.Value ?? new AutoVexDowngradeOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<HotVulnerableSymbol>> DetectHotVulnerableSymbolsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
_logger.LogInformation("Detecting hot vulnerable symbols for image {ImageDigest}", imageDigest);
|
||||
|
||||
// Step 1: Get hot symbols for this image
|
||||
var hotSymbols = await _hotSymbolService.GetHotSymbolsAsync(
|
||||
imageDigest,
|
||||
_defaultOptions.ObservationWindow,
|
||||
cancellationToken);
|
||||
|
||||
if (hotSymbols.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No hot symbols found for image {ImageDigest}", imageDigest);
|
||||
return [];
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} hot symbols for image {ImageDigest}", hotSymbols.Count, imageDigest);
|
||||
|
||||
// Step 2: Correlate with known vulnerabilities
|
||||
var correlations = await _correlator.CorrelateWithVulnerabilitiesAsync(
|
||||
imageDigest,
|
||||
hotSymbols,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {Count} hot vulnerable symbols for image {ImageDigest}",
|
||||
correlations.Count, imageDigest);
|
||||
|
||||
return correlations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<VexDowngradeResult>> GenerateDowngradesAsync(
|
||||
IReadOnlyList<HotVulnerableSymbol> detections,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detections);
|
||||
|
||||
if (detections.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var results = new List<VexDowngradeResult>(detections.Count);
|
||||
|
||||
foreach (var detection in detections)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check thresholds
|
||||
if (detection.ObservationCount < _defaultOptions.MinObservationCount)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping {CveId} - observation count {Count} below threshold {Threshold}",
|
||||
detection.CveId, detection.ObservationCount, _defaultOptions.MinObservationCount);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (detection.CpuPercentage < _defaultOptions.MinCpuPercentage)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping {CveId} - CPU percentage {Cpu:P2} below threshold {Threshold:P2}",
|
||||
detection.CveId, detection.CpuPercentage / 100, _defaultOptions.MinCpuPercentage / 100);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate downgrade
|
||||
var result = await _generator.GenerateDowngradeAsync(
|
||||
detection,
|
||||
_defaultOptions,
|
||||
cancellationToken);
|
||||
|
||||
results.Add(result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated VEX downgrade for {CveId} in {ImageDigest}: {Status}",
|
||||
detection.CveId, detection.ImageDigest, result.Success ? "Success" : "Failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate downgrade for {CveId}", detection.CveId);
|
||||
results.Add(new VexDowngradeResult
|
||||
{
|
||||
Success = false,
|
||||
Source = detection,
|
||||
Error = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AutoVexDowngradeReport> ProcessImageAsync(
|
||||
string imageDigest,
|
||||
AutoVexDowngradeOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var effectiveOptions = options ?? _defaultOptions;
|
||||
var processedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_logger.LogInformation("Processing auto-VEX downgrade for image {ImageDigest}", imageDigest);
|
||||
|
||||
// Detect hot vulnerable symbols
|
||||
var detections = await DetectHotVulnerableSymbolsAsync(imageDigest, cancellationToken);
|
||||
|
||||
// Generate downgrades
|
||||
var results = await GenerateDowngradesAsync(detections, cancellationToken);
|
||||
|
||||
// Count pending approvals
|
||||
var pendingApproval = detections.Count(d =>
|
||||
(effectiveOptions.RequireApprovalForHighSeverity && IsHighSeverity(d.CveId)) ||
|
||||
(effectiveOptions.RequireApprovalForKev && IsKev(d.CveId)));
|
||||
|
||||
var report = new AutoVexDowngradeReport
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
ProcessedAt = processedAt,
|
||||
Options = effectiveOptions,
|
||||
Detections = [.. detections],
|
||||
Results = [.. results],
|
||||
PendingApprovalCount = pendingApproval
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Auto-VEX processing complete for {ImageDigest}: {Success} succeeded, {Failed} failed, {Pending} pending approval",
|
||||
imageDigest, report.SuccessCount, report.FailureCount, report.PendingApprovalCount);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
private static bool IsHighSeverity(string cveId)
|
||||
{
|
||||
// TODO: Integrate with vulnerability database for actual CVSS scores
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsKev(string cveId)
|
||||
{
|
||||
// TODO: Integrate with CISA KEV catalog
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for querying hot symbols from the signals system.
|
||||
/// </summary>
|
||||
public interface IHotSymbolQueryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets hot symbols for an image within a time window.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HotSymbolInfo>> GetHotSymbolsAsync(
|
||||
string imageDigest,
|
||||
TimeSpan window,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot symbol information from the signals system.
|
||||
/// </summary>
|
||||
public sealed record HotSymbolInfo
|
||||
{
|
||||
public required string SymbolId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
public required long ObservationCount { get; init; }
|
||||
public required double CpuPercentage { get; init; }
|
||||
public required ImmutableArray<string> TopStacks { get; init; }
|
||||
public required ImmutableArray<string> ContainerIds { get; init; }
|
||||
public required DateTimeOffset WindowStart { get; init; }
|
||||
public required DateTimeOffset WindowEnd { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for correlating hot symbols with known vulnerabilities.
|
||||
/// </summary>
|
||||
public interface IVulnerableSymbolCorrelator
|
||||
{
|
||||
/// <summary>
|
||||
/// Correlates hot symbols with known vulnerabilities.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<HotVulnerableSymbol>> CorrelateWithVulnerabilitiesAsync(
|
||||
string imageDigest,
|
||||
IReadOnlyList<HotSymbolInfo> hotSymbols,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for generating VEX downgrade statements.
|
||||
/// </summary>
|
||||
public interface IVexDowngradeGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a VEX downgrade statement for a hot vulnerable symbol.
|
||||
/// </summary>
|
||||
Task<VexDowngradeResult> GenerateDowngradeAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
AutoVexDowngradeOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftGateIntegration.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Tasks: AUTOVEX-08, AUTOVEX-10, AUTOVEX-11 — Gate re-evaluation and notifications
|
||||
// Description: Integrates VEX downgrades with policy gates and notification routing.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates VEX downgrades with drift gate evaluation.
|
||||
/// </summary>
|
||||
public interface IDriftGateIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers drift gate re-evaluation after a VEX downgrade.
|
||||
/// </summary>
|
||||
/// <param name="downgradeResult">The VEX downgrade result.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of gate re-evaluation.</returns>
|
||||
Task<GateEvaluationResult> TriggerGateReEvaluationAsync(
|
||||
VexDowngradeResult downgradeResult,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets available policy gate actions.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyGateAction>> GetAvailableActionsAsync(
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of gate evaluation after VEX downgrade.
|
||||
/// </summary>
|
||||
public sealed record GateEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether evaluation completed successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gate verdict.
|
||||
/// </summary>
|
||||
public required GateVerdict Verdict { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actions triggered by the evaluation.
|
||||
/// </summary>
|
||||
public required ImmutableArray<TriggeredAction> Actions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notifications sent.
|
||||
/// </summary>
|
||||
public required ImmutableArray<NotificationSent> Notifications { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate verdict after evaluation.
|
||||
/// </summary>
|
||||
public enum GateVerdict
|
||||
{
|
||||
/// <summary>
|
||||
/// Pass - no blocking issues.
|
||||
/// </summary>
|
||||
Pass,
|
||||
|
||||
/// <summary>
|
||||
/// Warn - issues present but not blocking.
|
||||
/// </summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>
|
||||
/// Block - release blocked due to issues.
|
||||
/// </summary>
|
||||
Block,
|
||||
|
||||
/// <summary>
|
||||
/// Quarantine - existing deployment flagged for review.
|
||||
/// </summary>
|
||||
Quarantine
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy gate action that can be triggered.
|
||||
/// </summary>
|
||||
public sealed record PolicyGateAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Action identifier.
|
||||
/// </summary>
|
||||
public required string ActionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action type.
|
||||
/// </summary>
|
||||
public required PolicyGateActionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action requires approval.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity threshold that triggers this action.
|
||||
/// </summary>
|
||||
public double? SeverityThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of policy gate actions.
|
||||
/// </summary>
|
||||
public enum PolicyGateActionType
|
||||
{
|
||||
/// <summary>
|
||||
/// Block release pipeline.
|
||||
/// </summary>
|
||||
ReleaseBlock,
|
||||
|
||||
/// <summary>
|
||||
/// Freeze canary deployment.
|
||||
/// </summary>
|
||||
CanaryFreeze,
|
||||
|
||||
/// <summary>
|
||||
/// Quarantine running containers.
|
||||
/// </summary>
|
||||
Quarantine,
|
||||
|
||||
/// <summary>
|
||||
/// Send notification only.
|
||||
/// </summary>
|
||||
NotifyOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Create ticket/issue.
|
||||
/// </summary>
|
||||
CreateTicket,
|
||||
|
||||
/// <summary>
|
||||
/// Trigger rollback.
|
||||
/// </summary>
|
||||
Rollback
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of an action that was triggered.
|
||||
/// </summary>
|
||||
public sealed record TriggeredAction
|
||||
{
|
||||
/// <summary>
|
||||
/// Action identifier.
|
||||
/// </summary>
|
||||
public required string ActionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action type.
|
||||
/// </summary>
|
||||
public required PolicyGateActionType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the action executed successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when triggered.
|
||||
/// </summary>
|
||||
public required DateTimeOffset TriggeredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional details or error message.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a notification that was sent.
|
||||
/// </summary>
|
||||
public sealed record NotificationSent
|
||||
{
|
||||
/// <summary>
|
||||
/// Notification channel (email, slack, webhook, etc).
|
||||
/// </summary>
|
||||
public required string Channel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Recipient identifier.
|
||||
/// </summary>
|
||||
public required string Recipient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether notification was sent successfully.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification template used.
|
||||
/// </summary>
|
||||
public required string Template { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when sent.
|
||||
/// </summary>
|
||||
public required DateTimeOffset SentAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of drift gate integration.
|
||||
/// </summary>
|
||||
public sealed class DriftGateIntegration : IDriftGateIntegration
|
||||
{
|
||||
private readonly ILogger<DriftGateIntegration> _logger;
|
||||
private readonly IPolicyGateEvaluator _gateEvaluator;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IActionExecutor _actionExecutor;
|
||||
|
||||
public DriftGateIntegration(
|
||||
ILogger<DriftGateIntegration> logger,
|
||||
IPolicyGateEvaluator gateEvaluator,
|
||||
INotificationService notificationService,
|
||||
IActionExecutor actionExecutor)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_gateEvaluator = gateEvaluator ?? throw new ArgumentNullException(nameof(gateEvaluator));
|
||||
_notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService));
|
||||
_actionExecutor = actionExecutor ?? throw new ArgumentNullException(nameof(actionExecutor));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateEvaluationResult> TriggerGateReEvaluationAsync(
|
||||
VexDowngradeResult downgradeResult,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(downgradeResult);
|
||||
|
||||
if (!downgradeResult.Success || downgradeResult.Statement == null)
|
||||
{
|
||||
return new GateEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Verdict = GateVerdict.Pass,
|
||||
Actions = [],
|
||||
Notifications = [],
|
||||
Error = "Cannot evaluate gate for failed downgrade"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var statement = downgradeResult.Statement;
|
||||
var detection = downgradeResult.Source;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Triggering gate re-evaluation for {CveId} in {ProductId}",
|
||||
statement.VulnerabilityId, statement.ProductId);
|
||||
|
||||
// Evaluate gate policies
|
||||
var verdict = await _gateEvaluator.EvaluateAsync(
|
||||
statement.VulnerabilityId,
|
||||
statement.ProductId,
|
||||
detection.CpuPercentage,
|
||||
detection.Confidence,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Gate verdict for {CveId}: {Verdict}",
|
||||
statement.VulnerabilityId, verdict);
|
||||
|
||||
// Execute actions based on verdict
|
||||
var actions = await ExecuteActionsAsync(verdict, statement, detection, cancellationToken);
|
||||
|
||||
// Send notifications
|
||||
var notifications = await SendNotificationsAsync(verdict, statement, detection, cancellationToken);
|
||||
|
||||
return new GateEvaluationResult
|
||||
{
|
||||
Success = true,
|
||||
Verdict = verdict,
|
||||
Actions = [.. actions],
|
||||
Notifications = [.. notifications]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to evaluate gate for {CveId}", downgradeResult.Source.CveId);
|
||||
return new GateEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Verdict = GateVerdict.Pass,
|
||||
Actions = [],
|
||||
Notifications = [],
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PolicyGateAction>> GetAvailableActionsAsync(
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _gateEvaluator.GetActionsForProductAsync(productId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<List<TriggeredAction>> ExecuteActionsAsync(
|
||||
GateVerdict verdict,
|
||||
VexDowngradeStatement statement,
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actions = new List<TriggeredAction>();
|
||||
|
||||
if (verdict == GateVerdict.Pass)
|
||||
{
|
||||
return actions;
|
||||
}
|
||||
|
||||
var availableActions = await _gateEvaluator.GetActionsForProductAsync(
|
||||
statement.ProductId,
|
||||
cancellationToken);
|
||||
|
||||
foreach (var action in availableActions)
|
||||
{
|
||||
// Check if action should trigger based on verdict
|
||||
if (!ShouldTriggerAction(action, verdict))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = await _actionExecutor.ExecuteAsync(
|
||||
action,
|
||||
statement,
|
||||
detection,
|
||||
cancellationToken);
|
||||
|
||||
actions.Add(result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed action {ActionId} ({Type}): {Success}",
|
||||
action.ActionId, action.Type, result.Success);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private async Task<List<NotificationSent>> SendNotificationsAsync(
|
||||
GateVerdict verdict,
|
||||
VexDowngradeStatement statement,
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var template = BuildNotificationTemplate(verdict, statement, detection);
|
||||
|
||||
var notifications = await _notificationService.SendAsync(
|
||||
statement.ProductId,
|
||||
template,
|
||||
cancellationToken);
|
||||
|
||||
return notifications.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldTriggerAction(PolicyGateAction action, GateVerdict verdict)
|
||||
{
|
||||
return verdict switch
|
||||
{
|
||||
GateVerdict.Block => action.Type is PolicyGateActionType.ReleaseBlock
|
||||
or PolicyGateActionType.CanaryFreeze
|
||||
or PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
GateVerdict.Quarantine => action.Type is PolicyGateActionType.Quarantine
|
||||
or PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
GateVerdict.Warn => action.Type is PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationTemplate BuildNotificationTemplate(
|
||||
GateVerdict verdict,
|
||||
VexDowngradeStatement statement,
|
||||
HotVulnerableSymbol detection)
|
||||
{
|
||||
var severity = verdict switch
|
||||
{
|
||||
GateVerdict.Block => NotificationSeverity.Critical,
|
||||
GateVerdict.Quarantine => NotificationSeverity.High,
|
||||
GateVerdict.Warn => NotificationSeverity.Medium,
|
||||
_ => NotificationSeverity.Low
|
||||
};
|
||||
|
||||
return new NotificationTemplate
|
||||
{
|
||||
TemplateId = "autovex-downgrade",
|
||||
Severity = severity,
|
||||
Subject = $"{statement.VulnerabilityId} observed in production - {detection.Symbol}",
|
||||
Body = $"""
|
||||
Vulnerable symbol detected in production:
|
||||
|
||||
CVE: {statement.VulnerabilityId}
|
||||
Symbol: {detection.Symbol}
|
||||
CPU Usage: {detection.CpuPercentage:F1}%
|
||||
Observation Count: {detection.ObservationCount}
|
||||
Build-ID: {detection.BuildId}
|
||||
Image: {detection.ImageDigest}
|
||||
|
||||
Gate Verdict: {verdict}
|
||||
|
||||
Evidence window: {detection.Window.Start:u} to {detection.Window.End:u}
|
||||
""",
|
||||
Properties = new Dictionary<string, string>
|
||||
{
|
||||
["cve"] = statement.VulnerabilityId,
|
||||
["symbol"] = detection.Symbol,
|
||||
["cpu_percentage"] = detection.CpuPercentage.ToString("F1"),
|
||||
["verdict"] = verdict.ToString()
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for evaluating policy gates.
|
||||
/// </summary>
|
||||
public interface IPolicyGateEvaluator
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates the gate for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<GateVerdict> EvaluateAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
double cpuPercentage,
|
||||
double confidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets available actions for a product.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PolicyGateAction>> GetActionsForProductAsync(
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for sending notifications.
|
||||
/// </summary>
|
||||
public interface INotificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends notifications for a product.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotificationSent>> SendAsync(
|
||||
string productId,
|
||||
NotificationTemplate template,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification template.
|
||||
/// </summary>
|
||||
public sealed record NotificationTemplate
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required NotificationSeverity Severity { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public ImmutableDictionary<string, string>? Properties { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification severity levels.
|
||||
/// </summary>
|
||||
public enum NotificationSeverity
|
||||
{
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for executing gate actions.
|
||||
/// </summary>
|
||||
public interface IActionExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a policy gate action.
|
||||
/// </summary>
|
||||
Task<TriggeredAction> ExecuteAsync(
|
||||
PolicyGateAction action,
|
||||
VexDowngradeStatement statement,
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReachabilityLatticeUpdater.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Tasks: AUTOVEX-07, AUTOVEX-09 — Lattice state updates and evidence scoring
|
||||
// Description: Updates reachability lattice state when runtime observations occur.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Updates reachability lattice state based on runtime observations.
|
||||
/// </summary>
|
||||
public interface IReachabilityLatticeUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates lattice state when a vulnerable symbol is observed at runtime.
|
||||
/// </summary>
|
||||
/// <param name="detection">The hot vulnerable symbol detection.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result of the lattice update.</returns>
|
||||
Task<LatticeUpdateResult> UpdateForRuntimeObservationAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries current lattice state for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<LatticeState?> GetStateAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reachability lattice states (8-state model).
|
||||
/// </summary>
|
||||
public enum LatticeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Unknown - no analysis performed.
|
||||
/// </summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Not present - component not in SBOM.
|
||||
/// </summary>
|
||||
NotPresent = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Present but unreachable - code exists but not in call graph.
|
||||
/// </summary>
|
||||
PresentUnreachable = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Statically reachable - in call graph but not confirmed at runtime.
|
||||
/// </summary>
|
||||
StaticallyReachable = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observed - code executed in production.
|
||||
/// </summary>
|
||||
RuntimeObserved = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed reachable - both statically and runtime confirmed.
|
||||
/// </summary>
|
||||
ConfirmedReachable = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Entry point - directly invokable from outside.
|
||||
/// </summary>
|
||||
EntryPoint = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Sink - security-sensitive operation reached.
|
||||
/// </summary>
|
||||
Sink = 7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a lattice state update.
|
||||
/// </summary>
|
||||
public sealed record LatticeUpdateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the update was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous lattice state.
|
||||
/// </summary>
|
||||
public LatticeState? PreviousState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New lattice state.
|
||||
/// </summary>
|
||||
public required LatticeState NewState { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the state actually changed.
|
||||
/// </summary>
|
||||
public bool StateChanged => PreviousState != NewState;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence score update.
|
||||
/// </summary>
|
||||
public EvidenceScoreUpdate? ScoreUpdate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence score update from lattice change.
|
||||
/// </summary>
|
||||
public sealed record EvidenceScoreUpdate
|
||||
{
|
||||
/// <summary>
|
||||
/// Previous RTS (Runtime Score) value.
|
||||
/// </summary>
|
||||
public double? PreviousRts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// New RTS value.
|
||||
/// </summary>
|
||||
public required double NewRts { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Weighted score change.
|
||||
/// </summary>
|
||||
public double ScoreDelta => NewRts - (PreviousRts ?? 0.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of reachability lattice updater.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityLatticeUpdater : IReachabilityLatticeUpdater
|
||||
{
|
||||
private readonly ILogger<ReachabilityLatticeUpdater> _logger;
|
||||
private readonly ILatticeStateStore _stateStore;
|
||||
private readonly IEvidenceScoreCalculator _scoreCalculator;
|
||||
|
||||
public ReachabilityLatticeUpdater(
|
||||
ILogger<ReachabilityLatticeUpdater> logger,
|
||||
ILatticeStateStore stateStore,
|
||||
IEvidenceScoreCalculator scoreCalculator)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||
_scoreCalculator = scoreCalculator ?? throw new ArgumentNullException(nameof(scoreCalculator));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LatticeUpdateResult> UpdateForRuntimeObservationAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detection);
|
||||
|
||||
var productId = $"pkg:oci/image@{detection.ImageDigest}";
|
||||
|
||||
try
|
||||
{
|
||||
// Get current state
|
||||
var currentState = await GetStateAsync(detection.CveId, productId, cancellationToken);
|
||||
var previousState = currentState ?? LatticeState.Unknown;
|
||||
|
||||
// Compute new state based on lattice rules
|
||||
var newState = ComputeNewState(previousState, detection);
|
||||
|
||||
// Update state store
|
||||
await _stateStore.SetStateAsync(
|
||||
detection.CveId,
|
||||
productId,
|
||||
newState,
|
||||
cancellationToken);
|
||||
|
||||
// Calculate evidence score update
|
||||
var scoreUpdate = await _scoreCalculator.CalculateRtsUpdateAsync(
|
||||
detection,
|
||||
previousState,
|
||||
newState,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated lattice state for {CveId}/{ProductId}: {Previous} → {New}",
|
||||
detection.CveId, productId, previousState, newState);
|
||||
|
||||
return new LatticeUpdateResult
|
||||
{
|
||||
Success = true,
|
||||
PreviousState = previousState,
|
||||
NewState = newState,
|
||||
ScoreUpdate = scoreUpdate
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update lattice state for {CveId}", detection.CveId);
|
||||
return new LatticeUpdateResult
|
||||
{
|
||||
Success = false,
|
||||
NewState = LatticeState.Unknown,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LatticeState?> GetStateAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _stateStore.GetStateAsync(cveId, productId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes new lattice state based on current state and runtime observation.
|
||||
/// </summary>
|
||||
private static LatticeState ComputeNewState(
|
||||
LatticeState currentState,
|
||||
HotVulnerableSymbol detection)
|
||||
{
|
||||
// Lattice transition rules for runtime observation
|
||||
return currentState switch
|
||||
{
|
||||
// Unknown + runtime observation = RuntimeObserved
|
||||
LatticeState.Unknown => LatticeState.RuntimeObserved,
|
||||
|
||||
// NotPresent shouldn't happen (we found the symbol), escalate to RuntimeObserved
|
||||
LatticeState.NotPresent => LatticeState.RuntimeObserved,
|
||||
|
||||
// PresentUnreachable + runtime observation = RuntimeObserved
|
||||
LatticeState.PresentUnreachable => LatticeState.RuntimeObserved,
|
||||
|
||||
// StaticallyReachable + runtime observation = ConfirmedReachable
|
||||
LatticeState.StaticallyReachable => LatticeState.ConfirmedReachable,
|
||||
|
||||
// RuntimeObserved stays RuntimeObserved (idempotent)
|
||||
LatticeState.RuntimeObserved => LatticeState.RuntimeObserved,
|
||||
|
||||
// ConfirmedReachable stays ConfirmedReachable
|
||||
LatticeState.ConfirmedReachable => LatticeState.ConfirmedReachable,
|
||||
|
||||
// EntryPoint + runtime observation = ConfirmedReachable (preserves entry point info)
|
||||
LatticeState.EntryPoint => LatticeState.ConfirmedReachable,
|
||||
|
||||
// Sink + runtime observation = Sink (sink is highest priority)
|
||||
LatticeState.Sink => LatticeState.Sink,
|
||||
|
||||
_ => LatticeState.RuntimeObserved
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store for lattice state persistence.
|
||||
/// </summary>
|
||||
public interface ILatticeStateStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current lattice state for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task<LatticeState?> GetStateAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the lattice state for a CVE/product pair.
|
||||
/// </summary>
|
||||
Task SetStateAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
LatticeState state,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculator for evidence-weighted scores.
|
||||
/// </summary>
|
||||
public interface IEvidenceScoreCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates RTS (Runtime Score) update based on lattice state change.
|
||||
/// </summary>
|
||||
Task<EvidenceScoreUpdate> CalculateRtsUpdateAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
LatticeState previousState,
|
||||
LatticeState newState,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of evidence score calculator.
|
||||
/// </summary>
|
||||
public sealed class EvidenceScoreCalculator : IEvidenceScoreCalculator
|
||||
{
|
||||
// RTS weights by lattice state
|
||||
private static readonly ImmutableDictionary<LatticeState, double> RtsWeights =
|
||||
new Dictionary<LatticeState, double>
|
||||
{
|
||||
[LatticeState.Unknown] = 0.0,
|
||||
[LatticeState.NotPresent] = 0.0,
|
||||
[LatticeState.PresentUnreachable] = 0.1,
|
||||
[LatticeState.StaticallyReachable] = 0.4,
|
||||
[LatticeState.RuntimeObserved] = 0.8,
|
||||
[LatticeState.ConfirmedReachable] = 0.9,
|
||||
[LatticeState.EntryPoint] = 0.85,
|
||||
[LatticeState.Sink] = 1.0
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<EvidenceScoreUpdate> CalculateRtsUpdateAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
LatticeState previousState,
|
||||
LatticeState newState,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var previousRts = RtsWeights.GetValueOrDefault(previousState, 0.0);
|
||||
var newRts = RtsWeights.GetValueOrDefault(newState, 0.0);
|
||||
|
||||
// Apply confidence modifier based on observation quality
|
||||
var confidenceModifier = detection.Confidence;
|
||||
newRts = Math.Min(1.0, newRts * (0.5 + 0.5 * confidenceModifier));
|
||||
|
||||
return Task.FromResult(new EvidenceScoreUpdate
|
||||
{
|
||||
PreviousRts = previousRts,
|
||||
NewRts = newRts
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// TimeBoxedConfidence.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Tasks: AUTOVEX-12, AUTOVEX-13 — Time-boxed confidence with TTL and expiry
|
||||
// Description: Manages time-boxed VEX confidence with TTL and automatic expiry.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Manages time-boxed VEX confidence with TTL.
|
||||
/// </summary>
|
||||
public interface ITimeBoxedConfidenceManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a time-boxed confidence entry.
|
||||
/// </summary>
|
||||
Task<TimeBoxedConfidence> CreateAsync(
|
||||
VexDowngradeStatement statement,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current confidence for a CVE/product.
|
||||
/// </summary>
|
||||
Task<TimeBoxedConfidence?> GetAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes confidence TTL with new evidence.
|
||||
/// </summary>
|
||||
Task<TimeBoxedConfidence> RefreshAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
RuntimeObservationEvidence evidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Expires stale confidences.
|
||||
/// </summary>
|
||||
Task<int> ExpireStaleAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active (non-expired) confidences.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TimeBoxedConfidence>> GetActiveAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time-boxed confidence record.
|
||||
/// </summary>
|
||||
public sealed record TimeBoxedConfidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier.
|
||||
/// </summary>
|
||||
public required string Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier.
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component path or package.
|
||||
/// </summary>
|
||||
public required string ComponentPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol name.
|
||||
/// </summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current confidence value (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence state.
|
||||
/// </summary>
|
||||
public required ConfidenceState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the confidence was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the confidence was last refreshed.
|
||||
/// </summary>
|
||||
public required DateTimeOffset LastRefreshedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the confidence expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times refreshed with new evidence.
|
||||
/// </summary>
|
||||
public required int RefreshCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence history (limited to most recent).
|
||||
/// </summary>
|
||||
public required ImmutableArray<EvidenceSnapshot> EvidenceHistory { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this confidence has expired.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Time remaining until expiry.
|
||||
/// </summary>
|
||||
public TimeSpan TimeRemaining => IsExpired
|
||||
? TimeSpan.Zero
|
||||
: ExpiresAt - DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confidence state.
|
||||
/// </summary>
|
||||
public enum ConfidenceState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial state, waiting for more evidence.
|
||||
/// </summary>
|
||||
Provisional,
|
||||
|
||||
/// <summary>
|
||||
/// Confirmed with sufficient evidence.
|
||||
/// </summary>
|
||||
Confirmed,
|
||||
|
||||
/// <summary>
|
||||
/// Refreshed with recent evidence.
|
||||
/// </summary>
|
||||
Refreshed,
|
||||
|
||||
/// <summary>
|
||||
/// Decaying due to lack of recent evidence.
|
||||
/// </summary>
|
||||
Decaying,
|
||||
|
||||
/// <summary>
|
||||
/// Expired - no longer valid.
|
||||
/// </summary>
|
||||
Expired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of evidence at a point in time.
|
||||
/// </summary>
|
||||
public sealed record EvidenceSnapshot
|
||||
{
|
||||
/// <summary>
|
||||
/// When this snapshot was taken.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Observation count at time of snapshot.
|
||||
/// </summary>
|
||||
public required int ObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CPU percentage at time of snapshot.
|
||||
/// </summary>
|
||||
public required double CpuPercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Evidence score at time of snapshot.
|
||||
/// </summary>
|
||||
public required double EvidenceScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for time-boxed confidence management.
|
||||
/// </summary>
|
||||
public sealed class TimeBoxedConfidenceOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default TTL for new confidences.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum TTL allowed.
|
||||
/// </summary>
|
||||
public TimeSpan MaxTtl { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Minimum TTL allowed.
|
||||
/// </summary>
|
||||
public TimeSpan MinTtl { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// TTL extension per refresh.
|
||||
/// </summary>
|
||||
public TimeSpan RefreshExtension { get; set; } = TimeSpan.FromHours(12);
|
||||
|
||||
/// <summary>
|
||||
/// Number of refreshes before confidence becomes confirmed.
|
||||
/// </summary>
|
||||
public int ConfirmationThreshold { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Confidence decay rate per hour after expiry starts.
|
||||
/// </summary>
|
||||
public double DecayRatePerHour { get; set; } = 0.1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum evidence history entries to keep.
|
||||
/// </summary>
|
||||
public int MaxEvidenceHistory { get; set; } = 10;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of time-boxed confidence manager.
|
||||
/// </summary>
|
||||
public sealed class TimeBoxedConfidenceManager : ITimeBoxedConfidenceManager
|
||||
{
|
||||
private readonly ILogger<TimeBoxedConfidenceManager> _logger;
|
||||
private readonly TimeBoxedConfidenceOptions _options;
|
||||
private readonly IConfidenceRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public TimeBoxedConfidenceManager(
|
||||
ILogger<TimeBoxedConfidenceManager> logger,
|
||||
IOptions<TimeBoxedConfidenceOptions> options,
|
||||
IConfidenceRepository repository,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeBoxedConfidence> CreateAsync(
|
||||
VexDowngradeStatement statement,
|
||||
TimeSpan ttl,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(statement);
|
||||
|
||||
// Clamp TTL to valid range
|
||||
var clampedTtl = ClampTtl(ttl);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var confidence = new TimeBoxedConfidence
|
||||
{
|
||||
Id = $"tbc-{Guid.NewGuid():N}",
|
||||
CveId = statement.VulnerabilityId,
|
||||
ProductId = statement.ProductId,
|
||||
ComponentPath = statement.ComponentPath,
|
||||
Symbol = statement.Symbol,
|
||||
Confidence = statement.RuntimeScore,
|
||||
State = ConfidenceState.Provisional,
|
||||
CreatedAt = now,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = now.Add(clampedTtl),
|
||||
RefreshCount = 0,
|
||||
EvidenceHistory =
|
||||
[
|
||||
new EvidenceSnapshot
|
||||
{
|
||||
Timestamp = now,
|
||||
ObservationCount = 1,
|
||||
CpuPercentage = 0.0, // Initial - will be updated on refresh
|
||||
EvidenceScore = statement.RuntimeScore
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await _repository.SaveAsync(confidence, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created time-boxed confidence {Id} for {CveId}/{ProductId}, expires at {ExpiresAt}",
|
||||
confidence.Id, confidence.CveId, confidence.ProductId, confidence.ExpiresAt);
|
||||
|
||||
return confidence;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeBoxedConfidence?> GetAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var confidence = await _repository.GetAsync(cveId, productId, cancellationToken);
|
||||
|
||||
if (confidence == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update state based on current time
|
||||
return UpdateState(confidence);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TimeBoxedConfidence> RefreshAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
RuntimeObservationEvidence evidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(evidence);
|
||||
|
||||
var existing = await _repository.GetAsync(cveId, productId, cancellationToken)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No time-boxed confidence exists for {cveId}/{productId}");
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Calculate new expiry
|
||||
var newExpiry = CalculateNewExpiry(existing, now);
|
||||
var newRefreshCount = existing.RefreshCount + 1;
|
||||
|
||||
// Determine new state
|
||||
var newState = newRefreshCount >= _options.ConfirmationThreshold
|
||||
? ConfidenceState.Confirmed
|
||||
: ConfidenceState.Refreshed;
|
||||
|
||||
// Update evidence history (keep limited entries)
|
||||
var newHistory = existing.EvidenceHistory
|
||||
.Add(new EvidenceSnapshot
|
||||
{
|
||||
Timestamp = now,
|
||||
ObservationCount = evidence.ObservationCount,
|
||||
CpuPercentage = evidence.AverageCpuPercentage,
|
||||
EvidenceScore = evidence.Score
|
||||
})
|
||||
.TakeLast(_options.MaxEvidenceHistory)
|
||||
.ToImmutableArray();
|
||||
|
||||
var refreshed = existing with
|
||||
{
|
||||
Confidence = Math.Max(existing.Confidence, evidence.Score),
|
||||
State = newState,
|
||||
LastRefreshedAt = now,
|
||||
ExpiresAt = newExpiry,
|
||||
RefreshCount = newRefreshCount,
|
||||
EvidenceHistory = newHistory
|
||||
};
|
||||
|
||||
await _repository.SaveAsync(refreshed, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Refreshed confidence {Id} for {CveId}/{ProductId}, new expiry {ExpiresAt}, state {State}",
|
||||
refreshed.Id, refreshed.CveId, refreshed.ProductId, refreshed.ExpiresAt, refreshed.State);
|
||||
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ExpireStaleAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expired = await _repository.GetExpiredAsync(now, cancellationToken);
|
||||
|
||||
var count = 0;
|
||||
foreach (var confidence in expired)
|
||||
{
|
||||
var updated = confidence with
|
||||
{
|
||||
State = ConfidenceState.Expired,
|
||||
Confidence = ApplyDecay(confidence, now)
|
||||
};
|
||||
|
||||
await _repository.SaveAsync(updated, cancellationToken);
|
||||
count++;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Expired confidence {Id} for {CveId}/{ProductId}",
|
||||
confidence.Id, confidence.CveId, confidence.ProductId);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<TimeBoxedConfidence>> GetActiveAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var active = await _repository.GetActiveAsync(now, cancellationToken);
|
||||
|
||||
// Update states
|
||||
return active.Select(UpdateState).ToList();
|
||||
}
|
||||
|
||||
private TimeSpan ClampTtl(TimeSpan ttl)
|
||||
{
|
||||
if (ttl < _options.MinTtl)
|
||||
{
|
||||
return _options.MinTtl;
|
||||
}
|
||||
|
||||
if (ttl > _options.MaxTtl)
|
||||
{
|
||||
return _options.MaxTtl;
|
||||
}
|
||||
|
||||
return ttl;
|
||||
}
|
||||
|
||||
private DateTimeOffset CalculateNewExpiry(TimeBoxedConfidence existing, DateTimeOffset now)
|
||||
{
|
||||
// Extend by refresh extension, capped at max TTL from creation
|
||||
var maxExpiry = existing.CreatedAt.Add(_options.MaxTtl);
|
||||
var extended = now.Add(_options.RefreshExtension);
|
||||
|
||||
return extended > maxExpiry ? maxExpiry : extended;
|
||||
}
|
||||
|
||||
private TimeBoxedConfidence UpdateState(TimeBoxedConfidence confidence)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
if (confidence.State == ConfidenceState.Expired)
|
||||
{
|
||||
return confidence;
|
||||
}
|
||||
|
||||
if (now >= confidence.ExpiresAt)
|
||||
{
|
||||
return confidence with
|
||||
{
|
||||
State = ConfidenceState.Expired,
|
||||
Confidence = ApplyDecay(confidence, now)
|
||||
};
|
||||
}
|
||||
|
||||
// Check if in decay window (last 25% of TTL)
|
||||
var totalTtl = confidence.ExpiresAt - confidence.CreatedAt;
|
||||
var decayStart = confidence.ExpiresAt.Subtract(TimeSpan.FromTicks(totalTtl.Ticks / 4));
|
||||
|
||||
if (now >= decayStart && confidence.State != ConfidenceState.Confirmed)
|
||||
{
|
||||
return confidence with
|
||||
{
|
||||
State = ConfidenceState.Decaying,
|
||||
Confidence = ApplyDecay(confidence, now)
|
||||
};
|
||||
}
|
||||
|
||||
return confidence;
|
||||
}
|
||||
|
||||
private double ApplyDecay(TimeBoxedConfidence confidence, DateTimeOffset now)
|
||||
{
|
||||
if (now < confidence.ExpiresAt)
|
||||
{
|
||||
// Pre-expiry decay (gradual)
|
||||
var totalTtl = confidence.ExpiresAt - confidence.CreatedAt;
|
||||
var decayStart = confidence.ExpiresAt.Subtract(TimeSpan.FromTicks(totalTtl.Ticks / 4));
|
||||
|
||||
if (now < decayStart)
|
||||
{
|
||||
return confidence.Confidence;
|
||||
}
|
||||
|
||||
var decayWindow = confidence.ExpiresAt - decayStart;
|
||||
var decayProgress = (now - decayStart).TotalHours / decayWindow.TotalHours;
|
||||
var decayFactor = 1.0 - (decayProgress * 0.25); // Max 25% decay before expiry
|
||||
|
||||
return Math.Max(0.0, confidence.Confidence * decayFactor);
|
||||
}
|
||||
|
||||
// Post-expiry decay
|
||||
var hoursExpired = (now - confidence.ExpiresAt).TotalHours;
|
||||
var decay = hoursExpired * _options.DecayRatePerHour;
|
||||
|
||||
return Math.Max(0.0, confidence.Confidence - decay);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Repository for storing confidence records.
|
||||
/// </summary>
|
||||
public interface IConfidenceRepository
|
||||
{
|
||||
Task<TimeBoxedConfidence?> GetAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveAsync(
|
||||
TimeBoxedConfidence confidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<TimeBoxedConfidence>> GetExpiredAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<TimeBoxedConfidence>> GetActiveAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation for testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryConfidenceRepository : IConfidenceRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TimeBoxedConfidence> _store = new();
|
||||
|
||||
private static string Key(string cveId, string productId) => $"{cveId}:{productId}";
|
||||
|
||||
public Task<TimeBoxedConfidence?> GetAsync(
|
||||
string cveId,
|
||||
string productId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store.TryGetValue(Key(cveId, productId), out var confidence);
|
||||
return Task.FromResult(confidence);
|
||||
}
|
||||
|
||||
public Task SaveAsync(
|
||||
TimeBoxedConfidence confidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_store[Key(confidence.CveId, confidence.ProductId)] = confidence;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimeBoxedConfidence>> GetExpiredAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expired = _store.Values
|
||||
.Where(c => c.ExpiresAt <= asOf && c.State != ConfidenceState.Expired)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimeBoxedConfidence>>(expired);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimeBoxedConfidence>> GetActiveAsync(
|
||||
DateTimeOffset asOf,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var active = _store.Values
|
||||
.Where(c => c.ExpiresAt > asOf)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<TimeBoxedConfidence>>(active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexDowngradeGenerator.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Tasks: AUTOVEX-03 to AUTOVEX-06 — VEX downgrade generation with DSSE and Rekor
|
||||
// Description: Generates DSSE-signed VEX downgrade statements with transparency logging.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of VEX downgrade generator.
|
||||
/// </summary>
|
||||
public sealed class VexDowngradeGenerator : IVexDowngradeGenerator
|
||||
{
|
||||
private readonly ILogger<VexDowngradeGenerator> _logger;
|
||||
private readonly IDsseSigningService? _dsseService;
|
||||
private readonly ITransparencyLogService? _transparencyService;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public VexDowngradeGenerator(
|
||||
ILogger<VexDowngradeGenerator> logger,
|
||||
IDsseSigningService? dsseService = null,
|
||||
ITransparencyLogService? transparencyService = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_dsseService = dsseService;
|
||||
_transparencyService = transparencyService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VexDowngradeResult> GenerateDowngradeAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
AutoVexDowngradeOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(detection);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
try
|
||||
{
|
||||
// Build the downgrade statement
|
||||
var statement = BuildStatement(detection, options);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Built VEX downgrade statement {StatementId} for {CveId}",
|
||||
statement.StatementId, detection.CveId);
|
||||
|
||||
string? dsseDigest = null;
|
||||
string? rekorEntryId = null;
|
||||
|
||||
// Sign with DSSE if enabled
|
||||
if (options.SignWithDsse && _dsseService != null)
|
||||
{
|
||||
var dsseResult = await SignStatementAsync(statement, cancellationToken);
|
||||
dsseDigest = dsseResult.Digest;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Signed statement {StatementId} with DSSE: {Digest}",
|
||||
statement.StatementId, dsseDigest);
|
||||
|
||||
// Log to Rekor if enabled
|
||||
if (options.LogToRekor && _transparencyService != null)
|
||||
{
|
||||
rekorEntryId = await LogToRekorAsync(
|
||||
dsseResult.Envelope,
|
||||
statement,
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Logged statement {StatementId} to Rekor: {EntryId}",
|
||||
statement.StatementId, rekorEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
return new VexDowngradeResult
|
||||
{
|
||||
Success = true,
|
||||
Source = detection,
|
||||
Statement = statement,
|
||||
DsseDigest = dsseDigest,
|
||||
RekorEntryId = rekorEntryId
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate VEX downgrade for {CveId}", detection.CveId);
|
||||
return new VexDowngradeResult
|
||||
{
|
||||
Success = false,
|
||||
Source = detection,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private VexDowngradeStatement BuildStatement(
|
||||
HotVulnerableSymbol detection,
|
||||
AutoVexDowngradeOptions options)
|
||||
{
|
||||
var statementId = GenerateStatementId(detection);
|
||||
var topStacks = detection.TopStacks.Length > options.MaxTopStacks
|
||||
? detection.TopStacks.Take(options.MaxTopStacks).ToImmutableArray()
|
||||
: detection.TopStacks;
|
||||
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
Symbol = detection.Symbol,
|
||||
SymbolDigest = detection.SymbolDigest,
|
||||
BuildId = detection.BuildId,
|
||||
Window = detection.Window,
|
||||
CpuPercentage = detection.CpuPercentage,
|
||||
ObservationCount = detection.ObservationCount,
|
||||
TopStacks = topStacks,
|
||||
ContainerIds = detection.ContainerIds,
|
||||
StaticProof = detection.FuncProofRef
|
||||
};
|
||||
|
||||
var statusNotes = BuildStatusNotes(detection);
|
||||
|
||||
return new VexDowngradeStatement
|
||||
{
|
||||
StatementId = statementId,
|
||||
VulnerabilityId = detection.CveId,
|
||||
ProductId = $"pkg:oci/{GetImageName(detection.ImageDigest)}@{detection.ImageDigest}",
|
||||
Status = VexDowngradeStatus.Affected,
|
||||
StatusNotes = statusNotes,
|
||||
Evidence = evidence,
|
||||
GeneratedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string GenerateStatementId(HotVulnerableSymbol detection)
|
||||
{
|
||||
var input = $"{detection.CveId}:{detection.ImageDigest}:{detection.SymbolDigest}:{detection.Window.Start:O}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return $"stellaops:autovex:{Convert.ToHexString(hash)[..16].ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string BuildStatusNotes(HotVulnerableSymbol detection)
|
||||
{
|
||||
return $"Vulnerable symbol '{detection.Symbol}' observed in production. " +
|
||||
$"Observation count: {detection.ObservationCount}, CPU: {detection.CpuPercentage:F1}%, " +
|
||||
$"Window: {detection.Window.Start:u} to {detection.Window.End:u}. " +
|
||||
$"Build-ID: {detection.BuildId[..Math.Min(16, detection.BuildId.Length)]}...";
|
||||
}
|
||||
|
||||
private static string GetImageName(string imageDigest)
|
||||
{
|
||||
// Extract image name from digest, default to "image" if not determinable
|
||||
return "image";
|
||||
}
|
||||
|
||||
private async Task<DsseSignResult> SignStatementAsync(
|
||||
VexDowngradeStatement statement,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(statement, JsonOptions);
|
||||
var payloadBase64 = Convert.ToBase64String(payload);
|
||||
|
||||
var envelope = await _dsseService!.SignAsync(
|
||||
payloadBase64,
|
||||
VexDowngradeMediaTypes.StatementPayloadType,
|
||||
cancellationToken);
|
||||
|
||||
var envelopeJson = JsonSerializer.SerializeToUtf8Bytes(envelope, JsonOptions);
|
||||
var digest = $"sha256:{Convert.ToHexString(SHA256.HashData(envelopeJson)).ToLowerInvariant()}";
|
||||
|
||||
return new DsseSignResult(envelope, digest);
|
||||
}
|
||||
|
||||
private async Task<string?> LogToRekorAsync(
|
||||
object dsseEnvelope,
|
||||
VexDowngradeStatement statement,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _transparencyService!.LogEntryAsync(
|
||||
dsseEnvelope,
|
||||
VexDowngradeMediaTypes.StatementPayloadType,
|
||||
statement.VulnerabilityId,
|
||||
cancellationToken);
|
||||
|
||||
return result.EntryId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to log to Rekor, continuing without transparency log");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record DsseSignResult(object Envelope, string Digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Media types for VEX downgrade statements.
|
||||
/// </summary>
|
||||
public static class VexDowngradeMediaTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// DSSE payload type for VEX downgrade statements.
|
||||
/// </summary>
|
||||
public const string StatementPayloadType = "application/vnd.stellaops.vex.downgrade+json";
|
||||
|
||||
/// <summary>
|
||||
/// Media type for signed VEX downgrade envelope.
|
||||
/// </summary>
|
||||
public const string SignedEnvelopeType = "application/vnd.stellaops.vex.downgrade.dsse+json";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for DSSE signing operations.
|
||||
/// </summary>
|
||||
public interface IDsseSigningService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a payload with DSSE.
|
||||
/// </summary>
|
||||
Task<object> SignAsync(
|
||||
string payloadBase64,
|
||||
string payloadType,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for transparency log operations.
|
||||
/// </summary>
|
||||
public interface ITransparencyLogService
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs an entry to the transparency log.
|
||||
/// </summary>
|
||||
Task<TransparencyLogResult> LogEntryAsync(
|
||||
object dsseEnvelope,
|
||||
string payloadType,
|
||||
string subject,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from transparency log operation.
|
||||
/// </summary>
|
||||
public sealed record TransparencyLogResult
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string LogId { get; init; }
|
||||
public required long LogIndex { get; init; }
|
||||
public DateTimeOffset? IntegratedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexNotReachableJustification.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Task: AUTOVEX-14 — VEX with not_reachable_at_runtime justification
|
||||
// Description: Generates VEX statements with not_reachable_at_runtime justification
|
||||
// when runtime evidence shows symbol is present but not observed.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex;
|
||||
|
||||
/// <summary>
|
||||
/// Generates VEX statements with not_reachable_at_runtime justification.
|
||||
/// </summary>
|
||||
public interface INotReachableJustificationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a VEX statement for a symbol that is present but not observed at runtime.
|
||||
/// </summary>
|
||||
Task<NotReachableVexResult> GenerateNotReachableVexAsync(
|
||||
NotReachableAnalysis analysis,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes runtime data to find symbols that are present but not reached.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotReachableAnalysis>> AnalyzeUnreachedSymbolsAsync(
|
||||
string imageDigest,
|
||||
TimeSpan observationWindow,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analysis of a symbol that is present but not reached at runtime.
|
||||
/// </summary>
|
||||
public sealed record NotReachableAnalysis
|
||||
{
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier.
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Component path (package or library).
|
||||
/// </summary>
|
||||
public required string ComponentPath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Symbol that is present but not reached.
|
||||
/// </summary>
|
||||
public required string Symbol { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest being analyzed.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How the symbol's presence was determined.
|
||||
/// </summary>
|
||||
public required PresenceMethod PresenceMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of the observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ObservationStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of the observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ObservationEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total duration of observation.
|
||||
/// </summary>
|
||||
public TimeSpan ObservationDuration => ObservationEnd - ObservationStart;
|
||||
|
||||
/// <summary>
|
||||
/// Number of runtime samples during the window.
|
||||
/// </summary>
|
||||
public required int RuntimeSampleCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times any code in the component was observed.
|
||||
/// </summary>
|
||||
public required int ComponentObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of times the specific symbol was observed (should be 0 for not_reachable).
|
||||
/// </summary>
|
||||
public required int SymbolObservationCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in the not-reachable determination (0.0-1.0).
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Static analysis paths that could theoretically reach the symbol.
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? StaticPaths { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons why the symbol was not reached.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotReachableReason>? Reasons { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Method used to determine symbol presence.
|
||||
/// </summary>
|
||||
public enum PresenceMethod
|
||||
{
|
||||
/// <summary>
|
||||
/// Symbol found via SBOM component scan.
|
||||
/// </summary>
|
||||
SbomComponent,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol found in binary via static analysis.
|
||||
/// </summary>
|
||||
StaticBinaryAnalysis,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol found in debug symbols (DWARF/PDB).
|
||||
/// </summary>
|
||||
DebugSymbols,
|
||||
|
||||
/// <summary>
|
||||
/// Symbol found in build-id index.
|
||||
/// </summary>
|
||||
BuildIdIndex
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason why a symbol was not reached at runtime.
|
||||
/// </summary>
|
||||
public sealed record NotReachableReason
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason category.
|
||||
/// </summary>
|
||||
public required NotReachableCategory Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence in this specific reason.
|
||||
/// </summary>
|
||||
public double? Confidence { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Categories of not-reachable reasons.
|
||||
/// </summary>
|
||||
public enum NotReachableCategory
|
||||
{
|
||||
/// <summary>
|
||||
/// Feature gated and gate was never triggered.
|
||||
/// </summary>
|
||||
FeatureGated,
|
||||
|
||||
/// <summary>
|
||||
/// Error handler that was never triggered.
|
||||
/// </summary>
|
||||
ErrorHandler,
|
||||
|
||||
/// <summary>
|
||||
/// Dead code that has no callers.
|
||||
/// </summary>
|
||||
DeadCode,
|
||||
|
||||
/// <summary>
|
||||
/// Only reached in test environments.
|
||||
/// </summary>
|
||||
TestOnly,
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific code for a different platform.
|
||||
/// </summary>
|
||||
WrongPlatform,
|
||||
|
||||
/// <summary>
|
||||
/// Requires configuration that isn't active.
|
||||
/// </summary>
|
||||
ConfigurationDisabled,
|
||||
|
||||
/// <summary>
|
||||
/// Part of optional plugin not loaded.
|
||||
/// </summary>
|
||||
UnloadedPlugin,
|
||||
|
||||
/// <summary>
|
||||
/// Unknown reason - symbol simply not observed.
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating a not-reachable VEX statement.
|
||||
/// </summary>
|
||||
public sealed record NotReachableVexResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether generation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The generated VEX statement.
|
||||
/// </summary>
|
||||
public NotReachableVexStatement? Statement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Warnings generated during analysis.
|
||||
/// </summary>
|
||||
public ImmutableArray<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement with not_reachable_at_runtime justification.
|
||||
/// </summary>
|
||||
public sealed record NotReachableVexStatement
|
||||
{
|
||||
/// <summary>
|
||||
/// Statement identifier.
|
||||
/// </summary>
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE identifier.
|
||||
/// </summary>
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Product identifier.
|
||||
/// </summary>
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status (should be NotAffected).
|
||||
/// </summary>
|
||||
public required VexStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Justification (should be VulnerableCodeNotInExecutePath).
|
||||
/// </summary>
|
||||
public required VexJustification Justification { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Impact statement.
|
||||
/// </summary>
|
||||
public required string ImpactStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action statement (guidance for consumers).
|
||||
/// </summary>
|
||||
public string? ActionStatement { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the statement was generated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the statement expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ValidUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Runtime observation evidence.
|
||||
/// </summary>
|
||||
public required RuntimeNotReachableEvidence Evidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope if signed.
|
||||
/// </summary>
|
||||
public DsseEnvelope? DsseEnvelope { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX status values.
|
||||
/// </summary>
|
||||
public enum VexStatus
|
||||
{
|
||||
NotAffected,
|
||||
Affected,
|
||||
Fixed,
|
||||
UnderInvestigation
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX justification values.
|
||||
/// </summary>
|
||||
public enum VexJustification
|
||||
{
|
||||
ComponentNotPresent,
|
||||
VulnerableCodeNotPresent,
|
||||
VulnerableCodeNotInExecutePath,
|
||||
VulnerableCodeCannotBeControlledByAdversary,
|
||||
InlineMitigationsAlreadyExist
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence that vulnerable code is not reachable at runtime.
|
||||
/// </summary>
|
||||
public sealed record RuntimeNotReachableEvidence
|
||||
{
|
||||
/// <summary>
|
||||
/// Image digest observed.
|
||||
/// </summary>
|
||||
public required string ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ObservationStart { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of observation window.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ObservationEnd { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total runtime samples.
|
||||
/// </summary>
|
||||
public required int TotalSamples { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Samples hitting the component (but not the vulnerable symbol).
|
||||
/// </summary>
|
||||
public required int ComponentSamples { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Samples hitting the vulnerable symbol (should be 0).
|
||||
/// </summary>
|
||||
public required int VulnerableSymbolSamples { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Confidence level.
|
||||
/// </summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reasons identified for why symbol is not reached.
|
||||
/// </summary>
|
||||
public ImmutableArray<NotReachableReason>? Reasons { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE envelope for signed statements.
|
||||
/// </summary>
|
||||
public sealed record DsseEnvelope
|
||||
{
|
||||
public required string PayloadType { get; init; }
|
||||
public required string Payload { get; init; }
|
||||
public required ImmutableArray<DsseSignature> Signatures { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DSSE signature.
|
||||
/// </summary>
|
||||
public sealed record DsseSignature
|
||||
{
|
||||
public required string KeyId { get; init; }
|
||||
public required string Sig { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of not-reachable justification service.
|
||||
/// </summary>
|
||||
public sealed class NotReachableJustificationService : INotReachableJustificationService
|
||||
{
|
||||
private readonly ILogger<NotReachableJustificationService> _logger;
|
||||
private readonly IRuntimeDataService _runtimeDataService;
|
||||
private readonly ISymbolPresenceService _presenceService;
|
||||
private readonly IDsseSigningService? _signingService;
|
||||
private readonly NotReachableOptions _options;
|
||||
|
||||
public NotReachableJustificationService(
|
||||
ILogger<NotReachableJustificationService> logger,
|
||||
IRuntimeDataService runtimeDataService,
|
||||
ISymbolPresenceService presenceService,
|
||||
IDsseSigningService? signingService = null,
|
||||
NotReachableOptions? options = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_runtimeDataService = runtimeDataService ?? throw new ArgumentNullException(nameof(runtimeDataService));
|
||||
_presenceService = presenceService ?? throw new ArgumentNullException(nameof(presenceService));
|
||||
_signingService = signingService;
|
||||
_options = options ?? new NotReachableOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<NotReachableVexResult> GenerateNotReachableVexAsync(
|
||||
NotReachableAnalysis analysis,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(analysis);
|
||||
|
||||
// Validate the analysis shows no observation
|
||||
if (analysis.SymbolObservationCount > 0)
|
||||
{
|
||||
return new NotReachableVexResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Symbol was observed {analysis.SymbolObservationCount} times - cannot generate not_reachable VEX"
|
||||
};
|
||||
}
|
||||
|
||||
// Check confidence threshold
|
||||
if (analysis.Confidence < _options.MinConfidenceThreshold)
|
||||
{
|
||||
return new NotReachableVexResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Confidence {analysis.Confidence:F2} is below threshold {_options.MinConfidenceThreshold:F2}"
|
||||
};
|
||||
}
|
||||
|
||||
// Check observation window
|
||||
if (analysis.ObservationDuration < _options.MinObservationWindow)
|
||||
{
|
||||
return new NotReachableVexResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Observation window {analysis.ObservationDuration} is below minimum {_options.MinObservationWindow}"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var statementId = $"notreachable-{Guid.NewGuid():N}";
|
||||
|
||||
var evidence = new RuntimeNotReachableEvidence
|
||||
{
|
||||
ImageDigest = analysis.ImageDigest,
|
||||
ObservationStart = analysis.ObservationStart,
|
||||
ObservationEnd = analysis.ObservationEnd,
|
||||
TotalSamples = analysis.RuntimeSampleCount,
|
||||
ComponentSamples = analysis.ComponentObservationCount,
|
||||
VulnerableSymbolSamples = 0,
|
||||
Confidence = analysis.Confidence,
|
||||
Reasons = analysis.Reasons
|
||||
};
|
||||
|
||||
var impactStatement = BuildImpactStatement(analysis);
|
||||
|
||||
var statement = new NotReachableVexStatement
|
||||
{
|
||||
StatementId = statementId,
|
||||
VulnerabilityId = analysis.CveId,
|
||||
ProductId = analysis.ProductId,
|
||||
Status = VexStatus.NotAffected,
|
||||
Justification = VexJustification.VulnerableCodeNotInExecutePath,
|
||||
ImpactStatement = impactStatement,
|
||||
ActionStatement = _options.DefaultActionStatement,
|
||||
Timestamp = now,
|
||||
ValidUntil = now.Add(_options.DefaultValidityPeriod),
|
||||
Evidence = evidence
|
||||
};
|
||||
|
||||
// Sign if signing service available
|
||||
if (_signingService != null)
|
||||
{
|
||||
var envelope = await _signingService.SignAsync(
|
||||
statement,
|
||||
"application/vnd.stellaops.vex.not-reachable+json",
|
||||
cancellationToken);
|
||||
|
||||
statement = statement with { DsseEnvelope = envelope };
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Generated not_reachable VEX {StatementId} for {CveId} in {ProductId}",
|
||||
statementId, analysis.CveId, analysis.ProductId);
|
||||
|
||||
var warnings = new List<string>();
|
||||
if (analysis.ComponentObservationCount == 0)
|
||||
{
|
||||
warnings.Add("Component itself was never observed - consider whether this component is actually deployed");
|
||||
}
|
||||
|
||||
return new NotReachableVexResult
|
||||
{
|
||||
Success = true,
|
||||
Statement = statement,
|
||||
Warnings = warnings.Count > 0 ? [.. warnings] : null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate not_reachable VEX for {CveId}", analysis.CveId);
|
||||
return new NotReachableVexResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<NotReachableAnalysis>> AnalyzeUnreachedSymbolsAsync(
|
||||
string imageDigest,
|
||||
TimeSpan observationWindow,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(imageDigest);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var windowStart = now.Subtract(observationWindow);
|
||||
|
||||
// Get known vulnerable symbols for this image
|
||||
var vulnerableSymbols = await _presenceService.GetVulnerableSymbolsAsync(
|
||||
imageDigest,
|
||||
cancellationToken);
|
||||
|
||||
// Get runtime observations during the window
|
||||
var observations = await _runtimeDataService.GetObservationsAsync(
|
||||
imageDigest,
|
||||
windowStart,
|
||||
now,
|
||||
cancellationToken);
|
||||
|
||||
var observedSymbols = observations
|
||||
.Select(o => o.Symbol)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var results = new List<NotReachableAnalysis>();
|
||||
|
||||
foreach (var vulnerable in vulnerableSymbols)
|
||||
{
|
||||
// Check if this symbol was observed
|
||||
if (observedSymbols.Contains(vulnerable.Symbol))
|
||||
{
|
||||
continue; // Skip - was observed
|
||||
}
|
||||
|
||||
// Count component observations
|
||||
var componentObs = observations
|
||||
.Count(o => o.ComponentPath == vulnerable.ComponentPath);
|
||||
|
||||
// Calculate confidence based on observation coverage
|
||||
var confidence = CalculateConfidence(
|
||||
observations.Count,
|
||||
componentObs,
|
||||
observationWindow);
|
||||
|
||||
var analysis = new NotReachableAnalysis
|
||||
{
|
||||
CveId = vulnerable.CveId,
|
||||
ProductId = vulnerable.ProductId,
|
||||
ComponentPath = vulnerable.ComponentPath,
|
||||
Symbol = vulnerable.Symbol,
|
||||
ImageDigest = imageDigest,
|
||||
PresenceMethod = vulnerable.PresenceMethod,
|
||||
ObservationStart = windowStart,
|
||||
ObservationEnd = now,
|
||||
RuntimeSampleCount = observations.Count,
|
||||
ComponentObservationCount = componentObs,
|
||||
SymbolObservationCount = 0,
|
||||
Confidence = confidence,
|
||||
Reasons = InferReasons(componentObs, observations.Count)
|
||||
};
|
||||
|
||||
results.Add(analysis);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Analyzed {Image}: found {Count} unreached vulnerable symbols",
|
||||
imageDigest, results.Count);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private double CalculateConfidence(
|
||||
int totalSamples,
|
||||
int componentSamples,
|
||||
TimeSpan window)
|
||||
{
|
||||
// Base confidence from sample count
|
||||
var sampleConfidence = Math.Min(1.0, totalSamples / 1000.0);
|
||||
|
||||
// Boost if component itself is active
|
||||
var componentFactor = componentSamples > 0 ? 1.2 : 0.8;
|
||||
|
||||
// Boost for longer observation windows
|
||||
var windowFactor = Math.Min(1.0, window.TotalHours / 24.0);
|
||||
|
||||
return Math.Min(1.0, sampleConfidence * componentFactor * windowFactor);
|
||||
}
|
||||
|
||||
private static ImmutableArray<NotReachableReason> InferReasons(
|
||||
int componentSamples,
|
||||
int totalSamples)
|
||||
{
|
||||
var reasons = new List<NotReachableReason>();
|
||||
|
||||
if (componentSamples == 0)
|
||||
{
|
||||
reasons.Add(new NotReachableReason
|
||||
{
|
||||
Category = NotReachableCategory.UnloadedPlugin,
|
||||
Description = "Component was never observed in runtime samples",
|
||||
Confidence = 0.6
|
||||
});
|
||||
}
|
||||
else if (componentSamples > 0 && totalSamples > 100)
|
||||
{
|
||||
reasons.Add(new NotReachableReason
|
||||
{
|
||||
Category = NotReachableCategory.FeatureGated,
|
||||
Description = "Component is active but specific symbol path not triggered",
|
||||
Confidence = 0.7
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
reasons.Add(new NotReachableReason
|
||||
{
|
||||
Category = NotReachableCategory.Unknown,
|
||||
Description = "Symbol not observed during monitoring window",
|
||||
Confidence = 0.5
|
||||
});
|
||||
}
|
||||
|
||||
return [.. reasons];
|
||||
}
|
||||
|
||||
private static string BuildImpactStatement(NotReachableAnalysis analysis)
|
||||
{
|
||||
var reasonText = analysis.Reasons?.FirstOrDefault()?.Description
|
||||
?? "not observed during runtime monitoring";
|
||||
|
||||
return $"The vulnerable code path in {analysis.Symbol} within component " +
|
||||
$"{analysis.ComponentPath} was {reasonText}. " +
|
||||
$"Based on {analysis.RuntimeSampleCount} runtime samples over " +
|
||||
$"{analysis.ObservationDuration.TotalHours:F1} hours, the vulnerable function " +
|
||||
$"was never executed, indicating it is not reachable in this deployment configuration.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for not-reachable justification generation.
|
||||
/// </summary>
|
||||
public sealed class NotReachableOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum confidence threshold to generate a not_reachable VEX.
|
||||
/// </summary>
|
||||
public double MinConfidenceThreshold { get; set; } = 0.6;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum observation window required.
|
||||
/// </summary>
|
||||
public TimeSpan MinObservationWindow { get; set; } = TimeSpan.FromHours(4);
|
||||
|
||||
/// <summary>
|
||||
/// Default validity period for generated statements.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultValidityPeriod { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Default action statement for generated VEX.
|
||||
/// </summary>
|
||||
public string DefaultActionStatement { get; set; } =
|
||||
"Continue monitoring runtime execution. Re-evaluate if deployment configuration changes.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service to query runtime observation data.
|
||||
/// </summary>
|
||||
public interface IRuntimeDataService
|
||||
{
|
||||
Task<IReadOnlyList<RuntimeObservation>> GetObservationsAsync(
|
||||
string imageDigest,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A runtime observation record.
|
||||
/// </summary>
|
||||
public sealed record RuntimeObservation
|
||||
{
|
||||
public required string Symbol { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public double? CpuPercentage { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service to query symbol presence in images.
|
||||
/// </summary>
|
||||
public interface ISymbolPresenceService
|
||||
{
|
||||
Task<IReadOnlyList<VulnerableSymbolPresence>> GetVulnerableSymbolsAsync(
|
||||
string imageDigest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A vulnerable symbol's presence in an image.
|
||||
/// </summary>
|
||||
public sealed record VulnerableSymbolPresence
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductId { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required PresenceMethod PresenceMethod { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// AutoVexDowngradeServiceTests.cs
|
||||
// Sprint: SPRINT_20251226_011_BE_auto_vex_downgrade
|
||||
// Task: AUTOVEX-16 — Integration tests for auto-VEX downgrade
|
||||
// Description: Unit and integration tests for AutoVexDowngradeService.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.Core.AutoVex.Tests;
|
||||
|
||||
public class AutoVexDowngradeServiceTests
|
||||
{
|
||||
private readonly TestHotSymbolQueryService _hotSymbolService;
|
||||
private readonly TestVulnerableSymbolCorrelator _correlator;
|
||||
private readonly AutoVexDowngradeOptions _options;
|
||||
private readonly AutoVexDowngradeService _sut;
|
||||
|
||||
public AutoVexDowngradeServiceTests()
|
||||
{
|
||||
_hotSymbolService = new TestHotSymbolQueryService();
|
||||
_correlator = new TestVulnerableSymbolCorrelator();
|
||||
_options = new AutoVexDowngradeOptions
|
||||
{
|
||||
MinObservationCount = 5,
|
||||
MinCpuPercentage = 1.0,
|
||||
MinConfidenceThreshold = 0.7
|
||||
};
|
||||
|
||||
_sut = new AutoVexDowngradeService(
|
||||
NullLogger<AutoVexDowngradeService>.Instance,
|
||||
Options.Create(_options),
|
||||
_hotSymbolService,
|
||||
_correlator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectHotVulnerableSymbols_ReturnsEmptyWhenNoHotSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
_hotSymbolService.SetHotSymbols([]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectHotVulnerableSymbols_FiltersOutNonVulnerable()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
|
||||
_hotSymbolService.SetHotSymbols(
|
||||
[
|
||||
new HotSymbolEntry
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
BuildId = "build-001",
|
||||
SymbolId = "sym-001",
|
||||
Symbol = "libfoo::safe_function",
|
||||
ObservationCount = 100,
|
||||
CpuPercentage = 15.0
|
||||
}
|
||||
]);
|
||||
|
||||
_correlator.SetCorrelations([]); // No CVE correlation
|
||||
|
||||
// Act
|
||||
var result = await _sut.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectHotVulnerableSymbols_ReturnsVulnerableSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
|
||||
_hotSymbolService.SetHotSymbols(
|
||||
[
|
||||
new HotSymbolEntry
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
BuildId = "build-001",
|
||||
SymbolId = "sym-001",
|
||||
Symbol = "libfoo::parse_header",
|
||||
ObservationCount = 100,
|
||||
CpuPercentage = 15.0
|
||||
}
|
||||
]);
|
||||
|
||||
_correlator.SetCorrelations(
|
||||
[
|
||||
new VulnerableSymbolCorrelation
|
||||
{
|
||||
SymbolId = "sym-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
PackagePath = "libfoo",
|
||||
Confidence = 0.95
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.Equal("CVE-2024-1234", result[0].CveId);
|
||||
Assert.Equal("libfoo::parse_header", result[0].Symbol);
|
||||
Assert.Equal(15.0, result[0].CpuPercentage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectHotVulnerableSymbols_FiltersOutBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
|
||||
_hotSymbolService.SetHotSymbols(
|
||||
[
|
||||
new HotSymbolEntry
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
BuildId = "build-001",
|
||||
SymbolId = "sym-001",
|
||||
Symbol = "libfoo::parse_header",
|
||||
ObservationCount = 3, // Below threshold of 5
|
||||
CpuPercentage = 0.5 // Below threshold of 1.0
|
||||
}
|
||||
]);
|
||||
|
||||
_correlator.SetCorrelations(
|
||||
[
|
||||
new VulnerableSymbolCorrelation
|
||||
{
|
||||
SymbolId = "sym-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
PackagePath = "libfoo",
|
||||
Confidence = 0.95
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result); // Filtered out due to thresholds
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DetectHotVulnerableSymbols_CalculatesConfidenceCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
|
||||
_hotSymbolService.SetHotSymbols(
|
||||
[
|
||||
new HotSymbolEntry
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
BuildId = "build-001",
|
||||
SymbolId = "sym-001",
|
||||
Symbol = "libfoo::parse_header",
|
||||
ObservationCount = 1000, // High observation count
|
||||
CpuPercentage = 25.0 // High CPU
|
||||
}
|
||||
]);
|
||||
|
||||
_correlator.SetCorrelations(
|
||||
[
|
||||
new VulnerableSymbolCorrelation
|
||||
{
|
||||
SymbolId = "sym-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
PackagePath = "libfoo",
|
||||
Confidence = 0.95
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _sut.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Single(result);
|
||||
Assert.True(result[0].Confidence > 0.9); // High confidence expected
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessImageAsync_CompletePipeline()
|
||||
{
|
||||
// Arrange
|
||||
var imageDigest = "sha256:abc123";
|
||||
var window = TimeWindow.FromDuration(TimeSpan.FromHours(1));
|
||||
|
||||
_hotSymbolService.SetHotSymbols(
|
||||
[
|
||||
new HotSymbolEntry
|
||||
{
|
||||
ImageDigest = imageDigest,
|
||||
BuildId = "build-001",
|
||||
SymbolId = "sym-001",
|
||||
Symbol = "libssl::ssl3_get_record",
|
||||
ObservationCount = 500,
|
||||
CpuPercentage = 12.5
|
||||
}
|
||||
]);
|
||||
|
||||
_correlator.SetCorrelations(
|
||||
[
|
||||
new VulnerableSymbolCorrelation
|
||||
{
|
||||
SymbolId = "sym-001",
|
||||
CveId = "CVE-2024-5678",
|
||||
PackagePath = "openssl",
|
||||
Confidence = 0.92
|
||||
}
|
||||
]);
|
||||
|
||||
var generator = new TestVexDowngradeGenerator();
|
||||
var service = new AutoVexDowngradeService(
|
||||
NullLogger<AutoVexDowngradeService>.Instance,
|
||||
Options.Create(_options),
|
||||
_hotSymbolService,
|
||||
_correlator);
|
||||
|
||||
// Act
|
||||
var detections = await service.DetectHotVulnerableSymbolsAsync(imageDigest, window);
|
||||
|
||||
// Assert
|
||||
Assert.Single(detections);
|
||||
var detection = detections[0];
|
||||
Assert.Equal("CVE-2024-5678", detection.CveId);
|
||||
Assert.Equal("openssl", detection.PackagePath);
|
||||
Assert.Equal(500, detection.ObservationCount);
|
||||
}
|
||||
|
||||
#region Test Doubles
|
||||
|
||||
private class TestHotSymbolQueryService : IHotSymbolQueryService
|
||||
{
|
||||
private List<HotSymbolEntry> _hotSymbols = [];
|
||||
|
||||
public void SetHotSymbols(List<HotSymbolEntry> symbols) => _hotSymbols = symbols;
|
||||
|
||||
public Task<IReadOnlyList<HotSymbolEntry>> GetHotSymbolsAsync(
|
||||
string imageDigest,
|
||||
TimeWindow window,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _hotSymbols
|
||||
.Where(s => s.ImageDigest == imageDigest)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<HotSymbolEntry>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
private class TestVulnerableSymbolCorrelator : IVulnerableSymbolCorrelator
|
||||
{
|
||||
private List<VulnerableSymbolCorrelation> _correlations = [];
|
||||
|
||||
public void SetCorrelations(List<VulnerableSymbolCorrelation> correlations)
|
||||
=> _correlations = correlations;
|
||||
|
||||
public Task<IReadOnlyList<VulnerableSymbolCorrelation>> CorrelateAsync(
|
||||
IReadOnlyList<HotSymbolEntry> hotSymbols,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var symbolIds = hotSymbols.Select(s => s.SymbolId).ToHashSet();
|
||||
var result = _correlations
|
||||
.Where(c => symbolIds.Contains(c.SymbolId))
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<VulnerableSymbolCorrelation>>(result);
|
||||
}
|
||||
}
|
||||
|
||||
private class TestVexDowngradeGenerator : IVexDowngradeGenerator
|
||||
{
|
||||
public Task<VexDowngradeResult> GenerateAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var statement = new VexDowngradeStatement
|
||||
{
|
||||
StatementId = $"vex-{Guid.NewGuid():N}",
|
||||
VulnerabilityId = detection.CveId,
|
||||
ProductId = detection.ProductId,
|
||||
ComponentPath = detection.PackagePath,
|
||||
Symbol = detection.Symbol,
|
||||
OriginalStatus = "not_affected",
|
||||
NewStatus = "affected",
|
||||
Justification = "vulnerable_code_in_execute_path",
|
||||
RuntimeScore = detection.Confidence,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
DssePayload = null,
|
||||
RekorLogIndex = null
|
||||
};
|
||||
|
||||
return Task.FromResult(new VexDowngradeResult
|
||||
{
|
||||
Success = true,
|
||||
Source = detection,
|
||||
Statement = statement
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public class TimeBoxedConfidenceManagerTests
|
||||
{
|
||||
private readonly InMemoryConfidenceRepository _repository;
|
||||
private readonly TimeBoxedConfidenceOptions _options;
|
||||
private readonly TimeBoxedConfidenceManager _sut;
|
||||
|
||||
public TimeBoxedConfidenceManagerTests()
|
||||
{
|
||||
_repository = new InMemoryConfidenceRepository();
|
||||
_options = new TimeBoxedConfidenceOptions
|
||||
{
|
||||
DefaultTtl = TimeSpan.FromHours(24),
|
||||
MaxTtl = TimeSpan.FromDays(7),
|
||||
MinTtl = TimeSpan.FromHours(1),
|
||||
RefreshExtension = TimeSpan.FromHours(12),
|
||||
ConfirmationThreshold = 3,
|
||||
DecayRatePerHour = 0.1
|
||||
};
|
||||
|
||||
_sut = new TimeBoxedConfidenceManager(
|
||||
NullLogger<TimeBoxedConfidenceManager>.Instance,
|
||||
Options.Create(_options),
|
||||
_repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CreatesProvisionalConfidence()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexDowngradeStatement
|
||||
{
|
||||
StatementId = "stmt-001",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductId = "product-001",
|
||||
ComponentPath = "libfoo",
|
||||
Symbol = "libfoo::parse",
|
||||
OriginalStatus = "not_affected",
|
||||
NewStatus = "affected",
|
||||
Justification = "runtime_observed",
|
||||
RuntimeScore = 0.85,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.CreateAsync(statement, TimeSpan.FromHours(24));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("CVE-2024-1234", result.CveId);
|
||||
Assert.Equal("product-001", result.ProductId);
|
||||
Assert.Equal(ConfidenceState.Provisional, result.State);
|
||||
Assert.Equal(0, result.RefreshCount);
|
||||
Assert.False(result.IsExpired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_UpdatesStateAndExtendsTtl()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexDowngradeStatement
|
||||
{
|
||||
StatementId = "stmt-001",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductId = "product-001",
|
||||
ComponentPath = "libfoo",
|
||||
Symbol = "libfoo::parse",
|
||||
OriginalStatus = "not_affected",
|
||||
NewStatus = "affected",
|
||||
Justification = "runtime_observed",
|
||||
RuntimeScore = 0.85,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var created = await _sut.CreateAsync(statement, TimeSpan.FromHours(24));
|
||||
var originalExpiry = created.ExpiresAt;
|
||||
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
BuildId = "build-001",
|
||||
ObservationCount = 50,
|
||||
AverageCpuPercentage = 5.0,
|
||||
Score = 0.9,
|
||||
Window = new TimeWindow
|
||||
{
|
||||
Start = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
End = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var refreshed = await _sut.RefreshAsync("CVE-2024-1234", "product-001", evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ConfidenceState.Refreshed, refreshed.State);
|
||||
Assert.Equal(1, refreshed.RefreshCount);
|
||||
Assert.True(refreshed.ExpiresAt >= originalExpiry);
|
||||
Assert.Equal(2, refreshed.EvidenceHistory.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_BecomesConfirmedAfterThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var statement = new VexDowngradeStatement
|
||||
{
|
||||
StatementId = "stmt-001",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
ProductId = "product-001",
|
||||
ComponentPath = "libfoo",
|
||||
Symbol = "libfoo::parse",
|
||||
OriginalStatus = "not_affected",
|
||||
NewStatus = "affected",
|
||||
Justification = "runtime_observed",
|
||||
RuntimeScore = 0.85,
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _sut.CreateAsync(statement, TimeSpan.FromHours(24));
|
||||
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
BuildId = "build-001",
|
||||
ObservationCount = 50,
|
||||
AverageCpuPercentage = 5.0,
|
||||
Score = 0.9,
|
||||
Window = new TimeWindow
|
||||
{
|
||||
Start = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
End = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
// Act - refresh 3 times (confirmation threshold)
|
||||
await _sut.RefreshAsync("CVE-2024-1234", "product-001", evidence);
|
||||
await _sut.RefreshAsync("CVE-2024-1234", "product-001", evidence);
|
||||
var final = await _sut.RefreshAsync("CVE-2024-1234", "product-001", evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ConfidenceState.Confirmed, final.State);
|
||||
Assert.Equal(3, final.RefreshCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsNullForNonExistent()
|
||||
{
|
||||
// Act
|
||||
var result = await _sut.GetAsync("CVE-NONEXISTENT", "product-000");
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class ReachabilityLatticeUpdaterTests
|
||||
{
|
||||
[Fact]
|
||||
public void UpdateState_UnknownToRuntimeObserved()
|
||||
{
|
||||
// Arrange
|
||||
var current = LatticeState.Unknown;
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
BuildId = "build-001",
|
||||
ObservationCount = 10,
|
||||
AverageCpuPercentage = 5.0,
|
||||
Score = 0.8,
|
||||
Window = new TimeWindow
|
||||
{
|
||||
Start = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
End = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ReachabilityLatticeUpdater.ComputeTransition(current, evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(LatticeState.RuntimeObserved, result.NewState);
|
||||
Assert.True(result.Changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_StaticallyReachableToConfirmedReachable()
|
||||
{
|
||||
// Arrange
|
||||
var current = LatticeState.StaticallyReachable;
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
BuildId = "build-001",
|
||||
ObservationCount = 100,
|
||||
AverageCpuPercentage = 15.0,
|
||||
Score = 0.95,
|
||||
Window = new TimeWindow
|
||||
{
|
||||
Start = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
End = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ReachabilityLatticeUpdater.ComputeTransition(current, evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(LatticeState.ConfirmedReachable, result.NewState);
|
||||
Assert.True(result.Changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_EntryPointRemains()
|
||||
{
|
||||
// Arrange - EntryPoint is maximum state, should not change
|
||||
var current = LatticeState.EntryPoint;
|
||||
var evidence = new RuntimeObservationEvidence
|
||||
{
|
||||
BuildId = "build-001",
|
||||
ObservationCount = 10,
|
||||
AverageCpuPercentage = 5.0,
|
||||
Score = 0.8,
|
||||
Window = new TimeWindow
|
||||
{
|
||||
Start = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
End = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = ReachabilityLatticeUpdater.ComputeTransition(current, evidence);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(LatticeState.EntryPoint, result.NewState);
|
||||
Assert.False(result.Changed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LatticeState.Unknown, 0.0)]
|
||||
[InlineData(LatticeState.NotPresent, 0.0)]
|
||||
[InlineData(LatticeState.PresentUnreachable, 0.1)]
|
||||
[InlineData(LatticeState.StaticallyReachable, 0.4)]
|
||||
[InlineData(LatticeState.RuntimeObserved, 0.7)]
|
||||
[InlineData(LatticeState.ConfirmedReachable, 0.9)]
|
||||
[InlineData(LatticeState.EntryPoint, 1.0)]
|
||||
[InlineData(LatticeState.Sink, 1.0)]
|
||||
public void GetRtsWeight_ReturnsCorrectWeight(LatticeState state, double expectedWeight)
|
||||
{
|
||||
// Act
|
||||
var weight = ReachabilityLatticeUpdater.GetRtsWeight(state);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedWeight, weight, precision: 2);
|
||||
}
|
||||
}
|
||||
|
||||
public class DriftGateIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void GateVerdict_BlockTriggersCorrectActions()
|
||||
{
|
||||
// Arrange
|
||||
var action = new PolicyGateAction
|
||||
{
|
||||
ActionId = "release-block",
|
||||
Type = PolicyGateActionType.ReleaseBlock,
|
||||
Description = "Block release pipeline"
|
||||
};
|
||||
|
||||
// Act - using reflection or internal testing
|
||||
var shouldTrigger = ShouldTriggerAction(action, GateVerdict.Block);
|
||||
|
||||
// Assert
|
||||
Assert.True(shouldTrigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateVerdict_PassTriggersNoActions()
|
||||
{
|
||||
// Arrange
|
||||
var action = new PolicyGateAction
|
||||
{
|
||||
ActionId = "release-block",
|
||||
Type = PolicyGateActionType.ReleaseBlock,
|
||||
Description = "Block release pipeline"
|
||||
};
|
||||
|
||||
// Act
|
||||
var shouldTrigger = ShouldTriggerAction(action, GateVerdict.Pass);
|
||||
|
||||
// Assert
|
||||
Assert.False(shouldTrigger);
|
||||
}
|
||||
|
||||
// Helper to test action triggering logic
|
||||
private static bool ShouldTriggerAction(PolicyGateAction action, GateVerdict verdict)
|
||||
{
|
||||
return verdict switch
|
||||
{
|
||||
GateVerdict.Block => action.Type is PolicyGateActionType.ReleaseBlock
|
||||
or PolicyGateActionType.CanaryFreeze
|
||||
or PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
GateVerdict.Quarantine => action.Type is PolicyGateActionType.Quarantine
|
||||
or PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
GateVerdict.Warn => action.Type is PolicyGateActionType.NotifyOnly
|
||||
or PolicyGateActionType.CreateTicket,
|
||||
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Test Models
|
||||
|
||||
internal sealed record HotSymbolEntry
|
||||
{
|
||||
public required string ImageDigest { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
public required string SymbolId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public required double CpuPercentage { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record VulnerableSymbolCorrelation
|
||||
{
|
||||
public required string SymbolId { get; init; }
|
||||
public required string CveId { get; init; }
|
||||
public required string PackagePath { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
}
|
||||
|
||||
internal interface IHotSymbolQueryService
|
||||
{
|
||||
Task<IReadOnlyList<HotSymbolEntry>> GetHotSymbolsAsync(
|
||||
string imageDigest,
|
||||
TimeWindow window,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal interface IVulnerableSymbolCorrelator
|
||||
{
|
||||
Task<IReadOnlyList<VulnerableSymbolCorrelation>> CorrelateAsync(
|
||||
IReadOnlyList<HotSymbolEntry> hotSymbols,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal interface IVexDowngradeGenerator
|
||||
{
|
||||
Task<VexDowngradeResult> GenerateAsync(
|
||||
HotVulnerableSymbol detection,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed record TimeWindow
|
||||
{
|
||||
public required DateTimeOffset Start { get; init; }
|
||||
public required DateTimeOffset End { get; init; }
|
||||
|
||||
public static TimeWindow FromDuration(TimeSpan duration)
|
||||
{
|
||||
var end = DateTimeOffset.UtcNow;
|
||||
return new TimeWindow
|
||||
{
|
||||
Start = end.Subtract(duration),
|
||||
End = end
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
Reference in New Issue
Block a user