This commit is contained in:
master
2025-10-19 10:38:55 +03:00
parent 8dc7273e27
commit aef7ffb535
250 changed files with 17967 additions and 66 deletions

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicyAuditRepository
{
Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicyAuditRepository : IPolicyAuditRepository
{
private readonly List<PolicyAuditEntry> _entries = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicyAuditEntry entry, CancellationToken cancellationToken = default)
{
if (entry is null)
{
throw new ArgumentNullException(nameof(entry));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_entries.Add(entry);
_entries.Sort(static (left, right) => left.CreatedAt.CompareTo(right.CreatedAt));
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicyAuditEntry>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicyAuditEntry> query = _entries;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -0,0 +1,12 @@
using System;
namespace StellaOps.Policy;
public sealed record PolicyAuditEntry(
Guid Id,
DateTimeOffset CreatedAt,
string Action,
string RevisionId,
string Digest,
string? Actor,
string Message);

View 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();
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Policy;
public sealed record PolicyDiagnosticsReport(
string Version,
int RuleCount,
int ErrorCount,
int WarningCount,
DateTimeOffset GeneratedAt,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<string> Recommendations);
public static class PolicyDiagnostics
{
public static PolicyDiagnosticsReport Create(PolicyBindingResult bindingResult, TimeProvider? timeProvider = null)
{
if (bindingResult is null)
{
throw new ArgumentNullException(nameof(bindingResult));
}
var time = (timeProvider ?? TimeProvider.System).GetUtcNow();
var errorCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Error);
var warningCount = bindingResult.Issues.Count(static issue => issue.Severity == PolicyIssueSeverity.Warning);
var recommendations = BuildRecommendations(bindingResult.Document, errorCount, warningCount);
return new PolicyDiagnosticsReport(
bindingResult.Document.Version,
bindingResult.Document.Rules.Length,
errorCount,
warningCount,
time,
bindingResult.Issues,
recommendations);
}
private static ImmutableArray<string> BuildRecommendations(PolicyDocument document, int errorCount, int warningCount)
{
var messages = ImmutableArray.CreateBuilder<string>();
if (errorCount > 0)
{
messages.Add("Resolve policy errors before promoting the revision; fallback rules may be applied while errors remain.");
}
if (warningCount > 0)
{
messages.Add("Review policy warnings and ensure intentional overrides are documented.");
}
if (document.Rules.Length == 0)
{
messages.Add("Add at least one policy rule to enforce gating logic.");
}
var quietRules = document.Rules
.Where(static rule => rule.Action.Quiet)
.Select(static rule => rule.Name)
.ToArray();
if (quietRules.Length > 0)
{
messages.Add($"Quiet rules detected ({string.Join(", ", quietRules)}); verify scoring behaviour aligns with expectations.");
}
if (messages.Count == 0)
{
messages.Add("Policy validated successfully; no additional action required.");
}
return messages.ToImmutable();
}
}

View File

@@ -0,0 +1,211 @@
using System;
using System.Buffers;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Policy;
public static class PolicyDigest
{
public static string Compute(PolicyDocument document)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
SkipValidation = true,
}))
{
WriteDocument(writer, document);
}
var hash = SHA256.HashData(buffer.WrittenSpan);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyDocument document)
{
writer.WriteStartObject();
writer.WriteString("version", document.Version);
if (!document.Metadata.IsEmpty)
{
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in document.Metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
if (!string.IsNullOrWhiteSpace(rule.Identifier))
{
writer.WriteString("id", rule.Identifier);
}
if (!string.IsNullOrWhiteSpace(rule.Description))
{
writer.WriteString("description", rule.Description);
}
WriteMetadata(writer, rule.Metadata);
WriteSeverities(writer, rule.Severities);
WriteStringArray(writer, "environments", rule.Environments);
WriteStringArray(writer, "sources", rule.Sources);
WriteStringArray(writer, "vendors", rule.Vendors);
WriteStringArray(writer, "licenses", rule.Licenses);
WriteStringArray(writer, "tags", rule.Tags);
if (!rule.Match.IsEmpty)
{
writer.WritePropertyName("match");
writer.WriteStartObject();
WriteStringArray(writer, "images", rule.Match.Images);
WriteStringArray(writer, "repositories", rule.Match.Repositories);
WriteStringArray(writer, "packages", rule.Match.Packages);
WriteStringArray(writer, "purls", rule.Match.Purls);
WriteStringArray(writer, "cves", rule.Match.Cves);
WriteStringArray(writer, "paths", rule.Match.Paths);
WriteStringArray(writer, "layerDigests", rule.Match.LayerDigests);
WriteStringArray(writer, "usedByEntrypoint", rule.Match.UsedByEntrypoint);
writer.WriteEndObject();
}
WriteAction(writer, rule.Action);
if (rule.Expires is DateTimeOffset expires)
{
writer.WriteString("expires", expires.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(rule.Justification))
{
writer.WriteString("justification", rule.Justification);
}
writer.WriteEndObject();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyAction action)
{
writer.WritePropertyName("action");
writer.WriteStartObject();
writer.WriteString("type", action.Type.ToString().ToLowerInvariant());
if (action.Quiet)
{
writer.WriteBoolean("quiet", true);
}
if (action.Ignore is { } ignore)
{
if (ignore.Until is DateTimeOffset until)
{
writer.WriteString("until", until.ToUniversalTime().ToString("O"));
}
if (!string.IsNullOrWhiteSpace(ignore.Justification))
{
writer.WriteString("justification", ignore.Justification);
}
}
if (action.Escalate is { } escalate)
{
if (escalate.MinimumSeverity is { } severity)
{
writer.WriteString("severity", severity.ToString());
}
if (escalate.RequireKev)
{
writer.WriteBoolean("kev", true);
}
if (escalate.MinimumEpss is double epss)
{
writer.WriteNumber("epss", epss);
}
}
if (action.RequireVex is { } requireVex)
{
WriteStringArray(writer, "vendors", requireVex.Vendors);
WriteStringArray(writer, "justifications", requireVex.Justifications);
}
writer.WriteEndObject();
}
private static void WriteMetadata(Utf8JsonWriter writer, ImmutableDictionary<string, string> metadata)
{
if (metadata.IsEmpty)
{
return;
}
writer.WritePropertyName("metadata");
writer.WriteStartObject();
foreach (var pair in metadata.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
writer.WriteString(pair.Key, pair.Value);
}
writer.WriteEndObject();
}
private static void WriteSeverities(Utf8JsonWriter writer, ImmutableArray<PolicySeverity> severities)
{
if (severities.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName("severity");
writer.WriteStartArray();
foreach (var severity in severities)
{
writer.WriteStringValue(severity.ToString());
}
writer.WriteEndArray();
}
private static void WriteStringArray(Utf8JsonWriter writer, string propertyName, ImmutableArray<string> values)
{
if (values.IsDefaultOrEmpty)
{
return;
}
writer.WritePropertyName(propertyName);
writer.WriteStartArray();
foreach (var value in values)
{
writer.WriteStringValue(value);
}
writer.WriteEndArray();
}
}

View File

@@ -0,0 +1,192 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
/// <summary>
/// Canonical representation of a StellaOps policy document.
/// </summary>
public sealed record PolicyDocument(
string Version,
ImmutableArray<PolicyRule> Rules,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyDocument Empty { get; } = new(
PolicySchema.CurrentVersion,
ImmutableArray<PolicyRule>.Empty,
ImmutableDictionary<string, string>.Empty);
}
public static class PolicySchema
{
public const string SchemaId = "https://schemas.stella-ops.org/policy/policy-schema@1.json";
public const string CurrentVersion = "1.0";
public static PolicyDocumentFormat DetectFormat(string fileName)
{
if (fileName is null)
{
throw new ArgumentNullException(nameof(fileName));
}
var lower = fileName.Trim().ToLowerInvariant();
if (lower.EndsWith(".yaml", StringComparison.Ordinal) || lower.EndsWith(".yml", StringComparison.Ordinal))
{
return PolicyDocumentFormat.Yaml;
}
return PolicyDocumentFormat.Json;
}
}
public sealed record PolicyRule(
string Name,
string? Identifier,
string? Description,
PolicyAction Action,
ImmutableArray<PolicySeverity> Severities,
ImmutableArray<string> Environments,
ImmutableArray<string> Sources,
ImmutableArray<string> Vendors,
ImmutableArray<string> Licenses,
ImmutableArray<string> Tags,
PolicyRuleMatchCriteria Match,
DateTimeOffset? Expires,
string? Justification,
ImmutableDictionary<string, string> Metadata)
{
public static PolicyRuleMatchCriteria EmptyMatch { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public static PolicyRule Create(
string name,
PolicyAction action,
ImmutableArray<PolicySeverity> severities,
ImmutableArray<string> environments,
ImmutableArray<string> sources,
ImmutableArray<string> vendors,
ImmutableArray<string> licenses,
ImmutableArray<string> tags,
PolicyRuleMatchCriteria match,
DateTimeOffset? expires,
string? justification,
string? identifier = null,
string? description = null,
ImmutableDictionary<string, string>? metadata = null)
{
metadata ??= ImmutableDictionary<string, string>.Empty;
return new PolicyRule(
name,
identifier,
description,
action,
severities,
environments,
sources,
vendors,
licenses,
tags,
match,
expires,
justification,
metadata);
}
public bool MatchesAnyEnvironment => Environments.IsDefaultOrEmpty;
}
public sealed record PolicyRuleMatchCriteria(
ImmutableArray<string> Images,
ImmutableArray<string> Repositories,
ImmutableArray<string> Packages,
ImmutableArray<string> Purls,
ImmutableArray<string> Cves,
ImmutableArray<string> Paths,
ImmutableArray<string> LayerDigests,
ImmutableArray<string> UsedByEntrypoint)
{
public static PolicyRuleMatchCriteria Create(
ImmutableArray<string> images,
ImmutableArray<string> repositories,
ImmutableArray<string> packages,
ImmutableArray<string> purls,
ImmutableArray<string> cves,
ImmutableArray<string> paths,
ImmutableArray<string> layerDigests,
ImmutableArray<string> usedByEntrypoint)
=> new(
images,
repositories,
packages,
purls,
cves,
paths,
layerDigests,
usedByEntrypoint);
public static PolicyRuleMatchCriteria Empty { get; } = new(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty);
public bool IsEmpty =>
Images.IsDefaultOrEmpty &&
Repositories.IsDefaultOrEmpty &&
Packages.IsDefaultOrEmpty &&
Purls.IsDefaultOrEmpty &&
Cves.IsDefaultOrEmpty &&
Paths.IsDefaultOrEmpty &&
LayerDigests.IsDefaultOrEmpty &&
UsedByEntrypoint.IsDefaultOrEmpty;
}
public sealed record PolicyAction(
PolicyActionType Type,
PolicyIgnoreOptions? Ignore,
PolicyEscalateOptions? Escalate,
PolicyRequireVexOptions? RequireVex,
bool Quiet);
public enum PolicyActionType
{
Block,
Ignore,
Warn,
Defer,
Escalate,
RequireVex,
}
public sealed record PolicyIgnoreOptions(DateTimeOffset? Until, string? Justification);
public sealed record PolicyEscalateOptions(
PolicySeverity? MinimumSeverity,
bool RequireKev,
double? MinimumEpss);
public sealed record PolicyRequireVexOptions(
ImmutableArray<string> Vendors,
ImmutableArray<string> Justifications);
public enum PolicySeverity
{
Critical,
High,
Medium,
Low,
Informational,
None,
Unknown,
}

View File

@@ -0,0 +1,270 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public static class PolicyEvaluation
{
public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
if (scoringConfig is null)
{
throw new ArgumentNullException(nameof(scoringConfig));
}
if (finding is null)
{
throw new ArgumentNullException(nameof(finding));
}
var severityWeight = scoringConfig.SeverityWeights.TryGetValue(finding.Severity, out var weight)
? weight
: scoringConfig.SeverityWeights.GetValueOrDefault(PolicySeverity.Unknown, 0);
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
return BuildVerdict(rule, finding, scoringConfig, severityWeight);
}
return PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
}
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
double severityWeight)
{
var action = rule.Action;
var status = MapAction(action);
var notes = BuildNotes(action);
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
inputs["severityWeight"] = severityWeight;
double score = severityWeight;
string? quietedBy = null;
var quiet = false;
switch (status)
{
case PolicyVerdictStatus.Ignored:
score = Math.Max(0, severityWeight - config.IgnorePenalty);
inputs["ignorePenalty"] = config.IgnorePenalty;
break;
case PolicyVerdictStatus.Warned:
score = Math.Max(0, severityWeight - config.WarnPenalty);
inputs["warnPenalty"] = config.WarnPenalty;
break;
case PolicyVerdictStatus.Deferred:
score = Math.Max(0, severityWeight - (config.WarnPenalty / 2));
inputs["deferPenalty"] = config.WarnPenalty / 2;
break;
}
if (action.Quiet)
{
var quietAllowed = action.RequireVex is not null || action.Type == PolicyActionType.RequireVex;
if (quietAllowed)
{
score = Math.Max(0, score - config.QuietPenalty);
inputs["quietPenalty"] = config.QuietPenalty;
quietedBy = rule.Name;
quiet = true;
}
else
{
inputs.Remove("ignorePenalty");
var warnScore = Math.Max(0, severityWeight - config.WarnPenalty);
inputs["warnPenalty"] = config.WarnPenalty;
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
return new PolicyVerdict(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
action.Type.ToString(),
warnNotes,
warnScore,
config.Version,
inputs.ToImmutable(),
QuietedBy: null,
Quiet: false);
}
}
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
score,
config.Version,
inputs.ToImmutable(),
quietedBy,
quiet);
}
private static bool RuleMatches(PolicyRule rule, PolicyFinding finding)
{
if (!rule.Severities.IsDefaultOrEmpty && !rule.Severities.Contains(finding.Severity))
{
return false;
}
if (!Matches(rule.Environments, finding.Environment))
{
return false;
}
if (!Matches(rule.Sources, finding.Source))
{
return false;
}
if (!Matches(rule.Vendors, finding.Vendor))
{
return false;
}
if (!Matches(rule.Licenses, finding.License))
{
return false;
}
if (!RuleMatchCriteria(rule.Match, finding))
{
return false;
}
return true;
}
private static bool Matches(ImmutableArray<string> ruleValues, string? candidate)
{
if (ruleValues.IsDefaultOrEmpty)
{
return true;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
return ruleValues.Contains(candidate, StringComparer.OrdinalIgnoreCase);
}
private static bool RuleMatchCriteria(PolicyRuleMatchCriteria criteria, PolicyFinding finding)
{
if (!criteria.Images.IsDefaultOrEmpty && !ContainsValue(criteria.Images, finding.Image, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Repositories.IsDefaultOrEmpty && !ContainsValue(criteria.Repositories, finding.Repository, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Packages.IsDefaultOrEmpty && !ContainsValue(criteria.Packages, finding.Package, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Purls.IsDefaultOrEmpty && !ContainsValue(criteria.Purls, finding.Purl, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Cves.IsDefaultOrEmpty && !ContainsValue(criteria.Cves, finding.Cve, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.Paths.IsDefaultOrEmpty && !ContainsValue(criteria.Paths, finding.Path, StringComparer.Ordinal))
{
return false;
}
if (!criteria.LayerDigests.IsDefaultOrEmpty && !ContainsValue(criteria.LayerDigests, finding.LayerDigest, StringComparer.OrdinalIgnoreCase))
{
return false;
}
if (!criteria.UsedByEntrypoint.IsDefaultOrEmpty)
{
var match = false;
foreach (var tag in criteria.UsedByEntrypoint)
{
if (finding.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
match = true;
break;
}
}
if (!match)
{
return false;
}
}
return true;
}
private static bool ContainsValue(ImmutableArray<string> values, string? candidate, StringComparer comparer)
{
if (values.IsDefaultOrEmpty)
{
return true;
}
if (string.IsNullOrWhiteSpace(candidate))
{
return false;
}
return values.Contains(candidate, comparer);
}
private static PolicyVerdictStatus MapAction(PolicyAction action)
=> action.Type switch
{
PolicyActionType.Block => PolicyVerdictStatus.Blocked,
PolicyActionType.Ignore => PolicyVerdictStatus.Ignored,
PolicyActionType.Warn => PolicyVerdictStatus.Warned,
PolicyActionType.Defer => PolicyVerdictStatus.Deferred,
PolicyActionType.Escalate => PolicyVerdictStatus.Escalated,
PolicyActionType.RequireVex => PolicyVerdictStatus.RequiresVex,
_ => PolicyVerdictStatus.Pass,
};
private static string? BuildNotes(PolicyAction action)
{
if (action.Ignore is { } ignore && !string.IsNullOrWhiteSpace(ignore.Justification))
{
return ignore.Justification;
}
if (action.Escalate is { } escalate && escalate.MinimumSeverity is { } severity)
{
return $"Escalate >= {severity}";
}
return null;
}
private static string? AppendNote(string? existing, string addition)
=> string.IsNullOrWhiteSpace(existing) ? addition : string.Concat(existing, " | ", addition);
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyFinding(
string FindingId,
PolicySeverity Severity,
string? Environment,
string? Source,
string? Vendor,
string? License,
string? Image,
string? Repository,
string? Package,
string? Purl,
string? Cve,
string? Path,
string? LayerDigest,
ImmutableArray<string> Tags)
{
public static PolicyFinding Create(
string findingId,
PolicySeverity severity,
string? environment = null,
string? source = null,
string? vendor = null,
string? license = null,
string? image = null,
string? repository = null,
string? package = null,
string? purl = null,
string? cve = null,
string? path = null,
string? layerDigest = null,
ImmutableArray<string>? tags = null)
=> new(
findingId,
severity,
environment,
source,
vendor,
license,
image,
repository,
package,
purl,
cve,
path,
layerDigest,
tags ?? ImmutableArray<string>.Empty);
}

View File

@@ -0,0 +1,28 @@
using System;
namespace StellaOps.Policy;
/// <summary>
/// Represents a validation or normalization issue discovered while processing a policy document.
/// </summary>
public sealed record PolicyIssue(string Code, string Message, PolicyIssueSeverity Severity, string Path)
{
public static PolicyIssue Error(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Error, path);
public static PolicyIssue Warning(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Warning, path);
public static PolicyIssue Info(string code, string message, string path)
=> new(code, message, PolicyIssueSeverity.Info, path);
public PolicyIssue EnsurePath(string fallbackPath)
=> string.IsNullOrWhiteSpace(Path) ? this with { Path = fallbackPath } : this;
}
public enum PolicyIssueSeverity
{
Error,
Warning,
Info,
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyPreviewRequest(
string ImageDigest,
ImmutableArray<PolicyFinding> Findings,
ImmutableArray<PolicyVerdict> BaselineVerdicts,
PolicySnapshot? SnapshotOverride = null,
PolicySnapshotContent? ProposedPolicy = null);
public sealed record PolicyPreviewResponse(
bool Success,
string PolicyDigest,
string? RevisionId,
ImmutableArray<PolicyIssue> Issues,
ImmutableArray<PolicyVerdictDiff> Diffs,
int ChangedCount);

View File

@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicyPreviewService
{
private readonly PolicySnapshotStore _snapshotStore;
private readonly ILogger<PolicyPreviewService> _logger;
public PolicyPreviewService(PolicySnapshotStore snapshotStore, ILogger<PolicyPreviewService> logger)
{
_snapshotStore = snapshotStore ?? throw new ArgumentNullException(nameof(snapshotStore));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicyPreviewResponse> PreviewAsync(PolicyPreviewRequest request, CancellationToken cancellationToken = default)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var (snapshot, bindingIssues) = await ResolveSnapshotAsync(request, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
_logger.LogWarning("Policy preview failed: snapshot unavailable or validation errors. Issues={Count}", bindingIssues.Length);
return new PolicyPreviewResponse(false, string.Empty, null, bindingIssues, ImmutableArray<PolicyVerdictDiff>.Empty, 0);
}
var projected = Evaluate(snapshot.Document, snapshot.ScoringConfig, request.Findings);
var baseline = BuildBaseline(request.BaselineVerdicts, projected, snapshot.ScoringConfig);
var diffs = BuildDiffs(baseline, projected);
var changed = diffs.Count(static diff => diff.Changed);
_logger.LogDebug("Policy preview computed for {ImageDigest}. Changed={Changed}", request.ImageDigest, changed);
return new PolicyPreviewResponse(true, snapshot.Digest, snapshot.RevisionId, bindingIssues, diffs, changed);
}
private async Task<(PolicySnapshot? Snapshot, ImmutableArray<PolicyIssue> Issues)> ResolveSnapshotAsync(PolicyPreviewRequest request, CancellationToken cancellationToken)
{
if (request.ProposedPolicy is not null)
{
var binding = PolicyBinder.Bind(request.ProposedPolicy.Content, request.ProposedPolicy.Format);
if (!binding.Success)
{
return (null, binding.Issues);
}
var digest = PolicyDigest.Compute(binding.Document);
var snapshot = new PolicySnapshot(
request.SnapshotOverride?.RevisionNumber + 1 ?? 0,
request.SnapshotOverride?.RevisionId ?? "preview",
digest,
DateTimeOffset.UtcNow,
request.ProposedPolicy.Actor,
request.ProposedPolicy.Format,
binding.Document,
binding.Issues,
PolicyScoringConfig.Default);
return (snapshot, binding.Issues);
}
if (request.SnapshotOverride is not null)
{
return (request.SnapshotOverride, ImmutableArray<PolicyIssue>.Empty);
}
var latest = await _snapshotStore.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null)
{
return (latest, ImmutableArray<PolicyIssue>.Empty);
}
return (null, ImmutableArray.Create(PolicyIssue.Error("policy.preview.snapshot_missing", "No policy snapshot is available for preview.", "$")));
}
private static ImmutableArray<PolicyVerdict> Evaluate(PolicyDocument document, PolicyScoringConfig scoringConfig, ImmutableArray<PolicyFinding> findings)
{
if (findings.IsDefaultOrEmpty)
{
return ImmutableArray<PolicyVerdict>.Empty;
}
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
foreach (var finding in findings)
{
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding);
results.Add(verdict);
}
return results.ToImmutable();
}
private static ImmutableDictionary<string, PolicyVerdict> BuildBaseline(ImmutableArray<PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected, PolicyScoringConfig scoringConfig)
{
var builder = ImmutableDictionary.CreateBuilder<string, PolicyVerdict>(StringComparer.Ordinal);
if (!baseline.IsDefaultOrEmpty)
{
foreach (var verdict in baseline)
{
if (!string.IsNullOrEmpty(verdict.FindingId) && !builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, verdict);
}
}
}
foreach (var verdict in projected)
{
if (!builder.ContainsKey(verdict.FindingId))
{
builder.Add(verdict.FindingId, PolicyVerdict.CreateBaseline(verdict.FindingId, scoringConfig));
}
}
return builder.ToImmutable();
}
private static ImmutableArray<PolicyVerdictDiff> BuildDiffs(ImmutableDictionary<string, PolicyVerdict> baseline, ImmutableArray<PolicyVerdict> projected)
{
var diffs = ImmutableArray.CreateBuilder<PolicyVerdictDiff>(projected.Length);
foreach (var verdict in projected.OrderBy(static v => v.FindingId, StringComparer.Ordinal))
{
var baseVerdict = baseline.TryGetValue(verdict.FindingId, out var existing)
? existing
: new PolicyVerdict(verdict.FindingId, PolicyVerdictStatus.Pass);
diffs.Add(new PolicyVerdictDiff(baseVerdict, verdict));
}
return diffs.ToImmutable();
}
}

View File

@@ -0,0 +1,30 @@
using System;
using System.IO;
using System.Reflection;
using System.Text;
namespace StellaOps.Policy;
public static class PolicySchemaResource
{
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-schema@1.json";
public static Stream OpenSchemaStream()
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(SchemaResourceName);
if (stream is null)
{
throw new InvalidOperationException($"Unable to locate embedded schema resource '{SchemaResourceName}'.");
}
return stream;
}
public static string ReadSchemaJson()
{
using var stream = OpenSchemaStream();
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return reader.ReadToEnd();
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicyScoringConfig(
string Version,
ImmutableDictionary<PolicySeverity, double> SeverityWeights,
double QuietPenalty,
double WarnPenalty,
double IgnorePenalty,
ImmutableDictionary<string, double> TrustOverrides)
{
public static string BaselineVersion => "1.0";
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
}

View File

@@ -0,0 +1,266 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.Policy;
public sealed record PolicyScoringBindingResult(
bool Success,
PolicyScoringConfig? Config,
ImmutableArray<PolicyIssue> Issues);
public static class PolicyScoringConfigBinder
{
private const string DefaultResourceName = "StellaOps.Policy.Schemas.policy-scoring-default.json";
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
public static PolicyScoringConfig LoadDefault()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(DefaultResourceName)
?? throw new InvalidOperationException($"Embedded resource '{DefaultResourceName}' not found.");
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
var json = reader.ReadToEnd();
var binding = Bind(json, PolicyDocumentFormat.Json);
if (!binding.Success || binding.Config is null)
{
throw new InvalidOperationException("Failed to load default policy scoring configuration.");
}
return binding.Config;
}
public static PolicyScoringBindingResult Bind(string content, PolicyDocumentFormat format)
{
if (string.IsNullOrWhiteSpace(content))
{
var issue = PolicyIssue.Error("scoring.empty", "Scoring configuration content is empty.", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
try
{
var root = Parse(content, format);
if (root is not JsonObject obj)
{
var issue = PolicyIssue.Error("scoring.invalid", "Scoring configuration must be a JSON object.", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
var config = BuildConfig(obj, issues);
var hasErrors = issues.Any(issue => issue.Severity == PolicyIssueSeverity.Error);
return new PolicyScoringBindingResult(!hasErrors, config, issues.ToImmutable());
}
catch (JsonException ex)
{
var issue = PolicyIssue.Error("scoring.parse.json", $"Failed to parse scoring JSON: {ex.Message}", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
catch (YamlDotNet.Core.YamlException ex)
{
var issue = PolicyIssue.Error("scoring.parse.yaml", $"Failed to parse scoring YAML: {ex.Message}", "$");
return new PolicyScoringBindingResult(false, null, ImmutableArray.Create(issue));
}
}
private static JsonNode? Parse(string content, PolicyDocumentFormat format)
{
return format switch
{
PolicyDocumentFormat.Json => JsonNode.Parse(content, new JsonNodeOptions { PropertyNameCaseInsensitive = true }),
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported scoring configuration format."),
};
}
private static JsonNode? ConvertYamlToJsonNode(string content)
{
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
return PolicyBinderUtilities.ConvertYamlObject(yamlObject);
}
private static PolicyScoringConfig BuildConfig(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
var version = ReadString(obj, "version", issues, required: true) ?? PolicyScoringConfig.BaselineVersion;
var severityWeights = ReadSeverityWeights(obj, issues);
var quietPenalty = ReadDouble(obj, "quietPenalty", issues, defaultValue: 45);
var warnPenalty = ReadDouble(obj, "warnPenalty", issues, defaultValue: 15);
var ignorePenalty = ReadDouble(obj, "ignorePenalty", issues, defaultValue: 35);
var trustOverrides = ReadTrustOverrides(obj, issues);
return new PolicyScoringConfig(
version,
severityWeights,
quietPenalty,
warnPenalty,
ignorePenalty,
trustOverrides);
}
private static ImmutableDictionary<PolicySeverity, double> ReadSeverityWeights(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("severityWeights", out var node) || node is not JsonObject severityObj)
{
issues.Add(PolicyIssue.Error("scoring.severityWeights.missing", "severityWeights section is required.", "$.severityWeights"));
return ImmutableDictionary<PolicySeverity, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<PolicySeverity, double>();
foreach (var severity in Enum.GetValues<PolicySeverity>())
{
var key = severity.ToString();
if (!severityObj.TryGetPropertyValue(key, out var valueNode))
{
issues.Add(PolicyIssue.Warning("scoring.severityWeights.default", $"Severity '{key}' not specified; defaulting to 0.", $"$.severityWeights.{key}"));
builder[severity] = 0;
continue;
}
var value = ExtractDouble(valueNode, issues, $"$.severityWeights.{key}");
builder[severity] = value;
}
return builder.ToImmutable();
}
private static double ReadDouble(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, double defaultValue)
{
if (!obj.TryGetPropertyValue(property, out var node))
{
issues.Add(PolicyIssue.Warning("scoring.numeric.default", $"{property} not specified; defaulting to {defaultValue:0.##}.", $"$.{property}"));
return defaultValue;
}
return ExtractDouble(node, issues, $"$.{property}");
}
private static double ExtractDouble(JsonNode? node, ImmutableArray<PolicyIssue>.Builder issues, string path)
{
if (node is null)
{
issues.Add(PolicyIssue.Warning("scoring.numeric.null", $"Value at {path} missing; defaulting to 0.", path));
return 0;
}
if (node is JsonValue value)
{
if (value.TryGetValue(out double number))
{
return number;
}
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out number))
{
return number;
}
}
issues.Add(PolicyIssue.Error("scoring.numeric.invalid", $"Value at {path} is not numeric.", path));
return 0;
}
private static ImmutableDictionary<string, double> ReadTrustOverrides(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
{
if (!obj.TryGetPropertyValue("trustOverrides", out var node) || node is not JsonObject trustObj)
{
return ImmutableDictionary<string, double>.Empty;
}
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in trustObj)
{
var value = ExtractDouble(pair.Value, issues, $"$.trustOverrides.{pair.Key}");
builder[pair.Key] = value;
}
return builder.ToImmutable();
}
private static string? ReadString(JsonObject obj, string property, ImmutableArray<PolicyIssue>.Builder issues, bool required)
{
if (!obj.TryGetPropertyValue(property, out var node) || node is null)
{
if (required)
{
issues.Add(PolicyIssue.Error("scoring.string.missing", $"{property} is required.", $"$.{property}"));
}
return null;
}
if (node is JsonValue value && value.TryGetValue(out string? text))
{
return text?.Trim();
}
issues.Add(PolicyIssue.Error("scoring.string.invalid", $"{property} must be a string.", $"$.{property}"));
return null;
}
}
internal static class PolicyBinderUtilities
{
public static JsonNode? ConvertYamlObject(object? value)
{
switch (value)
{
case null:
return null;
case string s:
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal:
return JsonValue.Create(Convert.ToDouble(value, CultureInfo.InvariantCulture));
case IDictionary dictionary:
{
var obj = new JsonObject();
foreach (DictionaryEntry entry in dictionary)
{
if (entry.Key is null)
{
continue;
}
obj[entry.Key.ToString()!] = ConvertYamlObject(entry.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertYamlObject(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public sealed record PolicySnapshot(
long RevisionNumber,
string RevisionId,
string Digest,
DateTimeOffset CreatedAt,
string? CreatedBy,
PolicyDocumentFormat Format,
PolicyDocument Document,
ImmutableArray<PolicyIssue> Issues,
PolicyScoringConfig ScoringConfig);
public sealed record PolicySnapshotContent(
string Content,
PolicyDocumentFormat Format,
string? Actor,
string? Source,
string? Description);
public sealed record PolicySnapshotSaveResult(
bool Success,
bool Created,
string Digest,
PolicySnapshot? Snapshot,
PolicyBindingResult BindingResult);

View File

@@ -0,0 +1,101 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy;
public sealed class PolicySnapshotStore
{
private readonly IPolicySnapshotRepository _snapshotRepository;
private readonly IPolicyAuditRepository _auditRepository;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicySnapshotStore> _logger;
private readonly SemaphoreSlim _mutex = new(1, 1);
public PolicySnapshotStore(
IPolicySnapshotRepository snapshotRepository,
IPolicyAuditRepository auditRepository,
TimeProvider? timeProvider,
ILogger<PolicySnapshotStore> logger)
{
_snapshotRepository = snapshotRepository ?? throw new ArgumentNullException(nameof(snapshotRepository));
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PolicySnapshotSaveResult> SaveAsync(PolicySnapshotContent content, CancellationToken cancellationToken = default)
{
if (content is null)
{
throw new ArgumentNullException(nameof(content));
}
var bindingResult = PolicyBinder.Bind(content.Content, content.Format);
if (!bindingResult.Success)
{
_logger.LogWarning("Policy snapshot rejected due to validation errors (Format: {Format})", content.Format);
return new PolicySnapshotSaveResult(false, false, string.Empty, null, bindingResult);
}
var digest = PolicyDigest.Compute(bindingResult.Document);
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var latest = await _snapshotRepository.GetLatestAsync(cancellationToken).ConfigureAwait(false);
if (latest is not null && string.Equals(latest.Digest, digest, StringComparison.Ordinal))
{
_logger.LogInformation("Policy snapshot unchanged; digest {Digest} matches revision {RevisionId}", digest, latest.RevisionId);
return new PolicySnapshotSaveResult(true, false, digest, latest, bindingResult);
}
var revisionNumber = (latest?.RevisionNumber ?? 0) + 1;
var revisionId = $"rev-{revisionNumber}";
var createdAt = _timeProvider.GetUtcNow();
var scoringConfig = PolicyScoringConfig.Default;
var snapshot = new PolicySnapshot(
revisionNumber,
revisionId,
digest,
createdAt,
content.Actor,
content.Format,
bindingResult.Document,
bindingResult.Issues,
scoringConfig);
await _snapshotRepository.AddAsync(snapshot, cancellationToken).ConfigureAwait(false);
var auditMessage = content.Description ?? "Policy snapshot created";
var auditEntry = new PolicyAuditEntry(
Guid.NewGuid(),
createdAt,
"snapshot.created",
revisionId,
digest,
content.Actor,
auditMessage);
await _auditRepository.AddAsync(auditEntry, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Policy snapshot saved. Revision {RevisionId}, digest {Digest}, issues {IssueCount}",
revisionId,
digest,
bindingResult.Issues.Length);
return new PolicySnapshotSaveResult(true, true, digest, snapshot, bindingResult);
}
finally
{
_mutex.Release();
}
}
public Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
=> _snapshotRepository.GetLatestAsync(cancellationToken);
}

View File

@@ -0,0 +1,241 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed record PolicyValidationCliOptions
{
public IReadOnlyList<string> Inputs { get; init; } = Array.Empty<string>();
/// <summary>
/// Writes machine-readable JSON instead of human-formatted text.
/// </summary>
public bool OutputJson { get; init; }
/// <summary>
/// When enabled, warnings cause a non-zero exit code.
/// </summary>
public bool Strict { get; init; }
}
public sealed record PolicyValidationFileResult(
string Path,
PolicyBindingResult BindingResult,
PolicyDiagnosticsReport Diagnostics);
public sealed class PolicyValidationCli
{
private readonly TextWriter _output;
private readonly TextWriter _error;
public PolicyValidationCli(TextWriter? output = null, TextWriter? error = null)
{
_output = output ?? Console.Out;
_error = error ?? Console.Error;
}
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
if (options.Inputs.Count == 0)
{
await _error.WriteLineAsync("No input files provided. Supply one or more policy file paths.");
return 64; // EX_USAGE
}
var results = new List<PolicyValidationFileResult>();
foreach (var input in options.Inputs)
{
cancellationToken.ThrowIfCancellationRequested();
var resolvedPaths = ResolveInput(input);
if (resolvedPaths.Count == 0)
{
await _error.WriteLineAsync($"No files matched '{input}'.");
continue;
}
foreach (var path in resolvedPaths)
{
cancellationToken.ThrowIfCancellationRequested();
var format = PolicySchema.DetectFormat(path);
var content = await File.ReadAllTextAsync(path, cancellationToken);
var bindingResult = PolicyBinder.Bind(content, format);
var diagnostics = PolicyDiagnostics.Create(bindingResult);
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
}
}
if (results.Count == 0)
{
await _error.WriteLineAsync("No files were processed.");
return 65; // EX_DATAERR
}
if (options.OutputJson)
{
WriteJson(results);
}
else
{
await WriteTextAsync(results, cancellationToken);
}
var hasErrors = results.Any(static result => !result.BindingResult.Success);
var hasWarnings = results.Any(static result => result.BindingResult.Issues.Any(static issue => issue.Severity == PolicyIssueSeverity.Warning));
if (hasErrors)
{
return 1;
}
if (options.Strict && hasWarnings)
{
return 2;
}
return 0;
}
private async Task WriteTextAsync(IReadOnlyList<PolicyValidationFileResult> results, CancellationToken cancellationToken)
{
foreach (var result in results)
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = MakeRelative(result.Path);
await _output.WriteLineAsync($"{relativePath} [{result.BindingResult.Format}]");
if (result.BindingResult.Issues.Length == 0)
{
await _output.WriteLineAsync(" OK");
continue;
}
foreach (var issue in result.BindingResult.Issues)
{
var severity = issue.Severity.ToString().ToUpperInvariant().PadRight(7);
await _output.WriteLineAsync($" {severity} {issue.Path} :: {issue.Message} ({issue.Code})");
}
}
}
private void WriteJson(IReadOnlyList<PolicyValidationFileResult> results)
{
var payload = results.Select(static result => new
{
path = result.Path,
format = result.BindingResult.Format.ToString().ToLowerInvariant(),
success = result.BindingResult.Success,
issues = result.BindingResult.Issues.Select(static issue => new
{
code = issue.Code,
message = issue.Message,
severity = issue.Severity.ToString().ToLowerInvariant(),
path = issue.Path,
}),
diagnostics = new
{
version = result.Diagnostics.Version,
ruleCount = result.Diagnostics.RuleCount,
errorCount = result.Diagnostics.ErrorCount,
warningCount = result.Diagnostics.WarningCount,
generatedAt = result.Diagnostics.GeneratedAt,
recommendations = result.Diagnostics.Recommendations,
},
})
.ToArray();
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
WriteIndented = true,
});
_output.WriteLine(json);
}
private static IReadOnlyList<string> ResolveInput(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return Array.Empty<string>();
}
var expanded = Environment.ExpandEnvironmentVariables(input.Trim());
if (File.Exists(expanded))
{
return new[] { Path.GetFullPath(expanded) };
}
if (Directory.Exists(expanded))
{
return Directory.EnumerateFiles(expanded, "*.*", SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
var directory = Path.GetDirectoryName(expanded);
var searchPattern = Path.GetFileName(expanded);
if (string.IsNullOrEmpty(searchPattern))
{
return Array.Empty<string>();
}
if (string.IsNullOrEmpty(directory))
{
directory = ".";
}
if (!Directory.Exists(directory))
{
return Array.Empty<string>();
}
return Directory.EnumerateFiles(directory, searchPattern, SearchOption.TopDirectoryOnly)
.Where(static path => MatchesPolicyExtension(path))
.OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)
.Select(Path.GetFullPath)
.ToArray();
}
private static bool MatchesPolicyExtension(string path)
{
var extension = Path.GetExtension(path);
return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".yml", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".json", StringComparison.OrdinalIgnoreCase);
}
private static string MakeRelative(string path)
{
try
{
var fullPath = Path.GetFullPath(path);
var current = Directory.GetCurrentDirectory();
if (fullPath.StartsWith(current, StringComparison.OrdinalIgnoreCase))
{
return fullPath[current.Length..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return fullPath;
}
catch
{
return path;
}
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Policy;
public enum PolicyVerdictStatus
{
Pass,
Blocked,
Ignored,
Warned,
Deferred,
Escalated,
RequiresVex,
}
public sealed record PolicyVerdict(
string FindingId,
PolicyVerdictStatus Status,
string? RuleName = null,
string? RuleAction = null,
string? Notes = null,
double Score = 0,
string ConfigVersion = "1.0",
ImmutableDictionary<string, double>? Inputs = null,
string? QuietedBy = null,
bool Quiet = false)
{
public static PolicyVerdict CreateBaseline(string findingId, PolicyScoringConfig scoringConfig)
{
var inputs = ImmutableDictionary<string, double>.Empty;
return new PolicyVerdict(
findingId,
PolicyVerdictStatus.Pass,
RuleName: null,
RuleAction: null,
Notes: null,
Score: 0,
ConfigVersion: scoringConfig.Version,
Inputs: inputs,
QuietedBy: null,
Quiet: false);
}
public ImmutableDictionary<string, double> GetInputs()
=> Inputs ?? ImmutableDictionary<string, double>.Empty;
}
public sealed record PolicyVerdictDiff(
PolicyVerdict Baseline,
PolicyVerdict Projected)
{
public bool Changed
{
get
{
if (Baseline.Status != Projected.Status)
{
return true;
}
if (!string.Equals(Baseline.RuleName, Projected.RuleName, StringComparison.Ordinal))
{
return true;
}
if (Math.Abs(Baseline.Score - Projected.Score) > 0.0001)
{
return true;
}
if (!string.Equals(Baseline.QuietedBy, Projected.QuietedBy, StringComparison.Ordinal))
{
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,176 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.stella-ops.org/policy/policy-schema@1.json",
"title": "StellaOps Policy Schema v1",
"type": "object",
"required": ["version", "rules"],
"properties": {
"version": {
"type": ["string", "number"],
"enum": ["1", "1.0", 1, 1.0]
},
"description": {
"type": "string"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
},
"rules": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/rule"
}
}
},
"additionalProperties": true,
"$defs": {
"identifier": {
"type": "string",
"minLength": 1
},
"severity": {
"type": "string",
"enum": ["Critical", "High", "Medium", "Low", "Informational", "None", "Unknown"]
},
"stringArray": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"rule": {
"type": "object",
"required": ["name", "action"],
"properties": {
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"severity": {
"type": "array",
"items": {
"$ref": "#/$defs/severity"
},
"uniqueItems": true
},
"sources": {
"$ref": "#/$defs/stringArray"
},
"vendors": {
"$ref": "#/$defs/stringArray"
},
"licenses": {
"$ref": "#/$defs/stringArray"
},
"tags": {
"$ref": "#/$defs/stringArray"
},
"environments": {
"$ref": "#/$defs/stringArray"
},
"images": {
"$ref": "#/$defs/stringArray"
},
"repositories": {
"$ref": "#/$defs/stringArray"
},
"packages": {
"$ref": "#/$defs/stringArray"
},
"purls": {
"$ref": "#/$defs/stringArray"
},
"cves": {
"$ref": "#/$defs/stringArray"
},
"paths": {
"$ref": "#/$defs/stringArray"
},
"layerDigests": {
"$ref": "#/$defs/stringArray"
},
"usedByEntrypoint": {
"$ref": "#/$defs/stringArray"
},
"justification": {
"type": "string"
},
"quiet": {
"type": "boolean"
},
"action": {
"oneOf": [
{
"type": "string",
"enum": ["block", "fail", "deny", "ignore", "warn", "defer", "escalate", "requireVex"]
},
{
"type": "object",
"required": ["type"],
"properties": {
"type": {
"type": "string"
},
"quiet": {
"type": "boolean"
},
"until": {
"type": "string",
"format": "date-time"
},
"justification": {
"type": "string"
},
"severity": {
"$ref": "#/$defs/severity"
},
"vendors": {
"$ref": "#/$defs/stringArray"
},
"justifications": {
"$ref": "#/$defs/stringArray"
},
"epss": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"kev": {
"type": "boolean"
}
},
"additionalProperties": true
}
]
},
"expires": {
"type": "string",
"format": "date-time"
},
"until": {
"type": "string",
"format": "date-time"
},
"metadata": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean"]
}
}
},
"additionalProperties": true
}
}
}

View File

@@ -0,0 +1,21 @@
{
"version": "1.0",
"severityWeights": {
"Critical": 90.0,
"High": 75.0,
"Medium": 50.0,
"Low": 25.0,
"Informational": 10.0,
"None": 0.0,
"Unknown": 60.0
},
"quietPenalty": 45.0,
"warnPenalty": 15.0,
"ignorePenalty": 35.0,
"trustOverrides": {
"vendor": 1.0,
"distro": 0.85,
"platform": 0.75,
"community": 0.65
}
}

View File

@@ -3,5 +3,18 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25306.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public interface IPolicySnapshotRepository
{
Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Policy;
public sealed class InMemoryPolicySnapshotRepository : IPolicySnapshotRepository
{
private readonly List<PolicySnapshot> _snapshots = new();
private readonly SemaphoreSlim _mutex = new(1, 1);
public async Task AddAsync(PolicySnapshot snapshot, CancellationToken cancellationToken = default)
{
if (snapshot is null)
{
throw new ArgumentNullException(nameof(snapshot));
}
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
_snapshots.Add(snapshot);
_snapshots.Sort(static (left, right) => left.RevisionNumber.CompareTo(right.RevisionNumber));
}
finally
{
_mutex.Release();
}
}
public async Task<PolicySnapshot?> GetLatestAsync(CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return _snapshots.Count == 0 ? null : _snapshots[^1];
}
finally
{
_mutex.Release();
}
}
public async Task<IReadOnlyList<PolicySnapshot>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
await _mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
IEnumerable<PolicySnapshot> query = _snapshots;
if (limit > 0)
{
query = query.TakeLast(limit);
}
return query.ToImmutableArray();
}
finally
{
_mutex.Release();
}
}
}

View File

@@ -2,12 +2,17 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-CORE-09-001 | TODO | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. |
| POLICY-CORE-09-002 | TODO | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. |
| POLICY-CORE-09-003 | TODO | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. |
| POLICY-CORE-09-001 | DONE | Policy Guild | SCANNER-WEB-09-101 | Define YAML schema/binder, diagnostics, CLI validation for policy files. | Schema doc published; binder loads sample policy; validation errors actionable. |
| POLICY-CORE-09-002 | DONE | Policy Guild | POLICY-CORE-09-001 | Implement policy snapshot store + revision digests + audit logging. | Snapshots persisted with digest; tests compare revisions; audit entries created. |
| POLICY-CORE-09-003 | DONE | Policy Guild | POLICY-CORE-09-002 | `/policy/preview` API (image digest → projected verdict delta). | Preview returns diff JSON; integration tests with mocked report; docs updated. |
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. |
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004 | Scoring/quiet engine compute score, enforce VEX-only quiet rules, emit inputs and provenance. | Engine unit tests cover severity weighting; outputs include provenance data. |
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005 | Unknown state & confidence decay deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. |
| POLICY-CORE-09-004 | TODO | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
| POLICY-CORE-09-005 | TODO | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
| POLICY-CORE-09-006 | TODO | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
## Notes
- 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md.
- 2025-10-18: POLICY-CORE-09-002 completed. Snapshot store + audit trail implemented with deterministic digest hashing and tests covering revision increments and dedupe.
- 2025-10-18: POLICY-CORE-09-003 delivered. Preview service evaluates policy projections vs. baseline, returns verdict diffs, and ships with unit coverage.