up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ConsoleSurface;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<SimulationFindingResult> BuildFindings(
|
||||
string policyVersion,
|
||||
IReadOnlyList<ConsoleArtifactScope> artifacts,
|
||||
int maxFindings,
|
||||
int seed)
|
||||
{
|
||||
var results = new List<SimulationFindingResult>();
|
||||
|
||||
foreach (var artifact in artifacts.OrderBy(a => a.ArtifactDigest, StringComparer.Ordinal))
|
||||
{
|
||||
var baseSeed = HashToBytes($"{policyVersion}:{artifact.ArtifactDigest}:{seed}");
|
||||
var include = baseSeed[0] % 7 != 0; // occasionally drop to simulate removal
|
||||
if (!include)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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 }));
|
||||
|
||||
// Add a secondary finding for variability if budget allows
|
||||
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;
|
||||
}
|
||||
|
||||
private static ConsoleSeverityBreakdown BuildSeverityBreakdown(IReadOnlyList<SimulationFindingResult> 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<ConsoleRuleImpact> BuildRuleImpact(
|
||||
IReadOnlyList<SimulationFindingResult> baseline,
|
||||
IReadOnlyList<SimulationFindingResult> candidate)
|
||||
{
|
||||
var ruleImpact = new Dictionary<string, (int added, int removed, Dictionary<string, int> 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<string, int>(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<string, int>(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<SimulationFindingResult> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user