sprints work
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// UnifiedEvidenceService.cs
|
||||
// Sprint: SPRINT_9200_0001_0002_SCANNER_unified_evidence_endpoint
|
||||
// Description: Implementation of IUnifiedEvidenceService for assembling evidence.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
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 ILogger<UnifiedEvidenceService> _logger;
|
||||
|
||||
private const double DefaultPolicyTrustThreshold = 0.7;
|
||||
|
||||
public UnifiedEvidenceService(
|
||||
TriageDbContext dbContext,
|
||||
IGatingReasonService gatingService,
|
||||
IReplayCommandService replayService,
|
||||
ILogger<UnifiedEvidenceService> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_gatingService = gatingService ?? throw new ArgumentNullException(nameof(gatingService));
|
||||
_replayService = replayService ?? throw new ArgumentNullException(nameof(replayService));
|
||||
_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 = DateTimeOffset.UtcNow,
|
||||
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")),
|
||||
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 = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static 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);
|
||||
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)
|
||||
{
|
||||
if (timestamp is null) return 0.3;
|
||||
var age = DateTimeOffset.UtcNow - 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user