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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
});
}
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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