// -----------------------------------------------------------------------------
// 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"
)
}
);
}
}