using System; using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Json.Schema; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace StellaOps.Policy; public sealed record PolicyScoringBindingResult( bool Success, PolicyScoringConfig? Config, ImmutableArray Issues); public static class PolicyScoringConfigBinder { private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json"; private static readonly JsonSchema ScoringSchema = PolicyScoringSchema.Schema; private static readonly ImmutableDictionary DefaultReachabilityBuckets = CreateDefaultReachabilityBuckets(); private static readonly PolicyUnknownConfidenceConfig DefaultUnknownConfidence = CreateDefaultUnknownConfidence(); private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); public static PolicyScoringConfig LoadDefault() { var assembly = Assembly.GetExecutingAssembly(); using var stream = assembly.GetManifestResourceStream(DefaultResourceName) ?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found."); using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); var json = reader.ReadToEnd(); var binding = Bind(json, PolicyDocumentFormat.Json); if (!binding.Success || binding.Config is null) { throw new InvalidOperationException("Failed to load default policy scoring configuration."); } return binding.Config; } public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format) { if (string.IsNullOrWhiteSpace(content)) { var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$"); return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); } try { var root = Parse(content, format); if (root is not JsonObject obj) { var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$"); return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); } var issues = ImmutableArray.CreateBuilder(); var schemaIssues = ValidateAgainstSchema(root); issues.AddRange(schemaIssues); if (schemaIssues.Any(static issue => issue.Severity == PolicyIssueSeverity.Error)) { return new PolicyScoringBindingResult(false, null, issues.ToImmutable()); } var config = BuildConfig(obj, issues); var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error); return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable()); } catch (JsonException ex) { var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$"); return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); } catch (YamlDotNet.Core.YamlException ex) { var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$"); return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue)); } } private static JsonNode? Parse(string content, PolicyDocumentFormat format) { return format switch { PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }), PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."), }; } private static JsonNode? ConvertYamlToJsonNode(string content) { var yamlObject = YamlDeserializer.Deserialize(content); return PolicyBinderUtilities.ConvertYamlObject(yamlObject); } private static ImmutableArray ValidateAgainstSchema(JsonNode root) { try { using var document = JsonDocument.Parse(root.ToJsonString(new JsonSerializerOptions { WriteIndented = false, })); var result = ScoringSchema.Evaluate(document.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List, RequireFormatValidation = true, }); if (result.IsValid) { return ImmutableArray.Empty; } var issues = ImmutableArray.CreateBuilder(); var seen = new HashSet(StringComparer.Ordinal); CollectSchemaIssues(result, issues, seen); return issues.ToImmutable(); } catch (JsonException ex) { return ImmutableArray.Create(PolicyIssue.Error("scoring.schema.normalize", $"Failed to normalize scoring configuration for schema validation: {ex.Message}", "$")); } } private static void CollectSchemaIssues(EvaluationResults result, ImmutableArray.Builder issues, HashSet seen) { if (result.Errors is { Count: > 0 }) { foreach (var pair in result.Errors) { var keyword = SanitizeKeyword(pair.Key); var path = ConvertPointerToPath(result.InstanceLocation?.ToString() ?? "#"); var message = pair.Value ?? "Schema violation."; var key = $"{path}|{keyword}|{message}"; if (seen.Add(key)) { issues.Add(PolicyIssue.Error($"scoring.schema.{keyword}", message, path)); } } } if (result.Details is null) { return; } foreach (var detail in result.Details) { CollectSchemaIssues(detail, issues, seen); } } private static string ConvertPointerToPath(string pointer) { if (string.IsNullOrEmpty(pointer) || pointer == "#") { return "$"; } if (pointer[0] == '#') { pointer = pointer.Length > 1 ? pointer[1..] : string.Empty; } if (string.IsNullOrEmpty(pointer)) { return "$"; } var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries); var builder = new StringBuilder("$"); foreach (var segment in segments) { var unescaped = segment.Replace("~1", "/").Replace("~0", "~"); if (int.TryParse(unescaped, out var index)) { builder.Append('[').Append(index).Append(']'); } else { builder.Append('.').Append(unescaped); } } return builder.ToString(); } private static string SanitizeKeyword(string keyword) { if (string.IsNullOrWhiteSpace(keyword)) { return "unknown"; } var builder = new StringBuilder(keyword.Length); foreach (var ch in keyword) { if (char.IsLetterOrDigit(ch)) { builder.Append(char.ToLowerInvariant(ch)); } else if (ch is '.' or '_' or '-') { builder.Append(ch); } else { builder.Append('_'); } } return builder.Length == 0 ? "unknown" : builder.ToString(); } private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray.Builder issues) { var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion; var severityWeights = ReadSeverityWeights(obj, issues); var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45); var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15); var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35); var trustOverrides = ReadTrustOverrides(obj, issues); var reachabilityBuckets = ReadReachabilityBuckets(obj, issues); var unknownConfidence = ReadUnknownConfidence(obj, issues); return new PolicyScoringConfig( version, severityWeights, quietPenalty, warnPenalty, ignorePenalty, trustOverrides, reachabilityBuckets, unknownConfidence); } private static ImmutableDictionary CreateDefaultReachabilityBuckets() { var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); builder["entrypoint"] = 1.0; builder["direct"] = 0.85; builder["indirect"] = 0.6; builder["runtime"] = 0.45; builder["unreachable"] = 0.25; builder["unknown"] = 0.5; return builder.ToImmutable(); } private static PolicyUnknownConfidenceConfig CreateDefaultUnknownConfidence() { var bands = ImmutableArray.Create( new PolicyUnknownConfidenceBand("high", 0.65, "Fresh unknowns with recent telemetry."), new PolicyUnknownConfidenceBand("medium", 0.35, "Unknowns aging toward action required."), new PolicyUnknownConfidenceBand("low", 0.0, "Stale unknowns that must be triaged.")); return new PolicyUnknownConfidenceConfig(0.8, 0.05, 0.2, bands); } private static ImmutableDictionary ReadReachabilityBuckets(JsonObject obj, ImmutableArray.Builder issues) { if (!obj.TryGetPropertyValue("reachabilityBuckets", out var node)) { issues.Add(PolicyIssue.Warning("scoring.reachability.default", "reachabilityBuckets not specified; defaulting to baseline weights.", "$.reachabilityBuckets")); return DefaultReachabilityBuckets; } if (node is not JsonObject bucketsObj) { issues.Add(PolicyIssue.Error("scoring.reachability.type", "reachabilityBuckets must be an object.", "$.reachabilityBuckets")); return DefaultReachabilityBuckets; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); foreach (var pair in bucketsObj) { if (pair.Value is null) { issues.Add(PolicyIssue.Warning("scoring.reachability.null", $"Bucket '{pair.Key}' is null; defaulting to 0.", $"$.reachabilityBuckets.{pair.Key}")); builder[pair.Key] = 0; continue; } var value = ExtractDouble(pair.Value, issues, $"$.reachabilityBuckets.{pair.Key}"); builder[pair.Key] = value; } if (builder.Count == 0) { issues.Add(PolicyIssue.Warning("scoring.reachability.empty", "No reachability buckets defined; using defaults.", "$.reachabilityBuckets")); return DefaultReachabilityBuckets; } return builder.ToImmutable(); } private static PolicyUnknownConfidenceConfig ReadUnknownConfidence(JsonObject obj, ImmutableArray.Builder issues) { if (!obj.TryGetPropertyValue("unknownConfidence", out var node)) { issues.Add(PolicyIssue.Warning("scoring.unknown.default", "unknownConfidence not specified; defaulting to baseline decay settings.", "$.unknownConfidence")); return DefaultUnknownConfidence; } if (node is not JsonObject configObj) { issues.Add(PolicyIssue.Error("scoring.unknown.type", "unknownConfidence must be an object.", "$.unknownConfidence")); return DefaultUnknownConfidence; } var initial = DefaultUnknownConfidence.Initial; if (configObj.TryGetPropertyValue("initial", out var initialNode)) { initial = ExtractDouble(initialNode, issues, "$.unknownConfidence.initial"); } else { issues.Add(PolicyIssue.Warning("scoring.unknown.initial.default", "initial not specified; using baseline value.", "$.unknownConfidence.initial")); } var decay = DefaultUnknownConfidence.DecayPerDay; if (configObj.TryGetPropertyValue("decayPerDay", out var decayNode)) { decay = ExtractDouble(decayNode, issues, "$.unknownConfidence.decayPerDay"); } else { issues.Add(PolicyIssue.Warning("scoring.unknown.decay.default", "decayPerDay not specified; using baseline value.", "$.unknownConfidence.decayPerDay")); } var floor = DefaultUnknownConfidence.Floor; if (configObj.TryGetPropertyValue("floor", out var floorNode)) { floor = ExtractDouble(floorNode, issues, "$.unknownConfidence.floor"); } else { issues.Add(PolicyIssue.Warning("scoring.unknown.floor.default", "floor not specified; using baseline value.", "$.unknownConfidence.floor")); } var bands = ReadConfidenceBands(configObj, issues); if (bands.IsDefaultOrEmpty) { bands = DefaultUnknownConfidence.Bands; } if (initial < 0 || initial > 1) { issues.Add(PolicyIssue.Warning("scoring.unknown.initial.range", "initial confidence should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.initial")); initial = Math.Clamp(initial, 0, 1); } if (decay < 0 || decay > 1) { issues.Add(PolicyIssue.Warning("scoring.unknown.decay.range", "decayPerDay should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.decayPerDay")); decay = Math.Clamp(decay, 0, 1); } if (floor < 0 || floor > 1) { issues.Add(PolicyIssue.Warning("scoring.unknown.floor.range", "floor should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.floor")); floor = Math.Clamp(floor, 0, 1); } return new PolicyUnknownConfidenceConfig(initial, decay, floor, bands); } private static ImmutableArray ReadConfidenceBands(JsonObject configObj, ImmutableArray.Builder issues) { if (!configObj.TryGetPropertyValue("bands", out var node)) { return ImmutableArray.Empty; } if (node is not JsonArray array) { issues.Add(PolicyIssue.Error("scoring.unknown.bands.type", "unknownConfidence.bands must be an array.", "$.unknownConfidence.bands")); return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < array.Count; index++) { var element = array[index]; if (element is not JsonObject bandObj) { issues.Add(PolicyIssue.Warning("scoring.unknown.band.type", "Band entry must be an object.", $"$.unknownConfidence.bands[{index}]")); continue; } string? name = null; if (bandObj.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue nameValue && nameValue.TryGetValue(out string? text)) { name = text?.Trim(); } if (string.IsNullOrWhiteSpace(name)) { issues.Add(PolicyIssue.Error("scoring.unknown.band.name", "Band entry requires a non-empty 'name'.", $"$.unknownConfidence.bands[{index}].name")); continue; } if (!seen.Add(name)) { issues.Add(PolicyIssue.Warning("scoring.unknown.band.duplicate", $"Duplicate band '{name}' encountered.", $"$.unknownConfidence.bands[{index}].name")); continue; } if (!bandObj.TryGetPropertyValue("min", out var minNode)) { issues.Add(PolicyIssue.Error("scoring.unknown.band.min", $"Band '{name}' is missing 'min'.", $"$.unknownConfidence.bands[{index}].min")); continue; } var min = ExtractDouble(minNode, issues, $"$.unknownConfidence.bands[{index}].min"); if (min < 0 || min > 1) { issues.Add(PolicyIssue.Warning("scoring.unknown.band.range", $"Band '{name}' min should be between 0 and 1. Clamping to valid range.", $"$.unknownConfidence.bands[{index}].min")); min = Math.Clamp(min, 0, 1); } string? description = null; if (bandObj.TryGetPropertyValue("description", out var descriptionNode) && descriptionNode is JsonValue descriptionValue && descriptionValue.TryGetValue(out string? descriptionText)) { description = descriptionText?.Trim(); } builder.Add(new PolicyUnknownConfidenceBand(name, min, description)); } if (builder.Count == 0) { return ImmutableArray.Empty; } return builder.ToImmutable() .OrderByDescending(static band => band.Min) .ToImmutableArray(); } private static ImmutableDictionary ReadSeverityWeights(JsonObject obj, ImmutableArray.Builder issues) { if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj) { issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights")); return ImmutableDictionary.Empty; } var builder = ImmutableDictionary.CreateBuilder(); foreach (var severity in Enum.GetValues()) { var key = severity.ToString(); if (!severityObj.TryGetPropertyValue(key, out var valueNode)) { issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}")); builder[severity] = 0; continue; } var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}"); builder[severity] = value; } return builder.ToImmutable(); } private static double ReadDouble(JsonObject obj, string property, ImmutableArray.Builder issues, double defaultValue) { if (!obj.TryGetPropertyValue(property, out var node)) { issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}")); return defaultValue; } return ExtractDouble(node, issues, $"$.{property}"); } private static double ExtractDouble(JsonNode? node, ImmutableArray.Builder issues, string path) { if (node is null) { issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path)); return 0; } if (node is JsonValue value) { if (value.TryGetValue(out double number)) { return number; } if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number)) { return number; } } issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path)); return 0; } private static ImmutableDictionary ReadTrustOverrides(JsonObject obj, ImmutableArray.Builder issues) { if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj) { return ImmutableDictionary.Empty; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); foreach (var pair in trustObj) { var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}"); builder[pair.Key] = value; } return builder.ToImmutable(); } private static string? ReadString(JsonObject obj, string property, ImmutableArray.Builder issues, bool required) { if (!obj.TryGetPropertyValue(property, out var node) || node is null) { if (required) { issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}")); } return null; } if (node is JsonValue value && value.TryGetValue(out string? text)) { return text?.Trim(); } issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}")); return null; } } internal static class PolicyBinderUtilities { public static JsonNode? ConvertYamlObject(object? value) { switch (value) { case null: return null; case string s when bool.TryParse(s, out var boolValue): return JsonValue.Create(boolValue); case string s: return JsonValue.Create(s); case bool b: return JsonValue.Create(b); case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal: return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture)); case IDictionary dictionary: { var obj = new JsonObject(); foreach (DictionaryEntry entry in dictionary) { if (entry.Key is null) { continue; } obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value); } return obj; } case IEnumerable enumerable: { var array = new JsonArray(); foreach (var item in enumerable) { array.Add(ConvertYamlObject(item)); } return array; } default: return JsonValue.Create(value.ToString()); } } }