using System.Collections.Immutable; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using StellaOps.Canonical.Json; using StellaOps.Policy; using StellaOps.Policy.Engine.Materialization; namespace StellaOps.Policy.Engine.Attestation; /// /// Builds DSSE verdict predicates from policy explain traces. /// public sealed class VerdictPredicateBuilder { /// /// Initializes a new instance of VerdictPredicateBuilder. /// public VerdictPredicateBuilder() { } /// /// Builds a verdict predicate from a policy explain trace. /// public VerdictPredicate Build(PolicyExplainTrace trace) { if (trace is null) { throw new ArgumentNullException(nameof(trace)); } // Map verdict var verdict = new VerdictInfo( status: MapVerdictStatus(trace.Verdict.Status), severity: MapSeverity(trace.Verdict.Severity), score: trace.Verdict.Score ?? 0.0, rationale: trace.Verdict.Rationale ); // Map rule chain var ruleChain = trace.RuleChain .Select(r => new VerdictRuleExecution( ruleId: r.RuleId, action: r.Action, decision: r.Decision, score: r.Score != 0 ? r.Score : null )) .ToList(); // Map evidence var evidence = trace.Evidence .Select(e => new VerdictEvidence( type: e.Type, reference: e.Reference, source: e.Source, status: e.Status, digest: ComputeEvidenceDigest(e), weight: e.Weight != 0 ? e.Weight : null, metadata: e.Metadata.Any() ? e.Metadata.ToImmutableSortedDictionary() : null )) .ToList(); // Map VEX impacts var vexImpacts = trace.VexImpacts .Select(v => new VerdictVexImpact( statementId: v.StatementId, provider: v.Provider, status: v.Status, accepted: v.Accepted, justification: v.Justification )) .ToList(); // Extract reachability (if present in metadata) var reachability = ExtractReachability(trace); // Build metadata with determinism hash var metadata = BuildMetadata(trace, evidence); return new VerdictPredicate( tenantId: trace.TenantId, policyId: trace.PolicyId, policyVersion: trace.PolicyVersion, runId: trace.RunId, findingId: trace.FindingId, evaluatedAt: trace.EvaluatedAt, verdict: verdict, ruleChain: ruleChain, evidence: evidence, vexImpacts: vexImpacts, reachability: reachability, metadata: metadata ); } /// /// Serializes a verdict predicate to canonical JSON. /// public string Serialize(VerdictPredicate predicate) { if (predicate is null) { throw new ArgumentNullException(nameof(predicate)); } var canonical = CanonJson.Canonicalize(predicate); return Encoding.UTF8.GetString(canonical); } /// /// Computes the determinism hash for a verdict predicate. /// public string ComputeDeterminismHash(VerdictPredicate predicate) { if (predicate is null) { throw new ArgumentNullException(nameof(predicate)); } // Sort and concatenate all evidence digests var evidenceDigests = predicate.Evidence .Where(e => e.Digest is not null) .Select(e => e.Digest!) .OrderBy(d => d, StringComparer.Ordinal) .ToList(); // Add verdict status, severity, score var components = new List { predicate.Verdict.Status, predicate.Verdict.Severity, predicate.Verdict.Score.ToString("F2", CultureInfo.InvariantCulture), }; components.AddRange(evidenceDigests); // Compute SHA256 hash var combined = string.Join(":", components); var bytes = Encoding.UTF8.GetBytes(combined); var hash = SHA256.HashData(bytes); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static string MapVerdictStatus(PolicyVerdictStatus status) { return status switch { PolicyVerdictStatus.Pass => "passed", PolicyVerdictStatus.Warned => "warned", PolicyVerdictStatus.Blocked => "blocked", PolicyVerdictStatus.Ignored => "ignored", PolicyVerdictStatus.Deferred => "deferred", PolicyVerdictStatus.Escalated => "escalated", PolicyVerdictStatus.RequiresVex => "requires_vex", _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown verdict status.") }; } private static string MapSeverity(SeverityRank? severity) { if (severity is null) { return "none"; } return severity.Value switch { SeverityRank.Critical => "critical", SeverityRank.High => "high", SeverityRank.Medium => "medium", SeverityRank.Low => "low", SeverityRank.Info => "info", SeverityRank.None => "none", _ => "none" }; } private static string? ComputeEvidenceDigest(PolicyExplainEvidence evidence) { // If evidence has a reference that looks like a digest, use it if (evidence.Reference.StartsWith("sha256:", StringComparison.Ordinal)) { return evidence.Reference; } // Otherwise, compute digest from reference + source + status var content = $"{evidence.Type}:{evidence.Reference}:{evidence.Source}:{evidence.Status}"; var bytes = Encoding.UTF8.GetBytes(content); var hash = SHA256.HashData(bytes); return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}"; } private static VerdictReachability? ExtractReachability(PolicyExplainTrace trace) { // Check if metadata contains reachability status if (!trace.Metadata.TryGetValue("reachabilitystatus", out var reachabilityStatus)) { return null; } // TODO: Extract full reachability paths from trace or evidence // For now, return basic reachability status return new VerdictReachability( status: reachabilityStatus, paths: null ); } private ImmutableSortedDictionary BuildMetadata( PolicyExplainTrace trace, List evidence) { var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); // Add component PURL if present if (trace.Metadata.TryGetValue("componentpurl", out var componentPurl)) { builder["componentpurl"] = componentPurl; } // Add SBOM ID if present if (trace.Metadata.TryGetValue("sbomid", out var sbomId)) { builder["sbomid"] = sbomId; } // Add trace ID if present if (trace.Metadata.TryGetValue("traceid", out var traceId)) { builder["traceid"] = traceId; } // Compute and add determinism hash // Temporarily create predicate to compute hash var tempPredicate = new VerdictPredicate( tenantId: trace.TenantId, policyId: trace.PolicyId, policyVersion: trace.PolicyVersion, runId: trace.RunId, findingId: trace.FindingId, evaluatedAt: trace.EvaluatedAt, verdict: new VerdictInfo( status: MapVerdictStatus(trace.Verdict.Status), severity: MapSeverity(trace.Verdict.Severity), score: trace.Verdict.Score ?? 0.0 ), ruleChain: null, evidence: evidence, vexImpacts: null, reachability: null, metadata: null ); builder["determinismhash"] = ComputeDeterminismHash(tempPredicate); return builder.ToImmutable(); } }