sprints and audit work
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user