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