using StellaOps.Findings.Ledger.WebService.Contracts; namespace StellaOps.Findings.Ledger.WebService.Services; public interface IEvidenceGraphBuilder { Task BuildAsync(Guid findingId, CancellationToken ct); } public sealed class EvidenceGraphBuilder : IEvidenceGraphBuilder { private readonly IEvidenceRepository _evidenceRepo; private readonly IAttestationVerifier _attestationVerifier; public EvidenceGraphBuilder( IEvidenceRepository evidenceRepo, IAttestationVerifier attestationVerifier) { _evidenceRepo = evidenceRepo; _attestationVerifier = attestationVerifier; } public async Task BuildAsync( Guid findingId, CancellationToken ct) { var evidence = await _evidenceRepo.GetFullEvidenceAsync(findingId, ct); if (evidence is null) return null; var nodes = new List(); var edges = new List(); // Build verdict node (root) var verdictNode = BuildVerdictNode(evidence.Verdict); nodes.Add(verdictNode); // Build policy trace node if (evidence.PolicyTrace is not null) { var policyNode = await BuildPolicyNodeAsync(evidence.PolicyTrace, ct); nodes.Add(policyNode); edges.Add(new EvidenceEdge { From = policyNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.DerivedFrom, Label = "policy evaluation" }); } // Build VEX nodes foreach (var vex in evidence.VexStatements) { var vexNode = await BuildVexNodeAsync(vex, ct); nodes.Add(vexNode); edges.Add(new EvidenceEdge { From = vexNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.DerivedFrom, Label = vex.Status.ToLowerInvariant() }); } // Build reachability node if (evidence.Reachability is not null) { var reachNode = await BuildReachabilityNodeAsync(evidence.Reachability, ct); nodes.Add(reachNode); edges.Add(new EvidenceEdge { From = reachNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.Corroborates, Label = "reachability analysis" }); } // Build runtime nodes foreach (var runtime in evidence.RuntimeObservations) { var runtimeNode = await BuildRuntimeNodeAsync(runtime, ct); nodes.Add(runtimeNode); edges.Add(new EvidenceEdge { From = runtimeNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.Corroborates, Label = "runtime observation" }); } // Build SBOM node if (evidence.SbomComponent is not null) { var sbomNode = BuildSbomNode(evidence.SbomComponent); nodes.Add(sbomNode); edges.Add(new EvidenceEdge { From = sbomNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.References, Label = "component" }); } // Build provenance node if (evidence.Provenance is not null) { var provNode = await BuildProvenanceNodeAsync(evidence.Provenance, ct); nodes.Add(provNode); edges.Add(new EvidenceEdge { From = provNode.Id, To = verdictNode.Id, Relation = EvidenceRelation.VerifiedBy, Label = "provenance" }); } return new EvidenceGraphResponse { FindingId = findingId, VulnerabilityId = evidence.VulnerabilityId, Nodes = nodes, Edges = edges, RootNodeId = verdictNode.Id, GeneratedAt = DateTimeOffset.UtcNow }; } private EvidenceNode BuildVerdictNode(VerdictEvidence verdict) { return new EvidenceNode { Id = $"verdict:{verdict.Digest}", Type = EvidenceNodeType.Verdict, Label = $"Verdict: {verdict.Status}", Digest = verdict.Digest, Issuer = verdict.Issuer, Timestamp = verdict.Timestamp, Signature = new SignatureStatus { IsSigned = false }, ContentUrl = $"/api/v1/evidence/{verdict.Digest}" }; } private async Task BuildPolicyNodeAsync( PolicyTraceEvidence policy, CancellationToken ct) { var signature = await VerifySignatureAsync(policy.AttestationDigest, ct); return new EvidenceNode { Id = $"policy:{policy.Digest}", Type = EvidenceNodeType.PolicyTrace, Label = $"Policy: {policy.PolicyName}", Digest = policy.Digest, Issuer = policy.Issuer, Timestamp = policy.Timestamp, Signature = signature, Metadata = new Dictionary { ["policyName"] = policy.PolicyName, ["policyVersion"] = policy.PolicyVersion }, ContentUrl = $"/api/v1/evidence/{policy.Digest}" }; } private async Task BuildVexNodeAsync( VexEvidence vex, CancellationToken ct) { var signature = await VerifySignatureAsync(vex.AttestationDigest, ct); return new EvidenceNode { Id = $"vex:{vex.Digest}", Type = EvidenceNodeType.VexStatement, Label = $"VEX: {vex.Status}", Digest = vex.Digest, Issuer = vex.Issuer, Timestamp = vex.Timestamp, Signature = signature, Metadata = new Dictionary { ["status"] = vex.Status, ["justification"] = vex.Justification ?? string.Empty }, ContentUrl = $"/api/v1/evidence/{vex.Digest}" }; } private async Task BuildReachabilityNodeAsync( ReachabilityEvidence reach, CancellationToken ct) { var signature = await VerifySignatureAsync(reach.AttestationDigest, ct); return new EvidenceNode { Id = $"reach:{reach.Digest}", Type = EvidenceNodeType.Reachability, Label = $"Reachability: {reach.State}", Digest = reach.Digest, Issuer = reach.Issuer, Timestamp = reach.Timestamp, Signature = signature, Metadata = new Dictionary { ["state"] = reach.State, ["confidence"] = reach.Confidence.ToString("F2") }, ContentUrl = $"/api/v1/evidence/{reach.Digest}" }; } private async Task BuildRuntimeNodeAsync( RuntimeEvidence runtime, CancellationToken ct) { var signature = await VerifySignatureAsync(runtime.AttestationDigest, ct); return new EvidenceNode { Id = $"runtime:{runtime.Digest}", Type = EvidenceNodeType.RuntimeObservation, Label = $"Runtime: {runtime.ObservationType}", Digest = runtime.Digest, Issuer = runtime.Issuer, Timestamp = runtime.Timestamp, Signature = signature, Metadata = new Dictionary { ["observationType"] = runtime.ObservationType, ["durationMinutes"] = runtime.DurationMinutes.ToString() }, ContentUrl = $"/api/v1/evidence/{runtime.Digest}" }; } private EvidenceNode BuildSbomNode(SbomComponentEvidence sbom) { return new EvidenceNode { Id = $"sbom:{sbom.Digest}", Type = EvidenceNodeType.SbomComponent, Label = $"Component: {sbom.ComponentName}", Digest = sbom.Digest, Issuer = sbom.Issuer, Timestamp = sbom.Timestamp, Signature = new SignatureStatus { IsSigned = false }, Metadata = new Dictionary { ["purl"] = sbom.Purl, ["version"] = sbom.Version }, ContentUrl = $"/api/v1/evidence/{sbom.Digest}" }; } private async Task BuildProvenanceNodeAsync( ProvenanceEvidence prov, CancellationToken ct) { var signature = await VerifySignatureAsync(prov.AttestationDigest, ct); return new EvidenceNode { Id = $"prov:{prov.Digest}", Type = EvidenceNodeType.Provenance, Label = $"Provenance: {prov.BuilderType}", Digest = prov.Digest, Issuer = prov.Issuer, Timestamp = prov.Timestamp, Signature = signature, Metadata = new Dictionary { ["builderType"] = prov.BuilderType, ["repoUrl"] = prov.RepoUrl ?? string.Empty }, ContentUrl = $"/api/v1/evidence/{prov.Digest}" }; } private async Task VerifySignatureAsync( string? attestationDigest, CancellationToken ct) { if (attestationDigest is null) { return new SignatureStatus { IsSigned = false }; } var result = await _attestationVerifier.VerifyAsync(attestationDigest, ct); return new SignatureStatus { IsSigned = true, IsValid = result.IsValid, SignerIdentity = result.SignerIdentity, SignedAt = result.SignedAt, KeyId = result.KeyId, RekorLogIndex = result.RekorLogIndex }; } } /// /// Repository for evidence retrieval. /// public interface IEvidenceRepository { Task GetFullEvidenceAsync(Guid findingId, CancellationToken ct); } /// /// Service for attestation verification. /// public interface IAttestationVerifier { Task VerifyAsync(string digest, CancellationToken ct); } /// /// Full evidence bundle for a finding. /// public sealed record FullEvidence { public required string VulnerabilityId { get; init; } public required VerdictEvidence Verdict { get; init; } public PolicyTraceEvidence? PolicyTrace { get; init; } public IReadOnlyList VexStatements { get; init; } = Array.Empty(); public ReachabilityEvidence? Reachability { get; init; } public IReadOnlyList RuntimeObservations { get; init; } = Array.Empty(); public SbomComponentEvidence? SbomComponent { get; init; } public ProvenanceEvidence? Provenance { get; init; } } public sealed record VerdictEvidence { public required string Status { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } } public sealed record PolicyTraceEvidence { public required string PolicyName { get; init; } public required string PolicyVersion { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } public string? AttestationDigest { get; init; } } public sealed record VexEvidence { public required string Status { get; init; } public string? Justification { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } public string? AttestationDigest { get; init; } } public sealed record ReachabilityEvidence { public required string State { get; init; } public required decimal Confidence { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } public string? AttestationDigest { get; init; } } public sealed record RuntimeEvidence { public required string ObservationType { get; init; } public required int DurationMinutes { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } public string? AttestationDigest { get; init; } } public sealed record SbomComponentEvidence { public required string ComponentName { get; init; } public required string Purl { get; init; } public required string Version { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } } public sealed record ProvenanceEvidence { public required string BuilderType { get; init; } public string? RepoUrl { get; init; } public required string Digest { get; init; } public required string Issuer { get; init; } public required DateTimeOffset Timestamp { get; init; } public string? AttestationDigest { get; init; } } public sealed record AttestationVerificationResult { public required bool IsValid { get; init; } public string? SignerIdentity { get; init; } public DateTimeOffset? SignedAt { get; init; } public string? KeyId { get; init; } public long? RekorLogIndex { get; init; } }