up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (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-27 23:44:42 +02:00
parent ef6e4b2067
commit 3b96b2e3ea
298 changed files with 47516 additions and 1168 deletions

View File

@@ -0,0 +1,140 @@
using System.Text.Json.Serialization;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Request to run a risk simulation.
/// </summary>
public sealed record RiskSimulationRequest(
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("profile_version")] string? ProfileVersion,
[property: JsonPropertyName("findings")] IReadOnlyList<SimulationFinding> Findings,
[property: JsonPropertyName("include_contributions")] bool IncludeContributions = true,
[property: JsonPropertyName("include_distribution")] bool IncludeDistribution = true,
[property: JsonPropertyName("simulation_mode")] SimulationMode Mode = SimulationMode.Full);
/// <summary>
/// A finding to include in the simulation.
/// </summary>
public sealed record SimulationFinding(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("advisory_id")] string? AdvisoryId,
[property: JsonPropertyName("signals")] Dictionary<string, object?> Signals);
/// <summary>
/// Simulation mode.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<SimulationMode>))]
public enum SimulationMode
{
/// <summary>
/// Run full simulation with all computations.
/// </summary>
[JsonPropertyName("full")]
Full,
/// <summary>
/// Quick estimation without detailed breakdowns.
/// </summary>
[JsonPropertyName("quick")]
Quick,
/// <summary>
/// What-if analysis with hypothetical changes.
/// </summary>
[JsonPropertyName("whatif")]
WhatIf
}
/// <summary>
/// Result of a risk simulation.
/// </summary>
public sealed record RiskSimulationResult(
[property: JsonPropertyName("simulation_id")] string SimulationId,
[property: JsonPropertyName("profile_id")] string ProfileId,
[property: JsonPropertyName("profile_version")] string ProfileVersion,
[property: JsonPropertyName("profile_hash")] string ProfileHash,
[property: JsonPropertyName("timestamp")] DateTimeOffset Timestamp,
[property: JsonPropertyName("finding_scores")] IReadOnlyList<FindingScore> FindingScores,
[property: JsonPropertyName("distribution")] RiskDistribution? Distribution,
[property: JsonPropertyName("top_movers")] IReadOnlyList<TopMover>? TopMovers,
[property: JsonPropertyName("aggregate_metrics")] AggregateRiskMetrics AggregateMetrics,
[property: JsonPropertyName("execution_time_ms")] double ExecutionTimeMs);
/// <summary>
/// Computed risk score for a finding.
/// </summary>
public sealed record FindingScore(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("raw_score")] double RawScore,
[property: JsonPropertyName("normalized_score")] double NormalizedScore,
[property: JsonPropertyName("severity")] RiskSeverity Severity,
[property: JsonPropertyName("action")] RiskAction RecommendedAction,
[property: JsonPropertyName("contributions")] IReadOnlyList<SignalContribution>? Contributions,
[property: JsonPropertyName("overrides_applied")] IReadOnlyList<AppliedOverride>? OverridesApplied);
/// <summary>
/// Contribution of a signal to the risk score.
/// </summary>
public sealed record SignalContribution(
[property: JsonPropertyName("signal_name")] string SignalName,
[property: JsonPropertyName("signal_value")] object? SignalValue,
[property: JsonPropertyName("weight")] double Weight,
[property: JsonPropertyName("contribution")] double Contribution,
[property: JsonPropertyName("contribution_percentage")] double ContributionPercentage);
/// <summary>
/// An override that was applied during scoring.
/// </summary>
public sealed record AppliedOverride(
[property: JsonPropertyName("override_type")] string OverrideType,
[property: JsonPropertyName("predicate")] Dictionary<string, object> Predicate,
[property: JsonPropertyName("original_value")] object? OriginalValue,
[property: JsonPropertyName("applied_value")] object? AppliedValue,
[property: JsonPropertyName("reason")] string? Reason);
/// <summary>
/// Distribution of risk scores across findings.
/// </summary>
public sealed record RiskDistribution(
[property: JsonPropertyName("buckets")] IReadOnlyList<RiskBucket> Buckets,
[property: JsonPropertyName("percentiles")] Dictionary<string, double> Percentiles,
[property: JsonPropertyName("severity_breakdown")] Dictionary<string, int> SeverityBreakdown);
/// <summary>
/// A bucket in the risk distribution.
/// </summary>
public sealed record RiskBucket(
[property: JsonPropertyName("range_min")] double RangeMin,
[property: JsonPropertyName("range_max")] double RangeMax,
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("percentage")] double Percentage);
/// <summary>
/// A top mover in risk scoring (highest impact findings).
/// </summary>
public sealed record TopMover(
[property: JsonPropertyName("finding_id")] string FindingId,
[property: JsonPropertyName("component_purl")] string? ComponentPurl,
[property: JsonPropertyName("score")] double Score,
[property: JsonPropertyName("severity")] RiskSeverity Severity,
[property: JsonPropertyName("primary_driver")] string PrimaryDriver,
[property: JsonPropertyName("driver_contribution")] double DriverContribution);
/// <summary>
/// Aggregate risk metrics across all findings.
/// </summary>
public sealed record AggregateRiskMetrics(
[property: JsonPropertyName("total_findings")] int TotalFindings,
[property: JsonPropertyName("mean_score")] double MeanScore,
[property: JsonPropertyName("median_score")] double MedianScore,
[property: JsonPropertyName("std_deviation")] double StdDeviation,
[property: JsonPropertyName("max_score")] double MaxScore,
[property: JsonPropertyName("min_score")] double MinScore,
[property: JsonPropertyName("critical_count")] int CriticalCount,
[property: JsonPropertyName("high_count")] int HighCount,
[property: JsonPropertyName("medium_count")] int MediumCount,
[property: JsonPropertyName("low_count")] int LowCount,
[property: JsonPropertyName("informational_count")] int InformationalCount);

