// ----------------------------------------------------------------------------- // EvidenceCompositionService.cs // Sprint: SPRINT_3800_0003_0001_evidence_api_endpoint // Description: Composes unified evidence responses from multiple sources. // ----------------------------------------------------------------------------- using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Domain; namespace StellaOps.Scanner.WebService.Services; /// /// Composes unified evidence responses for findings by aggregating data from /// reachability, boundary, VEX, and scoring services. /// public sealed class EvidenceCompositionService : IEvidenceCompositionService { private readonly IScanCoordinator _scanCoordinator; private readonly IReachabilityQueryService _reachabilityQueryService; private readonly IReachabilityExplainService _reachabilityExplainService; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; private readonly EvidenceCompositionOptions _options; public EvidenceCompositionService( IScanCoordinator scanCoordinator, IReachabilityQueryService reachabilityQueryService, IReachabilityExplainService reachabilityExplainService, IOptions options, ILogger logger, TimeProvider? timeProvider = null) { _scanCoordinator = scanCoordinator ?? throw new ArgumentNullException(nameof(scanCoordinator)); _reachabilityQueryService = reachabilityQueryService ?? throw new ArgumentNullException(nameof(reachabilityQueryService)); _reachabilityExplainService = reachabilityExplainService ?? throw new ArgumentNullException(nameof(reachabilityExplainService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? new EvidenceCompositionOptions(); _timeProvider = timeProvider ?? TimeProvider.System; } /// public async Task GetEvidenceAsync( ScanId scanId, string findingId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(findingId); // Parse finding ID: "CVE-XXXX-XXXXX@pkg:ecosystem/name@version" var (cveId, purl) = ParseFindingId(findingId); if (string.IsNullOrEmpty(cveId) || string.IsNullOrEmpty(purl)) { _logger.LogWarning("Invalid finding ID format: {FindingId}", findingId); return null; } // Verify scan exists var scan = await _scanCoordinator.GetAsync(scanId, cancellationToken).ConfigureAwait(false); if (scan is null) { _logger.LogDebug("Scan not found: {ScanId}", scanId.Value); return null; } // Get reachability finding to verify it exists var findings = await _reachabilityQueryService.GetFindingsAsync( scanId, cveFilter: cveId, statusFilter: null, cancellationToken).ConfigureAwait(false); var finding = findings.FirstOrDefault(f => f.CveId.Equals(cveId, StringComparison.OrdinalIgnoreCase) && f.Purl.Equals(purl, StringComparison.OrdinalIgnoreCase)); if (finding is null) { _logger.LogDebug("Finding not found: {FindingId} in scan {ScanId}", findingId, scanId.Value); return null; } // Get detailed reachability explanation var explanation = await _reachabilityExplainService.ExplainAsync( scanId, cveId, purl, cancellationToken).ConfigureAwait(false); // Build score explanation (simplified local computation) var scoreExplanation = BuildScoreExplanation(finding, explanation); // Compose the response var now = _timeProvider.GetUtcNow(); // Calculate expiry based on evidence sources var (expiresAt, isStale) = CalculateTtlAndStaleness(now, explanation); return new FindingEvidenceResponse { FindingId = findingId, Cve = cveId, Component = BuildComponentRef(purl), ReachablePath = explanation?.PathWitness, Entrypoint = BuildEntrypointProof(explanation), Boundary = null, // Boundary extraction requires RichGraph, deferred to SPRINT_3800_0003_0002 Vex = null, // VEX requires Excititor query, deferred to SPRINT_3800_0003_0002 ScoreExplain = scoreExplanation, LastSeen = now, ExpiresAt = expiresAt, IsStale = isStale, AttestationRefs = BuildAttestationRefs(scan, explanation) }; } /// /// Calculates the evidence expiry time and staleness based on evidence sources. /// Uses the minimum expiry time from all evidence sources. /// private (DateTimeOffset expiresAt, bool isStale) CalculateTtlAndStaleness( DateTimeOffset now, ReachabilityExplanation? explanation) { var defaultTtl = TimeSpan.FromDays(_options.DefaultEvidenceTtlDays); var warningThreshold = TimeSpan.FromDays(_options.StaleWarningThresholdDays); // Default: evidence expires from when it was computed (now) var reachabilityExpiry = now.Add(defaultTtl); // If we have evidence chain with timestamps, use those instead // For now, we use now as the base timestamp since ReachabilityExplanation // doesn't expose a resolved timestamp. Future enhancement: add timestamp to explanation. // VEX expiry would be calculated from VEX timestamp + VexTtl // For now, since VEX is not yet integrated, we skip this // TODO: When VEX is integrated, add: vexExpiry = vexTimestamp.Add(vexTtl); // Use the minimum expiry time (evidence chain is as fresh as the oldest source) var expiresAt = reachabilityExpiry; // Evidence is stale if it has expired var isStale = expiresAt <= now; // Also consider "near-stale" (within warning threshold) for logging if (!isStale && (expiresAt - now) <= warningThreshold) { _logger.LogDebug("Evidence nearing expiry: expires in {TimeRemaining}", expiresAt - now); } return (expiresAt, isStale); } private static (string? cveId, string? purl) ParseFindingId(string findingId) { // Format: "CVE-XXXX-XXXXX@pkg:ecosystem/name@version" var atIndex = findingId.IndexOf('@'); if (atIndex <= 0 || atIndex >= findingId.Length - 1) { return (null, null); } var cveId = findingId[..atIndex]; var purl = findingId[(atIndex + 1)..]; // Validate CVE format (basic check) if (!cveId.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase)) { return (null, null); } // Validate PURL format (basic check) if (!purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)) { return (null, null); } return (cveId, purl); } private static ComponentRef BuildComponentRef(string purl) { // Parse PURL: "pkg:ecosystem/name@version" var parts = purl.Replace("pkg:", "", StringComparison.OrdinalIgnoreCase) .Split('/', '@'); var ecosystem = parts.Length > 0 ? parts[0] : "unknown"; var name = parts.Length > 1 ? parts[1] : "unknown"; var version = parts.Length > 2 ? parts[2] : "unknown"; return new ComponentRef { Purl = purl, Name = name, Version = version, Type = ecosystem }; } private static EntrypointProof? BuildEntrypointProof(ReachabilityExplanation? explanation) { if (explanation?.PathWitness is null || explanation.PathWitness.Count == 0) { return null; } var firstHop = explanation.PathWitness[0]; var entrypointType = InferEntrypointType(firstHop); return new EntrypointProof { Type = entrypointType, Fqn = firstHop, Phase = "runtime" }; } private static string InferEntrypointType(string fqn) { var lower = fqn.ToLowerInvariant(); if (lower.Contains("controller") || lower.Contains("handler") || lower.Contains("http")) { return "http_handler"; } if (lower.Contains("grpc") || lower.Contains("rpc")) { return "grpc_method"; } if (lower.Contains("main") || lower.Contains("program")) { return "cli_command"; } return "internal"; } private ScoreExplanationDto BuildScoreExplanation( ReachabilityFinding finding, ReachabilityExplanation? explanation) { // Simplified score computation based on reachability status var contributions = new List(); double riskScore = 0.0; // Reachability contribution (0-25 points) var (reachabilityContribution, reachabilityExplanation) = finding.Status.ToLowerInvariant() switch { "reachable" => (25.0, "Code path leads directly to vulnerable function"), "direct" => (20.0, "Direct dependency call to vulnerable package"), "runtime" => (22.0, "Runtime evidence shows execution path"), "unreachable" => (0.0, "No execution path to vulnerable code"), _ => (12.0, "Reachability unknown, conservative estimate") }; if (reachabilityContribution > 0) { contributions.Add(new ScoreContributionDto { Factor = "reachability", Weight = 1.0, RawValue = reachabilityContribution, Contribution = reachabilityContribution, Explanation = reachabilityExplanation }); riskScore += reachabilityContribution; } // Confidence contribution (0-10 points) var confidenceContribution = finding.Confidence * 10.0; contributions.Add(new ScoreContributionDto { Factor = "confidence", Weight = 1.0, RawValue = finding.Confidence, Contribution = confidenceContribution, Explanation = $"Analysis confidence: {finding.Confidence:P0}" }); riskScore += confidenceContribution; // Gate discount (-10 to 0 points) if (explanation?.Why is not null) { var gateCount = explanation.Why.Count(w => w.Code.StartsWith("gate_", StringComparison.OrdinalIgnoreCase)); if (gateCount > 0) { var gateDiscount = Math.Min(gateCount * -3.0, -10.0); contributions.Add(new ScoreContributionDto { Factor = "gate_protection", Weight = 1.0, RawValue = gateCount, Contribution = gateDiscount, Explanation = $"{gateCount} protective gate(s) detected" }); riskScore += gateDiscount; } } // Clamp to 0-100 riskScore = Math.Clamp(riskScore, 0.0, 100.0); return new ScoreExplanationDto { Kind = "stellaops_evidence_v1", RiskScore = riskScore, Contributions = contributions, LastSeen = _timeProvider.GetUtcNow() }; } private static IReadOnlyList? BuildAttestationRefs( ScanSnapshot scan, ReachabilityExplanation? explanation) { var refs = new List(); // Add scan manifest hash as attestation reference if (scan.Replay?.ManifestHash is not null) { refs.Add(scan.Replay.ManifestHash); } // Add spine ID if available if (explanation?.SpineId is not null) { refs.Add(explanation.SpineId); } // Add callgraph digest if available if (explanation?.Evidence?.StaticAnalysis?.CallgraphDigest is not null) { refs.Add(explanation.Evidence.StaticAnalysis.CallgraphDigest); } return refs.Count > 0 ? refs : null; } } /// /// Configuration options for evidence composition. /// public sealed class EvidenceCompositionOptions { /// /// Default TTL for reachability/scan evidence in days. /// public int DefaultEvidenceTtlDays { get; set; } = 7; /// /// TTL for VEX evidence in days (typically longer than scan data). /// public int VexEvidenceTtlDays { get; set; } = 30; /// /// Warning threshold before expiry in days. Evidence within this window /// is considered "near-stale" and triggers warnings. /// public int StaleWarningThresholdDays { get; set; } = 1; /// /// Whether to include VEX evidence when available. /// public bool IncludeVexEvidence { get; set; } = true; /// /// Whether to include boundary proof when available. /// public bool IncludeBoundaryProof { get; set; } = true; }