|
|
|
|
@@ -0,0 +1,321 @@
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// ReachabilityEvidenceStageExecutor.cs
|
|
|
|
|
// Sprint: EVID-001 - Reachability Evidence Pipeline
|
|
|
|
|
// Task: EVID-001-005
|
|
|
|
|
// Description: Scan stage executor that generates reachability evidence for CVEs.
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
|
using StellaOps.Scanner.Reachability.Jobs;
|
|
|
|
|
using StellaOps.Scanner.Reachability.Services;
|
|
|
|
|
using StellaOps.Scanner.Reachability.Vex;
|
|
|
|
|
using CoreScanAnalysisKeys = StellaOps.Scanner.Core.Contracts.ScanAnalysisKeys;
|
|
|
|
|
|
|
|
|
|
namespace StellaOps.Scanner.Worker.Processing.Reachability;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Scan stage executor that generates reachability evidence for vulnerability findings.
|
|
|
|
|
/// Analyzes CVE reachability using the 3-layer model and emits VEX statements.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class ReachabilityEvidenceStageExecutor : IScanStageExecutor
|
|
|
|
|
{
|
|
|
|
|
private readonly IReachabilityEvidenceJobExecutor _jobExecutor;
|
|
|
|
|
private readonly ICveSymbolMappingService _mappingService;
|
|
|
|
|
private readonly IVexStatusDeterminer _vexDeterminer;
|
|
|
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
|
private readonly ILogger<ReachabilityEvidenceStageExecutor> _logger;
|
|
|
|
|
|
|
|
|
|
public ReachabilityEvidenceStageExecutor(
|
|
|
|
|
IReachabilityEvidenceJobExecutor jobExecutor,
|
|
|
|
|
ICveSymbolMappingService mappingService,
|
|
|
|
|
IVexStatusDeterminer vexDeterminer,
|
|
|
|
|
TimeProvider timeProvider,
|
|
|
|
|
ILogger<ReachabilityEvidenceStageExecutor> logger)
|
|
|
|
|
{
|
|
|
|
|
_jobExecutor = jobExecutor ?? throw new ArgumentNullException(nameof(jobExecutor));
|
|
|
|
|
_mappingService = mappingService ?? throw new ArgumentNullException(nameof(mappingService));
|
|
|
|
|
_vexDeterminer = vexDeterminer ?? throw new ArgumentNullException(nameof(vexDeterminer));
|
|
|
|
|
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
|
|
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string StageName => "reachability-evidence";
|
|
|
|
|
|
|
|
|
|
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
|
|
|
|
|
|
|
|
// Extract CVE findings that have symbol mappings
|
|
|
|
|
var cveFindings = await ExtractEligibleCveFindingsAsync(context, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (cveFindings.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"No eligible CVE findings with symbol mappings for job {JobId}",
|
|
|
|
|
context.JobId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Generating reachability evidence for {CveCount} CVEs in job {JobId}",
|
|
|
|
|
cveFindings.Count,
|
|
|
|
|
context.JobId);
|
|
|
|
|
|
|
|
|
|
var results = new List<ReachabilityEvidenceJobResult>();
|
|
|
|
|
var vexStatements = new List<VexStatement>();
|
|
|
|
|
|
|
|
|
|
foreach (var (cveId, purl) in cveFindings)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var result = await ProcessCveAsync(context, cveId, purl, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (result is not null)
|
|
|
|
|
{
|
|
|
|
|
results.Add(result);
|
|
|
|
|
|
|
|
|
|
// Generate VEX statement if we have a verdict
|
|
|
|
|
if (result.Stack is not null)
|
|
|
|
|
{
|
|
|
|
|
var productId = $"{context.ImageDigest}:{purl}";
|
|
|
|
|
var evidenceUris = result.EvidenceUri is not null
|
|
|
|
|
? new[] { result.EvidenceUri }
|
|
|
|
|
: Array.Empty<string>();
|
|
|
|
|
|
|
|
|
|
var vexStatement = _vexDeterminer.CreateStatement(
|
|
|
|
|
result.Stack,
|
|
|
|
|
productId,
|
|
|
|
|
evidenceUris);
|
|
|
|
|
|
|
|
|
|
vexStatements.Add(vexStatement);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(ex,
|
|
|
|
|
"Failed to process reachability for CVE {CveId} PURL {Purl} in job {JobId}",
|
|
|
|
|
cveId, purl, context.JobId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store results in analysis context
|
|
|
|
|
if (results.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
context.Analysis.Set(ReachabilityAnalysisKeys.ReachabilityEvidenceResults, results);
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Reachability evidence generated for {Count} CVEs in job {JobId}",
|
|
|
|
|
results.Count,
|
|
|
|
|
context.JobId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (vexStatements.Count > 0)
|
|
|
|
|
{
|
|
|
|
|
context.Analysis.Set(ReachabilityAnalysisKeys.VexStatements, vexStatements);
|
|
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Generated {Count} VEX statements for job {JobId}",
|
|
|
|
|
vexStatements.Count,
|
|
|
|
|
context.JobId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<List<(string CveId, string Purl)>> ExtractEligibleCveFindingsAsync(
|
|
|
|
|
ScanJobContext context,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var eligibleFindings = new List<(string CveId, string Purl)>();
|
|
|
|
|
var seenCves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
|
|
|
|
|
|
// Extract CVE+PURL pairs from findings
|
|
|
|
|
var cvePurlPairs = ExtractCvePurlPairs(context);
|
|
|
|
|
|
|
|
|
|
foreach (var (cveId, purl) in cvePurlPairs)
|
|
|
|
|
{
|
|
|
|
|
if (seenCves.Contains(cveId))
|
|
|
|
|
continue;
|
|
|
|
|
|
|
|
|
|
// Check if we have symbol mappings for this CVE
|
|
|
|
|
var hasMapping = await _mappingService.HasMappingAsync(cveId, cancellationToken);
|
|
|
|
|
if (hasMapping)
|
|
|
|
|
{
|
|
|
|
|
eligibleFindings.Add((cveId, purl));
|
|
|
|
|
seenCves.Add(cveId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return eligibleFindings;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<ReachabilityEvidenceJobResult?> ProcessCveAsync(
|
|
|
|
|
ScanJobContext context,
|
|
|
|
|
string cveId,
|
|
|
|
|
string purl,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var imageDigest = context.ImageDigest;
|
|
|
|
|
if (string.IsNullOrEmpty(imageDigest))
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning("Cannot process reachability for CVE {CveId}: no image digest available", cveId);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var jobId = ReachabilityEvidenceJob.ComputeJobId(
|
|
|
|
|
imageDigest,
|
|
|
|
|
cveId,
|
|
|
|
|
purl);
|
|
|
|
|
|
|
|
|
|
var job = new ReachabilityEvidenceJob
|
|
|
|
|
{
|
|
|
|
|
JobId = jobId,
|
|
|
|
|
ImageDigest = imageDigest,
|
|
|
|
|
CveId = cveId,
|
|
|
|
|
Purl = purl,
|
|
|
|
|
SourceCommit = context.Analysis.TryGet<string>(ReachabilityAnalysisKeys.SourceCommit, out var commit)
|
|
|
|
|
? commit
|
|
|
|
|
: null,
|
|
|
|
|
Options = new ReachabilityJobOptions
|
|
|
|
|
{
|
|
|
|
|
IncludeL2 = false, // Requires binary paths not available in scan context
|
|
|
|
|
IncludeL3 = false, // Requires container ID
|
|
|
|
|
MaxPathsPerSink = 5,
|
|
|
|
|
MaxDepth = 256
|
|
|
|
|
},
|
|
|
|
|
QueuedAt = _timeProvider.GetUtcNow()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"Executing reachability evidence job {JobId} for CVE {CveId}",
|
|
|
|
|
jobId, cveId);
|
|
|
|
|
|
|
|
|
|
var result = await _jobExecutor.ExecuteAsync(job, cancellationToken);
|
|
|
|
|
|
|
|
|
|
if (result.Status == JobStatus.Completed && result.Stack is not null)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogDebug(
|
|
|
|
|
"Reachability verdict for CVE {CveId}: {Verdict}",
|
|
|
|
|
cveId, result.Stack.Verdict);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static List<(string CveId, string Purl)> ExtractCvePurlPairs(ScanJobContext context)
|
|
|
|
|
{
|
|
|
|
|
var pairs = new List<(string CveId, string Purl)>();
|
|
|
|
|
|
|
|
|
|
// Extract from OS package analyzer results
|
|
|
|
|
if (context.Analysis.TryGet<object>(CoreScanAnalysisKeys.OsPackageAnalyzers, out var osResults) &&
|
|
|
|
|
osResults is System.Collections.IDictionary osDictionary)
|
|
|
|
|
{
|
|
|
|
|
foreach (var analyzerResult in osDictionary.Values)
|
|
|
|
|
{
|
|
|
|
|
if (analyzerResult is not null)
|
|
|
|
|
{
|
|
|
|
|
ExtractPairsFromAnalyzerResult(analyzerResult, pairs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extract from language analyzer results
|
|
|
|
|
if (context.Analysis.TryGet<object>(CoreScanAnalysisKeys.LanguageAnalyzerResults, out var langResults) &&
|
|
|
|
|
langResults is System.Collections.IDictionary langDictionary)
|
|
|
|
|
{
|
|
|
|
|
foreach (var analyzerResult in langDictionary.Values)
|
|
|
|
|
{
|
|
|
|
|
if (analyzerResult is not null)
|
|
|
|
|
{
|
|
|
|
|
ExtractPairsFromAnalyzerResult(analyzerResult, pairs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return pairs;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void ExtractPairsFromAnalyzerResult(
|
|
|
|
|
object analyzerResult,
|
|
|
|
|
List<(string CveId, string Purl)> pairs)
|
|
|
|
|
{
|
|
|
|
|
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)
|
|
|
|
|
{
|
|
|
|
|
ExtractPairFromFinding(vuln, pairs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get Findings property
|
|
|
|
|
var findingsProperty = resultType.GetProperty("Findings");
|
|
|
|
|
if (findingsProperty?.GetValue(analyzerResult) is IEnumerable<object> findingsList)
|
|
|
|
|
{
|
|
|
|
|
foreach (var finding in findingsList)
|
|
|
|
|
{
|
|
|
|
|
ExtractPairFromFinding(finding, pairs);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void ExtractPairFromFinding(
|
|
|
|
|
object finding,
|
|
|
|
|
List<(string CveId, string Purl)> pairs)
|
|
|
|
|
{
|
|
|
|
|
var findingType = finding.GetType();
|
|
|
|
|
string? cveId = null;
|
|
|
|
|
string? purl = null;
|
|
|
|
|
|
|
|
|
|
// Try CveId property
|
|
|
|
|
var cveIdProperty = findingType.GetProperty("CveId");
|
|
|
|
|
if (cveIdProperty?.GetValue(finding) is string cve && !string.IsNullOrWhiteSpace(cve))
|
|
|
|
|
{
|
|
|
|
|
cveId = cve;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try VulnerabilityId property
|
|
|
|
|
if (cveId is null)
|
|
|
|
|
{
|
|
|
|
|
var vulnIdProperty = findingType.GetProperty("VulnerabilityId");
|
|
|
|
|
if (vulnIdProperty?.GetValue(finding) is string vulnId &&
|
|
|
|
|
!string.IsNullOrWhiteSpace(vulnId) &&
|
|
|
|
|
vulnId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
{
|
|
|
|
|
cveId = vulnId;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try Purl property
|
|
|
|
|
var purlProperty = findingType.GetProperty("Purl");
|
|
|
|
|
if (purlProperty?.GetValue(finding) is string p && !string.IsNullOrWhiteSpace(p))
|
|
|
|
|
{
|
|
|
|
|
purl = p;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try PackageUrl property
|
|
|
|
|
if (purl is null)
|
|
|
|
|
{
|
|
|
|
|
var packageUrlProperty = findingType.GetProperty("PackageUrl");
|
|
|
|
|
if (packageUrlProperty?.GetValue(finding) is string pkg && !string.IsNullOrWhiteSpace(pkg))
|
|
|
|
|
{
|
|
|
|
|
purl = pkg;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(cveId) && !string.IsNullOrWhiteSpace(purl))
|
|
|
|
|
{
|
|
|
|
|
pairs.Add((cveId, purl));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Analysis keys for reachability evidence stage.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class ReachabilityAnalysisKeys
|
|
|
|
|
{
|
|
|
|
|
public const string ReachabilityEvidenceResults = "reachability.evidence.results";
|
|
|
|
|
public const string VexStatements = "reachability.vex.statements";
|
|
|
|
|
public const string SourceCommit = "source.commit";
|
|
|
|
|
}
|