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

@@ -26,6 +26,9 @@ public static class ScanStageNames
// Sprint: SPRINT_20251229_046_BE - Secrets Leak Detection
public const string ScanSecrets = "scan-secrets";
// Sprint: SPRINT_20260106_003_002 - VEX Gate Service
public const string VexGate = "vex-gate";
public static readonly IReadOnlyList<string> Ordered = new[]
{
IngestReplay,
@@ -36,6 +39,7 @@ public static class ScanStageNames
ScanSecrets,
BinaryLookup,
EpssEnrichment,
VexGate,
ComposeArtifacts,
Entropy,
GeneratePoE,

View File

@@ -41,7 +41,8 @@ internal sealed record SurfaceManifestRequest(
string? ReplayBundleUri = null,
string? ReplayBundleHash = null,
string? ReplayPolicyPin = null,
string? ReplayFeedPin = null);
string? ReplayFeedPin = null,
SurfaceFacetSeals? FacetSeals = null);
internal interface ISurfaceManifestPublisher
{
@@ -138,7 +139,9 @@ internal sealed class SurfaceManifestPublisher : ISurfaceManifestPublisher
Sha256 = request.ReplayBundleHash ?? string.Empty,
PolicySnapshotId = request.ReplayPolicyPin,
FeedSnapshotId = request.ReplayFeedPin
}
},
// FCT-022: Facet seals for per-facet drift tracking (SPRINT_20260105_002_002_FACET)
FacetSeals = request.FacetSeals
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifestDocument, SerializerOptions);

View File

