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
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:
@@ -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);
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user