using System; using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace StellaOps.Policy; public enum PolicyDocumentFormat { Json, Yaml, } public sealed record PolicyBindingResult( bool Success, PolicyDocument Document, ImmutableArray Issues, PolicyDocumentFormat Format); public static class PolicyBinder { private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString, Converters = { new JsonStringEnumConverter() }, }; private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); public static PolicyBindingResult Bind(string content, PolicyDocumentFormat format) { if (string.IsNullOrWhiteSpace(content)) { var issues = ImmutableArray.Create( PolicyIssue.Error("policy.empty", "Policy document is empty.", "$")); return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); } try { var node = ParseToNode(content, format); if (node is not JsonObject obj) { var issues = ImmutableArray.Create( PolicyIssue.Error("policy.document.invalid", "Policy document must be an object.", "$")); return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); } var model = obj.Deserialize(SerializerOptions) ?? new PolicyDocumentModel(); var normalization = PolicyNormalizer.Normalize(model); var success = normalization.Issues.All(static issue => issue.Severity != PolicyIssueSeverity.Error); return new PolicyBindingResult(success, normalization.Document, normalization.Issues, format); } catch (JsonException ex) { var issues = ImmutableArray.Create( PolicyIssue.Error("policy.parse.json", $"Failed to parse policy JSON: {ex.Message}", "$")); return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); } catch (YamlDotNet.Core.YamlException ex) { var issues = ImmutableArray.Create( PolicyIssue.Error("policy.parse.yaml", $"Failed to parse policy YAML: {ex.Message}", "$")); return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format); } } public static PolicyBindingResult Bind(Stream stream, PolicyDocumentFormat format, Encoding? encoding = null) { if (stream is null) { throw new ArgumentNullException(nameof(stream)); } encoding ??= Encoding.UTF8; using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, leaveOpen: true); var content = reader.ReadToEnd(); return Bind(content, format); } private static JsonNode? ParseToNode(string content, PolicyDocumentFormat format) { return format switch { PolicyDocumentFormat.Json => JsonNode.Parse(content, documentOptions: new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip, }), PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content), _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported policy document format."), }; } private static JsonNode? ConvertYamlToJsonNode(string content) { var yamlObject = YamlDeserializer.Deserialize(content); return ConvertYamlObject(yamlObject); } private 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.ToDecimal(value, CultureInfo.InvariantCulture)); case DateTime dt: return JsonValue.Create(dt.ToString("O", CultureInfo.InvariantCulture)); case DateTimeOffset dto: return JsonValue.Create(dto.ToString("O", CultureInfo.InvariantCulture)); case Enum e: return JsonValue.Create(e.ToString()); case IDictionary dictionary: { var obj = new JsonObject(); foreach (DictionaryEntry entry in dictionary) { if (entry.Key is null) { continue; } var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture); if (string.IsNullOrWhiteSpace(key)) { continue; } obj[key!] = 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()); } } private sealed record PolicyDocumentModel { [JsonPropertyName("version")] public JsonNode? Version { get; init; } [JsonPropertyName("description")] public string? Description { get; init; } [JsonPropertyName("metadata")] public Dictionary? Metadata { get; init; } [JsonPropertyName("rules")] public List? Rules { get; init; } [JsonPropertyName("exceptions")] public PolicyExceptionsModel? Exceptions { get; init; } [JsonExtensionData] public Dictionary? Extensions { get; init; } } private sealed record PolicyRuleModel { [JsonPropertyName("id")] public string? Identifier { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("description")] public string? Description { get; init; } [JsonPropertyName("severity")] public List? Severity { get; init; } [JsonPropertyName("sources")] public List? Sources { get; init; } [JsonPropertyName("vendors")] public List? Vendors { get; init; } [JsonPropertyName("licenses")] public List? Licenses { get; init; } [JsonPropertyName("tags")] public List? Tags { get; init; } [JsonPropertyName("environments")] public List? Environments { get; init; } [JsonPropertyName("images")] public List? Images { get; init; } [JsonPropertyName("repositories")] public List? Repositories { get; init; } [JsonPropertyName("packages")] public List? Packages { get; init; } [JsonPropertyName("purls")] public List? Purls { get; init; } [JsonPropertyName("cves")] public List? Cves { get; init; } [JsonPropertyName("paths")] public List? Paths { get; init; } [JsonPropertyName("layerDigests")] public List? LayerDigests { get; init; } [JsonPropertyName("usedByEntrypoint")] public List? UsedByEntrypoint { get; init; } [JsonPropertyName("action")] public JsonNode? Action { get; init; } [JsonPropertyName("expires")] public JsonNode? Expires { get; init; } [JsonPropertyName("until")] public JsonNode? Until { get; init; } [JsonPropertyName("justification")] public string? Justification { get; init; } [JsonPropertyName("quiet")] public bool? Quiet { get; init; } [JsonPropertyName("metadata")] public Dictionary? Metadata { get; init; } [JsonExtensionData] public Dictionary? Extensions { get; init; } } private sealed record PolicyExceptionsModel { [JsonPropertyName("effects")] public List? Effects { get; init; } [JsonPropertyName("routingTemplates")] public List? RoutingTemplates { get; init; } [JsonExtensionData] public Dictionary? Extensions { get; init; } } private sealed record PolicyExceptionEffectModel { [JsonPropertyName("id")] public string? Id { get; init; } [JsonPropertyName("name")] public string? Name { get; init; } [JsonPropertyName("description")] public string? Description { get; init; } [JsonPropertyName("effect")] public string? Effect { get; init; } [JsonPropertyName("downgradeSeverity")] public string? DowngradeSeverity { get; init; } [JsonPropertyName("requiredControlId")] public string? RequiredControlId { get; init; } [JsonPropertyName("routingTemplate")] public string? RoutingTemplate { get; init; } [JsonPropertyName("maxDurationDays")] public int? MaxDurationDays { get; init; } [JsonExtensionData] public Dictionary? Extensions { get; init; } } private sealed record PolicyExceptionRoutingTemplateModel { [JsonPropertyName("id")] public string? Id { get; init; } [JsonPropertyName("description")] public string? Description { get; init; } [JsonPropertyName("authorityRouteId")] public string? AuthorityRouteId { get; init; } [JsonPropertyName("requireMfa")] public bool? RequireMfa { get; init; } [JsonExtensionData] public Dictionary? Extensions { get; init; } } private sealed class PolicyNormalizer { private static readonly ImmutableDictionary SeverityMap = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["critical"] = PolicySeverity.Critical, ["high"] = PolicySeverity.High, ["medium"] = PolicySeverity.Medium, ["moderate"] = PolicySeverity.Medium, ["low"] = PolicySeverity.Low, ["informational"] = PolicySeverity.Informational, ["info"] = PolicySeverity.Informational, ["none"] = PolicySeverity.None, ["unknown"] = PolicySeverity.Unknown, }.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase); public static (PolicyDocument Document, ImmutableArray Issues) Normalize(PolicyDocumentModel model) { var issues = ImmutableArray.CreateBuilder(); var version = NormalizeVersion(model.Version, issues); var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues); var rules = NormalizeRules(model.Rules, issues); var exceptions = NormalizeExceptions(model.Exceptions, issues); if (model.Extensions is { Count: > 0 }) { foreach (var pair in model.Extensions) { issues.Add(PolicyIssue.Warning( "policy.document.extension", $"Unrecognized document property '{pair.Key}' has been ignored.", $"$.{pair.Key}")); } } var document = new PolicyDocument( version ?? PolicySchema.CurrentVersion, rules, metadata, exceptions); var orderedIssues = SortIssues(issues); return (document, orderedIssues); } private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray.Builder issues) { if (versionNode is null) { issues.Add(PolicyIssue.Warning("policy.version.missing", "Policy version not specified; defaulting to 1.0.", "$.version")); return PolicySchema.CurrentVersion; } if (versionNode is JsonValue value) { if (value.TryGetValue(out string? versionText)) { versionText = versionText?.Trim(); if (string.IsNullOrEmpty(versionText)) { issues.Add(PolicyIssue.Error("policy.version.empty", "Policy version is empty.", "$.version")); return null; } if (IsSupportedVersion(versionText)) { return CanonicalizeVersion(versionText); } issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{versionText}'. Expected '{PolicySchema.CurrentVersion}'.", "$.version")); return null; } if (value.TryGetValue(out double numericVersion)) { var numericText = numericVersion.ToString("0.0###", CultureInfo.InvariantCulture); if (IsSupportedVersion(numericText)) { return CanonicalizeVersion(numericText); } issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{numericText}'.", "$.version")); return null; } } var raw = versionNode.ToJsonString(); issues.Add(PolicyIssue.Error("policy.version.invalid", $"Policy version must be a string. Received: {raw}", "$.version")); return null; } private static bool IsSupportedVersion(string versionText) => string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase) || string.Equals(versionText, "1.0", StringComparison.OrdinalIgnoreCase) || string.Equals(versionText, PolicySchema.CurrentVersion, StringComparison.OrdinalIgnoreCase); private static string CanonicalizeVersion(string versionText) => string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase) ? "1.0" : versionText; private static ImmutableDictionary NormalizeMetadata( Dictionary? metadata, string path, ImmutableArray.Builder issues) { if (metadata is null || metadata.Count == 0) { return ImmutableDictionary.Empty; } var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); foreach (var pair in metadata) { var key = pair.Key?.Trim(); if (string.IsNullOrEmpty(key)) { issues.Add(PolicyIssue.Warning("policy.metadata.key.empty", "Metadata keys must be non-empty strings.", path)); continue; } var value = ConvertNodeToString(pair.Value); builder[key] = value; } return builder.ToImmutable(); } private static ImmutableArray NormalizeRules( List? rules, ImmutableArray.Builder issues) { if (rules is null || rules.Count == 0) { issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules")); return ImmutableArray.Empty; } var normalized = new List<(PolicyRule Rule, int Index)>(rules.Count); var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < rules.Count; index++) { var model = rules[index]; var normalizedRule = NormalizeRule(model, index, issues); if (normalizedRule is null) { continue; } if (!seenNames.Add(normalizedRule.Name)) { issues.Add(PolicyIssue.Warning( "policy.rules.duplicateName", $"Duplicate rule name '{normalizedRule.Name}' detected; evaluation order may be ambiguous.", $"$.rules[{index}].name")); } normalized.Add((normalizedRule, index)); } return normalized .OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase) .ThenBy(static tuple => tuple.Index) .Select(static tuple => tuple.Rule) .ToImmutableArray(); } private static PolicyExceptionConfiguration NormalizeExceptions( PolicyExceptionsModel? model, ImmutableArray.Builder issues) { if (model is null) { return PolicyExceptionConfiguration.Empty; } var effects = NormalizeExceptionEffects(model.Effects, "$.exceptions.effects", issues); var routingTemplates = NormalizeExceptionRoutingTemplates(model.RoutingTemplates, "$.exceptions.routingTemplates", issues); if (model.Extensions is { Count: > 0 }) { foreach (var pair in model.Extensions) { issues.Add(PolicyIssue.Warning( "policy.exceptions.extension", $"Unrecognized exceptions property '{pair.Key}' has been ignored.", $"$.exceptions.{pair.Key}")); } } return new PolicyExceptionConfiguration(effects, routingTemplates); } private static ImmutableArray NormalizeExceptionEffects( List? models, string path, ImmutableArray.Builder issues) { if (models is null || models.Count == 0) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < models.Count; index++) { var model = models[index]; var basePath = $"{path}[{index}]"; var id = NormalizeOptionalString(model.Id); if (string.IsNullOrEmpty(id)) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.id.missing", "Exception effect id is required.", $"{basePath}.id")); continue; } if (!seenIds.Add(id)) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.id.duplicate", $"Duplicate exception effect id '{id}'.", $"{basePath}.id")); continue; } var effectType = NormalizeExceptionEffectType(model.Effect, $"{basePath}.effect", issues); if (effectType is null) { continue; } PolicySeverity? downgradeSeverity = null; if (!string.IsNullOrWhiteSpace(model.DowngradeSeverity)) { var severityText = NormalizeOptionalString(model.DowngradeSeverity); if (!string.IsNullOrEmpty(severityText) && SeverityMap.TryGetValue(severityText, out var mapped)) { downgradeSeverity = mapped; } else if (!string.IsNullOrEmpty(severityText)) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.downgrade.invalidSeverity", $"Unknown downgradeSeverity '{severityText}'.", $"{basePath}.downgradeSeverity")); } } var requiredControlId = NormalizeOptionalString(model.RequiredControlId); if (effectType == PolicyExceptionEffectType.RequireControl && string.IsNullOrEmpty(requiredControlId)) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.control.missing", "requireControl effects must specify requiredControlId.", $"{basePath}.requiredControlId")); continue; } if (effectType == PolicyExceptionEffectType.Downgrade && downgradeSeverity is null) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.downgrade.missingSeverity", "downgrade effects must specify downgradeSeverity.", $"{basePath}.downgradeSeverity")); continue; } var name = NormalizeOptionalString(model.Name); var routingTemplate = NormalizeOptionalString(model.RoutingTemplate); var description = NormalizeOptionalString(model.Description); int? maxDurationDays = null; if (model.MaxDurationDays is { } durationValue) { if (durationValue <= 0) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.duration.invalid", "maxDurationDays must be greater than zero.", $"{basePath}.maxDurationDays")); } else { maxDurationDays = durationValue; } } if (model.Extensions is { Count: > 0 }) { foreach (var pair in model.Extensions) { issues.Add(PolicyIssue.Warning( "policy.exceptions.effect.extension", $"Unrecognized exception effect property '{pair.Key}' has been ignored.", $"{basePath}.{pair.Key}")); } } builder.Add(new PolicyExceptionEffect( id, name, effectType.Value, downgradeSeverity, requiredControlId, routingTemplate, maxDurationDays, description)); } return builder.ToImmutable(); } private static ImmutableArray NormalizeExceptionRoutingTemplates( List? models, string path, ImmutableArray.Builder issues) { if (models is null || models.Count == 0) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); for (var index = 0; index < models.Count; index++) { var model = models[index]; var basePath = $"{path}[{index}]"; var id = NormalizeOptionalString(model.Id); if (string.IsNullOrEmpty(id)) { issues.Add(PolicyIssue.Error( "policy.exceptions.routing.id.missing", "Routing template id is required.", $"{basePath}.id")); continue; } if (!seenIds.Add(id)) { issues.Add(PolicyIssue.Error( "policy.exceptions.routing.id.duplicate", $"Duplicate routing template id '{id}'.", $"{basePath}.id")); continue; } var authorityRouteId = NormalizeOptionalString(model.AuthorityRouteId); if (string.IsNullOrEmpty(authorityRouteId)) { issues.Add(PolicyIssue.Error( "policy.exceptions.routing.authority.missing", "Routing template must specify authorityRouteId.", $"{basePath}.authorityRouteId")); continue; } var description = NormalizeOptionalString(model.Description); var requireMfa = model.RequireMfa ?? false; if (model.Extensions is { Count: > 0 }) { foreach (var pair in model.Extensions) { issues.Add(PolicyIssue.Warning( "policy.exceptions.routing.extension", $"Unrecognized routing template property '{pair.Key}' has been ignored.", $"{basePath}.{pair.Key}")); } } builder.Add(new PolicyExceptionRoutingTemplate( id, authorityRouteId, requireMfa, description)); } return builder.ToImmutable(); } private static PolicyExceptionEffectType? NormalizeExceptionEffectType( string? value, string path, ImmutableArray.Builder issues) { var normalized = NormalizeOptionalString(value); if (string.IsNullOrEmpty(normalized)) { issues.Add(PolicyIssue.Error( "policy.exceptions.effect.type.missing", "Exception effect type is required.", path)); return null; } switch (normalized.ToLowerInvariant()) { case "suppress": return PolicyExceptionEffectType.Suppress; case "defer": return PolicyExceptionEffectType.Defer; case "downgrade": return PolicyExceptionEffectType.Downgrade; case "requirecontrol": return PolicyExceptionEffectType.RequireControl; default: issues.Add(PolicyIssue.Error( "policy.exceptions.effect.type.invalid", $"Unsupported exception effect type '{normalized}'.", path)); return null; } } private static PolicyRule? NormalizeRule( PolicyRuleModel model, int index, ImmutableArray.Builder issues) { var basePath = $"$.rules[{index}]"; var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues); if (name is null) { return null; } var identifier = NormalizeOptionalString(model.Identifier); var description = NormalizeOptionalString(model.Description); var metadata = NormalizeMetadata(model.Metadata, $"{basePath}.metadata", issues); var severities = NormalizeSeverityList(model.Severity, $"{basePath}.severity", issues); var environments = NormalizeStringList(model.Environments, $"{basePath}.environments", issues); var sources = NormalizeStringList(model.Sources, $"{basePath}.sources", issues); var vendors = NormalizeStringList(model.Vendors, $"{basePath}.vendors", issues); var licenses = NormalizeStringList(model.Licenses, $"{basePath}.licenses", issues); var tags = NormalizeStringList(model.Tags, $"{basePath}.tags", issues); var match = new PolicyRuleMatchCriteria( NormalizeStringList(model.Images, $"{basePath}.images", issues), NormalizeStringList(model.Repositories, $"{basePath}.repositories", issues), NormalizeStringList(model.Packages, $"{basePath}.packages", issues), NormalizeStringList(model.Purls, $"{basePath}.purls", issues), NormalizeStringList(model.Cves, $"{basePath}.cves", issues), NormalizeStringList(model.Paths, $"{basePath}.paths", issues), NormalizeStringList(model.LayerDigests, $"{basePath}.layerDigests", issues), NormalizeStringList(model.UsedByEntrypoint, $"{basePath}.usedByEntrypoint", issues)); var action = NormalizeAction(model, basePath, issues); var justification = NormalizeOptionalString(model.Justification); var expires = NormalizeTemporal(model.Expires ?? model.Until, $"{basePath}.expires", issues); if (model.Extensions is { Count: > 0 }) { foreach (var pair in model.Extensions) { issues.Add(PolicyIssue.Warning( "policy.rule.extension", $"Unrecognized rule property '{pair.Key}' has been ignored.", $"{basePath}.{pair.Key}")); } } return PolicyRule.Create( name, action, severities, environments, sources, vendors, licenses, tags, match, expires, justification, identifier, description, metadata); } private static PolicyAction NormalizeAction( PolicyRuleModel model, string basePath, ImmutableArray.Builder issues) { var actionNode = model.Action; var quiet = model.Quiet ?? false; if (!quiet && model.Extensions is not null && model.Extensions.TryGetValue("quiet", out var quietExtension) && quietExtension.ValueKind == JsonValueKind.True) { quiet = true; } string? justification = NormalizeOptionalString(model.Justification); DateTimeOffset? until = NormalizeTemporal(model.Until, $"{basePath}.until", issues); DateTimeOffset? expires = NormalizeTemporal(model.Expires, $"{basePath}.expires", issues); var effectiveUntil = until ?? expires; if (actionNode is null) { issues.Add(PolicyIssue.Error("policy.action.missing", "Rule action is required.", $"{basePath}.action")); return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: false); } string? actionType = null; JsonObject? actionObject = null; switch (actionNode) { case JsonValue value when value.TryGetValue(out string? text): actionType = text; break; case JsonValue value when value.TryGetValue(out bool booleanValue): actionType = booleanValue ? "block" : "ignore"; break; case JsonObject obj: actionObject = obj; if (obj.TryGetPropertyValue("type", out var typeNode) && typeNode is JsonValue typeValue && typeValue.TryGetValue(out string? typeText)) { actionType = typeText; } else { issues.Add(PolicyIssue.Error("policy.action.type", "Action object must contain a 'type' property.", $"{basePath}.action.type")); } if (obj.TryGetPropertyValue("quiet", out var quietNode) && quietNode is JsonValue quietValue && quietValue.TryGetValue(out bool quietFlag)) { quiet = quietFlag; } if (obj.TryGetPropertyValue("until", out var untilNode)) { effectiveUntil ??= NormalizeTemporal(untilNode, $"{basePath}.action.until", issues); } if (obj.TryGetPropertyValue("justification", out var justificationNode) && justificationNode is JsonValue justificationValue && justificationValue.TryGetValue(out string? justificationText)) { justification = NormalizeOptionalString(justificationText); } break; default: actionType = actionNode.ToString(); break; } if (string.IsNullOrWhiteSpace(actionType)) { issues.Add(PolicyIssue.Error("policy.action.type", "Action type is required.", $"{basePath}.action")); return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: quiet); } actionType = actionType.Trim(); var (type, typeIssues) = MapActionType(actionType, $"{basePath}.action"); foreach (var issue in typeIssues) { issues.Add(issue); } PolicyIgnoreOptions? ignoreOptions = null; PolicyEscalateOptions? escalateOptions = null; PolicyRequireVexOptions? requireVexOptions = null; if (type == PolicyActionType.Ignore) { ignoreOptions = new PolicyIgnoreOptions(effectiveUntil, justification); } else if (type == PolicyActionType.Escalate) { escalateOptions = NormalizeEscalateOptions(actionObject, $"{basePath}.action", issues); } else if (type == PolicyActionType.RequireVex) { requireVexOptions = NormalizeRequireVexOptions(actionObject, $"{basePath}.action", issues); } return new PolicyAction(type, ignoreOptions, escalateOptions, requireVexOptions, quiet); } private static (PolicyActionType Type, ImmutableArray Issues) MapActionType(string value, string path) { var issues = ImmutableArray.Empty; var lower = value.ToLowerInvariant(); return lower switch { "block" or "fail" or "deny" => (PolicyActionType.Block, issues), "ignore" or "mute" => (PolicyActionType.Ignore, issues), "warn" or "warning" => (PolicyActionType.Warn, issues), "defer" => (PolicyActionType.Defer, issues), "escalate" => (PolicyActionType.Escalate, issues), "requirevex" or "require_vex" or "require-vex" => (PolicyActionType.RequireVex, issues), _ => (PolicyActionType.Block, ImmutableArray.Create(PolicyIssue.Warning( "policy.action.unknown", $"Unknown action '{value}' encountered. Defaulting to 'block'.", path))), }; } private static PolicyEscalateOptions? NormalizeEscalateOptions( JsonObject? actionObject, string path, ImmutableArray.Builder issues) { if (actionObject is null) { return null; } PolicySeverity? minSeverity = null; bool requireKev = false; double? minEpss = null; if (actionObject.TryGetPropertyValue("severity", out var severityNode) && severityNode is JsonValue severityValue && severityValue.TryGetValue(out string? severityText)) { if (SeverityMap.TryGetValue(severityText ?? string.Empty, out var mapped)) { minSeverity = mapped; } else { issues.Add(PolicyIssue.Warning("policy.action.escalate.severity", $"Unknown escalate severity '{severityText}'.", $"{path}.severity")); } } if (actionObject.TryGetPropertyValue("kev", out var kevNode) && kevNode is JsonValue kevValue && kevValue.TryGetValue(out bool kevFlag)) { requireKev = kevFlag; } if (actionObject.TryGetPropertyValue("epss", out var epssNode)) { var parsed = ParseDouble(epssNode, $"{path}.epss", issues); if (parsed is { } epssValue) { if (epssValue < 0 || epssValue > 1) { issues.Add(PolicyIssue.Warning("policy.action.escalate.epssRange", "EPS score must be between 0 and 1.", $"{path}.epss")); } else { minEpss = epssValue; } } } return new PolicyEscalateOptions(minSeverity, requireKev, minEpss); } private static PolicyRequireVexOptions? NormalizeRequireVexOptions( JsonObject? actionObject, string path, ImmutableArray.Builder issues) { if (actionObject is null) { return null; } var vendors = ImmutableArray.Empty; var justifications = ImmutableArray.Empty; if (actionObject.TryGetPropertyValue("vendors", out var vendorsNode)) { vendors = NormalizeJsonStringArray(vendorsNode, $"{path}.vendors", issues); } if (actionObject.TryGetPropertyValue("justifications", out var justificationsNode)) { justifications = NormalizeJsonStringArray(justificationsNode, $"{path}.justifications", issues); } return new PolicyRequireVexOptions(vendors, justifications); } private static ImmutableArray NormalizeStringList( List? values, string path, ImmutableArray.Builder issues) { if (values is null || values.Count == 0) { return ImmutableArray.Empty; } var builder = ImmutableHashSet.CreateBuilder(StringComparer.OrdinalIgnoreCase); foreach (var value in values) { var normalized = NormalizeOptionalString(value); if (string.IsNullOrEmpty(normalized)) { issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path)); continue; } builder.Add(normalized); } return builder.ToImmutable() .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } private static ImmutableArray NormalizeSeverityList( List? values, string path, ImmutableArray.Builder issues) { if (values is null || values.Count == 0) { return ImmutableArray.Empty; } var builder = ImmutableArray.CreateBuilder(); foreach (var value in values) { var normalized = NormalizeOptionalString(value); if (string.IsNullOrEmpty(normalized)) { issues.Add(PolicyIssue.Warning("policy.severity.blank", "Blank severity was ignored.", path)); continue; } if (SeverityMap.TryGetValue(normalized, out var severity)) { builder.Add(severity); } else { issues.Add(PolicyIssue.Error("policy.severity.invalid", $"Unknown severity '{value}'.", path)); } } return builder.Distinct().OrderBy(static sev => sev).ToImmutableArray(); } private static ImmutableArray NormalizeJsonStringArray( JsonNode? node, string path, ImmutableArray.Builder issues) { if (node is null) { return ImmutableArray.Empty; } if (node is JsonArray array) { var values = new List(array.Count); foreach (var element in array) { var text = ConvertNodeToString(element); if (string.IsNullOrWhiteSpace(text)) { issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path)); } else { values.Add(text); } } return values .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase) .ToImmutableArray(); } var single = ConvertNodeToString(node); return ImmutableArray.Create(single); } private static double? ParseDouble(JsonNode? node, string path, ImmutableArray.Builder issues) { if (node is null) { return null; } if (node is JsonValue value) { if (value.TryGetValue(out double numeric)) { return numeric; } if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out numeric)) { return numeric; } } issues.Add(PolicyIssue.Warning("policy.number.invalid", $"Value '{node.ToJsonString()}' is not a valid number.", path)); return null; } private static DateTimeOffset? NormalizeTemporal(JsonNode? node, string path, ImmutableArray.Builder issues) { if (node is null) { return null; } if (node is JsonValue value) { if (value.TryGetValue(out DateTimeOffset dto)) { return dto; } if (value.TryGetValue(out DateTime dt)) { return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc)); } if (value.TryGetValue(out string? text)) { if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) { return parsed; } if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedDate)) { return new DateTimeOffset(parsedDate); } } } issues.Add(PolicyIssue.Warning("policy.date.invalid", $"Value '{node.ToJsonString()}' is not a valid ISO-8601 timestamp.", path)); return null; } private static string? NormalizeRequiredString( string? value, string path, string fieldDescription, ImmutableArray.Builder issues) { var normalized = NormalizeOptionalString(value); if (!string.IsNullOrEmpty(normalized)) { return normalized; } issues.Add(PolicyIssue.Error( "policy.required", $"{fieldDescription} is required.", path)); return null; } private static string? NormalizeOptionalString(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return value.Trim(); } private static string ConvertNodeToString(JsonNode? node) { if (node is null) { return string.Empty; } return node switch { JsonValue value when value.TryGetValue(out string? text) => text ?? string.Empty, JsonValue value when value.TryGetValue(out bool boolean) => boolean ? "true" : "false", JsonValue value when value.TryGetValue(out double numeric) => numeric.ToString(CultureInfo.InvariantCulture), JsonObject obj => obj.ToJsonString(), JsonArray array => array.ToJsonString(), _ => node.ToJsonString(), }; } private static ImmutableArray SortIssues(ImmutableArray.Builder issues) { return issues.ToImmutable() .OrderBy(static issue => issue.Severity switch { PolicyIssueSeverity.Error => 0, PolicyIssueSeverity.Warning => 1, _ => 2, }) .ThenBy(static issue => issue.Path, StringComparer.Ordinal) .ThenBy(static issue => issue.Code, StringComparer.Ordinal) .ToImmutableArray(); } } }