412 lines
14 KiB
C#
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; }
|
|
}
|