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
Policy Lint & Smoke / policy-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-28 09:40:40 +02:00
parent 1c6730a1d2
commit 05da719048
206 changed files with 34741 additions and 1751 deletions

View File

@@ -0,0 +1,371 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Request for what-if simulation supporting hypothetical SBOM diffs and draft policies.
/// </summary>
public sealed record WhatIfSimulationRequest
{
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID to apply diffs to.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Active policy pack ID to use as baseline.
/// If DraftPolicy is provided, this will be compared against.
/// </summary>
[JsonPropertyName("baseline_pack_id")]
public string? BaselinePackId { get; init; }
/// <summary>
/// Baseline policy version. If null, uses active version.
/// </summary>
[JsonPropertyName("baseline_pack_version")]
public int? BaselinePackVersion { get; init; }
/// <summary>
/// Draft policy to simulate (not yet activated).
/// If null, uses baseline policy.
/// </summary>
[JsonPropertyName("draft_policy")]
public WhatIfDraftPolicy? DraftPolicy { get; init; }
/// <summary>
/// SBOM diffs to apply hypothetically.
/// </summary>
[JsonPropertyName("sbom_diffs")]
public ImmutableArray<WhatIfSbomDiff> SbomDiffs { get; init; } = ImmutableArray<WhatIfSbomDiff>.Empty;
/// <summary>
/// Specific component PURLs to evaluate. If empty, evaluates affected by diffs.
/// </summary>
[JsonPropertyName("target_purls")]
public ImmutableArray<string> TargetPurls { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Maximum number of components to evaluate.
/// </summary>
[JsonPropertyName("limit")]
public int Limit { get; init; } = 1000;
/// <summary>
/// Whether to include detailed explanations for each decision.
/// </summary>
[JsonPropertyName("include_explanations")]
public bool IncludeExplanations { get; init; } = false;
/// <summary>
/// Correlation ID for tracing.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Draft policy definition for simulation.
/// </summary>
public sealed record WhatIfDraftPolicy
{
/// <summary>
/// Draft policy pack ID.
/// </summary>
[JsonPropertyName("pack_id")]
public required string PackId { get; init; }
/// <summary>
/// Draft policy version.
/// </summary>
[JsonPropertyName("version")]
public int Version { get; init; }
/// <summary>
/// Raw YAML policy definition to compile and evaluate.
/// If provided, this is compiled on-the-fly.
/// </summary>
[JsonPropertyName("policy_yaml")]
public string? PolicyYaml { get; init; }
/// <summary>
/// Pre-compiled bundle digest if available.
/// </summary>
[JsonPropertyName("bundle_digest")]
public string? BundleDigest { get; init; }
}
/// <summary>
/// Hypothetical SBOM modification for what-if simulation.
/// </summary>
public sealed record WhatIfSbomDiff
{
/// <summary>
/// Type of modification: add, remove, upgrade, downgrade.
/// </summary>
[JsonPropertyName("operation")]
public required string Operation { get; init; }
/// <summary>
/// Component PURL being modified.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// New version for upgrade/downgrade operations.
/// </summary>
[JsonPropertyName("new_version")]
public string? NewVersion { get; init; }
/// <summary>
/// Original version (for reference in upgrades/downgrades).
/// </summary>
[JsonPropertyName("original_version")]
public string? OriginalVersion { get; init; }
/// <summary>
/// Hypothetical advisory IDs affecting this component.
/// </summary>
[JsonPropertyName("advisory_ids")]
public ImmutableArray<string> AdvisoryIds { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Hypothetical VEX status for this component.
/// </summary>
[JsonPropertyName("vex_status")]
public string? VexStatus { get; init; }
/// <summary>
/// Hypothetical reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Response from what-if simulation.
/// </summary>
public sealed record WhatIfSimulationResponse
{
/// <summary>
/// Simulation identifier.
/// </summary>
[JsonPropertyName("simulation_id")]
public required string SimulationId { get; init; }
/// <summary>
/// Tenant identifier.
/// </summary>
[JsonPropertyName("tenant_id")]
public required string TenantId { get; init; }
/// <summary>
/// Base snapshot ID used.
/// </summary>
[JsonPropertyName("base_snapshot_id")]
public required string BaseSnapshotId { get; init; }
/// <summary>
/// Baseline policy used for comparison.
/// </summary>
[JsonPropertyName("baseline_policy")]
public required WhatIfPolicyRef BaselinePolicy { get; init; }
/// <summary>
/// Simulated policy (draft or modified).
/// </summary>
[JsonPropertyName("simulated_policy")]
public WhatIfPolicyRef? SimulatedPolicy { get; init; }
/// <summary>
/// Decision changes between baseline and simulation.
/// </summary>
[JsonPropertyName("decision_changes")]
public required ImmutableArray<WhatIfDecisionChange> DecisionChanges { get; init; }
/// <summary>
/// Summary of changes.
/// </summary>
[JsonPropertyName("summary")]
public required WhatIfSummary Summary { get; init; }
/// <summary>
/// When the simulation was executed.
/// </summary>
[JsonPropertyName("executed_at")]
public required DateTimeOffset ExecutedAt { get; init; }
/// <summary>
/// Execution duration in milliseconds.
/// </summary>
[JsonPropertyName("duration_ms")]
public long DurationMs { get; init; }
/// <summary>
/// Correlation ID.
/// </summary>
[JsonPropertyName("correlation_id")]
public string? CorrelationId { get; init; }
}
/// <summary>
/// Policy reference in simulation.
/// </summary>
public sealed record WhatIfPolicyRef(
[property: JsonPropertyName("pack_id")] string PackId,
[property: JsonPropertyName("version")] int Version,
[property: JsonPropertyName("bundle_digest")] string? BundleDigest,
[property: JsonPropertyName("is_draft")] bool IsDraft);
/// <summary>
/// A decision change detected in what-if simulation.
/// </summary>
public sealed record WhatIfDecisionChange
{
/// <summary>
/// Component PURL.
/// </summary>
[JsonPropertyName("purl")]
public required string Purl { get; init; }
/// <summary>
/// Advisory ID if applicable.
/// </summary>
[JsonPropertyName("advisory_id")]
public string? AdvisoryId { get; init; }
/// <summary>
/// Type of change: new, removed, status_changed, severity_changed.
/// </summary>
[JsonPropertyName("change_type")]
public required string ChangeType { get; init; }
/// <summary>
/// Baseline decision.
/// </summary>
[JsonPropertyName("baseline")]
public WhatIfDecision? Baseline { get; init; }
/// <summary>
/// Simulated decision.
/// </summary>
[JsonPropertyName("simulated")]
public WhatIfDecision? Simulated { get; init; }
/// <summary>
/// SBOM diff that caused this change, if any.
/// </summary>
[JsonPropertyName("caused_by_diff")]
public WhatIfSbomDiff? CausedByDiff { get; init; }
/// <summary>
/// Explanation for the change.
/// </summary>
[JsonPropertyName("explanation")]
public WhatIfExplanation? Explanation { get; init; }
}
/// <summary>
/// A decision in what-if simulation.
/// </summary>
public sealed record WhatIfDecision(
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("severity")] string? Severity,
[property: JsonPropertyName("rule_name")] string? RuleName,
[property: JsonPropertyName("priority")] int? Priority,
[property: JsonPropertyName("exception_applied")] bool ExceptionApplied);
/// <summary>
/// Explanation for a what-if decision.
/// </summary>
public sealed record WhatIfExplanation
{
/// <summary>
/// Rules that matched.
/// </summary>
[JsonPropertyName("matched_rules")]
public ImmutableArray<string> MatchedRules { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// Key factors in the decision.
/// </summary>
[JsonPropertyName("factors")]
public ImmutableArray<string> Factors { get; init; } = ImmutableArray<string>.Empty;
/// <summary>
/// VEX evidence considered.
/// </summary>
[JsonPropertyName("vex_evidence")]
public string? VexEvidence { get; init; }
/// <summary>
/// Reachability state.
/// </summary>
[JsonPropertyName("reachability")]
public string? Reachability { get; init; }
}
/// <summary>
/// Summary of what-if simulation results.
/// </summary>
public sealed record WhatIfSummary
{
/// <summary>
/// Total components evaluated.
/// </summary>
[JsonPropertyName("total_evaluated")]
public int TotalEvaluated { get; init; }
/// <summary>
/// Components with changed decisions.
/// </summary>
[JsonPropertyName("total_changed")]
public int TotalChanged { get; init; }
/// <summary>
/// Components newly affected.
/// </summary>
[JsonPropertyName("newly_affected")]
public int NewlyAffected { get; init; }
/// <summary>
/// Components no longer affected.
/// </summary>
[JsonPropertyName("no_longer_affected")]
public int NoLongerAffected { get; init; }
/// <summary>
/// Status changes by type.
/// </summary>
[JsonPropertyName("status_changes")]
public required ImmutableDictionary<string, int> StatusChanges { get; init; }
/// <summary>
/// Severity changes by type (e.g., "low_to_high").
/// </summary>
[JsonPropertyName("severity_changes")]
public required ImmutableDictionary<string, int> SeverityChanges { get; init; }
/// <summary>
/// Impact assessment.
/// </summary>
[JsonPropertyName("impact")]
public required WhatIfImpact Impact { get; init; }
}
/// <summary>
/// Impact assessment from what-if simulation.
/// </summary>
public sealed record WhatIfImpact(
[property: JsonPropertyName("risk_delta")] string RiskDelta, // increased, decreased, unchanged
[property: JsonPropertyName("blocked_count_delta")] int BlockedCountDelta,
[property: JsonPropertyName("warning_count_delta")] int WarningCountDelta,
[property: JsonPropertyName("recommendation")] string? Recommendation);

View File

@@ -0,0 +1,548 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Domain;
using StellaOps.Policy.Engine.EffectiveDecisionMap;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.WhatIfSimulation;
/// <summary>
/// Service for Graph What-if API simulations.
/// Supports hypothetical SBOM diffs and draft policies without persisting results.
/// </summary>
internal sealed class WhatIfSimulationService
{
private readonly IEffectiveDecisionMap _decisionMap;
private readonly IPolicyPackRepository _policyRepository;
private readonly PolicyCompilationService _compilationService;
private readonly ILogger<WhatIfSimulationService> _logger;
private readonly TimeProvider _timeProvider;
public WhatIfSimulationService(
IEffectiveDecisionMap decisionMap,
IPolicyPackRepository policyRepository,
PolicyCompilationService compilationService,
ILogger<WhatIfSimulationService> logger,
TimeProvider timeProvider)
{
_decisionMap = decisionMap ?? throw new ArgumentNullException(nameof(decisionMap));
_policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository));
_compilationService = compilationService ?? throw new ArgumentNullException(nameof(compilationService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Executes a what-if simulation without persisting results.
/// </summary>
public async Task<WhatIfSimulationResponse> SimulateAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity(
"policy.whatif.simulate", ActivityKind.Internal);
activity?.SetTag("tenant_id", request.TenantId);
activity?.SetTag("base_snapshot_id", request.BaseSnapshotId);
activity?.SetTag("has_draft_policy", request.DraftPolicy is not null);
activity?.SetTag("sbom_diff_count", request.SbomDiffs.Length);
var sw = Stopwatch.StartNew();
var simulationId = GenerateSimulationId(request);
var executedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Starting what-if simulation {SimulationId} for tenant {TenantId}, snapshot {SnapshotId}",
simulationId, request.TenantId, request.BaseSnapshotId);
try
{
// Get baseline policy info
var baselinePolicy = await GetBaselinePolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Get simulated policy info (draft or same as baseline)
var simulatedPolicy = await GetSimulatedPolicyAsync(request, cancellationToken).ConfigureAwait(false);
// Determine which components to evaluate
var targetPurls = await DetermineTargetPurlsAsync(request, cancellationToken).ConfigureAwait(false);
// Get baseline decisions from effective decision map
var baselineDecisions = await GetBaselineDecisionsAsync(
request.TenantId, request.BaseSnapshotId, targetPurls, cancellationToken).ConfigureAwait(false);
// Simulate decisions with hypothetical changes
var simulatedDecisions = await SimulateDecisionsAsync(
request, targetPurls, simulatedPolicy, cancellationToken).ConfigureAwait(false);
// Compute changes between baseline and simulated
var changes = ComputeChanges(
targetPurls, baselineDecisions, simulatedDecisions, request.SbomDiffs, request.IncludeExplanations);
// Compute summary
var summary = ComputeSummary(changes, baselineDecisions, simulatedDecisions);
sw.Stop();
_logger.LogInformation(
"Completed what-if simulation {SimulationId}: {Evaluated} evaluated, {Changed} changed in {Duration}ms",
simulationId, summary.TotalEvaluated, summary.TotalChanged, sw.ElapsedMilliseconds);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "success");
return new WhatIfSimulationResponse
{
SimulationId = simulationId,
TenantId = request.TenantId,
BaseSnapshotId = request.BaseSnapshotId,
BaselinePolicy = baselinePolicy,
SimulatedPolicy = simulatedPolicy,
DecisionChanges = changes,
Summary = summary,
ExecutedAt = executedAt,
DurationMs = sw.ElapsedMilliseconds,
CorrelationId = request.CorrelationId,
};
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "What-if simulation {SimulationId} failed", simulationId);
PolicyEngineTelemetry.RecordSimulation(request.TenantId, "failure");
PolicyEngineTelemetry.RecordError("whatif_simulation", request.TenantId);
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
private async Task<WhatIfPolicyRef> GetBaselinePolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.BaselinePackId is not null)
{
var version = request.BaselinePackVersion ?? 1;
// If no version specified, try to get the latest revision to find the active version
if (request.BaselinePackVersion is null)
{
var revision = await _policyRepository.GetRevisionAsync(request.BaselinePackId, 1, cancellationToken)
.ConfigureAwait(false);
if (revision?.Status == PolicyRevisionStatus.Active)
{
version = revision.Version;
}
}
var bundle = await _policyRepository.GetBundleAsync(request.BaselinePackId, version, cancellationToken)
.ConfigureAwait(false);
return new WhatIfPolicyRef(
request.BaselinePackId,
version,
bundle?.Digest,
IsDraft: false);
}
// Return a placeholder for "current effective policy"
return new WhatIfPolicyRef("default", 1, null, IsDraft: false);
}
private async Task<WhatIfPolicyRef?> GetSimulatedPolicyAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.DraftPolicy is null)
{
return null; // No draft - comparison is baseline vs hypothetical SBOM changes
}
string? bundleDigest = request.DraftPolicy.BundleDigest;
// If we have YAML, we could compile it on-the-fly (not persisting)
// For now, we just reference the draft
if (request.DraftPolicy.PolicyYaml is not null && bundleDigest is null)
{
// Compute a digest from the YAML for reference
bundleDigest = ComputeYamlDigest(request.DraftPolicy.PolicyYaml);
}
return new WhatIfPolicyRef(
request.DraftPolicy.PackId,
request.DraftPolicy.Version,
bundleDigest,
IsDraft: true);
}
private async Task<ImmutableArray<string>> DetermineTargetPurlsAsync(
WhatIfSimulationRequest request,
CancellationToken cancellationToken)
{
if (request.TargetPurls.Length > 0)
{
return request.TargetPurls.Take(request.Limit).ToImmutableArray();
}
// Get PURLs from SBOM diffs
var diffPurls = request.SbomDiffs.Select(d => d.Purl).Distinct().ToList();
if (diffPurls.Count > 0)
{
return diffPurls.Take(request.Limit).ToImmutableArray();
}
// Get from effective decision map
var allDecisions = await _decisionMap.GetAllForSnapshotAsync(
request.TenantId,
request.BaseSnapshotId,
new EffectiveDecisionFilter { Limit = request.Limit },
cancellationToken).ConfigureAwait(false);
return allDecisions.Select(d => d.AssetId).ToImmutableArray();
}
private async Task<Dictionary<string, WhatIfDecision>> GetBaselineDecisionsAsync(
string tenantId,
string snapshotId,
ImmutableArray<string> purls,
CancellationToken cancellationToken)
{
var result = await _decisionMap.GetBatchAsync(tenantId, snapshotId, purls.ToList(), cancellationToken)
.ConfigureAwait(false);
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
foreach (var (purl, entry) in result.Entries)
{
decisions[purl] = new WhatIfDecision(
entry.Status,
entry.Severity,
entry.RuleName,
entry.Priority,
entry.ExceptionId is not null);
}
return decisions;
}
private Task<Dictionary<string, WhatIfDecision>> SimulateDecisionsAsync(
WhatIfSimulationRequest request,
ImmutableArray<string> targetPurls,
WhatIfPolicyRef? simulatedPolicy,
CancellationToken cancellationToken)
{
// In a full implementation, this would:
// 1. Apply SBOM diffs to compute hypothetical component states
// 2. If draft policy, compile and evaluate against the draft
// 3. Otherwise, re-evaluate with hypothetical context changes
//
// For now, we compute simulated decisions based on the diffs
var decisions = new Dictionary<string, WhatIfDecision>(StringComparer.OrdinalIgnoreCase);
var diffsByPurl = request.SbomDiffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
cancellationToken.ThrowIfCancellationRequested();
if (diffsByPurl.TryGetValue(purl, out var diff))
{
var decision = SimulateDecisionForDiff(diff, simulatedPolicy);
decisions[purl] = decision;
}
else
{
// No diff for this PURL - simulate based on policy change if any
decisions[purl] = SimulateDecisionWithoutDiff(purl, simulatedPolicy);
}
}
return Task.FromResult(decisions);
}
private static WhatIfDecision SimulateDecisionForDiff(WhatIfSbomDiff diff, WhatIfPolicyRef? policy)
{
// Simulate based on diff operation and properties
return diff.Operation.ToLowerInvariant() switch
{
"remove" => new WhatIfDecision("allow", null, null, null, false),
"add" => SimulateNewComponentDecision(diff),
"upgrade" => SimulateUpgradeDecision(diff),
"downgrade" => SimulateDowngradeDecision(diff),
_ => new WhatIfDecision("allow", null, null, null, false),
};
}
private static WhatIfDecision SimulateNewComponentDecision(WhatIfSbomDiff diff)
{
// New components are evaluated based on advisory presence
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
var status = severity switch
{
"critical" or "high" => "deny",
"medium" => "warn",
_ => "allow"
};
// VEX can override
if (diff.VexStatus?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
{
status = "allow";
}
// Reachability can downgrade
if (diff.Reachability?.Equals("unreachable", StringComparison.OrdinalIgnoreCase) == true &&
status == "deny")
{
status = "warn";
}
return new WhatIfDecision(status, severity, "simulated_rule", 100, false);
}
return new WhatIfDecision("allow", null, null, null, false);
}
private static WhatIfDecision SimulateUpgradeDecision(WhatIfSbomDiff diff)
{
// Upgrades typically fix vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
// Some advisories remain
return new WhatIfDecision("warn", "low", "simulated_upgrade_rule", 50, false);
}
// Upgrade fixed all issues
return new WhatIfDecision("allow", null, "simulated_upgrade_rule", 50, false);
}
private static WhatIfDecision SimulateDowngradeDecision(WhatIfSbomDiff diff)
{
// Downgrades may introduce vulnerabilities
if (diff.AdvisoryIds.Length > 0)
{
var severity = DetermineSeverityFromAdvisories(diff.AdvisoryIds);
return new WhatIfDecision("deny", severity, "simulated_downgrade_rule", 150, false);
}
return new WhatIfDecision("warn", "low", "simulated_downgrade_rule", 150, false);
}
private static WhatIfDecision SimulateDecisionWithoutDiff(string purl, WhatIfPolicyRef? policy)
{
// If there's a draft policy, simulate potential changes from policy modification
if (policy?.IsDraft == true)
{
// Draft policies might change thresholds - simulate a potential change
return new WhatIfDecision("warn", "medium", "draft_policy_rule", 100, false);
}
// No change - return unchanged placeholder
return new WhatIfDecision("allow", null, null, null, false);
}
private static string DetermineSeverityFromAdvisories(ImmutableArray<string> advisoryIds)
{
// In reality, would look up actual severity from advisories
// For simulation, use a heuristic based on advisory count
if (advisoryIds.Length >= 5) return "critical";
if (advisoryIds.Length >= 3) return "high";
if (advisoryIds.Length >= 1) return "medium";
return "low";
}
private static ImmutableArray<WhatIfDecisionChange> ComputeChanges(
ImmutableArray<string> targetPurls,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated,
ImmutableArray<WhatIfSbomDiff> diffs,
bool includeExplanations)
{
var changes = new List<WhatIfDecisionChange>();
var diffsByPurl = diffs.ToDictionary(d => d.Purl, StringComparer.OrdinalIgnoreCase);
foreach (var purl in targetPurls)
{
var hasBaseline = baseline.TryGetValue(purl, out var baselineDecision);
var hasSimulated = simulated.TryGetValue(purl, out var simulatedDecision);
diffsByPurl.TryGetValue(purl, out var diff);
string? changeType = null;
if (!hasBaseline && hasSimulated)
{
changeType = "new";
}
else if (hasBaseline && !hasSimulated)
{
changeType = "removed";
}
else if (hasBaseline && hasSimulated)
{
if (baselineDecision!.Status != simulatedDecision!.Status)
{
changeType = "status_changed";
}
else if (baselineDecision.Severity != simulatedDecision.Severity)
{
changeType = "severity_changed";
}
}
if (changeType is not null)
{
var explanation = includeExplanations
? BuildExplanation(diff, baselineDecision, simulatedDecision)
: null;
changes.Add(new WhatIfDecisionChange
{
Purl = purl,
AdvisoryId = diff?.AdvisoryIds.FirstOrDefault(),
ChangeType = changeType,
Baseline = baselineDecision,
Simulated = simulatedDecision,
CausedByDiff = diff,
Explanation = explanation,
});
}
}
return changes.ToImmutableArray();
}
private static WhatIfExplanation BuildExplanation(
WhatIfSbomDiff? diff,
WhatIfDecision? baseline,
WhatIfDecision? simulated)
{
var factors = new List<string>();
var rules = new List<string>();
if (diff is not null)
{
factors.Add($"SBOM {diff.Operation}: {diff.Purl}");
if (diff.NewVersion is not null)
{
factors.Add($"Version change: {diff.OriginalVersion ?? "unknown"} -> {diff.NewVersion}");
}
if (diff.AdvisoryIds.Length > 0)
{
factors.Add($"Advisories: {string.Join(", ", diff.AdvisoryIds.Take(3))}");
}
}
if (baseline?.RuleName is not null)
{
rules.Add($"baseline:{baseline.RuleName}");
}
if (simulated?.RuleName is not null)
{
rules.Add($"simulated:{simulated.RuleName}");
}
return new WhatIfExplanation
{
MatchedRules = rules.ToImmutableArray(),
Factors = factors.ToImmutableArray(),
VexEvidence = diff?.VexStatus,
Reachability = diff?.Reachability,
};
}
private static WhatIfSummary ComputeSummary(
ImmutableArray<WhatIfDecisionChange> changes,
Dictionary<string, WhatIfDecision> baseline,
Dictionary<string, WhatIfDecision> simulated)
{
var statusChanges = new Dictionary<string, int>();
var severityChanges = new Dictionary<string, int>();
var newlyAffected = 0;
var noLongerAffected = 0;
var blockedDelta = 0;
var warningDelta = 0;
foreach (var change in changes)
{
switch (change.ChangeType)
{
case "new":
newlyAffected++;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "removed":
noLongerAffected++;
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Baseline?.Status == "warn") warningDelta--;
break;
case "status_changed":
var statusKey = $"{change.Baseline?.Status ?? "none"}_to_{change.Simulated?.Status ?? "none"}";
statusChanges.TryGetValue(statusKey, out var statusCount);
statusChanges[statusKey] = statusCount + 1;
// Update deltas
if (change.Baseline?.Status == "deny") blockedDelta--;
if (change.Simulated?.Status == "deny") blockedDelta++;
if (change.Baseline?.Status == "warn") warningDelta--;
if (change.Simulated?.Status == "warn") warningDelta++;
break;
case "severity_changed":
var sevKey = $"{change.Baseline?.Severity ?? "none"}_to_{change.Simulated?.Severity ?? "none"}";
severityChanges.TryGetValue(sevKey, out var sevCount);
severityChanges[sevKey] = sevCount + 1;
break;
}
}
var riskDelta = blockedDelta switch
{
> 0 => "increased",
< 0 => "decreased",
_ => warningDelta > 0 ? "increased" : warningDelta < 0 ? "decreased" : "unchanged"
};
var recommendation = riskDelta switch
{
"increased" => "Review changes before applying - risk profile increases",
"decreased" => "Changes appear safe - risk profile improves",
_ => "Neutral impact - proceed with caution"
};
return new WhatIfSummary
{
TotalEvaluated = baseline.Count + simulated.Count(kv => !baseline.ContainsKey(kv.Key)),
TotalChanged = changes.Length,
NewlyAffected = newlyAffected,
NoLongerAffected = noLongerAffected,
StatusChanges = statusChanges.ToImmutableDictionary(),
SeverityChanges = severityChanges.ToImmutableDictionary(),
Impact = new WhatIfImpact(riskDelta, blockedDelta, warningDelta, recommendation),
};
}
private static string GenerateSimulationId(WhatIfSimulationRequest request)
{
var seed = $"{request.TenantId}|{request.BaseSnapshotId}|{request.DraftPolicy?.PackId}|{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"whatif-{Convert.ToHexStringLower(hash)[..16]}";
}
private static string ComputeYamlDigest(string yaml)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(yaml));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}