sprints and audit work

This commit is contained in:
StellaOps Bot
2026-01-07 09:36:16 +02:00
parent 05833e0af2
commit ab364c6032
377 changed files with 64534 additions and 1627 deletions

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// CachingVexObservationProvider.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Caching wrapper for VEX observation provider with batch prefetch.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Caching wrapper for <see cref="IVexObservationProvider"/> that supports batch prefetch.
/// Implements short TTL bounded cache for gate throughput optimization.
/// </summary>
public sealed class CachingVexObservationProvider : IVexObservationBatchProvider, IDisposable
{
private readonly IVexObservationQuery _query;
private readonly string _tenantId;
private readonly MemoryCache _cache;
private readonly TimeSpan _cacheTtl;
private readonly ILogger<CachingVexObservationProvider> _logger;
private readonly SemaphoreSlim _prefetchLock = new(1, 1);
/// <summary>
/// Default cache size limit (number of entries).
/// </summary>
public const int DefaultCacheSizeLimit = 10_000;
/// <summary>
/// Default cache TTL.
/// </summary>
public static readonly TimeSpan DefaultCacheTtl = TimeSpan.FromMinutes(5);
public CachingVexObservationProvider(
IVexObservationQuery query,
string tenantId,
ILogger<CachingVexObservationProvider> logger,
TimeSpan? cacheTtl = null,
int? cacheSizeLimit = null)
{
_query = query;
_tenantId = tenantId;
_logger = logger;
_cacheTtl = cacheTtl ?? DefaultCacheTtl;
_cache = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = cacheSizeLimit ?? DefaultCacheSizeLimit,
});
}
/// <inheritdoc />
public async Task<VexObservationResult?> GetVexStatusAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var cacheKey = BuildCacheKey(vulnerabilityId, purl);
if (_cache.TryGetValue(cacheKey, out VexObservationResult? cached))
{
_logger.LogTrace("VEX cache hit: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
return cached;
}
_logger.LogTrace("VEX cache miss: {VulnerabilityId} / {Purl}", vulnerabilityId, purl);
var queryResult = await _query.GetEffectiveStatusAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
if (queryResult is null)
{
return null;
}
var result = MapToObservationResult(queryResult);
CacheResult(cacheKey, result);
return result;
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementInfo>> GetStatementsAsync(
string vulnerabilityId,
string purl,
CancellationToken cancellationToken = default)
{
var statements = await _query.GetStatementsAsync(
_tenantId,
vulnerabilityId,
purl,
cancellationToken);
return statements
.Select(s => new VexStatementInfo
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = s.Status,
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
})
.ToList();
}
/// <inheritdoc />
public async Task PrefetchAsync(
IReadOnlyList<VexLookupKey> keys,
CancellationToken cancellationToken = default)
{
if (keys.Count == 0)
{
return;
}
// Deduplicate and find keys not in cache
var uncachedKeys = keys
.DistinctBy(k => BuildCacheKey(k.VulnerabilityId, k.Purl))
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.Purl), out _))
.Select(k => new VexQueryKey(k.VulnerabilityId, k.Purl))
.ToList();
if (uncachedKeys.Count == 0)
{
_logger.LogDebug("Prefetch: all {Count} keys already cached", keys.Count);
return;
}
_logger.LogDebug(
"Prefetch: fetching {UncachedCount} of {TotalCount} keys",
uncachedKeys.Count,
keys.Count);
await _prefetchLock.WaitAsync(cancellationToken);
try
{
// Double-check after acquiring lock
uncachedKeys = uncachedKeys
.Where(k => !_cache.TryGetValue(BuildCacheKey(k.VulnerabilityId, k.ProductId), out _))
.ToList();
if (uncachedKeys.Count == 0)
{
return;
}
var batchResults = await _query.BatchLookupAsync(
_tenantId,
uncachedKeys,
cancellationToken);
foreach (var (key, result) in batchResults)
{
var cacheKey = BuildCacheKey(key.VulnerabilityId, key.ProductId);
var observationResult = MapToObservationResult(result);
CacheResult(cacheKey, observationResult);
}
_logger.LogDebug(
"Prefetch: cached {ResultCount} results",
batchResults.Count);
}
finally
{
_prefetchLock.Release();
}
}
/// <summary>
/// Gets cache statistics.
/// </summary>
public CacheStatistics GetStatistics() => new()
{
CurrentEntryCount = _cache.Count,
};
/// <inheritdoc />
public void Dispose()
{
_cache.Dispose();
_prefetchLock.Dispose();
}
private static string BuildCacheKey(string vulnerabilityId, string productId) =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"vex:{0}:{1}",
vulnerabilityId.ToUpperInvariant(),
productId.ToLowerInvariant());
private static VexObservationResult MapToObservationResult(VexObservationQueryResult queryResult) =>
new()
{
Status = queryResult.Status,
Justification = queryResult.Justification,
Confidence = queryResult.Confidence,
BackportHints = queryResult.BackportHints,
};
private void CacheResult(string cacheKey, VexObservationResult result)
{
var options = new MemoryCacheEntryOptions
{
Size = 1,
SlidingExpiration = _cacheTtl,
AbsoluteExpirationRelativeToNow = _cacheTtl * 2,
};
_cache.Set(cacheKey, result, options);
}
}
/// <summary>
/// Cache statistics for monitoring.
/// </summary>
public sealed record CacheStatistics
{
/// <summary>
/// Current number of entries in cache.
/// </summary>
public int CurrentEntryCount { get; init; }
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// IVexGateService.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Interface for VEX gate evaluation service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Service for evaluating findings against VEX evidence and policy rules.
/// Determines whether findings should pass, warn, or block before triage.
/// </summary>
public interface IVexGateService
{
/// <summary>
/// Evaluates a single finding against VEX evidence and policy rules.
/// </summary>
/// <param name="finding">Finding to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation result.</returns>
Task<VexGateResult> EvaluateAsync(
VexGateFinding finding,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates multiple findings in batch for efficiency.
/// </summary>
/// <param name="findings">Findings to evaluate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Gate evaluation results for each finding.</returns>
Task<ImmutableArray<GatedFinding>> EvaluateBatchAsync(
IReadOnlyList<VexGateFinding> findings,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Interface for pluggable VEX gate policy evaluation.
/// </summary>
public interface IVexGatePolicy
{
/// <summary>
/// Gets the current policy configuration.
/// </summary>
VexGatePolicy Policy { get; }
/// <summary>
/// Evaluates evidence against policy rules and returns the decision.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>Tuple of (decision, matched rule ID, rationale).</returns>
(VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence);
}
/// <summary>
/// Input finding for VEX gate evaluation.
/// </summary>
public sealed record VexGateFinding
{
/// <summary>
/// Unique identifier for the finding.
/// </summary>
public required string FindingId { get; init; }
/// <summary>
/// CVE or vulnerability identifier.
/// </summary>
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
public required string Purl { get; init; }
/// <summary>
/// Image digest containing the component.
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
public string? SeverityLevel { get; init; }
/// <summary>
/// Whether reachability has been analyzed.
/// </summary>
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls are in place.
/// </summary>
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Whether the vulnerability is known to be exploitable.
/// </summary>
public bool? IsExploitable { get; init; }
}
/// <summary>
/// Finding with gate evaluation result.
/// </summary>
public sealed record GatedFinding
{
/// <summary>
/// Reference to the original finding.
/// </summary>
public required VexGateFinding Finding { get; init; }
/// <summary>
/// Gate evaluation result.
/// </summary>
public required VexGateResult GateResult { get; init; }
}

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// IVexObservationQuery.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Query interface for VEX observations used by gate service.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Query interface for VEX observations.
/// Abstracts data access for gate service lookups.
/// </summary>
public interface IVexObservationQuery
{
/// <summary>
/// Looks up the effective VEX status for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>VEX observation result or null if not found.</returns>
Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="vulnerabilityId">CVE or vulnerability ID.</param>
/// <param name="productId">PURL or product identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of VEX statement information.</returns>
Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Performs batch lookup of VEX statuses for multiple vulnerability/product pairs.
/// More efficient than individual lookups for gate evaluation.
/// </summary>
/// <param name="tenantId">Tenant identifier.</param>
/// <param name="queries">List of vulnerability/product pairs to look up.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Dictionary mapping query keys to results.</returns>
Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for VEX query lookups.
/// </summary>
public sealed record VexQueryKey(string VulnerabilityId, string ProductId)
{
/// <summary>
/// Creates a normalized key for consistent lookup.
/// </summary>
public string ToNormalizedKey() =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0}|{1}",
VulnerabilityId.ToUpperInvariant(),
ProductId.ToLowerInvariant());
}
/// <summary>
/// Result from VEX observation query.
/// </summary>
public sealed record VexObservationQueryResult
{
/// <summary>
/// Effective VEX status.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// Confidence score for this status (0.0 to 1.0).
/// </summary>
public double Confidence { get; init; } = 1.0;
/// <summary>
/// Backport hints if status is Fixed.
/// </summary>
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Source of the statement (vendor name or issuer).
/// </summary>
public string? Source { get; init; }
/// <summary>
/// When the effective status was last updated.
/// </summary>
public DateTimeOffset LastUpdated { get; init; }
}
/// <summary>
/// Individual VEX statement query result.
/// </summary>
public sealed record VexStatementQueryResult
{
/// <summary>
/// Statement identifier.
/// </summary>
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the statement.
/// </summary>
public required string IssuerId { get; init; }
/// <summary>
/// VEX status in the statement.
/// </summary>
public required VexStatus Status { get; init; }
/// <summary>
/// Justification if status is NotAffected.
/// </summary>
public VexJustification? Justification { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight for this statement.
/// </summary>
public double TrustWeight { get; init; } = 1.0;
/// <summary>
/// Source URL for the statement.
/// </summary>
public string? SourceUrl { get; init; }
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>StellaOps.Scanner.Gate</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,305 @@
// -----------------------------------------------------------------------------
// VexGateAuditLogger.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T023
// Description: Audit logging for VEX gate decisions (compliance requirement).
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Interface for audit logging VEX gate decisions.
/// </summary>
public interface IVexGateAuditLogger
{
/// <summary>
/// Logs a gate evaluation event.
/// </summary>
void LogEvaluation(VexGateAuditEntry entry);
/// <summary>
/// Logs a batch gate evaluation summary.
/// </summary>
void LogBatchSummary(VexGateBatchAuditEntry entry);
}
/// <summary>
/// Audit entry for a single gate evaluation.
/// </summary>
public sealed record VexGateAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Finding ID that was evaluated.
/// </summary>
[JsonPropertyName("findingId")]
public required string FindingId { get; init; }
/// <summary>
/// Vulnerability ID (CVE).
/// </summary>
[JsonPropertyName("vulnerabilityId")]
public required string VulnerabilityId { get; init; }
/// <summary>
/// Package URL of the affected component.
/// </summary>
[JsonPropertyName("purl")]
public string? Purl { get; init; }
/// <summary>
/// Gate decision made.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Policy rule that matched.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Rationale for the decision.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// Evidence that contributed to the decision.
/// </summary>
[JsonPropertyName("evidence")]
public VexGateEvidenceSummary? Evidence { get; init; }
/// <summary>
/// Number of VEX statements consulted.
/// </summary>
[JsonPropertyName("statementCount")]
public int StatementCount { get; init; }
/// <summary>
/// Confidence score of the decision.
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// When the evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source IP or identifier of the requester (for compliance).
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Summarized evidence for audit logging.
/// </summary>
public sealed record VexGateEvidenceSummary
{
[JsonPropertyName("vendorStatus")]
public string? VendorStatus { get; init; }
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Audit entry for a batch gate evaluation.
/// </summary>
public sealed record VexGateBatchAuditEntry
{
/// <summary>
/// Unique audit entry ID.
/// </summary>
[JsonPropertyName("auditId")]
public required string AuditId { get; init; }
/// <summary>
/// Scan job ID.
/// </summary>
[JsonPropertyName("scanId")]
public required string ScanId { get; init; }
/// <summary>
/// Tenant ID.
/// </summary>
[JsonPropertyName("tenantId")]
public string? TenantId { get; init; }
/// <summary>
/// Total findings evaluated.
/// </summary>
[JsonPropertyName("totalFindings")]
public int TotalFindings { get; init; }
/// <summary>
/// Number that passed.
/// </summary>
[JsonPropertyName("passedCount")]
public int PassedCount { get; init; }
/// <summary>
/// Number with warnings.
/// </summary>
[JsonPropertyName("warnedCount")]
public int WarnedCount { get; init; }
/// <summary>
/// Number blocked.
/// </summary>
[JsonPropertyName("blockedCount")]
public int BlockedCount { get; init; }
/// <summary>
/// Policy version used.
/// </summary>
[JsonPropertyName("policyVersion")]
public string? PolicyVersion { get; init; }
/// <summary>
/// Whether gate was bypassed.
/// </summary>
[JsonPropertyName("bypassed")]
public bool Bypassed { get; init; }
/// <summary>
/// Evaluation duration in milliseconds.
/// </summary>
[JsonPropertyName("durationMs")]
public double DurationMs { get; init; }
/// <summary>
/// When the batch evaluation was performed (UTC).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Source context for compliance.
/// </summary>
[JsonPropertyName("sourceContext")]
public string? SourceContext { get; init; }
}
/// <summary>
/// Default implementation using structured logging.
/// </summary>
public sealed class VexGateAuditLogger : IVexGateAuditLogger
{
private readonly ILogger<VexGateAuditLogger> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public VexGateAuditLogger(ILogger<VexGateAuditLogger> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public void LogEvaluation(VexGateAuditEntry entry)
{
// Log as structured event for compliance systems to consume
_logger.LogInformation(
"VEX_GATE_AUDIT: {AuditId} | Scan={ScanId} | Finding={FindingId} | CVE={VulnerabilityId} | " +
"Decision={Decision} | Rule={PolicyRuleMatched} | Confidence={ConfidenceScore:F2} | " +
"Evidence=[Reachable={IsReachable}, Exploitable={IsExploitable}]",
entry.AuditId,
entry.ScanId,
entry.FindingId,
entry.VulnerabilityId,
entry.Decision,
entry.PolicyRuleMatched,
entry.ConfidenceScore,
entry.Evidence?.IsReachable ?? false,
entry.Evidence?.IsExploitable ?? false);
// Also log full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_AUDIT_DETAIL: {AuditJson}", json);
}
}
/// <inheritdoc />
public void LogBatchSummary(VexGateBatchAuditEntry entry)
{
_logger.LogInformation(
"VEX_GATE_BATCH_AUDIT: {AuditId} | Scan={ScanId} | Total={TotalFindings} | " +
"Passed={PassedCount} | Warned={WarnedCount} | Blocked={BlockedCount} | " +
"Bypassed={Bypassed} | Duration={DurationMs}ms",
entry.AuditId,
entry.ScanId,
entry.TotalFindings,
entry.PassedCount,
entry.WarnedCount,
entry.BlockedCount,
entry.Bypassed,
entry.DurationMs);
// Full JSON for audit trail
if (_logger.IsEnabled(LogLevel.Debug))
{
var json = JsonSerializer.Serialize(entry, JsonOptions);
_logger.LogDebug("VEX_GATE_BATCH_AUDIT_DETAIL: {AuditJson}", json);
}
}
}
/// <summary>
/// No-op audit logger for testing or when auditing is disabled.
/// </summary>
public sealed class NullVexGateAuditLogger : IVexGateAuditLogger
{
public static readonly NullVexGateAuditLogger Instance = new();
private NullVexGateAuditLogger() { }
public void LogEvaluation(VexGateAuditEntry entry) { }
public void LogBatchSummary(VexGateBatchAuditEntry entry) { }
}

View File

@@ -0,0 +1,38 @@
// -----------------------------------------------------------------------------
// VexGateDecision.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate decision enum for pre-triage filtering.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Decision outcome from VEX gate evaluation.
/// Determines whether a finding proceeds to triage and with what flags.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexGateDecision>))]
public enum VexGateDecision
{
/// <summary>
/// Finding cleared by VEX evidence - no action needed.
/// Typically when vendor status is NotAffected with sufficient trust.
/// </summary>
[JsonStringEnumMemberName("pass")]
Pass,
/// <summary>
/// Finding has partial evidence - proceed with caution.
/// Used when evidence is inconclusive or conditions partially met.
/// </summary>
[JsonStringEnumMemberName("warn")]
Warn,
/// <summary>
/// Finding requires immediate attention - exploitable and reachable.
/// Highest priority for triage queue.
/// </summary>
[JsonStringEnumMemberName("block")]
Block
}

View File

@@ -0,0 +1,263 @@
// -----------------------------------------------------------------------------
// VexGateExcititorAdapter.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Adapter bridging VexGateService with Excititor VEX statements.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Adapter that implements <see cref="IVexObservationQuery"/> by querying Excititor.
/// This is a reference implementation that can be used when Excititor is available.
/// </summary>
/// <remarks>
/// The actual Excititor integration requires a project reference to Excititor.Persistence.
/// This adapter provides the contract and can be implemented in a separate assembly
/// that has access to both Scanner.Gate and Excititor.Persistence.
/// </remarks>
public sealed class VexGateExcititorAdapter : IVexObservationQuery
{
private readonly IVexStatementDataSource _dataSource;
private readonly ILogger<VexGateExcititorAdapter> _logger;
public VexGateExcititorAdapter(
IVexStatementDataSource dataSource,
ILogger<VexGateExcititorAdapter> logger)
{
_dataSource = dataSource;
_logger = logger;
}
/// <inheritdoc />
public async Task<VexObservationQueryResult?> GetEffectiveStatusAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
_logger.LogDebug(
"Looking up effective VEX status: tenant={TenantId}, vuln={VulnerabilityId}, product={ProductId}",
tenantId, vulnerabilityId, productId);
var statement = await _dataSource.GetEffectiveStatementAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
if (statement is null)
{
return null;
}
return new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
/// <inheritdoc />
public async Task<IReadOnlyList<VexStatementQueryResult>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default)
{
var statements = await _dataSource.GetStatementsAsync(
tenantId,
vulnerabilityId,
productId,
cancellationToken);
return statements
.Select(s => new VexStatementQueryResult
{
StatementId = s.StatementId,
IssuerId = s.IssuerId,
Status = MapStatus(s.Status),
Justification = MapJustification(s.Justification),
Timestamp = s.Timestamp,
TrustWeight = s.TrustWeight,
SourceUrl = s.SourceUrl,
})
.ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<VexQueryKey, VexObservationQueryResult>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexQueryKey> queries,
CancellationToken cancellationToken = default)
{
if (queries.Count == 0)
{
return ImmutableDictionary<VexQueryKey, VexObservationQueryResult>.Empty;
}
_logger.LogDebug(
"Batch lookup of {Count} VEX queries for tenant {TenantId}",
queries.Count, tenantId);
var results = new Dictionary<VexQueryKey, VexObservationQueryResult>();
// Use batch lookup if data source supports it
if (_dataSource is IVexStatementBatchDataSource batchSource)
{
var batchKeys = queries
.Select(q => new VexBatchKey(q.VulnerabilityId, q.ProductId))
.ToList();
var batchResults = await batchSource.BatchLookupAsync(
tenantId,
batchKeys,
cancellationToken);
foreach (var (key, statement) in batchResults)
{
var queryKey = new VexQueryKey(key.VulnerabilityId, key.ProductId);
results[queryKey] = new VexObservationQueryResult
{
Status = MapStatus(statement.Status),
Justification = MapJustification(statement.Justification),
Confidence = statement.TrustWeight,
BackportHints = statement.BackportHints,
Source = statement.Source,
LastUpdated = statement.LastUpdated,
};
}
}
else
{
// Fallback to individual lookups
foreach (var query in queries)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await GetEffectiveStatusAsync(
tenantId,
query.VulnerabilityId,
query.ProductId,
cancellationToken);
if (result is not null)
{
results[query] = result;
}
}
}
return results;
}
private static VexStatus MapStatus(VexStatementStatus status) => status switch
{
VexStatementStatus.NotAffected => VexStatus.NotAffected,
VexStatementStatus.Affected => VexStatus.Affected,
VexStatementStatus.Fixed => VexStatus.Fixed,
VexStatementStatus.UnderInvestigation => VexStatus.UnderInvestigation,
_ => VexStatus.UnderInvestigation,
};
private static VexJustification? MapJustification(VexStatementJustification? justification) =>
justification switch
{
VexStatementJustification.ComponentNotPresent => VexJustification.ComponentNotPresent,
VexStatementJustification.VulnerableCodeNotPresent => VexJustification.VulnerableCodeNotPresent,
VexStatementJustification.VulnerableCodeNotInExecutePath => VexJustification.VulnerableCodeNotInExecutePath,
VexStatementJustification.VulnerableCodeCannotBeControlledByAdversary => VexJustification.VulnerableCodeCannotBeControlledByAdversary,
VexStatementJustification.InlineMitigationsAlreadyExist => VexJustification.InlineMitigationsAlreadyExist,
_ => null,
};
}
/// <summary>
/// Data source abstraction for VEX statements.
/// Implemented by Excititor persistence layer.
/// </summary>
public interface IVexStatementDataSource
{
/// <summary>
/// Gets the effective VEX statement for a vulnerability/product combination.
/// </summary>
Task<VexStatementData?> GetEffectiveStatementAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all VEX statements for a vulnerability/product combination.
/// </summary>
Task<IReadOnlyList<VexStatementData>> GetStatementsAsync(
string tenantId,
string vulnerabilityId,
string productId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Extended interface for batch data source operations.
/// </summary>
public interface IVexStatementBatchDataSource : IVexStatementDataSource
{
/// <summary>
/// Performs batch lookup of VEX statements.
/// </summary>
Task<IReadOnlyDictionary<VexBatchKey, VexStatementData>> BatchLookupAsync(
string tenantId,
IReadOnlyList<VexBatchKey> keys,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Key for batch VEX lookups.
/// </summary>
public sealed record VexBatchKey(string VulnerabilityId, string ProductId);
/// <summary>
/// VEX statement data transfer object.
/// </summary>
public sealed record VexStatementData
{
public required string StatementId { get; init; }
public required string IssuerId { get; init; }
public required VexStatementStatus Status { get; init; }
public VexStatementJustification? Justification { get; init; }
public required DateTimeOffset Timestamp { get; init; }
public DateTimeOffset LastUpdated { get; init; }
public double TrustWeight { get; init; } = 1.0;
public string? Source { get; init; }
public string? SourceUrl { get; init; }
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
}
/// <summary>
/// VEX statement status (mirrors Excititor's VexStatus).
/// </summary>
public enum VexStatementStatus
{
NotAffected,
Affected,
Fixed,
UnderInvestigation
}
/// <summary>
/// VEX statement justification (mirrors Excititor's VexJustification).
/// </summary>
public enum VexStatementJustification
{
ComponentNotPresent,
VulnerableCodeNotPresent,
VulnerableCodeNotInExecutePath,
VulnerableCodeCannotBeControlledByAdversary,
InlineMitigationsAlreadyExist
}

View File

@@ -0,0 +1,379 @@
// -----------------------------------------------------------------------------
// VexGateOptions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Configuration options for VEX gate, bindable from YAML/JSON config.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Configuration options for VEX gate service.
/// Binds to "VexGate" section in configuration files.
/// </summary>
public sealed class VexGateOptions : IValidatableObject
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "VexGate";
/// <summary>
/// Enable VEX-first gating. Default: false.
/// When disabled, all findings pass through to triage unchanged.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Default decision when no rules match. Default: Warn.
/// </summary>
public string DefaultDecision { get; set; } = "Warn";
/// <summary>
/// Policy version for audit/replay purposes.
/// Should be incremented when rules change.
/// </summary>
public string PolicyVersion { get; set; } = "1.0.0";
/// <summary>
/// Evaluation rules (ordered by priority, highest first).
/// </summary>
public List<VexGateRuleOptions> Rules { get; set; } = [];
/// <summary>
/// Caching settings for VEX observation lookups.
/// </summary>
public VexGateCacheOptions Cache { get; set; } = new();
/// <summary>
/// Audit logging settings.
/// </summary>
public VexGateAuditOptions Audit { get; set; } = new();
/// <summary>
/// Metrics settings.
/// </summary>
public VexGateMetricsOptions Metrics { get; set; } = new();
/// <summary>
/// Bypass settings for emergency scans.
/// </summary>
public VexGateBypassOptions Bypass { get; set; } = new();
/// <summary>
/// Converts this options instance to a VexGatePolicy.
/// </summary>
public VexGatePolicy ToPolicy()
{
var defaultDecision = ParseDecision(DefaultDecision);
var rules = Rules
.Select(r => r.ToRule())
.OrderByDescending(r => r.Priority)
.ToImmutableArray();
return new VexGatePolicy
{
DefaultDecision = defaultDecision,
Rules = rules,
};
}
/// <summary>
/// Creates options from a VexGatePolicy.
/// </summary>
public static VexGateOptions FromPolicy(VexGatePolicy policy)
{
return new VexGateOptions
{
Enabled = true,
DefaultDecision = policy.DefaultDecision.ToString(),
Rules = policy.Rules.Select(r => VexGateRuleOptions.FromRule(r)).ToList(),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Enabled && Rules.Count == 0)
{
yield return new ValidationResult(
"At least one rule is required when VexGate is enabled",
[nameof(Rules)]);
}
var ruleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rule in Rules)
{
if (string.IsNullOrWhiteSpace(rule.RuleId))
{
yield return new ValidationResult(
"Rule ID is required for all rules",
[nameof(Rules)]);
}
else if (!ruleIds.Add(rule.RuleId))
{
yield return new ValidationResult(
$"Duplicate rule ID: {rule.RuleId}",
[nameof(Rules)]);
}
}
if (Cache.TtlSeconds <= 0)
{
yield return new ValidationResult(
"Cache TTL must be positive",
[nameof(Cache)]);
}
if (Cache.MaxEntries <= 0)
{
yield return new ValidationResult(
"Cache max entries must be positive",
[nameof(Cache)]);
}
}
}
/// <summary>
/// Configuration options for a single VEX gate rule.
/// </summary>
public sealed class VexGateRuleOptions
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[Required]
public string RuleId { get; set; } = string.Empty;
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
public int Priority { get; set; } = 0;
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[Required]
public string Decision { get; set; } = "Warn";
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
public VexGateConditionOptions Condition { get; set; } = new();
/// <summary>
/// Converts to a VexGatePolicyRule.
/// </summary>
public VexGatePolicyRule ToRule()
{
return new VexGatePolicyRule
{
RuleId = RuleId,
Priority = Priority,
Decision = ParseDecision(Decision),
Condition = Condition.ToCondition(),
};
}
/// <summary>
/// Creates options from a VexGatePolicyRule.
/// </summary>
public static VexGateRuleOptions FromRule(VexGatePolicyRule rule)
{
return new VexGateRuleOptions
{
RuleId = rule.RuleId,
Priority = rule.Priority,
Decision = rule.Decision.ToString(),
Condition = VexGateConditionOptions.FromCondition(rule.Condition),
};
}
private static VexGateDecision ParseDecision(string value)
{
return value.ToUpperInvariant() switch
{
"PASS" => VexGateDecision.Pass,
"WARN" => VexGateDecision.Warn,
"BLOCK" => VexGateDecision.Block,
_ => VexGateDecision.Warn,
};
}
}
/// <summary>
/// Configuration options for a rule condition.
/// </summary>
public sealed class VexGateConditionOptions
{
/// <summary>
/// Required VEX vendor status.
/// Options: not_affected, fixed, affected, under_investigation.
/// </summary>
public string? VendorStatus { get; set; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
public bool? IsExploitable { get; set; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
public bool? IsReachable { get; set; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
public bool? HasCompensatingControl { get; set; }
/// <summary>
/// Whether the CVE is in KEV (Known Exploited Vulnerabilities).
/// </summary>
public bool? IsKnownExploited { get; set; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
public List<string>? SeverityLevels { get; set; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
public double? ConfidenceThreshold { get; set; }
/// <summary>
/// Converts to a VexGatePolicyCondition.
/// </summary>
public VexGatePolicyCondition ToCondition()
{
return new VexGatePolicyCondition
{
VendorStatus = ParseVexStatus(VendorStatus),
IsExploitable = IsExploitable,
IsReachable = IsReachable,
HasCompensatingControl = HasCompensatingControl,
SeverityLevels = SeverityLevels?.ToArray(),
MinConfidence = ConfidenceThreshold,
};
}
/// <summary>
/// Creates options from a VexGatePolicyCondition.
/// </summary>
public static VexGateConditionOptions FromCondition(VexGatePolicyCondition condition)
{
return new VexGateConditionOptions
{
VendorStatus = condition.VendorStatus?.ToString().ToLowerInvariant(),
IsExploitable = condition.IsExploitable,
IsReachable = condition.IsReachable,
HasCompensatingControl = condition.HasCompensatingControl,
SeverityLevels = condition.SeverityLevels?.ToList(),
ConfidenceThreshold = condition.MinConfidence,
};
}
private static VexStatus? ParseVexStatus(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return value.ToLowerInvariant() switch
{
"not_affected" or "notaffected" => VexStatus.NotAffected,
"fixed" => VexStatus.Fixed,
"affected" => VexStatus.Affected,
"under_investigation" or "underinvestigation" => VexStatus.UnderInvestigation,
_ => null,
};
}
}
/// <summary>
/// Cache configuration options.
/// </summary>
public sealed class VexGateCacheOptions
{
/// <summary>
/// TTL for cached VEX observations (seconds). Default: 300.
/// </summary>
public int TtlSeconds { get; set; } = 300;
/// <summary>
/// Maximum cache entries. Default: 10000.
/// </summary>
public int MaxEntries { get; set; } = 10000;
}
/// <summary>
/// Audit logging configuration options.
/// </summary>
public sealed class VexGateAuditOptions
{
/// <summary>
/// Enable structured audit logging for compliance. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Include full evidence in audit logs. Default: true.
/// </summary>
public bool IncludeEvidence { get; set; } = true;
/// <summary>
/// Log level for gate decisions. Default: Information.
/// </summary>
public string LogLevel { get; set; } = "Information";
}
/// <summary>
/// Metrics configuration options.
/// </summary>
public sealed class VexGateMetricsOptions
{
/// <summary>
/// Enable OpenTelemetry metrics. Default: true.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Histogram buckets for evaluation latency (milliseconds).
/// </summary>
public List<double> LatencyBuckets { get; set; } = [1, 5, 10, 25, 50, 100, 250];
}
/// <summary>
/// Bypass configuration options.
/// </summary>
public sealed class VexGateBypassOptions
{
/// <summary>
/// Allow gate bypass via CLI flag (--bypass-gate). Default: true.
/// </summary>
public bool AllowCliBypass { get; set; } = true;
/// <summary>
/// Require specific reason when bypassing. Default: false.
/// </summary>
public bool RequireReason { get; set; } = false;
/// <summary>
/// Emit warning when bypass is used. Default: true.
/// </summary>
public bool WarnOnBypass { get; set; } = true;
}

View File

@@ -0,0 +1,201 @@
// -----------------------------------------------------------------------------
// VexGatePolicy.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate policy configuration models.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX gate policy defining rules for gate decisions.
/// Rules are evaluated in priority order (highest first).
/// </summary>
public sealed record VexGatePolicy
{
/// <summary>
/// Ordered list of policy rules.
/// </summary>
[JsonPropertyName("rules")]
public required ImmutableArray<VexGatePolicyRule> Rules { get; init; }
/// <summary>
/// Default decision when no rules match.
/// </summary>
[JsonPropertyName("defaultDecision")]
public required VexGateDecision DefaultDecision { get; init; }
/// <summary>
/// Creates the default gate policy per product advisory.
/// </summary>
public static VexGatePolicy Default => new()
{
DefaultDecision = VexGateDecision.Warn,
Rules = ImmutableArray.Create(
new VexGatePolicyRule
{
RuleId = "block-exploitable-reachable",
Priority = 100,
Condition = new VexGatePolicyCondition
{
IsExploitable = true,
IsReachable = true,
HasCompensatingControl = false,
},
Decision = VexGateDecision.Block,
},
new VexGatePolicyRule
{
RuleId = "warn-high-not-reachable",
Priority = 90,
Condition = new VexGatePolicyCondition
{
SeverityLevels = ["critical", "high"],
IsReachable = false,
},
Decision = VexGateDecision.Warn,
},
new VexGatePolicyRule
{
RuleId = "pass-vendor-not-affected",
Priority = 80,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.NotAffected,
},
Decision = VexGateDecision.Pass,
},
new VexGatePolicyRule
{
RuleId = "pass-backport-confirmed",
Priority = 70,
Condition = new VexGatePolicyCondition
{
VendorStatus = VexStatus.Fixed,
},
Decision = VexGateDecision.Pass,
}
),
};
}
/// <summary>
/// A single policy rule for VEX gate evaluation.
/// </summary>
public sealed record VexGatePolicyRule
{
/// <summary>
/// Unique identifier for this rule.
/// </summary>
[JsonPropertyName("ruleId")]
public required string RuleId { get; init; }
/// <summary>
/// Condition that must match for this rule to apply.
/// </summary>
[JsonPropertyName("condition")]
public required VexGatePolicyCondition Condition { get; init; }
/// <summary>
/// Decision to apply when this rule matches.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Priority order (higher values evaluated first).
/// </summary>
[JsonPropertyName("priority")]
public required int Priority { get; init; }
}
/// <summary>
/// Condition for a policy rule to match.
/// All non-null properties must match for the condition to be satisfied.
/// </summary>
public sealed record VexGatePolicyCondition
{
/// <summary>
/// Required VEX vendor status.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Whether the vulnerability must be exploitable.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool? IsExploitable { get; init; }
/// <summary>
/// Whether the vulnerable code must be reachable.
/// </summary>
[JsonPropertyName("isReachable")]
public bool? IsReachable { get; init; }
/// <summary>
/// Whether compensating controls must be present.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool? HasCompensatingControl { get; init; }
/// <summary>
/// Required severity levels (any match).
/// </summary>
[JsonPropertyName("severityLevels")]
public string[]? SeverityLevels { get; init; }
/// <summary>
/// Minimum confidence score required.
/// </summary>
[JsonPropertyName("minConfidence")]
public double? MinConfidence { get; init; }
/// <summary>
/// Required VEX justification type.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Evaluates whether the evidence matches this condition.
/// </summary>
/// <param name="evidence">Evidence to evaluate.</param>
/// <returns>True if all specified conditions match.</returns>
public bool Matches(VexGateEvidence evidence)
{
if (VendorStatus is not null && evidence.VendorStatus != VendorStatus)
return false;
if (IsExploitable is not null && evidence.IsExploitable != IsExploitable)
return false;
if (IsReachable is not null && evidence.IsReachable != IsReachable)
return false;
if (HasCompensatingControl is not null && evidence.HasCompensatingControl != HasCompensatingControl)
return false;
if (SeverityLevels is not null && SeverityLevels.Length > 0)
{
if (evidence.SeverityLevel is null)
return false;
var matchesSeverity = SeverityLevels.Any(s =>
string.Equals(s, evidence.SeverityLevel, StringComparison.OrdinalIgnoreCase));
if (!matchesSeverity)
return false;
}
if (MinConfidence is not null && evidence.ConfidenceScore < MinConfidence)
return false;
if (Justification is not null && evidence.Justification != Justification)
return false;
return true;
}
}

View File

@@ -0,0 +1,116 @@
// -----------------------------------------------------------------------------
// VexGatePolicyEvaluator.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Policy evaluator for VEX gate decisions.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Default implementation of <see cref="IVexGatePolicy"/>.
/// Evaluates evidence against policy rules in priority order.
/// </summary>
public sealed class VexGatePolicyEvaluator : IVexGatePolicy
{
private readonly ILogger<VexGatePolicyEvaluator> _logger;
private readonly VexGatePolicy _policy;
public VexGatePolicyEvaluator(
IOptions<VexGatePolicyOptions> options,
ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = options.Value.Policy ?? VexGatePolicy.Default;
}
/// <summary>
/// Creates an evaluator with the default policy.
/// </summary>
public VexGatePolicyEvaluator(ILogger<VexGatePolicyEvaluator> logger)
{
_logger = logger;
_policy = VexGatePolicy.Default;
}
/// <inheritdoc />
public VexGatePolicy Policy => _policy;
/// <inheritdoc />
public (VexGateDecision Decision, string RuleId, string Rationale) Evaluate(VexGateEvidence evidence)
{
// Sort rules by priority descending and evaluate in order
var sortedRules = _policy.Rules
.OrderByDescending(r => r.Priority)
.ToList();
foreach (var rule in sortedRules)
{
if (rule.Condition.Matches(evidence))
{
var rationale = BuildRationale(rule, evidence);
_logger.LogDebug(
"VEX gate rule matched: {RuleId} -> {Decision} for evidence with vendor status {VendorStatus}",
rule.RuleId,
rule.Decision,
evidence.VendorStatus);
return (rule.Decision, rule.RuleId, rationale);
}
}
// No rule matched, return default
var defaultRationale = "No policy rule matched; applying default decision";
_logger.LogDebug(
"No VEX gate rule matched; defaulting to {Decision}",
_policy.DefaultDecision);
return (_policy.DefaultDecision, "default", defaultRationale);
}
private static string BuildRationale(VexGatePolicyRule rule, VexGateEvidence evidence)
{
return rule.RuleId switch
{
"block-exploitable-reachable" =>
"Exploitable + reachable, no compensating control",
"warn-high-not-reachable" =>
string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"{0} severity but not reachable from entrypoints",
evidence.SeverityLevel ?? "High"),
"pass-vendor-not-affected" =>
"Vendor VEX statement declares not_affected",
"pass-backport-confirmed" =>
"Vendor VEX statement confirms fixed via backport",
_ => string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"Policy rule '{0}' matched",
rule.RuleId)
};
}
}
/// <summary>
/// Options for VEX gate policy configuration.
/// </summary>
public sealed class VexGatePolicyOptions
{
/// <summary>
/// Custom policy to use instead of default.
/// </summary>
public VexGatePolicy? Policy { get; set; }
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; set; } = true;
}

View File

@@ -0,0 +1,144 @@
// -----------------------------------------------------------------------------
// VexGateResult.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: VEX gate evaluation result with evidence.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Result of VEX gate evaluation for a single finding.
/// Contains the decision, rationale, and supporting evidence.
/// </summary>
public sealed record VexGateResult
{
/// <summary>
/// Gate decision: Pass, Warn, or Block.
/// </summary>
[JsonPropertyName("decision")]
public required VexGateDecision Decision { get; init; }
/// <summary>
/// Human-readable explanation of why this decision was made.
/// </summary>
[JsonPropertyName("rationale")]
public required string Rationale { get; init; }
/// <summary>
/// ID of the policy rule that matched and produced this decision.
/// </summary>
[JsonPropertyName("policyRuleMatched")]
public required string PolicyRuleMatched { get; init; }
/// <summary>
/// VEX statements that contributed to this decision.
/// </summary>
[JsonPropertyName("contributingStatements")]
public required ImmutableArray<VexStatementRef> ContributingStatements { get; init; }
/// <summary>
/// Detailed evidence supporting the decision.
/// </summary>
[JsonPropertyName("evidence")]
public required VexGateEvidence Evidence { get; init; }
/// <summary>
/// When this evaluation was performed (UTC ISO-8601).
/// </summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
}
/// <summary>
/// Evidence collected during VEX gate evaluation.
/// </summary>
public sealed record VexGateEvidence
{
/// <summary>
/// VEX status from vendor or authoritative source.
/// Null if no VEX statement found.
/// </summary>
[JsonPropertyName("vendorStatus")]
public VexStatus? VendorStatus { get; init; }
/// <summary>
/// Justification type from VEX statement.
/// </summary>
[JsonPropertyName("justification")]
public VexJustification? Justification { get; init; }
/// <summary>
/// Whether the vulnerable code is reachable from entrypoints.
/// </summary>
[JsonPropertyName("isReachable")]
public bool IsReachable { get; init; }
/// <summary>
/// Whether compensating controls mitigate the vulnerability.
/// </summary>
[JsonPropertyName("hasCompensatingControl")]
public bool HasCompensatingControl { get; init; }
/// <summary>
/// Confidence score in the gate decision (0.0 to 1.0).
/// </summary>
[JsonPropertyName("confidenceScore")]
public double ConfidenceScore { get; init; }
/// <summary>
/// Hints about backport fixes detected.
/// </summary>
[JsonPropertyName("backportHints")]
public ImmutableArray<string> BackportHints { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Whether the vulnerability is exploitable based on available intelligence.
/// </summary>
[JsonPropertyName("isExploitable")]
public bool IsExploitable { get; init; }
/// <summary>
/// Severity level from the advisory.
/// </summary>
[JsonPropertyName("severityLevel")]
public string? SeverityLevel { get; init; }
}
/// <summary>
/// Reference to a VEX statement that contributed to a gate decision.
/// </summary>
public sealed record VexStatementRef
{
/// <summary>
/// Unique identifier for the VEX statement.
/// </summary>
[JsonPropertyName("statementId")]
public required string StatementId { get; init; }
/// <summary>
/// Issuer of the VEX statement.
/// </summary>
[JsonPropertyName("issuerId")]
public required string IssuerId { get; init; }
/// <summary>
/// VEX status declared in the statement.
/// </summary>
[JsonPropertyName("status")]
public required VexStatus Status { get; init; }
/// <summary>
/// When the statement was issued.
/// </summary>
[JsonPropertyName("timestamp")]
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Trust weight of this statement in consensus (0.0 to 1.0).
/// </summary>
[JsonPropertyName("trustWeight")]
public double TrustWeight { get; init; }
}

View File

@@ -0,0 +1,249 @@
// -----------------------------------------------------------------------------
// 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);
}

View File

@@ -0,0 +1,169 @@
// -----------------------------------------------------------------------------
// VexGateServiceCollectionExtensions.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T028 - Add gate policy to tenant configuration
// Description: Service collection extensions for registering VEX gate services.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// Extension methods for registering VEX gate services.
/// </summary>
public static class VexGateServiceCollectionExtensions
{
/// <summary>
/// Adds VEX gate services with configuration from the specified section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration root.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Bind and validate options
services.AddOptions<VexGateOptions>()
.Bind(configuration.GetSection(VexGateOptions.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
// Return a permissive policy when disabled
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with explicit options.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configureOptions">The options configuration action.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGate(
this IServiceCollection services,
Action<VexGateOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configureOptions);
// Configure and validate options
services.AddOptions<VexGateOptions>()
.Configure(configureOptions)
.ValidateDataAnnotations()
.ValidateOnStart();
// Register policy from options
services.AddSingleton<VexGatePolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
if (!options.Value.Enabled)
{
return new VexGatePolicy
{
DefaultDecision = VexGateDecision.Pass,
Rules = [],
};
}
return options.Value.ToPolicy();
});
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with configured limits
services.AddSingleton<IMemoryCache>(sp =>
{
var options = sp.GetRequiredService<IOptions<VexGateOptions>>();
return new MemoryCache(new MemoryCacheOptions
{
SizeLimit = options.Value.Cache.MaxEntries,
});
});
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
/// <summary>
/// Adds VEX gate services with default policy.
/// </summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddVexGateWithDefaultPolicy(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Configure with default options
services.AddOptions<VexGateOptions>()
.Configure(options =>
{
options.Enabled = true;
var defaultPolicy = VexGatePolicy.Default;
options.DefaultDecision = defaultPolicy.DefaultDecision.ToString();
options.Rules = defaultPolicy.Rules
.Select(VexGateRuleOptions.FromRule)
.ToList();
})
.ValidateDataAnnotations()
.ValidateOnStart();
// Register default policy
services.AddSingleton<VexGatePolicy>(_ => VexGatePolicy.Default);
// Register core services
services.AddSingleton<IVexGatePolicy, VexGatePolicyEvaluator>();
// Register caching with default limits
services.AddSingleton<IMemoryCache>(_ => new MemoryCache(new MemoryCacheOptions
{
SizeLimit = 10000,
}));
// Register VEX gate service
services.AddSingleton<IVexGateService, VexGateService>();
return services;
}
}

View File

@@ -0,0 +1,78 @@
// -----------------------------------------------------------------------------
// VexTypes.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Description: Local VEX type definitions for gate service independence.
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
namespace StellaOps.Scanner.Gate;
/// <summary>
/// VEX status values per OpenVEX specification.
/// Local definition to avoid dependency on SmartDiff/Excititor.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexStatus>))]
public enum VexStatus
{
/// <summary>
/// The vulnerability is not exploitable in this context.
/// </summary>
[JsonStringEnumMemberName("not_affected")]
NotAffected,
/// <summary>
/// The vulnerability is exploitable.
/// </summary>
[JsonStringEnumMemberName("affected")]
Affected,
/// <summary>
/// The vulnerability has been fixed.
/// </summary>
[JsonStringEnumMemberName("fixed")]
Fixed,
/// <summary>
/// The vulnerability is under investigation.
/// </summary>
[JsonStringEnumMemberName("under_investigation")]
UnderInvestigation
}
/// <summary>
/// VEX justification codes per OpenVEX specification.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<VexJustification>))]
public enum VexJustification
{
/// <summary>
/// The vulnerable component is not present.
/// </summary>
[JsonStringEnumMemberName("component_not_present")]
ComponentNotPresent,
/// <summary>
/// The vulnerable code is not present.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_present")]
VulnerableCodeNotPresent,
/// <summary>
/// The vulnerable code is not in the execute path.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_not_in_execute_path")]
VulnerableCodeNotInExecutePath,
/// <summary>
/// The vulnerable code cannot be controlled by an adversary.
/// </summary>
[JsonStringEnumMemberName("vulnerable_code_cannot_be_controlled_by_adversary")]
VulnerableCodeCannotBeControlledByAdversary,
/// <summary>
/// Inline mitigations already exist.
/// </summary>
[JsonStringEnumMemberName("inline_mitigations_already_exist")]
InlineMitigationsAlreadyExist
}