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.
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user