up
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / build-test (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / authority-container (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / docs (push) Has been cancelled
				
			
		
			
				
	
				Build Test Deploy / deploy (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			Build Test Deploy / build-test (push) Has been cancelled
				
			Build Test Deploy / authority-container (push) Has been cancelled
				
			Build Test Deploy / docs (push) Has been cancelled
				
			Build Test Deploy / deploy (push) Has been cancelled
				
			This commit is contained in:
		
							
								
								
									
										913
									
								
								src/StellaOps.Policy/PolicyBinder.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										913
									
								
								src/StellaOps.Policy/PolicyBinder.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,913 @@ | ||||
| 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: | ||||
|                 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(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user