Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger.WebService/Services/EvidenceGraphBuilder.cs

412 lines
14 KiB
C#

using StellaOps.Findings.Ledger.WebService.Contracts;
namespace StellaOps.Findings.Ledger.WebService.Services;
public interface IEvidenceGraphBuilder
{
Task<EvidenceGraphResponse?> 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<EvidenceGraphResponse?> BuildAsync(
Guid findingId,
CancellationToken ct)
{
var evidence = await _evidenceRepo.GetFullEvidenceAsync(findingId, ct);
if (evidence is null)
return null;
var nodes = new List<EvidenceNode>();
var edges = new List<EvidenceEdge>();
// 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<EvidenceNode> 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<string, string>
{
["policyName"] = policy.PolicyName,
["policyVersion"] = policy.PolicyVersion
},
ContentUrl = $"/api/v1/evidence/{policy.Digest}"
};
}
private async Task<EvidenceNode> 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<string, string>
{
["status"] = vex.Status,
["justification"] = vex.Justification ?? string.Empty
},
ContentUrl = $"/api/v1/evidence/{vex.Digest}"
};
}
private async Task<EvidenceNode> 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<string, string>
{
["state"] = reach.State,
["confidence"] = reach.Confidence.ToString("F2")
},
ContentUrl = $"/api/v1/evidence/{reach.Digest}"
};
}
private async Task<EvidenceNode> 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<string, string>
{
["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<string, string>
{
["purl"] = sbom.Purl,
["version"] = sbom.Version
},
ContentUrl = $"/api/v1/evidence/{sbom.Digest}"
};
}
private async Task<EvidenceNode> 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<string, string>
{
["builderType"] = prov.BuilderType,
["repoUrl"] = prov.RepoUrl ?? string.Empty
},
ContentUrl = $"/api/v1/evidence/{prov.Digest}"
};
}
private async Task<SignatureStatus> 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
};
}
}
/// <summary>
/// Repository for evidence retrieval.
/// </summary>
public interface IEvidenceRepository
{
Task<FullEvidence?> GetFullEvidenceAsync(Guid findingId, CancellationToken ct);
}
/// <summary>
/// Service for attestation verification.
/// </summary>
public interface IAttestationVerifier
{
Task<AttestationVerificationResult> VerifyAsync(string digest, CancellationToken ct);
}
/// <summary>
/// Full evidence bundle for a finding.
/// </summary>
public sealed record FullEvidence
{
public required string VulnerabilityId { get; init; }
public required VerdictEvidence Verdict { get; init; }
public PolicyTraceEvidence? PolicyTrace { get; init; }
public IReadOnlyList<VexEvidence> VexStatements { get; init; } = Array.Empty<VexEvidence>();
public ReachabilityEvidence? Reachability { get; init; }
public IReadOnlyList<RuntimeEvidence> RuntimeObservations { get; init; } = Array.Empty<RuntimeEvidence>();
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; }
}