using System; using System.Collections.Immutable; namespace StellaOps.Policy; public static class PolicyEvaluation { public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding) { if (document is null) { throw new ArgumentNullException(nameof(document)); } if (scoringConfig is null) { throw new ArgumentNullException(nameof(scoringConfig)); } if (finding is null) { throw new ArgumentNullException(nameof(finding)); } var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight) ? weight : scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0); foreach (var rule in document.Rules) { if (!RuleMatches(rule, finding)) { continue; } return BuildVerdict(rule, finding, scoringConfig, severityWeight); } return PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig); } private static PolicyVerdict BuildVerdict( PolicyRule rule, PolicyFinding finding, PolicyScoringConfig config, double severityWeight) { var action = rule.Action; var status = MapAction(action); var notes = BuildNotes(action); var inputs = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); inputs["severityWeight"] = severityWeight; double score = severityWeight; string? quietedBy = null; var quiet = false; switch (status) { case PolicyVerdictStatus.Ignored: score = Math.Max(0, severityWeight - config.IgnorePenalty); inputs["ignorePenalty"] = config.IgnorePenalty; break; case PolicyVerdictStatus.Warned: score = Math.Max(0, severityWeight - config.WarnPenalty); inputs["warnPenalty"] = config.WarnPenalty; break; case PolicyVerdictStatus.Deferred: score = Math.Max(0, severityWeight - (config.WarnPenalty / 2)); inputs["deferPenalty"] = config.WarnPenalty / 2; break; } if (action.Quiet) { var quietAllowed = action.RequireVex is not null || action.Type == PolicyActionType.RequireVex; if (quietAllowed) { score = Math.Max(0, score - config.QuietPenalty); inputs["quietPenalty"] = config.QuietPenalty; quietedBy = rule.Name; quiet = true; } else { inputs.Remove("ignorePenalty"); var warnScore = Math.Max(0, severityWeight - config.WarnPenalty); inputs["warnPenalty"] = config.WarnPenalty; var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications."); return new PolicyVerdict( finding.FindingId, PolicyVerdictStatus.Warned, rule.Name, action.Type.ToString(), warnNotes, warnScore, config.Version, inputs.ToImmutable(), QuietedBy: null, Quiet: false); } } return new PolicyVerdict( finding.FindingId, status, rule.Name, action.Type.ToString(), notes, score, config.Version, inputs.ToImmutable(), quietedBy, quiet); } private static bool RuleMatches(PolicyRule rule, PolicyFinding finding) { if (!rule.Severities.IsDefaultOrEmpty && !rule.Severities.Contains(finding.Severity)) { return false; } if (!Matches(rule.Environments, finding.Environment)) { return false; } if (!Matches(rule.Sources, finding.Source)) { return false; } if (!Matches(rule.Vendors, finding.Vendor)) { return false; } if (!Matches(rule.Licenses, finding.License)) { return false; } if (!RuleMatchCriteria(rule.Match, finding)) { return false; } return true; } private static bool Matches(ImmutableArray ruleValues, string? candidate) { if (ruleValues.IsDefaultOrEmpty) { return true; } if (string.IsNullOrWhiteSpace(candidate)) { return false; } return ruleValues.Contains(candidate, StringComparer.OrdinalIgnoreCase); } private static bool RuleMatchCriteria(PolicyRuleMatchCriteria criteria, PolicyFinding finding) { if (!criteria.Images.IsDefaultOrEmpty && !ContainsValue(criteria.Images, finding.Image, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.Repositories.IsDefaultOrEmpty && !ContainsValue(criteria.Repositories, finding.Repository, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.Packages.IsDefaultOrEmpty && !ContainsValue(criteria.Packages, finding.Package, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.Purls.IsDefaultOrEmpty && !ContainsValue(criteria.Purls, finding.Purl, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.Cves.IsDefaultOrEmpty && !ContainsValue(criteria.Cves, finding.Cve, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.Paths.IsDefaultOrEmpty && !ContainsValue(criteria.Paths, finding.Path, StringComparer.Ordinal)) { return false; } if (!criteria.LayerDigests.IsDefaultOrEmpty && !ContainsValue(criteria.LayerDigests, finding.LayerDigest, StringComparer.OrdinalIgnoreCase)) { return false; } if (!criteria.UsedByEntrypoint.IsDefaultOrEmpty) { var match = false; foreach (var tag in criteria.UsedByEntrypoint) { if (finding.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) { match = true; break; } } if (!match) { return false; } } return true; } private static bool ContainsValue(ImmutableArray values, string? candidate, StringComparer comparer) { if (values.IsDefaultOrEmpty) { return true; } if (string.IsNullOrWhiteSpace(candidate)) { return false; } return values.Contains(candidate, comparer); } private static PolicyVerdictStatus MapAction(PolicyAction action) => action.Type switch { PolicyActionType.Block => PolicyVerdictStatus.Blocked, PolicyActionType.Ignore => PolicyVerdictStatus.Ignored, PolicyActionType.Warn => PolicyVerdictStatus.Warned, PolicyActionType.Defer => PolicyVerdictStatus.Deferred, PolicyActionType.Escalate => PolicyVerdictStatus.Escalated, PolicyActionType.RequireVex => PolicyVerdictStatus.RequiresVex, _ => PolicyVerdictStatus.Pass, }; private static string? BuildNotes(PolicyAction action) { if (action.Ignore is { } ignore && !string.IsNullOrWhiteSpace(ignore.Justification)) { return ignore.Justification; } if (action.Escalate is { } escalate && escalate.MinimumSeverity is { } severity) { return $"Escalate >= {severity}"; } return null; } private static string? AppendNote(string? existing, string addition) => string.IsNullOrWhiteSpace(existing) ? addition : string.Concat(existing, " | ", addition); }