- Introduced `VexStatusChipComponent` to display VEX status with color coding and tooltips. - Implemented integration tests for reachability drift detection, covering various scenarios including drift detection, determinism, and error handling. - Enhanced `ScannerToSignalsReachabilityTests` with a null implementation of `ICallGraphSyncService` for better test isolation. - Updated project references to include the new Reachability Drift library.
375 lines
13 KiB
C#
375 lines
13 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Composes unified evidence responses for findings by aggregating data from
|
|
/// reachability, boundary, VEX, and scoring services.
|
|
/// </summary>
|
|
public sealed class EvidenceCompositionService : IEvidenceCompositionService
|
|
{
|
|
private readonly IScanCoordinator _scanCoordinator;
|
|
private readonly IReachabilityQueryService _reachabilityQueryService;
|
|
private readonly IReachabilityExplainService _reachabilityExplainService;
|
|
private readonly ILogger<EvidenceCompositionService> _logger;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly EvidenceCompositionOptions _options;
|
|
|
|
public EvidenceCompositionService(
|
|
IScanCoordinator scanCoordinator,
|
|
IReachabilityQueryService reachabilityQueryService,
|
|
IReachabilityExplainService reachabilityExplainService,
|
|
IOptions<EvidenceCompositionOptions> options,
|
|
ILogger<EvidenceCompositionService> 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FindingEvidenceResponse?> 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)
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates the evidence expiry time and staleness based on evidence sources.
|
|
/// Uses the minimum expiry time from all evidence sources.
|
|
/// </summary>
|
|
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<ScoreContributionDto>();
|
|
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<string>? BuildAttestationRefs(
|
|
ScanSnapshot scan,
|
|
ReachabilityExplanation? explanation)
|
|
{
|
|
var refs = new List<string>();
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configuration options for evidence composition.
|
|
/// </summary>
|
|
public sealed class EvidenceCompositionOptions
|
|
{
|
|
/// <summary>
|
|
/// Default TTL for reachability/scan evidence in days.
|
|
/// </summary>
|
|
public int DefaultEvidenceTtlDays { get; set; } = 7;
|
|
|
|
/// <summary>
|
|
/// TTL for VEX evidence in days (typically longer than scan data).
|
|
/// </summary>
|
|
public int VexEvidenceTtlDays { get; set; } = 30;
|
|
|
|
/// <summary>
|
|
/// Warning threshold before expiry in days. Evidence within this window
|
|
/// is considered "near-stale" and triggers warnings.
|
|
/// </summary>
|
|
public int StaleWarningThresholdDays { get; set; } = 1;
|
|
|
|
/// <summary>
|
|
/// Whether to include VEX evidence when available.
|
|
/// </summary>
|
|
public bool IncludeVexEvidence { get; set; } = true;
|
|
|
|
/// <summary>
|
|
/// Whether to include boundary proof when available.
|
|
/// </summary>
|
|
public bool IncludeBoundaryProof { get; set; } = true;
|
|
}
|