using System.Collections.Immutable; using System.Text.Json.Serialization; namespace StellaOps.Policy.Engine.Attestation; /// /// Predicate for DSSE-wrapped policy verdict attestations. /// URI: https://stellaops.dev/predicates/policy-verdict@v1 /// public sealed record VerdictPredicate { public const string PredicateType = "https://stellaops.dev/predicates/policy-verdict@v1"; public VerdictPredicate( string tenantId, string policyId, int policyVersion, string runId, string findingId, DateTimeOffset evaluatedAt, VerdictInfo verdict, IEnumerable? ruleChain = null, IEnumerable? evidence = null, IEnumerable? vexImpacts = null, VerdictReachability? reachability = null, ImmutableSortedDictionary? metadata = null) { Type = PredicateType; TenantId = Validation.EnsureTenantId(tenantId, nameof(tenantId)); PolicyId = Validation.EnsureSimpleIdentifier(policyId, nameof(policyId)); if (policyVersion <= 0) { throw new ArgumentOutOfRangeException(nameof(policyVersion), policyVersion, "Policy version must be positive."); } PolicyVersion = policyVersion; RunId = Validation.EnsureId(runId, nameof(runId)); FindingId = Validation.EnsureSimpleIdentifier(findingId, nameof(findingId)); EvaluatedAt = Validation.NormalizeTimestamp(evaluatedAt); Verdict = verdict ?? throw new ArgumentNullException(nameof(verdict)); RuleChain = NormalizeRuleChain(ruleChain); Evidence = NormalizeEvidence(evidence); VexImpacts = NormalizeVexImpacts(vexImpacts); Reachability = reachability; Metadata = NormalizeMetadata(metadata); } [JsonPropertyName("_type")] public string Type { get; } public string TenantId { get; } public string PolicyId { get; } public int PolicyVersion { get; } public string RunId { get; } public string FindingId { get; } public DateTimeOffset EvaluatedAt { get; } public VerdictInfo Verdict { get; } public ImmutableArray RuleChain { get; } public ImmutableArray Evidence { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableArray VexImpacts { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public VerdictReachability? Reachability { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableSortedDictionary Metadata { get; } private static ImmutableArray NormalizeRuleChain(IEnumerable? rules) { if (rules is null) { return ImmutableArray.Empty; } return rules .Where(static rule => rule is not null) .Select(static rule => rule!) .ToImmutableArray(); } private static ImmutableArray NormalizeEvidence(IEnumerable? evidence) { if (evidence is null) { return ImmutableArray.Empty; } return evidence .Where(static item => item is not null) .Select(static item => item!) .OrderBy(static item => item.Type, StringComparer.Ordinal) .ThenBy(static item => item.Reference, StringComparer.Ordinal) .ToImmutableArray(); } private static ImmutableArray NormalizeVexImpacts(IEnumerable? impacts) { if (impacts is null) { return ImmutableArray.Empty; } return impacts .Where(static impact => impact is not null) .Select(static impact => impact!) .OrderBy(static impact => impact.StatementId, StringComparer.Ordinal) .ToImmutableArray(); } private static ImmutableSortedDictionary NormalizeMetadata(ImmutableSortedDictionary? metadata) { if (metadata is null || metadata.Count == 0) { return ImmutableSortedDictionary.Empty; } var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var entry in metadata) { var key = Validation.TrimToNull(entry.Key)?.ToLowerInvariant(); var value = Validation.TrimToNull(entry.Value); if (key is not null && value is not null) { builder[key] = value; } } return builder.ToImmutable(); } } /// /// Verdict information (status, severity, score, rationale). /// public sealed record VerdictInfo { public VerdictInfo( string status, string severity, double score, string? rationale = null) { Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status)); Severity = Validation.TrimToNull(severity) ?? throw new ArgumentNullException(nameof(severity)); Score = score < 0 || score > 100 ? throw new ArgumentOutOfRangeException(nameof(score), score, "Score must be between 0 and 100.") : score; Rationale = Validation.TrimToNull(rationale); } public string Status { get; } public string Severity { get; } public double Score { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Rationale { get; } } /// /// Rule execution entry in verdict rule chain. /// public sealed record VerdictRuleExecution { public VerdictRuleExecution( string ruleId, string action, string decision, double? score = null) { RuleId = Validation.EnsureSimpleIdentifier(ruleId, nameof(ruleId)); Action = Validation.TrimToNull(action) ?? throw new ArgumentNullException(nameof(action)); Decision = Validation.TrimToNull(decision) ?? throw new ArgumentNullException(nameof(decision)); Score = score; } public string RuleId { get; } public string Action { get; } public string Decision { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Score { get; } } /// /// Evidence item considered during verdict evaluation. /// public sealed record VerdictEvidence { public VerdictEvidence( string type, string reference, string source, string status, string? digest = null, double? weight = null, ImmutableSortedDictionary? metadata = null) { Type = Validation.TrimToNull(type) ?? throw new ArgumentNullException(nameof(type)); Reference = Validation.TrimToNull(reference) ?? throw new ArgumentNullException(nameof(reference)); Source = Validation.TrimToNull(source) ?? throw new ArgumentNullException(nameof(source)); Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status)); Digest = Validation.TrimToNull(digest); Weight = weight; Metadata = NormalizeMetadata(metadata); } public string Type { get; } public string Reference { get; } public string Source { get; } public string Status { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Digest { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Weight { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableSortedDictionary Metadata { get; } private static ImmutableSortedDictionary NormalizeMetadata(ImmutableSortedDictionary? metadata) { if (metadata is null || metadata.Count == 0) { return ImmutableSortedDictionary.Empty; } return metadata; } } /// /// VEX statement impact on verdict. /// public sealed record VerdictVexImpact { public VerdictVexImpact( string statementId, string provider, string status, bool accepted, string? justification = null) { StatementId = Validation.TrimToNull(statementId) ?? throw new ArgumentNullException(nameof(statementId)); Provider = Validation.TrimToNull(provider) ?? throw new ArgumentNullException(nameof(provider)); Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status)); Accepted = accepted; Justification = Validation.TrimToNull(justification); } public string StatementId { get; } public string Provider { get; } public string Status { get; } public bool Accepted { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Justification { get; } } /// /// Reachability analysis results for verdict. /// public sealed record VerdictReachability { public VerdictReachability( string status, IEnumerable? paths = null) { Status = Validation.TrimToNull(status) ?? throw new ArgumentNullException(nameof(status)); Paths = NormalizePaths(paths); } public string Status { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public ImmutableArray Paths { get; } private static ImmutableArray NormalizePaths(IEnumerable? paths) { if (paths is null) { return ImmutableArray.Empty; } return paths .Where(static path => path is not null) .Select(static path => path!) .ToImmutableArray(); } } /// /// Reachability path from entrypoint to sink. /// public sealed record VerdictReachabilityPath { public VerdictReachabilityPath( string entrypoint, string sink, string confidence, string? digest = null) { Entrypoint = Validation.TrimToNull(entrypoint) ?? throw new ArgumentNullException(nameof(entrypoint)); Sink = Validation.TrimToNull(sink) ?? throw new ArgumentNullException(nameof(sink)); Confidence = Validation.TrimToNull(confidence) ?? throw new ArgumentNullException(nameof(confidence)); Digest = Validation.TrimToNull(digest); } public string Entrypoint { get; } public string Sink { get; } public string Confidence { get; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Digest { get; } }