Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/EvidenceCompositionService.cs
StellaOps Bot 5fc469ad98 feat: Add VEX Status Chip component and integration tests for reachability drift detection
- 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.
2025-12-20 01:26:42 +02:00

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;
}