// ----------------------------------------------------------------------------- // UnifiedEvidenceService.cs // Sprint: SPRINT_9200_0001_0002_SCANNER_unified_evidence_endpoint // Description: Implementation of IUnifiedEvidenceService for assembling evidence. // ----------------------------------------------------------------------------- using System.Globalization; using System.Security.Cryptography; using System.Text; using Microsoft.EntityFrameworkCore; using StellaOps.Scanner.Triage; using StellaOps.Scanner.Triage.Entities; using StellaOps.Scanner.WebService.Contracts; using System.Text.Json; namespace StellaOps.Scanner.WebService.Services; /// /// Assembles unified evidence packages for findings. /// public sealed class UnifiedEvidenceService : IUnifiedEvidenceService { private readonly TriageDbContext _dbContext; private readonly IGatingReasonService _gatingService; private readonly IReplayCommandService _replayService; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; private const double DefaultPolicyTrustThreshold = 0.7; public UnifiedEvidenceService( TriageDbContext dbContext, IGatingReasonService gatingService, IReplayCommandService replayService, TimeProvider timeProvider, ILogger logger) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _gatingService = gatingService ?? throw new ArgumentNullException(nameof(gatingService)); _replayService = replayService ?? throw new ArgumentNullException(nameof(replayService)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// public async Task GetUnifiedEvidenceAsync( string findingId, UnifiedEvidenceOptions? options = null, CancellationToken cancellationToken = default) { options ??= new UnifiedEvidenceOptions(); if (!Guid.TryParse(findingId, out var id)) { _logger.LogWarning("Invalid finding id format: {FindingId}", findingId); return null; } var finding = await _dbContext.Findings .Include(f => f.ReachabilityResults) .Include(f => f.EffectiveVexRecords) .Include(f => f.PolicyDecisions) .Include(f => f.EvidenceArtifacts) .Include(f => f.Attestations) .AsNoTracking() .FirstOrDefaultAsync(f => f.Id == id, cancellationToken) .ConfigureAwait(false); if (finding is null) { _logger.LogDebug("Finding not found: {FindingId}", findingId); return null; } // Build evidence tabs based on options var sbomEvidence = options.IncludeSbom ? BuildSbomEvidence(finding) : null; var reachabilityEvidence = options.IncludeReachability ? BuildReachabilityEvidence(finding) : null; var vexClaims = options.IncludeVexClaims ? BuildVexClaims(finding) : null; var attestations = options.IncludeAttestations ? BuildAttestations(finding) : null; var deltas = options.IncludeDeltas ? BuildDeltaEvidence(finding) : null; var policy = options.IncludePolicy ? BuildPolicyEvidence(finding) : null; // Get replay commands var replayResponse = await _replayService.GenerateForFindingAsync( new GenerateReplayCommandRequestDto { FindingId = findingId }, cancellationToken).ConfigureAwait(false); // Build manifest hashes var manifests = BuildManifestHashes(finding); // Build verification status var verification = BuildVerificationStatus(finding); // Compute cache key from content var cacheKey = ComputeCacheKey(finding); return new UnifiedEvidenceResponseDto { FindingId = findingId, CveId = finding.CveId ?? "unknown", ComponentPurl = finding.Purl, Sbom = sbomEvidence, Reachability = reachabilityEvidence, VexClaims = vexClaims, Attestations = attestations, Deltas = deltas, Policy = policy, Manifests = manifests, Verification = verification, ReplayCommand = replayResponse?.FullCommand?.Command, ShortReplayCommand = replayResponse?.ShortCommand?.Command, EvidenceBundleUrl = replayResponse?.Bundle?.DownloadUri, GeneratedAt = _timeProvider.GetUtcNow(), CacheKey = cacheKey }; } private SbomEvidenceDto? BuildSbomEvidence(TriageFinding finding) { var sbomArtifact = finding.EvidenceArtifacts? .FirstOrDefault(a => a.Type == TriageEvidenceType.SbomSlice); if (sbomArtifact is null) return null; return new SbomEvidenceDto { Format = sbomArtifact.MediaType ?? "unknown", Version = "1.0", DocumentUri = sbomArtifact.Uri, Digest = sbomArtifact.ContentHash, Component = BuildSbomComponent(finding) }; } private SbomComponentDto? BuildSbomComponent(TriageFinding finding) { if (finding.Purl is null) return null; return new SbomComponentDto { Purl = finding.Purl, Name = ExtractNameFromPurl(finding.Purl), Version = ExtractVersionFromPurl(finding.Purl), Ecosystem = ExtractEcosystemFromPurl(finding.Purl) }; } private ReachabilityEvidenceDto? BuildReachabilityEvidence(TriageFinding finding) { var reachability = finding.ReachabilityResults?.FirstOrDefault(); if (reachability is null) return null; return new ReachabilityEvidenceDto { SubgraphId = reachability.SubgraphId ?? finding.Id.ToString(), Status = reachability.Reachable == TriageReachability.Yes ? "reachable" : reachability.Reachable == TriageReachability.No ? "unreachable" : "unknown", Confidence = reachability.Confidence, Method = !string.IsNullOrEmpty(reachability.RuntimeProofRef) ? "runtime" : "static", GraphUri = $"/api/reachability/{reachability.SubgraphId}/graph" }; } private IReadOnlyList? BuildVexClaims(TriageFinding finding) { var vexRecords = finding.EffectiveVexRecords; if (vexRecords is null || vexRecords.Count == 0) return null; return vexRecords.Select(vex => new VexClaimDto { StatementId = vex.Id.ToString(), Source = vex.Issuer ?? "unknown", Status = vex.Status.ToString().ToLowerInvariant(), IssuedAt = vex.ValidFrom, TrustScore = ComputeVexTrustScore(vex), MeetsPolicyThreshold = ComputeVexTrustScore(vex) >= DefaultPolicyTrustThreshold, DocumentUri = vex.SourceRef }).ToList(); } private IReadOnlyList? BuildAttestations(TriageFinding finding) { var attestations = finding.Attestations; if (attestations is null || attestations.Count == 0) return null; return attestations.Select(att => new AttestationSummaryDto { Id = att.Id.ToString(), PredicateType = att.Type, SubjectDigest = att.EnvelopeHash ?? "unknown", Signer = att.Issuer, SignedAt = att.CollectedAt, VerificationStatus = !string.IsNullOrEmpty(att.LedgerRef) ? "verified" : "unverified", TransparencyLogEntry = att.LedgerRef, AttestationUri = att.ContentRef }).ToList(); } private DeltaEvidenceDto? BuildDeltaEvidence(TriageFinding finding) { if (finding.DeltaComparisonId is null) return null; return new DeltaEvidenceDto { DeltaId = finding.DeltaComparisonId.Value.ToString(), PreviousScanId = "unknown", // Would be populated from delta record CurrentScanId = finding.ScanId?.ToString() ?? "unknown", ComparedAt = finding.LastSeenAt, DeltaReportUri = $"/api/deltas/{finding.DeltaComparisonId}" }; } private PolicyEvidenceDto? BuildPolicyEvidence(TriageFinding finding) { var decisions = finding.PolicyDecisions; if (decisions is null || decisions.Count == 0) return null; var latestDecision = decisions.OrderByDescending(d => d.AppliedAt).FirstOrDefault(); if (latestDecision is null) return null; return new PolicyEvidenceDto { PolicyVersion = "1.0", // Would come from policy record PolicyDigest = ComputeDigest(latestDecision.PolicyId), Verdict = latestDecision.Action, RulesFired = new List { new PolicyRuleFiredDto { RuleId = latestDecision.PolicyId, Name = latestDecision.PolicyId, Effect = latestDecision.Action, Reason = latestDecision.Reason } }, PolicyDocumentUri = $"/api/policies/{latestDecision.PolicyId}" }; } private ManifestHashesDto BuildManifestHashes(TriageFinding finding) { var contentForHash = JsonSerializer.Serialize(new { finding.Id, finding.CveId, finding.Purl, VexCount = finding.EffectiveVexRecords?.Count ?? 0, ReachabilityCount = finding.ReachabilityResults?.Count ?? 0 }); return new ManifestHashesDto { ArtifactDigest = ComputeDigest(finding.Purl), ManifestHash = ComputeDigest(contentForHash), FeedSnapshotHash = ComputeDigest(finding.LastSeenAt.ToString("O", CultureInfo.InvariantCulture)), PolicyHash = ComputeDigest("default-policy"), KnowledgeSnapshotId = finding.KnowledgeSnapshotId }; } private VerificationStatusDto BuildVerificationStatus(TriageFinding finding) { var hasVex = finding.EffectiveVexRecords?.Count > 0; var hasReachability = finding.ReachabilityResults?.Count > 0; var hasAttestations = finding.Attestations?.Count > 0; var issues = new List(); if (!hasVex) issues.Add("No VEX records available"); if (!hasReachability) issues.Add("No reachability analysis available"); if (!hasAttestations) issues.Add("No attestations available"); var status = (hasVex && hasReachability && hasAttestations) ? "verified" : (hasVex || hasReachability) ? "partial" : "unknown"; return new VerificationStatusDto { Status = status, HashesVerified = true, // Simplified: always verified in this stub AttestationsVerified = hasAttestations, EvidenceComplete = hasVex && hasReachability, Issues = issues.Count > 0 ? issues : null, VerifiedAt = _timeProvider.GetUtcNow() }; } private double ComputeVexTrustScore(TriageEffectiveVex vex) { const double IssuerWeight = 0.4; const double RecencyWeight = 0.2; const double JustificationWeight = 0.2; const double EvidenceWeight = 0.2; var issuerTrust = GetIssuerTrust(vex.Issuer); var recencyTrust = GetRecencyTrust((DateTimeOffset?)vex.ValidFrom, _timeProvider.GetUtcNow()); var justificationTrust = GetJustificationTrust(vex.PrunedSourcesJson); var evidenceTrust = !string.IsNullOrEmpty(vex.DsseEnvelopeHash) ? 0.8 : 0.3; return (issuerTrust * IssuerWeight) + (recencyTrust * RecencyWeight) + (justificationTrust * JustificationWeight) + (evidenceTrust * EvidenceWeight); } private static double GetIssuerTrust(string? issuer) => issuer?.ToLowerInvariant() switch { "nvd" => 1.0, "redhat" or "canonical" or "debian" => 0.95, "suse" or "microsoft" => 0.9, _ when issuer?.Contains("vendor", StringComparison.OrdinalIgnoreCase) == true => 0.8, _ => 0.5 }; private static double GetRecencyTrust(DateTimeOffset? timestamp, DateTimeOffset now) { if (timestamp is null) return 0.3; var age = now - timestamp.Value; return age.TotalDays switch { <= 7 => 1.0, <= 30 => 0.9, <= 90 => 0.7, <= 365 => 0.5, _ => 0.3 }; } private static double GetJustificationTrust(string? justification) => justification?.Length switch { >= 500 => 1.0, >= 200 => 0.8, >= 50 => 0.6, _ => 0.4 }; private static string ComputeDigest(string input) { var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}"; } private string ComputeCacheKey(TriageFinding finding) { var keyContent = $"{finding.Id}:{finding.LastSeenAt:O}:{finding.EffectiveVexRecords?.Count ?? 0}"; return ComputeDigest(keyContent); } private static string ExtractNameFromPurl(string purl) { // pkg:npm/lodash@4.17.21 -> lodash var parts = purl.Split('/'); if (parts.Length < 2) return purl; var nameVersion = parts[^1]; var atIndex = nameVersion.IndexOf('@'); return atIndex > 0 ? nameVersion[..atIndex] : nameVersion; } private static string ExtractVersionFromPurl(string purl) { // pkg:npm/lodash@4.17.21 -> 4.17.21 var atIndex = purl.LastIndexOf('@'); return atIndex > 0 ? purl[(atIndex + 1)..] : "unknown"; } private static string ExtractEcosystemFromPurl(string purl) { // pkg:npm/lodash@4.17.21 -> npm if (!purl.StartsWith("pkg:")) return "unknown"; var rest = purl[4..]; var slashIndex = rest.IndexOf('/'); return slashIndex > 0 ? rest[..slashIndex] : rest; } }