250 lines
8.3 KiB
C#
250 lines
8.3 KiB
C#
// -----------------------------------------------------------------------------
|
|
// VexGateService.cs
|
|
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
|
// Description: VEX gate service implementation.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace StellaOps.Scanner.Gate;
|
|
|
|
/// <summary>
|
|
/// Default implementation of <see cref="IVexGateService"/>.
|
|
/// Evaluates findings against VEX evidence and policy rules.
|
|
/// </summary>
|
|
public sealed class VexGateService : IVexGateService
|
|
{
|
|
private readonly IVexGatePolicy _policyEvaluator;
|
|
private readonly IVexObservationProvider? _vexProvider;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<VexGateService> _logger;
|
|
|
|
public VexGateService(
|
|
IVexGatePolicy policyEvaluator,
|
|
TimeProvider timeProvider,
|
|
ILogger<VexGateService> logger,
|
|
IVexObservationProvider? vexProvider = null)
|
|
{
|
|
_policyEvaluator = policyEvaluator;
|
|
_vexProvider = vexProvider;
|
|
_timeProvider = timeProvider;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<VexGateResult> EvaluateAsync(
|
|
VexGateFinding finding,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
_logger.LogDebug(
|
|
"Evaluating VEX gate for finding {FindingId} ({VulnerabilityId})",
|
|
finding.FindingId,
|
|
finding.VulnerabilityId);
|
|
|
|
// Collect evidence from VEX provider and finding context
|
|
var evidence = await BuildEvidenceAsync(finding, cancellationToken);
|
|
|
|
// Evaluate against policy rules
|
|
var (decision, ruleId, rationale) = _policyEvaluator.Evaluate(evidence);
|
|
|
|
// Build statement references if we have VEX data
|
|
var contributingStatements = evidence.VendorStatus is not null
|
|
? await GetContributingStatementsAsync(
|
|
finding.VulnerabilityId,
|
|
finding.Purl,
|
|
cancellationToken)
|
|
: ImmutableArray<VexStatementRef>.Empty;
|
|
|
|
return new VexGateResult
|
|
{
|
|
Decision = decision,
|
|
Rationale = rationale,
|
|
PolicyRuleMatched = ruleId,
|
|
ContributingStatements = contributingStatements,
|
|
Evidence = evidence,
|
|
EvaluatedAt = _timeProvider.GetUtcNow(),
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
|
|
IReadOnlyList<VexGateFinding> findings,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (findings.Count == 0)
|
|
{
|
|
return ImmutableArray<GatedFinding>.Empty;
|
|
}
|
|
|
|
_logger.LogDebug("Evaluating VEX gate for {Count} findings in batch", findings.Count);
|
|
|
|
// Pre-fetch VEX data for all findings if provider supports batch
|
|
if (_vexProvider is IVexObservationBatchProvider batchProvider)
|
|
{
|
|
var queries = findings
|
|
.Select(f => new VexLookupKey(f.VulnerabilityId, f.Purl))
|
|
.Distinct()
|
|
.ToList();
|
|
|
|
await batchProvider.PrefetchAsync(queries, cancellationToken);
|
|
}
|
|
|
|
// Evaluate each finding
|
|
var results = new List<GatedFinding>(findings.Count);
|
|
|
|
foreach (var finding in findings)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var gateResult = await EvaluateAsync(finding, cancellationToken);
|
|
results.Add(new GatedFinding
|
|
{
|
|
Finding = finding,
|
|
GateResult = gateResult,
|
|
});
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"VEX gate batch complete: {Pass} passed, {Warn} warned, {Block} blocked",
|
|
results.Count(r => r.GateResult.Decision == VexGateDecision.Pass),
|
|
results.Count(r => r.GateResult.Decision == VexGateDecision.Warn),
|
|
results.Count(r => r.GateResult.Decision == VexGateDecision.Block));
|
|
|
|
return results.ToImmutableArray();
|
|
}
|
|
|
|
private async Task<VexGateEvidence> BuildEvidenceAsync(
|
|
VexGateFinding finding,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
VexStatus? vendorStatus = null;
|
|
VexJustification? justification = null;
|
|
var backportHints = ImmutableArray<string>.Empty;
|
|
var confidenceScore = 0.5; // Default confidence
|
|
|
|
// Query VEX provider if available
|
|
if (_vexProvider is not null)
|
|
{
|
|
var vexResult = await _vexProvider.GetVexStatusAsync(
|
|
finding.VulnerabilityId,
|
|
finding.Purl,
|
|
cancellationToken);
|
|
|
|
if (vexResult is not null)
|
|
{
|
|
vendorStatus = vexResult.Status;
|
|
justification = vexResult.Justification;
|
|
confidenceScore = vexResult.Confidence;
|
|
backportHints = vexResult.BackportHints;
|
|
}
|
|
}
|
|
|
|
// Use exploitability from finding or infer from VEX status
|
|
var isExploitable = finding.IsExploitable ?? (vendorStatus == VexStatus.Affected);
|
|
|
|
return new VexGateEvidence
|
|
{
|
|
VendorStatus = vendorStatus,
|
|
Justification = justification,
|
|
IsReachable = finding.IsReachable ?? true, // Conservative: assume reachable if unknown
|
|
HasCompensatingControl = finding.HasCompensatingControl ?? false,
|
|
ConfidenceScore = confidenceScore,
|
|
BackportHints = backportHints,
|
|
IsExploitable = isExploitable,
|
|
SeverityLevel = finding.SeverityLevel,
|
|
};
|
|
}
|
|
|
|
private async Task<ImmutableArray<VexStatementRef>> GetContributingStatementsAsync(
|
|
string vulnerabilityId,
|
|
string purl,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (_vexProvider is null)
|
|
{
|
|
return ImmutableArray<VexStatementRef>.Empty;
|
|
}
|
|
|
|
var statements = await _vexProvider.GetStatementsAsync(
|
|
vulnerabilityId,
|
|
purl,
|
|
cancellationToken);
|
|
|
|
return statements
|
|
.Select(s => new VexStatementRef
|
|
{
|
|
StatementId = s.StatementId,
|
|
IssuerId = s.IssuerId,
|
|
Status = s.Status,
|
|
Timestamp = s.Timestamp,
|
|
TrustWeight = s.TrustWeight,
|
|
})
|
|
.ToImmutableArray();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Key for VEX lookups.
|
|
/// </summary>
|
|
public sealed record VexLookupKey(string VulnerabilityId, string Purl);
|
|
|
|
/// <summary>
|
|
/// Result from VEX observation provider.
|
|
/// </summary>
|
|
public sealed record VexObservationResult
|
|
{
|
|
public required VexStatus Status { get; init; }
|
|
public VexJustification? Justification { get; init; }
|
|
public double Confidence { get; init; } = 1.0;
|
|
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// VEX statement info for contributing statements.
|
|
/// </summary>
|
|
public sealed record VexStatementInfo
|
|
{
|
|
public required string StatementId { get; init; }
|
|
public required string IssuerId { get; init; }
|
|
public required VexStatus Status { get; init; }
|
|
public required DateTimeOffset Timestamp { get; init; }
|
|
public double TrustWeight { get; init; } = 1.0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Interface for VEX observation data provider.
|
|
/// Abstracts access to VEX statements from Excititor or other sources.
|
|
/// </summary>
|
|
public interface IVexObservationProvider
|
|
{
|
|
/// <summary>
|
|
/// Gets the VEX status for a vulnerability and component.
|
|
/// </summary>
|
|
Task<VexObservationResult?> GetVexStatusAsync(
|
|
string vulnerabilityId,
|
|
string purl,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
/// <summary>
|
|
/// Gets all VEX statements for a vulnerability and component.
|
|
/// </summary>
|
|
Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
|
|
string vulnerabilityId,
|
|
string purl,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extended interface for batch VEX observation prefetching.
|
|
/// </summary>
|
|
public interface IVexObservationBatchProvider : IVexObservationProvider
|
|
{
|
|
/// <summary>
|
|
/// Prefetches VEX data for multiple lookups.
|
|
/// </summary>
|
|
Task PrefetchAsync(
|
|
IReadOnlyList<VexLookupKey> keys,
|
|
CancellationToken cancellationToken = default);
|
|
}
|