up
This commit is contained in:
@@ -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);
|
||||
@@ -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]}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user