using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using StellaOps.Policy.Engine.Simulation; namespace StellaOps.Policy.Engine.ConsoleSurface; /// /// Deterministic simulation diff metadata for Console surfaces (POLICY-CONSOLE-23-002). /// Generates stable before/after counts, rule impact, and samples without relying on /// wall-clock or external data. Intended as a contract-aligned shim until the /// Console surface is wired to live evaluation outputs. /// internal sealed class ConsoleSimulationDiffService { private static readonly string[] SeverityOrder = { "critical", "high", "medium", "low", "unknown" }; private static readonly string[] Outcomes = { "deny", "block", "warn", "allow" }; private static readonly string SchemaVersion = "console-policy-23-001"; private readonly SimulationAnalyticsService _analytics; public ConsoleSimulationDiffService(SimulationAnalyticsService analytics) { _analytics = analytics ?? throw new ArgumentNullException(nameof(analytics)); } public ConsoleSimulationDiffResponse Compute(ConsoleSimulationDiffRequest request) { ArgumentNullException.ThrowIfNull(request); var artifacts = (request.ArtifactScope?.Count ?? 0) == 0 ? new[] { new ConsoleArtifactScope("sha256:default") } : request.ArtifactScope!; // Respect budget caps if provided var maxFindings = Math.Clamp(request.Budget?.MaxFindings ?? 5, 1, 50_000); var maxSamples = Math.Clamp(request.Budget?.MaxExplainSamples ?? 20, 0, 200); var baselineFindings = BuildFindings(request.BaselinePolicyVersion, artifacts, maxFindings, seed: 1); var candidateFindings = BuildFindings(request.CandidatePolicyVersion, artifacts, maxFindings, seed: 2); // Delta summary for regressions/added/removed var delta = _analytics.ComputeDeltaSummary( request.BaselinePolicyVersion, request.CandidatePolicyVersion, baselineFindings, candidateFindings); var beforeBreakdown = BuildSeverityBreakdown(baselineFindings); var afterBreakdown = BuildSeverityBreakdown(candidateFindings); var added = candidateFindings.Count(c => baselineFindings.All(b => b.FindingId != c.FindingId)); var removed = baselineFindings.Count(b => candidateFindings.All(c => c.FindingId != b.FindingId)); var regressed = delta.SeverityChanges.Escalated; var ruleImpact = BuildRuleImpact(baselineFindings, candidateFindings); var samples = BuildSamples(candidateFindings, maxSamples); var response = new ConsoleSimulationDiffResponse( SchemaVersion, new ConsoleDiffSummary( Before: beforeBreakdown, After: afterBreakdown, Delta: new ConsoleDiffDelta(added, removed, regressed)), ruleImpact, samples, new ConsoleDiffProvenance( request.BaselinePolicyVersion, request.CandidatePolicyVersion, request.EvaluationTimestamp)); return response; } private static IReadOnlyList BuildFindings( string policyVersion, IReadOnlyList artifacts, int maxFindings, int seed) { var results = new List(); foreach (var artifact in artifacts.OrderBy(a => a.ArtifactDigest, StringComparer.Ordinal)) { var baseSeed = HashToBytes($"{policyVersion}:{artifact.ArtifactDigest}:{seed}"); var findingId = CreateDeterministicId("fid", policyVersion, artifact.ArtifactDigest, seed.ToString()); var severity = SeverityOrder[baseSeed[1] % SeverityOrder.Length]; var outcome = Outcomes[baseSeed[2] % Outcomes.Length]; var ruleId = $"RULE-{(baseSeed[3] % 9000) + 1000}"; results.Add(new SimulationFindingResult( FindingId: findingId, ComponentPurl: artifact.Purl ?? "pkg:generic/unknown@0.0.0", AdvisoryId: artifact.AdvisoryId ?? "unknown", Outcome: outcome, Severity: severity, FiredRules: new[] { ruleId })); if (results.Count >= maxFindings) { break; } // Add a secondary finding for variability if budget allows (deterministic) if (results.Count < maxFindings && baseSeed[4] % 5 == 0) { var secondaryId = CreateDeterministicId("fid", policyVersion, artifact.ArtifactDigest, seed + "-b"); var secondaryRule = $"RULE-{(baseSeed[5] % 9000) + 1000}"; var secondarySeverity = SeverityOrder[(baseSeed[6] + seed) % SeverityOrder.Length]; results.Add(new SimulationFindingResult( FindingId: secondaryId, ComponentPurl: artifact.Purl ?? "pkg:generic/unknown@0.0.0", AdvisoryId: artifact.AdvisoryId ?? "unknown", Outcome: Outcomes[(baseSeed[7] + seed) % Outcomes.Length], Severity: secondarySeverity, FiredRules: new[] { secondaryRule })); } if (results.Count >= maxFindings) { break; } } return results .OrderBy(r => r.FindingId, StringComparer.Ordinal) .Take(maxFindings) .ToList(); } private static ConsoleSeverityBreakdown BuildSeverityBreakdown(IReadOnlyList findings) { var counts = SeverityOrder.ToDictionary(s => s, _ => 0, StringComparer.OrdinalIgnoreCase); foreach (var finding in findings) { var severity = finding.Severity ?? "unknown"; counts.TryGetValue(severity, out var current); counts[severity] = current + 1; } return new ConsoleSeverityBreakdown( Total: findings.Count, Severity: counts.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)); } private static IReadOnlyList BuildRuleImpact( IReadOnlyList baseline, IReadOnlyList candidate) { var ruleImpact = new Dictionary shifts)>(StringComparer.Ordinal); var baseMap = baseline.ToDictionary(f => f.FindingId, f => f, StringComparer.Ordinal); foreach (var result in candidate) { var ruleId = result.FiredRules?.FirstOrDefault() ?? "RULE-0000"; if (!ruleImpact.TryGetValue(ruleId, out var entry)) { entry = (0, 0, new Dictionary(StringComparer.OrdinalIgnoreCase)); } if (!baseMap.TryGetValue(result.FindingId, out var baseResult)) { entry.added += 1; } else if (!string.Equals(baseResult.Severity, result.Severity, StringComparison.OrdinalIgnoreCase)) { var key = $"{baseResult.Severity}->{result.Severity}"; entry.shifts.TryGetValue(key, out var count); entry.shifts[key] = count + 1; } ruleImpact[ruleId] = entry; } foreach (var result in baseline) { var ruleId = result.FiredRules?.FirstOrDefault() ?? "RULE-0000"; if (!candidate.Any(c => c.FindingId == result.FindingId)) { if (!ruleImpact.TryGetValue(ruleId, out var entry)) { entry = (0, 0, new Dictionary(StringComparer.OrdinalIgnoreCase)); } entry.removed += 1; ruleImpact[ruleId] = entry; } } return ruleImpact .OrderBy(kvp => kvp.Key, StringComparer.Ordinal) .Select(kvp => new ConsoleRuleImpact( kvp.Key, kvp.Value.added, kvp.Value.removed, kvp.Value.shifts.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase))) .ToList(); } private static ConsoleDiffSamples BuildSamples(IReadOnlyList candidate, int maxSamples) { var ordered = candidate .OrderBy(f => f.FindingId, StringComparer.Ordinal) .Take(maxSamples) .ToList(); var explain = ordered .Select(f => CreateDeterministicId("trace", f.FindingId)) .ToImmutableArray(); var findings = ordered .Select(f => f.FindingId) .ToImmutableArray(); return new ConsoleDiffSamples(explain, findings); } private static string CreateDeterministicId(params string[] parts) { var input = string.Join("|", parts); var hash = HashToBytes(input); var sb = new StringBuilder("ulid-"); for (var i = 0; i < 8; i++) { sb.Append(hash[i].ToString("x2")); } return sb.ToString(); } private static byte[] HashToBytes(string input) { var bytes = Encoding.UTF8.GetBytes(input); return SHA256.HashData(bytes); } }