916 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			916 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| 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<PolicyIssue> 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<PolicyDocumentModel>(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<object?>(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<string, JsonNode?>? Metadata { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("rules")]
 | |
|         public List<PolicyRuleModel>? Rules { get; init; }
 | |
| 
 | |
|         [JsonExtensionData]
 | |
|         public Dictionary<string, JsonElement>? 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<string>? Severity { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("sources")]
 | |
|         public List<string>? Sources { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("vendors")]
 | |
|         public List<string>? Vendors { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("licenses")]
 | |
|         public List<string>? Licenses { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("tags")]
 | |
|         public List<string>? Tags { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("environments")]
 | |
|         public List<string>? Environments { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("images")]
 | |
|         public List<string>? Images { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("repositories")]
 | |
|         public List<string>? Repositories { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("packages")]
 | |
|         public List<string>? Packages { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("purls")]
 | |
|         public List<string>? Purls { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("cves")]
 | |
|         public List<string>? Cves { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("paths")]
 | |
|         public List<string>? Paths { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("layerDigests")]
 | |
|         public List<string>? LayerDigests { get; init; }
 | |
| 
 | |
|         [JsonPropertyName("usedByEntrypoint")]
 | |
|         public List<string>? 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<string, JsonNode?>? Metadata { get; init; }
 | |
| 
 | |
|         [JsonExtensionData]
 | |
|         public Dictionary<string, JsonElement>? Extensions { get; init; }
 | |
|     }
 | |
| 
 | |
|     private sealed class PolicyNormalizer
 | |
|     {
 | |
|         private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
 | |
|             new Dictionary<string, PolicySeverity>(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<PolicyIssue> Issues) Normalize(PolicyDocumentModel model)
 | |
|         {
 | |
|             var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
 | |
| 
 | |
|             var version = NormalizeVersion(model.Version, issues);
 | |
|             var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
 | |
|             var rules = NormalizeRules(model.Rules, 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);
 | |
| 
 | |
|             var orderedIssues = SortIssues(issues);
 | |
|             return (document, orderedIssues);
 | |
|         }
 | |
| 
 | |
|         private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.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<string, string> NormalizeMetadata(
 | |
|             Dictionary<string, JsonNode?>? metadata,
 | |
|             string path,
 | |
|             ImmutableArray<PolicyIssue>.Builder issues)
 | |
|         {
 | |
|             if (metadata is null || metadata.Count == 0)
 | |
|             {
 | |
|                 return ImmutableDictionary<string, string>.Empty;
 | |
|             }
 | |
| 
 | |
|             var builder = ImmutableDictionary.CreateBuilder<string, string>(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<PolicyRule> NormalizeRules(
 | |
|             List<PolicyRuleModel>? rules,
 | |
|             ImmutableArray<PolicyIssue>.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<PolicyRule>.Empty;
 | |
|             }
 | |
| 
 | |
|             var normalized = new List<(PolicyRule Rule, int Index)>(rules.Count);
 | |
|             var seenNames = new HashSet<string>(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 PolicyRule? NormalizeRule(
 | |
|             PolicyRuleModel model,
 | |
|             int index,
 | |
|             ImmutableArray<PolicyIssue>.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<PolicyIssue>.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<PolicyIssue> Issues) MapActionType(string value, string path)
 | |
|         {
 | |
|             var issues = ImmutableArray<PolicyIssue>.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<PolicyIssue>.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<PolicyIssue>.Builder issues)
 | |
|         {
 | |
|             if (actionObject is null)
 | |
|             {
 | |
|                 return null;
 | |
|             }
 | |
| 
 | |
|             var vendors = ImmutableArray<string>.Empty;
 | |
|             var justifications = ImmutableArray<string>.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<string> NormalizeStringList(
 | |
|             List<string>? values,
 | |
|             string path,
 | |
|             ImmutableArray<PolicyIssue>.Builder issues)
 | |
|         {
 | |
|             if (values is null || values.Count == 0)
 | |
|             {
 | |
|                 return ImmutableArray<string>.Empty;
 | |
|             }
 | |
| 
 | |
|             var builder = ImmutableHashSet.CreateBuilder<string>(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<PolicySeverity> NormalizeSeverityList(
 | |
|             List<string>? values,
 | |
|             string path,
 | |
|             ImmutableArray<PolicyIssue>.Builder issues)
 | |
|         {
 | |
|             if (values is null || values.Count == 0)
 | |
|             {
 | |
|                 return ImmutableArray<PolicySeverity>.Empty;
 | |
|             }
 | |
| 
 | |
|             var builder = ImmutableArray.CreateBuilder<PolicySeverity>();
 | |
|             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<string> NormalizeJsonStringArray(
 | |
|             JsonNode? node,
 | |
|             string path,
 | |
|             ImmutableArray<PolicyIssue>.Builder issues)
 | |
|         {
 | |
|             if (node is null)
 | |
|             {
 | |
|                 return ImmutableArray<string>.Empty;
 | |
|             }
 | |
| 
 | |
|             if (node is JsonArray array)
 | |
|             {
 | |
|                 var values = new List<string>(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<PolicyIssue>.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<PolicyIssue>.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<PolicyIssue>.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<PolicyIssue> SortIssues(ImmutableArray<PolicyIssue>.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();
 | |
|         }
 | |
|     }
 | |
| }
 |