@@ -0,0 +1,407 @@
// -----------------------------------------------------------------------------
// VexGateStageExecutor.cs
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
// Task: T015
// Description: Scan stage executor that applies VEX gate filtering to findings.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Gate;
using StellaOps.Scanner.Worker.Metrics;
namespace StellaOps.Scanner.Worker.Processing;
/// <summary>
/// Scan stage executor that applies VEX gate filtering to vulnerability findings.
/// Evaluates findings against VEX evidence and configurable policies to determine
/// which findings should pass, warn, or block the pipeline.
/// </summary>
public sealed class VexGateStageExecutor : IScanStageExecutor
{
private readonly IVexGateService _vexGateService;
private readonly ILogger<VexGateStageExecutor> _logger;
private readonly VexGateStageOptions _options;
private readonly IScanMetricsCollector? _metricsCollector;
public VexGateStageExecutor(
IVexGateService vexGateService,
ILogger<VexGateStageExecutor> logger,
IOptions<VexGateStageOptions> options,
IScanMetricsCollector? metricsCollector = null)
{
_vexGateService = vexGateService ?? throw new ArgumentNullException(nameof(vexGateService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? new VexGateStageOptions();
_metricsCollector = metricsCollector;
}
public string StageName => ScanStageNames.VexGate;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// Check if gate is bypassed (emergency scan mode)
if (_options.Bypass)
{
_logger.LogWarning(
"VEX gate bypassed for job {JobId} (emergency scan mode)",
context.JobId);
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, true);
return;
}
var startTime = context.TimeProvider.GetTimestamp();
// Extract findings from analysis context
var findings = ExtractFindings(context);
if (findings.Count == 0)
{
_logger.LogDebug(
"No findings found for job {JobId}; skipping VEX gate evaluation",
context.JobId);
StoreEmptySummary(context);
return;
}
_logger.LogInformation(
"Evaluating {FindingCount} findings through VEX gate for job {JobId}",
findings.Count,
context.JobId);
// Evaluate all findings in batch
var gatedResults = await _vexGateService.EvaluateBatchAsync(findings, cancellationToken)
.ConfigureAwait(false);
// Store results in analysis context
var resultsMap = gatedResults.ToDictionary(
r => r.Finding.FindingId,
r => r,
StringComparer.OrdinalIgnoreCase);
context.Analysis.Set(ScanAnalysisKeys.VexGateResults, resultsMap);
// Calculate and store summary
var summary = CalculateSummary(gatedResults, context.TimeProvider.GetUtcNow());
context.Analysis.Set(ScanAnalysisKeys.VexGateSummary, summary);
// Store policy version for traceability
context.Analysis.Set(ScanAnalysisKeys.VexGatePolicyVersion, _options.PolicyVersion ?? "default");
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, false);
// Record metrics
var elapsed = context.TimeProvider.GetElapsedTime(startTime);
RecordMetrics(summary, elapsed);
_logger.LogInformation(
"VEX gate completed for job {JobId}: {Passed} passed, {Warned} warned, {Blocked} blocked ({ElapsedMs}ms)",
context.JobId,
summary.PassedCount,
summary.WarnedCount,
summary.BlockedCount,
elapsed.TotalMilliseconds);
// Log blocked findings at warning level for visibility
if (summary.BlockedCount > 0)
{
LogBlockedFindings(gatedResults, context.JobId);
}
}
private IReadOnlyList<VexGateFinding> ExtractFindings(ScanJobContext context)
{
var findings = new List<VexGateFinding>();
// Extract from OS package analyzer results
ExtractFindingsFromAnalyzers(
context,
ScanAnalysisKeys.OsPackageAnalyzers,
findings);
// Extract from language analyzer results
ExtractFindingsFromAnalyzers(
context,
ScanAnalysisKeys.LanguageAnalyzerResults,
findings);
// Extract from binary vulnerability findings
if (context.Analysis.TryGet<IReadOnlyList<object>>(ScanAnalysisKeys.BinaryVulnerabilityFindings, out var binaryFindings))
{
foreach (var finding in binaryFindings)
{
var gateFinding = ConvertToGateFinding(finding);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
return findings;
}
private void ExtractFindingsFromAnalyzers(
ScanJobContext context,
string analysisKey,
List<VexGateFinding> findings)
{
if (!context.Analysis.TryGet<object>(analysisKey, out var results) ||
results is not System.Collections.IDictionary dictionary)
{
return;
}
foreach (var analyzerResult in dictionary.Values)
{
if (analyzerResult is null)
{
continue;
}
ExtractFindingsFromAnalyzerResult(analyzerResult, findings, context);
}
}
private void ExtractFindingsFromAnalyzerResult(
object analyzerResult,
List<VexGateFinding> findings,
ScanJobContext context)
{
var resultType = analyzerResult.GetType();
// Try to get Vulnerabilities property
var vulnsProperty = resultType.GetProperty("Vulnerabilities");
if (vulnsProperty?.GetValue(analyzerResult) is IEnumerable<object> vulns)
{
foreach (var vuln in vulns)
{
var gateFinding = ConvertToGateFinding(vuln);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
// Try to get Findings property
var findingsProperty = resultType.GetProperty("Findings");
if (findingsProperty?.GetValue(analyzerResult) is IEnumerable<object> findingsList)
{
foreach (var finding in findingsList)
{
var gateFinding = ConvertToGateFinding(finding);
if (gateFinding is not null)
{
findings.Add(gateFinding);
}
}
}
}
private static VexGateFinding? ConvertToGateFinding(object finding)
{
var findingType = finding.GetType();
// Extract vulnerability ID (CVE)
string? vulnId = null;
var cveIdProperty = findingType.GetProperty("CveId");
if (cveIdProperty?.GetValue(finding) is string cveId && !string.IsNullOrWhiteSpace(cveId))
{
vulnId = cveId;
}
else
{
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
if (vulnIdProperty?.GetValue(finding) is string vid && !string.IsNullOrWhiteSpace(vid))
{
vulnId = vid;
}
}
if (string.IsNullOrWhiteSpace(vulnId))
{
return null;
}
// Extract PURL
string? purl = null;
var purlProperty = findingType.GetProperty("Purl");
if (purlProperty?.GetValue(finding) is string p)
{
purl = p;
}
else
{
var packageProperty = findingType.GetProperty("PackageUrl");
if (packageProperty?.GetValue(finding) is string pu)
{
purl = pu;
}
}
// Extract finding ID
string findingId;
var idProperty = findingType.GetProperty("FindingId") ?? findingType.GetProperty("Id");
if (idProperty?.GetValue(finding) is string id && !string.IsNullOrWhiteSpace(id))
{
findingId = id;
}
else
{
// Generate a deterministic ID
findingId = $"{vulnId}:{purl ?? "unknown"}";
}
// Extract severity
string? severity = null;
var severityProperty = findingType.GetProperty("Severity") ?? findingType.GetProperty("SeverityLevel");
if (severityProperty?.GetValue(finding) is string sev)
{
severity = sev;
}
// Extract reachability (if available from previous stages)
bool? isReachable = null;
var reachableProperty = findingType.GetProperty("IsReachable");
if (reachableProperty?.GetValue(finding) is bool reachable)
{
isReachable = reachable;
}
// Extract exploitability (if available from EPSS or KEV)
bool? isExploitable = null;
var exploitableProperty = findingType.GetProperty("IsExploitable");
if (exploitableProperty?.GetValue(finding) is bool exploitable)
{
isExploitable = exploitable;
}
return new VexGateFinding
{
FindingId = findingId,
VulnerabilityId = vulnId,
Purl = purl ?? string.Empty,
ImageDigest = string.Empty, // Will be set from context if needed
SeverityLevel = severity,
IsReachable = isReachable ?? false,
IsExploitable = isExploitable ?? false,
HasCompensatingControl = false, // Would need additional context
};
}
private static VexGateSummary CalculateSummary(
ImmutableArray<GatedFinding> results,
DateTimeOffset evaluatedAt)
{
var passedCount = 0;
var warnedCount = 0;
var blockedCount = 0;
foreach (var result in results)
{
switch (result.GateResult.Decision)
{
case VexGateDecision.Pass:
passedCount++;
break;
case VexGateDecision.Warn:
warnedCount++;
break;
case VexGateDecision.Block:
blockedCount++;
break;
}
}
return new VexGateSummary
{
TotalFindings = results.Length,
PassedCount = passedCount,
WarnedCount = warnedCount,
BlockedCount = blockedCount,
EvaluatedAt = evaluatedAt,
};
}
private void StoreEmptySummary(ScanJobContext context)
{
var summary = new VexGateSummary
{
TotalFindings = 0,
PassedCount = 0,
WarnedCount = 0,
BlockedCount = 0,
EvaluatedAt = context.TimeProvider.GetUtcNow(),
};
context.Analysis.Set(ScanAnalysisKeys.VexGateSummary, summary);
context.Analysis.Set(ScanAnalysisKeys.VexGateResults, new Dictionary<string, GatedFinding>());
context.Analysis.Set(ScanAnalysisKeys.VexGateBypassed, false);
}
private void RecordMetrics(VexGateSummary summary, TimeSpan elapsed)
{
_metricsCollector?.RecordVexGateMetrics(
summary.TotalFindings,
summary.PassedCount,
summary.WarnedCount,
summary.BlockedCount,
elapsed);
}
private void LogBlockedFindings(ImmutableArray<GatedFinding> results, string jobId)
{
foreach (var result in results)
{
if (result.GateResult.Decision == VexGateDecision.Block)
{
_logger.LogWarning(
"VEX gate BLOCKED finding in job {JobId}: {VulnId} ({Purl}) - {Rationale}",
jobId,
result.Finding.VulnerabilityId,
result.Finding.Purl,
result.GateResult.Rationale);
}
}
}
}
/// <summary>
/// Options for VEX gate stage execution.
/// </summary>
public sealed class VexGateStageOptions
{
/// <summary>
/// If true, bypass VEX gate evaluation (emergency scan mode).
/// </summary>
public bool Bypass { get; set; }
/// <summary>
/// Policy version identifier for traceability.
/// </summary>
public string? PolicyVersion { get; set; }
}
/// <summary>
/// Summary of VEX gate evaluation results.
/// </summary>
public sealed record VexGateSummary
{
public required int TotalFindings { get; init; }
public required int PassedCount { get; init; }
public required int WarnedCount { get; init; }
public required int BlockedCount { get; init; }
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>
/// Percentage of findings that passed the gate.
/// </summary>
public double PassRate => TotalFindings > 0 ? (double)PassedCount / TotalFindings : 0;
/// <summary>
/// Percentage of findings that were blocked.
/// </summary>
public double BlockRate => TotalFindings > 0 ? (double)BlockedCount / TotalFindings : 0;
}