1235 lines
48 KiB
C#
1235 lines
48 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; }
|
|
|
|
[JsonPropertyName("exceptions")]
|
|
public PolicyExceptionsModel? Exceptions { 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 record PolicyExceptionsModel
|
|
{
|
|
[JsonPropertyName("effects")]
|
|
public List<PolicyExceptionEffectModel>? Effects { get; init; }
|
|
|
|
[JsonPropertyName("routingTemplates")]
|
|
public List<PolicyExceptionRoutingTemplateModel>? RoutingTemplates { get; init; }
|
|
|
|
[JsonExtensionData]
|
|
public Dictionary<string, JsonElement>? 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<string, JsonElement>? 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<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);
|
|
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<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 PolicyExceptionConfiguration NormalizeExceptions(
|
|
PolicyExceptionsModel? model,
|
|
ImmutableArray<PolicyIssue>.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<PolicyExceptionEffect> NormalizeExceptionEffects(
|
|
List<PolicyExceptionEffectModel>? models,
|
|
string path,
|
|
ImmutableArray<PolicyIssue>.Builder issues)
|
|
{
|
|
if (models is null || models.Count == 0)
|
|
{
|
|
return ImmutableArray<PolicyExceptionEffect>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<PolicyExceptionEffect>();
|
|
var seenIds = new HashSet<string>(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<PolicyExceptionRoutingTemplate> NormalizeExceptionRoutingTemplates(
|
|
List<PolicyExceptionRoutingTemplateModel>? models,
|
|
string path,
|
|
ImmutableArray<PolicyIssue>.Builder issues)
|
|
{
|
|
if (models is null || models.Count == 0)
|
|
{
|
|
return ImmutableArray<PolicyExceptionRoutingTemplate>.Empty;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<PolicyExceptionRoutingTemplate>();
|
|
var seenIds = new HashSet<string>(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<PolicyIssue>.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<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();
|
|
}
|
|
}
|
|
}
|