Files
git.stella-ops.org/src/Policy/__Libraries/StellaOps.Policy/RiskProfileDiagnostics.cs
StellaOps Bot 3b96b2e3ea
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
up
2025-11-27 23:45:09 +02:00

360 lines
12 KiB
C#

using System.Collections.Immutable;
using System.Text.Json;
using Json.Schema;
using StellaOps.Policy.RiskProfile.Models;
using StellaOps.Policy.RiskProfile.Schema;
using StellaOps.Policy.RiskProfile.Validation;
namespace StellaOps.Policy;
/// <summary>
/// Diagnostics report for a risk profile validation.
/// </summary>
public sealed record RiskProfileDiagnosticsReport(
string ProfileId,
string Version,
int SignalCount,
int WeightCount,
int OverrideCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<RiskProfileIssue> Issues,
ImmutableArray<string> Recommendations);
/// <summary>
/// Represents a validation issue in a risk profile.
/// </summary>
public sealed record RiskProfileIssue(
string Code,
string Message,
RiskProfileIssueSeverity Severity,
string Path)
{
public static RiskProfileIssue Error(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Error, path);
public static RiskProfileIssue Warning(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Warning, path);
public static RiskProfileIssue Info(string code, string message, string path)
=> new(code, message, RiskProfileIssueSeverity.Info, path);
}
/// <summary>
/// Severity levels for risk profile issues.
/// </summary>
public enum RiskProfileIssueSeverity
{
Error,
Warning,
Info
}
/// <summary>
/// Provides validation and diagnostics for risk profiles.
/// </summary>
public static class RiskProfileDiagnostics
{
private static readonly RiskProfileValidator Validator = new();
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
/// <summary>
/// Creates a diagnostics report for a risk profile.
/// </summary>
/// <param name="profile">The profile to validate.</param>
/// <param name="timeProvider">Optional time provider.</param>
/// <returns>Diagnostics report.</returns>
public static RiskProfileDiagnosticsReport Create(RiskProfileModel profile, TimeProvider? timeProvider = null)
{
ArgumentNullException.ThrowIfNull(profile);
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
ValidateStructure(profile, issues);
ValidateSignals(profile, issues);
ValidateWeights(profile, issues);
ValidateOverrides(profile, issues);
ValidateInheritance(profile, issues);
var errorCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Error);
var warningCount = issues.Count(static i => i.Severity == RiskProfileIssueSeverity.Warning);
var recommendations = BuildRecommendations(profile, errorCount, warningCount);
var overrideCount = profile.Overrides.Severity.Count + profile.Overrides.Decisions.Count;
return new RiskProfileDiagnosticsReport(
profile.Id,
profile.Version,
profile.Signals.Count,
profile.Weights.Count,
overrideCount,
errorCount,
warningCount,
time,
issues.ToImmutable(),
recommendations);
}
/// <summary>
/// Validates a risk profile JSON against the schema.
/// </summary>
/// <param name="json">The JSON to validate.</param>
/// <returns>Collection of validation issues.</returns>
public static ImmutableArray<RiskProfileIssue> ValidateJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
return ImmutableArray.Create(
RiskProfileIssue.Error("RISK001", "Profile JSON is required.", "/"));
}
try
{
var results = Validator.Validate(json);
if (results.IsValid)
{
return ImmutableArray<RiskProfileIssue>.Empty;
}
return ExtractSchemaErrors(results).ToImmutableArray();
}
catch (JsonException ex)
{
return ImmutableArray.Create(
RiskProfileIssue.Error("RISK002", $"Invalid JSON: {ex.Message}", "/"));
}
}
/// <summary>
/// Validates a risk profile model for semantic correctness.
/// </summary>
/// <param name="profile">The profile to validate.</param>
/// <returns>Collection of validation issues.</returns>
public static ImmutableArray<RiskProfileIssue> Validate(RiskProfileModel profile)
{
ArgumentNullException.ThrowIfNull(profile);
var issues = ImmutableArray.CreateBuilder<RiskProfileIssue>();
ValidateStructure(profile, issues);
ValidateSignals(profile, issues);
ValidateWeights(profile, issues);
ValidateOverrides(profile, issues);
ValidateInheritance(profile, issues);
return issues.ToImmutable();
}
private static void ValidateStructure(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (string.IsNullOrWhiteSpace(profile.Id))
{
issues.Add(RiskProfileIssue.Error("RISK010", "Profile ID is required.", "/id"));
}
else if (profile.Id.Contains(' '))
{
issues.Add(RiskProfileIssue.Warning("RISK011", "Profile ID should not contain spaces.", "/id"));
}
if (string.IsNullOrWhiteSpace(profile.Version))
{
issues.Add(RiskProfileIssue.Error("RISK012", "Profile version is required.", "/version"));
}
else if (!IsValidSemVer(profile.Version))
{
issues.Add(RiskProfileIssue.Warning("RISK013", "Profile version should follow SemVer format.", "/version"));
}
}
private static void ValidateSignals(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (profile.Signals.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK020", "Profile has no signals defined.", "/signals"));
return;
}
var signalNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < profile.Signals.Count; i++)
{
var signal = profile.Signals[i];
var path = $"/signals/{i}";
if (string.IsNullOrWhiteSpace(signal.Name))
{
issues.Add(RiskProfileIssue.Error("RISK021", $"Signal at index {i} has no name.", path));
}
else if (!signalNames.Add(signal.Name))
{
issues.Add(RiskProfileIssue.Error("RISK022", $"Duplicate signal name: {signal.Name}", path));
}
if (string.IsNullOrWhiteSpace(signal.Source))
{
issues.Add(RiskProfileIssue.Error("RISK023", $"Signal '{signal.Name}' has no source.", $"{path}/source"));
}
if (signal.Type == RiskSignalType.Numeric && string.IsNullOrWhiteSpace(signal.Unit))
{
issues.Add(RiskProfileIssue.Info("RISK024", $"Numeric signal '{signal.Name}' has no unit specified.", $"{path}/unit"));
}
}
}
private static void ValidateWeights(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
var signalNames = profile.Signals.Select(s => s.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
double totalWeight = 0;
foreach (var (name, weight) in profile.Weights)
{
if (!signalNames.Contains(name))
{
issues.Add(RiskProfileIssue.Warning("RISK030", $"Weight defined for unknown signal: {name}", $"/weights/{name}"));
}
if (weight < 0)
{
issues.Add(RiskProfileIssue.Error("RISK031", $"Weight for '{name}' is negative: {weight}", $"/weights/{name}"));
}
else if (weight == 0)
{
issues.Add(RiskProfileIssue.Info("RISK032", $"Weight for '{name}' is zero.", $"/weights/{name}"));
}
totalWeight += weight;
}
foreach (var signal in profile.Signals)
{
if (!profile.Weights.ContainsKey(signal.Name))
{
issues.Add(RiskProfileIssue.Warning("RISK033", $"Signal '{signal.Name}' has no weight defined.", $"/weights"));
}
}
if (totalWeight > 0 && Math.Abs(totalWeight - 1.0) > 0.01)
{
issues.Add(RiskProfileIssue.Info("RISK034", $"Weights sum to {totalWeight:F3}; consider normalizing to 1.0.", "/weights"));
}
}
private static void ValidateOverrides(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
for (int i = 0; i < profile.Overrides.Severity.Count; i++)
{
var rule = profile.Overrides.Severity[i];
var path = $"/overrides/severity/{i}";
if (rule.When.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK040", $"Severity override at index {i} has empty 'when' clause.", path));
}
}
for (int i = 0; i < profile.Overrides.Decisions.Count; i++)
{
var rule = profile.Overrides.Decisions[i];
var path = $"/overrides/decisions/{i}";
if (rule.When.Count == 0)
{
issues.Add(RiskProfileIssue.Warning("RISK041", $"Decision override at index {i} has empty 'when' clause.", path));
}
if (rule.Action == RiskAction.Deny && string.IsNullOrWhiteSpace(rule.Reason))
{
issues.Add(RiskProfileIssue.Warning("RISK042", $"Decision override at index {i} with 'deny' action should have a reason.", path));
}
}
}
private static void ValidateInheritance(RiskProfileModel profile, ImmutableArray<RiskProfileIssue>.Builder issues)
{
if (!string.IsNullOrWhiteSpace(profile.Extends))
{
if (string.Equals(profile.Extends, profile.Id, StringComparison.OrdinalIgnoreCase))
{
issues.Add(RiskProfileIssue.Error("RISK050", "Profile cannot extend itself.", "/extends"));
}
}
}
private static ImmutableArray<string> BuildRecommendations(RiskProfileModel profile, int errorCount, int warningCount)
{
var recommendations = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
recommendations.Add("Resolve errors before using this profile in production.");
}
if (warningCount > 0)
{
recommendations.Add("Review warnings to ensure profile behaves as expected.");
}
if (profile.Signals.Count == 0)
{
recommendations.Add("Add at least one signal to enable risk scoring.");
}
if (profile.Weights.Count == 0 && profile.Signals.Count > 0)
{
recommendations.Add("Define weights for signals to control scoring influence.");
}
var hasReachability = profile.Signals.Any(s =>
s.Name.Equals("reachability", StringComparison.OrdinalIgnoreCase));
if (!hasReachability)
{
recommendations.Add("Consider adding a reachability signal to prioritize exploitable vulnerabilities.");
}
if (recommendations.Count == 0)
{
recommendations.Add("Risk profile validated successfully; ready for use.");
}
return recommendations.ToImmutable();
}
private static IEnumerable<RiskProfileIssue> ExtractSchemaErrors(EvaluationResults results)
{
if (results.Details != null)
{
foreach (var detail in results.Details)
{
if (!detail.IsValid && detail.Errors != null)
{
foreach (var error in detail.Errors)
{
yield return RiskProfileIssue.Error(
"RISK003",
error.Value ?? "Schema validation failed",
detail.EvaluationPath?.ToString() ?? "/");
}
}
}
}
else if (!results.IsValid)
{
var errorMessage = results.Errors?.FirstOrDefault().Value ?? "Schema validation failed";
yield return RiskProfileIssue.Error("RISK003", errorMessage, "/");
}
}
private static bool IsValidSemVer(string version)
{
var parts = version.Split(['-', '+'], 2);
return Version.TryParse(parts[0], out _);
}
}