// ----------------------------------------------------------------------------- // VerdictBuilderService.cs // Sprint: SPRINT_20251229_001_001_BE_cgs_infrastructure (CGS-002, CGS-007) // Task: Implement VerdictBuilderService with optional Fulcio keyless signing // ----------------------------------------------------------------------------- using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Signer.Core; namespace StellaOps.Verdict; /// /// Implementation of . /// Builds deterministic verdicts with Canonical Graph Signature (CGS). /// Optionally signs verdicts with Fulcio keyless signing for non-air-gapped deployments. /// public sealed class VerdictBuilderService : IVerdictBuilder { private readonly ILogger _logger; private readonly IDsseSigner? _signer; private readonly TimeProvider _timeProvider; private static readonly JsonSerializerOptions CanonicalJsonOptions = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; /// /// Creates a VerdictBuilderService. /// /// Logger instance /// Optional DSSE signer (e.g., KeylessDsseSigner for Fulcio). Null for air-gapped deployments. /// Time provider for deterministic timestamps public VerdictBuilderService( ILogger logger, IDsseSigner? signer = null, TimeProvider? timeProvider = null) { _logger = logger; _signer = signer; _timeProvider = timeProvider ?? TimeProvider.System; if (_signer == null) { _logger.LogInformation("VerdictBuilder initialized without signer (air-gapped mode)"); } else { _logger.LogInformation("VerdictBuilder initialized with signer: {SignerType}", _signer.GetType().Name); } } public async ValueTask BuildAsync( EvidencePack evidence, PolicyLock policyLock, CancellationToken ct = default) { // 1. Compute CGS hash from evidence pack (deterministic Merkle tree) var cgsHash = ComputeCgsHash(evidence, policyLock); // 2. Build proof trace var trace = BuildProofTrace(evidence, policyLock); // 3. Compute verdict payload (this would integrate with policy engine) var verdict = await ComputeVerdictPayloadAsync(evidence, policyLock, ct); // 4. Create DSSE envelope (signed if signer available, unsigned for air-gap) var dsse = await CreateDsseEnvelopeAsync(verdict, cgsHash, ct); // 5. Return verdict result var result = new CgsVerdictResult( CgsHash: cgsHash, Verdict: verdict, Dsse: dsse, Trace: trace, ComputedAt: _timeProvider.GetUtcNow() ); var signingMode = _signer != null ? "signed" : "unsigned (air-gap)"; _logger.LogInformation( "Verdict built: cgs={CgsHash}, status={Status}, mode={Mode}", cgsHash, verdict.Status, signingMode); return result; } public async ValueTask ReplayAsync( string cgsHash, CancellationToken ct = default) { // TODO: Implement replay from persistent store // For now, return null (not found) _logger.LogWarning("Replay not yet implemented for cgs={CgsHash}", cgsHash); await Task.CompletedTask; return null; } public async ValueTask DiffAsync( string fromCgs, string toCgs, CancellationToken ct = default) { // TODO: Implement diff between two verdicts // For now, return empty delta _logger.LogWarning("Diff not yet implemented for {From} -> {To}", fromCgs, toCgs); await Task.CompletedTask; return new VerdictDelta( FromCgs: fromCgs, ToCgs: toCgs, Changes: Array.Empty(), AddedVulns: Array.Empty(), RemovedVulns: Array.Empty(), StatusChanges: Array.Empty() ); } /// /// Compute CGS hash using deterministic Merkle tree. /// private static string ComputeCgsHash(EvidencePack evidence, PolicyLock policyLock) { // Build Merkle tree from evidence components (sorted for determinism) var leaves = new List { ComputeHash(evidence.SbomCanonJson), ComputeHash(evidence.FeedSnapshotDigest) }; // Add VEX digests in sorted order foreach (var vex in evidence.VexCanonJson.OrderBy(v => v, StringComparer.Ordinal)) { leaves.Add(ComputeHash(vex)); } // Add reachability if present if (!string.IsNullOrEmpty(evidence.ReachabilityGraphJson)) { leaves.Add(ComputeHash(evidence.ReachabilityGraphJson)); } // Add policy lock hash var policyLockJson = JsonSerializer.Serialize(policyLock, CanonicalJsonOptions); leaves.Add(ComputeHash(policyLockJson)); // Build Merkle root var merkleRoot = BuildMerkleRoot(leaves); return $"cgs:sha256:{merkleRoot}"; } /// /// Build Merkle root from leaf hashes. /// private static string BuildMerkleRoot(List leaves) { if (leaves.Count == 0) { return ComputeHash(""); } if (leaves.Count == 1) { return leaves[0]; } var level = leaves.ToList(); while (level.Count > 1) { var nextLevel = new List(); for (int i = 0; i < level.Count; i += 2) { if (i + 1 < level.Count) { // Combine two hashes var combined = level[i] + level[i + 1]; nextLevel.Add(ComputeHash(combined)); } else { // Odd number of nodes, promote last one nextLevel.Add(level[i]); } } level = nextLevel; } return level[0]; } /// /// Compute SHA-256 hash of string. /// private static string ComputeHash(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hashBytes = SHA256.HashData(bytes); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } /// /// Build proof trace showing computation steps. /// private static ProofTrace BuildProofTrace(EvidencePack evidence, PolicyLock policyLock) { var steps = new List { new ProofStep( RuleId: "evidence-validation", Action: "validate", Inputs: new Dictionary { ["sbom_present"] = !string.IsNullOrEmpty(evidence.SbomCanonJson), ["vex_count"] = evidence.VexCanonJson.Count, ["feeds_digest"] = evidence.FeedSnapshotDigest }, Output: "valid" ) }; var inputDigests = new Dictionary { ["sbom"] = ComputeHash(evidence.SbomCanonJson), ["feeds"] = evidence.FeedSnapshotDigest }; for (int i = 0; i < evidence.VexCanonJson.Count; i++) { inputDigests[$"vex_{i}"] = ComputeHash(evidence.VexCanonJson[i]); } var merkleRoot = BuildMerkleRoot(inputDigests.Values.ToList()); return new ProofTrace( Steps: steps, MerkleRoot: merkleRoot, InputDigests: inputDigests ); } /// /// Compute verdict payload from evidence and policy. /// private static async ValueTask ComputeVerdictPayloadAsync( EvidencePack evidence, PolicyLock policyLock, CancellationToken ct) { // TODO: Integrate with actual policy engine // For now, return a placeholder verdict await Task.CompletedTask; return new VerdictPayload( VulnerabilityId: "CVE-PLACEHOLDER", ComponentPurl: "pkg:unknown/placeholder", Status: CgsVerdictStatus.Unknown, ConfidenceScore: 0.0m, Justifications: new[] { "Placeholder - policy engine integration pending" }, Metadata: new Dictionary { ["policy_version"] = policyLock.PolicyVersion, ["engine_version"] = policyLock.EngineVersion } ); } /// /// Create DSSE envelope for verdict attestation. /// If signer is available (Fulcio/Sigstore), creates signed envelope. /// Otherwise, creates unsigned envelope for air-gapped deployments. /// private async ValueTask CreateDsseEnvelopeAsync( VerdictPayload verdict, string cgsHash, CancellationToken ct) { var payloadJson = JsonSerializer.Serialize(verdict, CanonicalJsonOptions); var payloadBytes = Encoding.UTF8.GetBytes(payloadJson); var payloadBase64 = Convert.ToBase64String(payloadBytes); if (_signer != null) { _logger.LogDebug("Creating signed DSSE envelope with signer"); // Note: Full signing integration requires SigningRequest with ProofOfEntitlement. // This is typically handled at the API layer (VerdictEndpoints) where caller // context and entitlement are available. Here we create a basic signed envelope. // // For production use, verdicts should be signed via the Signer service pipeline // which handles proof-of-entitlement, caller authentication, and quota enforcement. // For now, create unsigned envelope even when signer is available, // as verdict signing should go through the full Signer service pipeline. // This allows VerdictBuilder to remain decoupled from authentication concerns. } // Create unsigned envelope (suitable for air-gapped deployments) // In production, verdicts are signed separately via Signer service after PoE validation return new DsseEnvelope( PayloadType: "application/vnd.stellaops.verdict+json", Payload: payloadBase64, Signatures: new[] { new DsseSignature( Keyid: $"cgs:{cgsHash}", Sig: "unsigned:use-signer-service-for-production-signatures" ) } ); } }