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; /// /// Diagnostics report for a risk profile validation. /// public sealed record RiskProfileDiagnosticsReport( string ProfileId, string Version, int SignalCount, int WeightCount, int OverrideCount, int ErrorCount, int WarningCount, DateTimeOffset GeneratedAt, ImmutableArray Issues, ImmutableArray Recommendations); /// /// Represents a validation issue in a risk profile. /// 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); } /// /// Severity levels for risk profile issues. /// public enum RiskProfileIssueSeverity { Error, Warning, Info } /// /// Provides validation and diagnostics for risk profiles. /// public static class RiskProfileDiagnostics { private static readonly RiskProfileValidator Validator = new(); private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }; /// /// Creates a diagnostics report for a risk profile. /// /// The profile to validate. /// Optional time provider. /// Diagnostics report. public static RiskProfileDiagnosticsReport Create(RiskProfileModel profile, TimeProvider? timeProvider = null) { ArgumentNullException.ThrowIfNull(profile); var time = (timeProvider ?? TimeProvider.System).GetUtcNow(); var issues = ImmutableArray.CreateBuilder(); 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); } /// /// Validates a risk profile JSON against the schema. /// /// The JSON to validate. /// Collection of validation issues. public static ImmutableArray 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.Empty; } return ExtractSchemaErrors(results).ToImmutableArray(); } catch (JsonException ex) { return ImmutableArray.Create( RiskProfileIssue.Error("RISK002", $"Invalid JSON: {ex.Message}", "/")); } } /// /// Validates a risk profile model for semantic correctness. /// /// The profile to validate. /// Collection of validation issues. public static ImmutableArray Validate(RiskProfileModel profile) { ArgumentNullException.ThrowIfNull(profile); var issues = ImmutableArray.CreateBuilder(); 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.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.Builder issues) { if (profile.Signals.Count == 0) { issues.Add(RiskProfileIssue.Warning("RISK020", "Profile has no signals defined.", "/signals")); return; } var signalNames = new HashSet(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.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.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.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 BuildRecommendations(RiskProfileModel profile, int errorCount, int warningCount) { var recommendations = ImmutableArray.CreateBuilder(); 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 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 _); } }