View File

@@ -0,0 +1,461 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Services;
using StellaOps.Policy.Engine.Telemetry;
using StellaOps.Policy.RiskProfile.Hashing;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.Simulation;
/// <summary>
/// Service for running risk simulations with score distributions and contribution breakdowns.
/// </summary>
public sealed class RiskSimulationService
{
private readonly ILogger<RiskSimulationService> _logger;
private readonly TimeProvider _timeProvider;
private readonly RiskProfileConfigurationService _profileService;
private readonly RiskProfileHasher _hasher;
private static readonly double[] PercentileLevels = { 0.25, 0.50, 0.75, 0.90, 0.95, 0.99 };
private const int TopMoverCount = 10;
private const int BucketCount = 10;
public RiskSimulationService(
ILogger<RiskSimulationService> logger,
TimeProvider timeProvider,
RiskProfileConfigurationService profileService)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
_hasher = new RiskProfileHasher();
}
/// <summary>
/// Runs a risk simulation.
/// </summary>
public RiskSimulationResult Simulate(RiskSimulationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
using var activity = PolicyEngineTelemetry.ActivitySource.StartActivity("risk_simulation.run");
activity?.SetTag("profile.id", request.ProfileId);
activity?.SetTag("finding.count", request.Findings.Count);
var sw = Stopwatch.StartNew();
var profile = _profileService.GetProfile(request.ProfileId);
if (profile == null)
{
throw new InvalidOperationException($"Risk profile '{request.ProfileId}' not found.");
}
var profileHash = _hasher.ComputeHash(profile);
var simulationId = GenerateSimulationId(request, profileHash);
var findingScores = request.Findings
.Select(f => ComputeFindingScore(f, profile, request.IncludeContributions))
.ToList();
var distribution = request.IncludeDistribution
? ComputeDistribution(findingScores)
: null;
var topMovers = request.IncludeContributions
? ComputeTopMovers(findingScores, request.Findings)
: null;
var aggregateMetrics = ComputeAggregateMetrics(findingScores);
sw.Stop();
_logger.LogInformation(
"Risk simulation {SimulationId} completed for {FindingCount} findings in {ElapsedMs}ms",
simulationId, request.Findings.Count, sw.Elapsed.TotalMilliseconds);
PolicyEngineTelemetry.RiskSimulationsRun.Add(1);
return new RiskSimulationResult(
SimulationId: simulationId,
ProfileId: profile.Id,
ProfileVersion: profile.Version,
ProfileHash: profileHash,
Timestamp: _timeProvider.GetUtcNow(),
FindingScores: findingScores.AsReadOnly(),
Distribution: distribution,
TopMovers: topMovers,
AggregateMetrics: aggregateMetrics,
ExecutionTimeMs: sw.Elapsed.TotalMilliseconds);
}
private FindingScore ComputeFindingScore(
SimulationFinding finding,
RiskProfileModel profile,
bool includeContributions)
{
var contributions = new List<SignalContribution>();
var overridesApplied = new List<AppliedOverride>();
var rawScore = 0.0;
// Compute score from signals and weights
foreach (var signal in profile.Signals)
{
if (!finding.Signals.TryGetValue(signal.Name, out var signalValue))
{
continue;
}
var numericValue = ConvertToNumeric(signalValue, signal.Type);
var weight = profile.Weights.GetValueOrDefault(signal.Name, 0.0);
var contribution = numericValue * weight;
rawScore += contribution;
if (includeContributions)
{
contributions.Add(new SignalContribution(
SignalName: signal.Name,
SignalValue: signalValue,
Weight: weight,
Contribution: contribution,
ContributionPercentage: 0.0)); // Will be computed after total
}
}
// Normalize score to 0-100 range
var normalizedScore = Math.Clamp(rawScore * 10, 0, 100);
// Apply severity overrides
var severity = DetermineSeverity(normalizedScore);
foreach (var severityOverride in profile.Overrides.Severity)
{
if (MatchesPredicate(finding.Signals, severityOverride.When))
{
var originalSeverity = severity;
severity = severityOverride.Set;
if (includeContributions)
{
overridesApplied.Add(new AppliedOverride(
OverrideType: "severity",
Predicate: severityOverride.When,
OriginalValue: originalSeverity.ToString(),
AppliedValue: severity.ToString(),
Reason: null));
}
break;
}
}
// Apply decision overrides
var recommendedAction = DetermineAction(severity);
foreach (var decisionOverride in profile.Overrides.Decisions)
{
if (MatchesPredicate(finding.Signals, decisionOverride.When))
{
var originalAction = recommendedAction;
recommendedAction = decisionOverride.Action;
if (includeContributions)
{
overridesApplied.Add(new AppliedOverride(
OverrideType: "decision",
Predicate: decisionOverride.When,
OriginalValue: originalAction.ToString(),
AppliedValue: recommendedAction.ToString(),
Reason: decisionOverride.Reason));
}
break;
}
}
// Update contribution percentages
if (includeContributions && rawScore > 0)
{
contributions = contributions
.Select(c => c with { ContributionPercentage = (c.Contribution / rawScore) * 100 })
.ToList();
}
return new FindingScore(
FindingId: finding.FindingId,
RawScore: rawScore,
NormalizedScore: normalizedScore,
Severity: severity,
RecommendedAction: recommendedAction,
Contributions: includeContributions ? contributions.AsReadOnly() : null,
OverridesApplied: includeContributions && overridesApplied.Count > 0
? overridesApplied.AsReadOnly()
: null);
}
private static double ConvertToNumeric(object? value, RiskSignalType signalType)
{
if (value == null)
{
return 0.0;
}
return signalType switch
{
RiskSignalType.Boolean => value switch
{
bool b => b ? 1.0 : 0.0,
JsonElement je when je.ValueKind == JsonValueKind.True => 1.0,
JsonElement je when je.ValueKind == JsonValueKind.False => 0.0,
string s when bool.TryParse(s, out var b) => b ? 1.0 : 0.0,
_ => 0.0
},
RiskSignalType.Numeric => value switch
{
double d => d,
float f => f,
int i => i,
long l => l,
decimal dec => (double)dec,
JsonElement je when je.TryGetDouble(out var d) => d,
string s when double.TryParse(s, out var d) => d,
_ => 0.0
},
RiskSignalType.Categorical => value switch
{
string s => MapCategoricalToNumeric(s),
JsonElement je when je.ValueKind == JsonValueKind.String => MapCategoricalToNumeric(je.GetString() ?? ""),
_ => 0.0
},
_ => 0.0
};
}
private static double MapCategoricalToNumeric(string category)
{
return category.ToLowerInvariant() switch
{
"none" or "unknown" => 0.0,
"indirect" or "low" => 0.3,
"direct" or "medium" => 0.6,
"high" or "critical" => 1.0,
_ => 0.5
};
}
private static RiskSeverity DetermineSeverity(double score)
{
return score switch
{
>= 90 => RiskSeverity.Critical,
>= 70 => RiskSeverity.High,
>= 40 => RiskSeverity.Medium,
>= 10 => RiskSeverity.Low,
_ => RiskSeverity.Informational
};
}
private static RiskAction DetermineAction(RiskSeverity severity)
{
return severity switch
{
RiskSeverity.Critical => RiskAction.Deny,
RiskSeverity.High => RiskAction.Deny,
RiskSeverity.Medium => RiskAction.Review,
_ => RiskAction.Allow
};
}
private static bool MatchesPredicate(Dictionary<string, object?> signals, Dictionary<string, object> predicate)
{
foreach (var (key, expected) in predicate)
{
if (!signals.TryGetValue(key, out var actual))
{
return false;
}
if (!ValuesEqual(actual, expected))
{
return false;
}
}
return true;
}
private static bool ValuesEqual(object? a, object? b)
{
if (a == null && b == null) return true;
if (a == null || b == null) return false;
// Handle JsonElement comparisons
if (a is JsonElement jeA && b is JsonElement jeB)
{
return jeA.GetRawText() == jeB.GetRawText();
}
if (a is JsonElement je)
{
a = je.ValueKind switch
{
JsonValueKind.String => je.GetString(),
JsonValueKind.Number => je.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => je.GetRawText()
};
}
if (b is JsonElement jeb)
{
b = jeb.ValueKind switch
{
JsonValueKind.String => jeb.GetString(),
JsonValueKind.Number => jeb.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => jeb.GetRawText()
};
}
return Equals(a, b);
}
private static RiskDistribution ComputeDistribution(List<FindingScore> scores)
{
if (scores.Count == 0)
{
return new RiskDistribution(
Buckets: Array.Empty<RiskBucket>(),
Percentiles: new Dictionary<string, double>(),
SeverityBreakdown: new Dictionary<string, int>
{
["critical"] = 0,
["high"] = 0,
["medium"] = 0,
["low"] = 0,
["informational"] = 0
});
}
var normalizedScores = scores.Select(s => s.NormalizedScore).OrderBy(x => x).ToList();
// Compute buckets
var buckets = new List<RiskBucket>();
var bucketSize = 100.0 / BucketCount;
for (var i = 0; i < BucketCount; i++)
{
var rangeMin = i * bucketSize;
var rangeMax = (i + 1) * bucketSize;
var count = normalizedScores.Count(s => s >= rangeMin && s < rangeMax);
buckets.Add(new RiskBucket(
RangeMin: rangeMin,
RangeMax: rangeMax,
Count: count,
Percentage: (double)count / scores.Count * 100));
}
// Compute percentiles
var percentiles = new Dictionary<string, double>();
foreach (var level in PercentileLevels)
{
var index = (int)(level * (normalizedScores.Count - 1));
percentiles[$"p{(int)(level * 100)}"] = normalizedScores[index];
}
// Severity breakdown
var severityBreakdown = scores
.GroupBy(s => s.Severity.ToString().ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.Count());
// Ensure all severities are present
foreach (var sev in new[] { "critical", "high", "medium", "low", "informational" })
{
severityBreakdown.TryAdd(sev, 0);
}
return new RiskDistribution(
Buckets: buckets.AsReadOnly(),
Percentiles: percentiles,
SeverityBreakdown: severityBreakdown);
}
private static IReadOnlyList<TopMover> ComputeTopMovers(
List<FindingScore> scores,
IReadOnlyList<SimulationFinding> findings)
{
var findingLookup = findings.ToDictionary(f => f.FindingId, StringComparer.OrdinalIgnoreCase);
return scores
.OrderByDescending(s => s.NormalizedScore)
.Take(TopMoverCount)
.Select(s =>
{
var finding = findingLookup.GetValueOrDefault(s.FindingId);
var primaryContribution = s.Contributions?
.OrderByDescending(c => c.ContributionPercentage)
.FirstOrDefault();
return new TopMover(
FindingId: s.FindingId,
ComponentPurl: finding?.ComponentPurl,
Score: s.NormalizedScore,
Severity: s.Severity,
PrimaryDriver: primaryContribution?.SignalName ?? "unknown",
DriverContribution: primaryContribution?.ContributionPercentage ?? 0);
})
.ToList()
.AsReadOnly();
}
private static AggregateRiskMetrics ComputeAggregateMetrics(List<FindingScore> scores)
{
if (scores.Count == 0)
{
return new AggregateRiskMetrics(
TotalFindings: 0,
MeanScore: 0,
MedianScore: 0,
StdDeviation: 0,
MaxScore: 0,
MinScore: 0,
CriticalCount: 0,
HighCount: 0,
MediumCount: 0,
LowCount: 0,
InformationalCount: 0);
}
var normalizedScores = scores.Select(s => s.NormalizedScore).ToList();
var mean = normalizedScores.Average();
var sortedScores = normalizedScores.OrderBy(x => x).ToList();
var median = sortedScores.Count % 2 == 0
? (sortedScores[sortedScores.Count / 2 - 1] + sortedScores[sortedScores.Count / 2]) / 2
: sortedScores[sortedScores.Count / 2];
var variance = normalizedScores.Average(s => Math.Pow(s - mean, 2));
var stdDev = Math.Sqrt(variance);
return new AggregateRiskMetrics(
TotalFindings: scores.Count,
MeanScore: Math.Round(mean, 2),
MedianScore: Math.Round(median, 2),
StdDeviation: Math.Round(stdDev, 2),
MaxScore: normalizedScores.Max(),
MinScore: normalizedScores.Min(),
CriticalCount: scores.Count(s => s.Severity == RiskSeverity.Critical),
HighCount: scores.Count(s => s.Severity == RiskSeverity.High),
MediumCount: scores.Count(s => s.Severity == RiskSeverity.Medium),
LowCount: scores.Count(s => s.Severity == RiskSeverity.Low),
InformationalCount: scores.Count(s => s.Severity == RiskSeverity.Informational));
}
private static string GenerateSimulationId(RiskSimulationRequest request, string profileHash)
{
var seed = $"{request.ProfileId}|{profileHash}|{request.Findings.Count}|{Guid.NewGuid()}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
return $"rsim-{Convert.ToHexStringLower(hash)[..16]}";
}
}