364 lines
14 KiB
C#
364 lines
14 KiB
C#
// -----------------------------------------------------------------------------
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Assembles unified evidence packages for findings.
|
|
/// </summary>
|
|
public sealed class UnifiedEvidenceService : IUnifiedEvidenceService
|
|
{
|
|
private readonly TriageDbContext _dbContext;
|
|
private readonly IGatingReasonService _gatingService;
|
|
private readonly IReplayCommandService _replayService;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<UnifiedEvidenceService> _logger;
|
|
|
|
private const double DefaultPolicyTrustThreshold = 0.7;
|
|
|
|
public UnifiedEvidenceService(
|
|
TriageDbContext dbContext,
|
|
IGatingReasonService gatingService,
|
|
IReplayCommandService replayService,
|
|
TimeProvider timeProvider,
|
|
ILogger<UnifiedEvidenceService> 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));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnifiedEvidenceResponseDto?> 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<VexClaimDto>? 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<AttestationSummaryDto>? 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<PolicyRuleFiredDto>
|
|
{
|
|
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<string>();
|
|
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;
|
|
}
|
|
}
|