Resolve Concelier/Excititor merge conflicts
This commit is contained in:
12
src/StellaOps.Policy/AGENTS.md
Normal file
12
src/StellaOps.Policy/AGENTS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# StellaOps.Policy — Agent Charter
|
||||
|
||||
## Mission
|
||||
Deliver the policy engine outlined in `docs/ARCHITECTURE_SCANNER.md` and related prose:
|
||||
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
|
||||
- Provide policy snapshot storage with revision digests and diagnostics.
|
||||
- Offer preview APIs to compare policy impacts on existing reports.
|
||||
|
||||
## Expectations
|
||||
- Coordinate with Scanner.WebService, Feedser, Vexer, UI, Notify.
|
||||
- Maintain deterministic serialization and unit tests for precedence rules.
|
||||
- Update `TASKS.md` and broadcast contract changes.
|
||||
12
src/StellaOps.Policy/Audit/IPolicyAuditRepository.cs
Normal file
12
src/StellaOps.Policy/Audit/IPolicyAuditRepository.cs
Normal 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);
|
||||
}
|
||||
52
src/StellaOps.Policy/Audit/InMemoryPolicyAuditRepository.cs
Normal file
52
src/StellaOps.Policy/Audit/InMemoryPolicyAuditRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/StellaOps.Policy/PolicyAuditEntry.cs
Normal file
12
src/StellaOps.Policy/PolicyAuditEntry.cs
Normal 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);
|
||||
915
src/StellaOps.Policy/PolicyBinder.cs
Normal file
915
src/StellaOps.Policy/PolicyBinder.cs
Normal file
@@ -0,0 +1,915 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public enum PolicyDocumentFormat
|
||||
{
|
||||
Json,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
public sealed record PolicyBindingResult(
|
||||
bool Success,
|
||||
PolicyDocument Document,
|
||||
ImmutableArray<PolicyIssue> Issues,
|
||||
PolicyDocumentFormat Format);
|
||||
|
||||
public static class PolicyBinder
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString,
|
||||
Converters =
|
||||
{
|
||||
new JsonStringEnumConverter()
|
||||
},
|
||||
};
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public static PolicyBindingResult Bind(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var issues = ImmutableArray.Create(
|
||||
PolicyIssue.Error("policy.empty", "Policy document is empty.", "$"));
|
||||
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var node = ParseToNode(content, format);
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
var issues = ImmutableArray.Create(
|
||||
PolicyIssue.Error("policy.document.invalid", "Policy document must be an object.", "$"));
|
||||
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
|
||||
}
|
||||
|
||||
var model = obj.Deserialize<PolicyDocumentModel>(SerializerOptions) ?? new PolicyDocumentModel();
|
||||
var normalization = PolicyNormalizer.Normalize(model);
|
||||
var success = normalization.Issues.All(static issue => issue.Severity != PolicyIssueSeverity.Error);
|
||||
return new PolicyBindingResult(success, normalization.Document, normalization.Issues, format);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var issues = ImmutableArray.Create(
|
||||
PolicyIssue.Error("policy.parse.json", $"Failed to parse policy JSON: {ex.Message}", "$"));
|
||||
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
|
||||
}
|
||||
catch (YamlDotNet.Core.YamlException ex)
|
||||
{
|
||||
var issues = ImmutableArray.Create(
|
||||
PolicyIssue.Error("policy.parse.yaml", $"Failed to parse policy YAML: {ex.Message}", "$"));
|
||||
return new PolicyBindingResult(false, PolicyDocument.Empty, issues, format);
|
||||
}
|
||||
}
|
||||
|
||||
public static PolicyBindingResult Bind(Stream stream, PolicyDocumentFormat format, Encoding? encoding = null)
|
||||
{
|
||||
if (stream is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(stream));
|
||||
}
|
||||
|
||||
encoding ??= Encoding.UTF8;
|
||||
using var reader = new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
var content = reader.ReadToEnd();
|
||||
return Bind(content, format);
|
||||
}
|
||||
|
||||
private static JsonNode? ParseToNode(string content, PolicyDocumentFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
PolicyDocumentFormat.Json => JsonNode.Parse(content, documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
}),
|
||||
PolicyDocumentFormat.Yaml => ConvertYamlToJsonNode(content),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unsupported policy document format."),
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertYamlToJsonNode(string content)
|
||||
{
|
||||
var yamlObject = YamlDeserializer.Deserialize<object?>(content);
|
||||
return ConvertYamlObject(yamlObject);
|
||||
}
|
||||
|
||||
private static JsonNode? ConvertYamlObject(object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
return null;
|
||||
case string s when bool.TryParse(s, out var boolValue):
|
||||
return JsonValue.Create(boolValue);
|
||||
case string s:
|
||||
return JsonValue.Create(s);
|
||||
case bool b:
|
||||
return JsonValue.Create(b);
|
||||
case sbyte or byte or short or ushort or int or uint or long or ulong or float or double or decimal:
|
||||
return JsonValue.Create(Convert.ToDecimal(value, CultureInfo.InvariantCulture));
|
||||
case DateTime dt:
|
||||
return JsonValue.Create(dt.ToString("O", CultureInfo.InvariantCulture));
|
||||
case DateTimeOffset dto:
|
||||
return JsonValue.Create(dto.ToString("O", CultureInfo.InvariantCulture));
|
||||
case Enum e:
|
||||
return JsonValue.Create(e.ToString());
|
||||
case IDictionary dictionary:
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
if (entry.Key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Convert.ToString(entry.Key, CultureInfo.InvariantCulture);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
obj[key!] = ConvertYamlObject(entry.Value);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
case IEnumerable enumerable:
|
||||
{
|
||||
var array = new JsonArray();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
array.Add(ConvertYamlObject(item));
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
default:
|
||||
return JsonValue.Create(value.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PolicyDocumentModel
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public JsonNode? Version { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, JsonNode?>? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("rules")]
|
||||
public List<PolicyRuleModel>? Rules { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? Extensions { get; init; }
|
||||
}
|
||||
|
||||
private sealed record PolicyRuleModel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Identifier { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; init; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; init; }
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public List<string>? Severity { get; init; }
|
||||
|
||||
[JsonPropertyName("sources")]
|
||||
public List<string>? Sources { get; init; }
|
||||
|
||||
[JsonPropertyName("vendors")]
|
||||
public List<string>? Vendors { get; init; }
|
||||
|
||||
[JsonPropertyName("licenses")]
|
||||
public List<string>? Licenses { get; init; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string>? Tags { get; init; }
|
||||
|
||||
[JsonPropertyName("environments")]
|
||||
public List<string>? Environments { get; init; }
|
||||
|
||||
[JsonPropertyName("images")]
|
||||
public List<string>? Images { get; init; }
|
||||
|
||||
[JsonPropertyName("repositories")]
|
||||
public List<string>? Repositories { get; init; }
|
||||
|
||||
[JsonPropertyName("packages")]
|
||||
public List<string>? Packages { get; init; }
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public List<string>? Purls { get; init; }
|
||||
|
||||
[JsonPropertyName("cves")]
|
||||
public List<string>? Cves { get; init; }
|
||||
|
||||
[JsonPropertyName("paths")]
|
||||
public List<string>? Paths { get; init; }
|
||||
|
||||
[JsonPropertyName("layerDigests")]
|
||||
public List<string>? LayerDigests { get; init; }
|
||||
|
||||
[JsonPropertyName("usedByEntrypoint")]
|
||||
public List<string>? UsedByEntrypoint { get; init; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public JsonNode? Action { get; init; }
|
||||
|
||||
[JsonPropertyName("expires")]
|
||||
public JsonNode? Expires { get; init; }
|
||||
|
||||
[JsonPropertyName("until")]
|
||||
public JsonNode? Until { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public string? Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("quiet")]
|
||||
public bool? Quiet { get; init; }
|
||||
|
||||
[JsonPropertyName("metadata")]
|
||||
public Dictionary<string, JsonNode?>? Metadata { get; init; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JsonElement>? Extensions { get; init; }
|
||||
}
|
||||
|
||||
private sealed class PolicyNormalizer
|
||||
{
|
||||
private static readonly ImmutableDictionary<string, PolicySeverity> SeverityMap =
|
||||
new Dictionary<string, PolicySeverity>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["critical"] = PolicySeverity.Critical,
|
||||
["high"] = PolicySeverity.High,
|
||||
["medium"] = PolicySeverity.Medium,
|
||||
["moderate"] = PolicySeverity.Medium,
|
||||
["low"] = PolicySeverity.Low,
|
||||
["informational"] = PolicySeverity.Informational,
|
||||
["info"] = PolicySeverity.Informational,
|
||||
["none"] = PolicySeverity.None,
|
||||
["unknown"] = PolicySeverity.Unknown,
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static (PolicyDocument Document, ImmutableArray<PolicyIssue> Issues) Normalize(PolicyDocumentModel model)
|
||||
{
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
|
||||
var version = NormalizeVersion(model.Version, issues);
|
||||
var metadata = NormalizeMetadata(model.Metadata, "$.metadata", issues);
|
||||
var rules = NormalizeRules(model.Rules, issues);
|
||||
|
||||
if (model.Extensions is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in model.Extensions)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning(
|
||||
"policy.document.extension",
|
||||
$"Unrecognized document property '{pair.Key}' has been ignored.",
|
||||
$"$.{pair.Key}"));
|
||||
}
|
||||
}
|
||||
|
||||
var document = new PolicyDocument(
|
||||
version ?? PolicySchema.CurrentVersion,
|
||||
rules,
|
||||
metadata);
|
||||
|
||||
var orderedIssues = SortIssues(issues);
|
||||
return (document, orderedIssues);
|
||||
}
|
||||
|
||||
private static string? NormalizeVersion(JsonNode? versionNode, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (versionNode is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.version.missing", "Policy version not specified; defaulting to 1.0.", "$.version"));
|
||||
return PolicySchema.CurrentVersion;
|
||||
}
|
||||
|
||||
if (versionNode is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out string? versionText))
|
||||
{
|
||||
versionText = versionText?.Trim();
|
||||
if (string.IsNullOrEmpty(versionText))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.version.empty", "Policy version is empty.", "$.version"));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (IsSupportedVersion(versionText))
|
||||
{
|
||||
return CanonicalizeVersion(versionText);
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{versionText}'. Expected '{PolicySchema.CurrentVersion}'.", "$.version"));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out double numericVersion))
|
||||
{
|
||||
var numericText = numericVersion.ToString("0.0###", CultureInfo.InvariantCulture);
|
||||
if (IsSupportedVersion(numericText))
|
||||
{
|
||||
return CanonicalizeVersion(numericText);
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error("policy.version.unsupported", $"Unsupported policy version '{numericText}'.", "$.version"));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var raw = versionNode.ToJsonString();
|
||||
issues.Add(PolicyIssue.Error("policy.version.invalid", $"Policy version must be a string. Received: {raw}", "$.version"));
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsSupportedVersion(string versionText)
|
||||
=> string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(versionText, "1.0", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(versionText, PolicySchema.CurrentVersion, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string CanonicalizeVersion(string versionText)
|
||||
=> string.Equals(versionText, "1", StringComparison.OrdinalIgnoreCase)
|
||||
? "1.0"
|
||||
: versionText;
|
||||
|
||||
private static ImmutableDictionary<string, string> NormalizeMetadata(
|
||||
Dictionary<string, JsonNode?>? metadata,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
{
|
||||
return ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in metadata)
|
||||
{
|
||||
var key = pair.Key?.Trim();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.metadata.key.empty", "Metadata keys must be non-empty strings.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ConvertNodeToString(pair.Value);
|
||||
builder[key] = value;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyRule> NormalizeRules(
|
||||
List<PolicyRuleModel>? rules,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (rules is null || rules.Count == 0)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.rules.empty", "At least one rule must be defined.", "$.rules"));
|
||||
return ImmutableArray<PolicyRule>.Empty;
|
||||
}
|
||||
|
||||
var normalized = new List<(PolicyRule Rule, int Index)>(rules.Count);
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var index = 0; index < rules.Count; index++)
|
||||
{
|
||||
var model = rules[index];
|
||||
var normalizedRule = NormalizeRule(model, index, issues);
|
||||
if (normalizedRule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenNames.Add(normalizedRule.Name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning(
|
||||
"policy.rules.duplicateName",
|
||||
$"Duplicate rule name '{normalizedRule.Name}' detected; evaluation order may be ambiguous.",
|
||||
$"$.rules[{index}].name"));
|
||||
}
|
||||
|
||||
normalized.Add((normalizedRule, index));
|
||||
}
|
||||
|
||||
return normalized
|
||||
.OrderBy(static tuple => tuple.Rule.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static tuple => tuple.Rule.Identifier ?? string.Empty, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(static tuple => tuple.Index)
|
||||
.Select(static tuple => tuple.Rule)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static PolicyRule? NormalizeRule(
|
||||
PolicyRuleModel model,
|
||||
int index,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var basePath = $"$.rules[{index}]";
|
||||
|
||||
var name = NormalizeRequiredString(model.Name, $"{basePath}.name", "Rule name", issues);
|
||||
if (name is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var identifier = NormalizeOptionalString(model.Identifier);
|
||||
var description = NormalizeOptionalString(model.Description);
|
||||
var metadata = NormalizeMetadata(model.Metadata, $"{basePath}.metadata", issues);
|
||||
|
||||
var severities = NormalizeSeverityList(model.Severity, $"{basePath}.severity", issues);
|
||||
var environments = NormalizeStringList(model.Environments, $"{basePath}.environments", issues);
|
||||
var sources = NormalizeStringList(model.Sources, $"{basePath}.sources", issues);
|
||||
var vendors = NormalizeStringList(model.Vendors, $"{basePath}.vendors", issues);
|
||||
var licenses = NormalizeStringList(model.Licenses, $"{basePath}.licenses", issues);
|
||||
var tags = NormalizeStringList(model.Tags, $"{basePath}.tags", issues);
|
||||
|
||||
var match = new PolicyRuleMatchCriteria(
|
||||
NormalizeStringList(model.Images, $"{basePath}.images", issues),
|
||||
NormalizeStringList(model.Repositories, $"{basePath}.repositories", issues),
|
||||
NormalizeStringList(model.Packages, $"{basePath}.packages", issues),
|
||||
NormalizeStringList(model.Purls, $"{basePath}.purls", issues),
|
||||
NormalizeStringList(model.Cves, $"{basePath}.cves", issues),
|
||||
NormalizeStringList(model.Paths, $"{basePath}.paths", issues),
|
||||
NormalizeStringList(model.LayerDigests, $"{basePath}.layerDigests", issues),
|
||||
NormalizeStringList(model.UsedByEntrypoint, $"{basePath}.usedByEntrypoint", issues));
|
||||
|
||||
var action = NormalizeAction(model, basePath, issues);
|
||||
var justification = NormalizeOptionalString(model.Justification);
|
||||
var expires = NormalizeTemporal(model.Expires ?? model.Until, $"{basePath}.expires", issues);
|
||||
|
||||
if (model.Extensions is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in model.Extensions)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning(
|
||||
"policy.rule.extension",
|
||||
$"Unrecognized rule property '{pair.Key}' has been ignored.",
|
||||
$"{basePath}.{pair.Key}"));
|
||||
}
|
||||
}
|
||||
|
||||
return PolicyRule.Create(
|
||||
name,
|
||||
action,
|
||||
severities,
|
||||
environments,
|
||||
sources,
|
||||
vendors,
|
||||
licenses,
|
||||
tags,
|
||||
match,
|
||||
expires,
|
||||
justification,
|
||||
identifier,
|
||||
description,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static PolicyAction NormalizeAction(
|
||||
PolicyRuleModel model,
|
||||
string basePath,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var actionNode = model.Action;
|
||||
var quiet = model.Quiet ?? false;
|
||||
if (!quiet && model.Extensions is not null && model.Extensions.TryGetValue("quiet", out var quietExtension) && quietExtension.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
quiet = true;
|
||||
}
|
||||
string? justification = NormalizeOptionalString(model.Justification);
|
||||
DateTimeOffset? until = NormalizeTemporal(model.Until, $"{basePath}.until", issues);
|
||||
DateTimeOffset? expires = NormalizeTemporal(model.Expires, $"{basePath}.expires", issues);
|
||||
|
||||
var effectiveUntil = until ?? expires;
|
||||
|
||||
if (actionNode is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.action.missing", "Rule action is required.", $"{basePath}.action"));
|
||||
return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: false);
|
||||
}
|
||||
|
||||
string? actionType = null;
|
||||
JsonObject? actionObject = null;
|
||||
|
||||
switch (actionNode)
|
||||
{
|
||||
case JsonValue value when value.TryGetValue(out string? text):
|
||||
actionType = text;
|
||||
break;
|
||||
case JsonValue value when value.TryGetValue(out bool booleanValue):
|
||||
actionType = booleanValue ? "block" : "ignore";
|
||||
break;
|
||||
case JsonObject obj:
|
||||
actionObject = obj;
|
||||
if (obj.TryGetPropertyValue("type", out var typeNode) && typeNode is JsonValue typeValue && typeValue.TryGetValue(out string? typeText))
|
||||
{
|
||||
actionType = typeText;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.action.type", "Action object must contain a 'type' property.", $"{basePath}.action.type"));
|
||||
}
|
||||
|
||||
if (obj.TryGetPropertyValue("quiet", out var quietNode) && quietNode is JsonValue quietValue && quietValue.TryGetValue(out bool quietFlag))
|
||||
{
|
||||
quiet = quietFlag;
|
||||
}
|
||||
|
||||
if (obj.TryGetPropertyValue("until", out var untilNode))
|
||||
{
|
||||
effectiveUntil ??= NormalizeTemporal(untilNode, $"{basePath}.action.until", issues);
|
||||
}
|
||||
|
||||
if (obj.TryGetPropertyValue("justification", out var justificationNode) && justificationNode is JsonValue justificationValue && justificationValue.TryGetValue(out string? justificationText))
|
||||
{
|
||||
justification = NormalizeOptionalString(justificationText);
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
actionType = actionNode.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(actionType))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.action.type", "Action type is required.", $"{basePath}.action"));
|
||||
return new PolicyAction(PolicyActionType.Block, null, null, null, Quiet: quiet);
|
||||
}
|
||||
|
||||
actionType = actionType.Trim();
|
||||
var (type, typeIssues) = MapActionType(actionType, $"{basePath}.action");
|
||||
foreach (var issue in typeIssues)
|
||||
{
|
||||
issues.Add(issue);
|
||||
}
|
||||
|
||||
PolicyIgnoreOptions? ignoreOptions = null;
|
||||
PolicyEscalateOptions? escalateOptions = null;
|
||||
PolicyRequireVexOptions? requireVexOptions = null;
|
||||
|
||||
if (type == PolicyActionType.Ignore)
|
||||
{
|
||||
ignoreOptions = new PolicyIgnoreOptions(effectiveUntil, justification);
|
||||
}
|
||||
else if (type == PolicyActionType.Escalate)
|
||||
{
|
||||
escalateOptions = NormalizeEscalateOptions(actionObject, $"{basePath}.action", issues);
|
||||
}
|
||||
else if (type == PolicyActionType.RequireVex)
|
||||
{
|
||||
requireVexOptions = NormalizeRequireVexOptions(actionObject, $"{basePath}.action", issues);
|
||||
}
|
||||
|
||||
return new PolicyAction(type, ignoreOptions, escalateOptions, requireVexOptions, quiet);
|
||||
}
|
||||
|
||||
private static (PolicyActionType Type, ImmutableArray<PolicyIssue> Issues) MapActionType(string value, string path)
|
||||
{
|
||||
var issues = ImmutableArray<PolicyIssue>.Empty;
|
||||
var lower = value.ToLowerInvariant();
|
||||
return lower switch
|
||||
{
|
||||
"block" or "fail" or "deny" => (PolicyActionType.Block, issues),
|
||||
"ignore" or "mute" => (PolicyActionType.Ignore, issues),
|
||||
"warn" or "warning" => (PolicyActionType.Warn, issues),
|
||||
"defer" => (PolicyActionType.Defer, issues),
|
||||
"escalate" => (PolicyActionType.Escalate, issues),
|
||||
"requirevex" or "require_vex" or "require-vex" => (PolicyActionType.RequireVex, issues),
|
||||
_ => (PolicyActionType.Block, ImmutableArray.Create(PolicyIssue.Warning(
|
||||
"policy.action.unknown",
|
||||
$"Unknown action '{value}' encountered. Defaulting to 'block'.",
|
||||
path))),
|
||||
};
|
||||
}
|
||||
|
||||
private static PolicyEscalateOptions? NormalizeEscalateOptions(
|
||||
JsonObject? actionObject,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (actionObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PolicySeverity? minSeverity = null;
|
||||
bool requireKev = false;
|
||||
double? minEpss = null;
|
||||
|
||||
if (actionObject.TryGetPropertyValue("severity", out var severityNode) && severityNode is JsonValue severityValue && severityValue.TryGetValue(out string? severityText))
|
||||
{
|
||||
if (SeverityMap.TryGetValue(severityText ?? string.Empty, out var mapped))
|
||||
{
|
||||
minSeverity = mapped;
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.action.escalate.severity", $"Unknown escalate severity '{severityText}'.", $"{path}.severity"));
|
||||
}
|
||||
}
|
||||
|
||||
if (actionObject.TryGetPropertyValue("kev", out var kevNode) && kevNode is JsonValue kevValue && kevValue.TryGetValue(out bool kevFlag))
|
||||
{
|
||||
requireKev = kevFlag;
|
||||
}
|
||||
|
||||
if (actionObject.TryGetPropertyValue("epss", out var epssNode))
|
||||
{
|
||||
var parsed = ParseDouble(epssNode, $"{path}.epss", issues);
|
||||
if (parsed is { } epssValue)
|
||||
{
|
||||
if (epssValue < 0 || epssValue > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.action.escalate.epssRange", "EPS score must be between 0 and 1.", $"{path}.epss"));
|
||||
}
|
||||
else
|
||||
{
|
||||
minEpss = epssValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyEscalateOptions(minSeverity, requireKev, minEpss);
|
||||
}
|
||||
|
||||
private static PolicyRequireVexOptions? NormalizeRequireVexOptions(
|
||||
JsonObject? actionObject,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (actionObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var vendors = ImmutableArray<string>.Empty;
|
||||
var justifications = ImmutableArray<string>.Empty;
|
||||
|
||||
if (actionObject.TryGetPropertyValue("vendors", out var vendorsNode))
|
||||
{
|
||||
vendors = NormalizeJsonStringArray(vendorsNode, $"{path}.vendors", issues);
|
||||
}
|
||||
|
||||
if (actionObject.TryGetPropertyValue("justifications", out var justificationsNode))
|
||||
{
|
||||
justifications = NormalizeJsonStringArray(justificationsNode, $"{path}.justifications", issues);
|
||||
}
|
||||
|
||||
return new PolicyRequireVexOptions(vendors, justifications);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringList(
|
||||
List<string>? values,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = NormalizeOptionalString(value);
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(normalized);
|
||||
}
|
||||
|
||||
return builder.ToImmutable()
|
||||
.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicySeverity> NormalizeSeverityList(
|
||||
List<string>? values,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicySeverity>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicySeverity>();
|
||||
foreach (var value in values)
|
||||
{
|
||||
var normalized = NormalizeOptionalString(value);
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.severity.blank", "Blank severity was ignored.", path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SeverityMap.TryGetValue(normalized, out var severity))
|
||||
{
|
||||
builder.Add(severity);
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("policy.severity.invalid", $"Unknown severity '{value}'.", path));
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Distinct().OrderBy(static sev => sev).ToImmutableArray();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeJsonStringArray(
|
||||
JsonNode? node,
|
||||
string path,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
if (node is JsonArray array)
|
||||
{
|
||||
var values = new List<string>(array.Count);
|
||||
foreach (var element in array)
|
||||
{
|
||||
var text = ConvertNodeToString(element);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("policy.list.blank", $"Blank entry detected; ignoring value at {path}.", path));
|
||||
}
|
||||
else
|
||||
{
|
||||
values.Add(text);
|
||||
}
|
||||
}
|
||||
|
||||
return values
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
var single = ConvertNodeToString(node);
|
||||
return ImmutableArray.Create(single);
|
||||
}
|
||||
|
||||
private static double? ParseDouble(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out double numeric))
|
||||
{
|
||||
return numeric;
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out string? text) && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out numeric))
|
||||
{
|
||||
return numeric;
|
||||
}
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Warning("policy.number.invalid", $"Value '{node.ToJsonString()}' is not a valid number.", path));
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? NormalizeTemporal(JsonNode? node, string path, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node is JsonValue value)
|
||||
{
|
||||
if (value.TryGetValue(out DateTimeOffset dto))
|
||||
{
|
||||
return dto;
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out DateTime dt))
|
||||
{
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(dt, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
if (value.TryGetValue(out string? text))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedDate))
|
||||
{
|
||||
return new DateTimeOffset(parsedDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Warning("policy.date.invalid", $"Value '{node.ToJsonString()}' is not a valid ISO-8601 timestamp.", path));
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeRequiredString(
|
||||
string? value,
|
||||
string path,
|
||||
string fieldDescription,
|
||||
ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
var normalized = NormalizeOptionalString(value);
|
||||
if (!string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
issues.Add(PolicyIssue.Error(
|
||||
"policy.required",
|
||||
$"{fieldDescription} is required.",
|
||||
path));
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string ConvertNodeToString(JsonNode? node)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return node switch
|
||||
{
|
||||
JsonValue value when value.TryGetValue(out string? text) => text ?? string.Empty,
|
||||
JsonValue value when value.TryGetValue(out bool boolean) => boolean ? "true" : "false",
|
||||
JsonValue value when value.TryGetValue(out double numeric) => numeric.ToString(CultureInfo.InvariantCulture),
|
||||
JsonObject obj => obj.ToJsonString(),
|
||||
JsonArray array => array.ToJsonString(),
|
||||
_ => node.ToJsonString(),
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyIssue> SortIssues(ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
return issues.ToImmutable()
|
||||
.OrderBy(static issue => issue.Severity switch
|
||||
{
|
||||
PolicyIssueSeverity.Error => 0,
|
||||
PolicyIssueSeverity.Warning => 1,
|
||||
_ => 2,
|
||||
})
|
||||
.ThenBy(static issue => issue.Path, StringComparer.Ordinal)
|
||||
.ThenBy(static issue => issue.Code, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/StellaOps.Policy/PolicyDiagnostics.cs
Normal file
77
src/StellaOps.Policy/PolicyDiagnostics.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
211
src/StellaOps.Policy/PolicyDigest.cs
Normal file
211
src/StellaOps.Policy/PolicyDigest.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
192
src/StellaOps.Policy/PolicyDocument.cs
Normal file
192
src/StellaOps.Policy/PolicyDocument.cs
Normal 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,
|
||||
}
|
||||
552
src/StellaOps.Policy/PolicyEvaluation.cs
Normal file
552
src/StellaOps.Policy/PolicyEvaluation.cs
Normal file
@@ -0,0 +1,552 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
|
||||
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);
|
||||
var trustKey = ResolveTrustKey(finding);
|
||||
var trustWeight = ResolveTrustWeight(scoringConfig, trustKey);
|
||||
var reachabilityKey = ResolveReachabilityKey(finding);
|
||||
var reachabilityWeight = ResolveReachabilityWeight(scoringConfig, reachabilityKey, out var resolvedReachabilityKey);
|
||||
var baseScore = severityWeight * trustWeight * reachabilityWeight;
|
||||
var components = new ScoringComponents(
|
||||
severityWeight,
|
||||
trustWeight,
|
||||
reachabilityWeight,
|
||||
baseScore,
|
||||
trustKey,
|
||||
resolvedReachabilityKey);
|
||||
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
if (!RuleMatches(rule, finding))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence);
|
||||
}
|
||||
|
||||
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
|
||||
return ApplyUnknownConfidence(baseline, unknownConfidence);
|
||||
}
|
||||
|
||||
private static PolicyVerdict BuildVerdict(
|
||||
PolicyRule rule,
|
||||
PolicyFinding finding,
|
||||
PolicyScoringConfig config,
|
||||
ScoringComponents components,
|
||||
UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
var action = rule.Action;
|
||||
var status = MapAction(action);
|
||||
var notes = BuildNotes(action);
|
||||
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
inputs["severityWeight"] = components.SeverityWeight;
|
||||
inputs["trustWeight"] = components.TrustWeight;
|
||||
inputs["reachabilityWeight"] = components.ReachabilityWeight;
|
||||
inputs["baseScore"] = components.BaseScore;
|
||||
if (!string.IsNullOrWhiteSpace(components.TrustKey))
|
||||
{
|
||||
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(components.ReachabilityKey))
|
||||
{
|
||||
inputs[$"reachability.{components.ReachabilityKey}"] = components.ReachabilityWeight;
|
||||
}
|
||||
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
|
||||
{
|
||||
notes = AppendNote(notes, description);
|
||||
}
|
||||
if (unknownConfidence is { } unknownDetails)
|
||||
{
|
||||
inputs["unknownConfidence"] = unknownDetails.Confidence;
|
||||
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
|
||||
}
|
||||
|
||||
double score = components.BaseScore;
|
||||
string? quietedBy = null;
|
||||
var quiet = false;
|
||||
|
||||
var quietRequested = action.Quiet;
|
||||
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
|
||||
|
||||
if (quietRequested && !quietAllowed)
|
||||
{
|
||||
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in inputs)
|
||||
{
|
||||
warnInputs[pair.Key] = pair.Value;
|
||||
}
|
||||
if (unknownConfidence is { } unknownInfo)
|
||||
{
|
||||
warnInputs["unknownConfidence"] = unknownInfo.Confidence;
|
||||
warnInputs["unknownAgeDays"] = unknownInfo.AgeDays;
|
||||
}
|
||||
|
||||
var warnPenalty = config.WarnPenalty;
|
||||
warnInputs["warnPenalty"] = warnPenalty;
|
||||
var warnScore = Math.Max(0, components.BaseScore - 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,
|
||||
warnInputs.ToImmutable(),
|
||||
QuietedBy: null,
|
||||
Quiet: false,
|
||||
UnknownConfidence: unknownConfidence?.Confidence,
|
||||
ConfidenceBand: unknownConfidence?.Band.Name,
|
||||
UnknownAgeDays: unknownConfidence?.AgeDays,
|
||||
SourceTrust: components.TrustKey,
|
||||
Reachability: components.ReachabilityKey);
|
||||
}
|
||||
|
||||
switch (status)
|
||||
{
|
||||
case PolicyVerdictStatus.Ignored:
|
||||
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Warned:
|
||||
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
|
||||
break;
|
||||
case PolicyVerdictStatus.Deferred:
|
||||
var deferPenalty = config.WarnPenalty / 2;
|
||||
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
|
||||
break;
|
||||
}
|
||||
|
||||
if (quietAllowed)
|
||||
{
|
||||
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
|
||||
quietedBy = rule.Name;
|
||||
quiet = true;
|
||||
}
|
||||
|
||||
return new PolicyVerdict(
|
||||
finding.FindingId,
|
||||
status,
|
||||
rule.Name,
|
||||
action.Type.ToString(),
|
||||
notes,
|
||||
score,
|
||||
config.Version,
|
||||
inputs.ToImmutable(),
|
||||
quietedBy,
|
||||
quiet,
|
||||
unknownConfidence?.Confidence,
|
||||
unknownConfidence?.Band.Name,
|
||||
unknownConfidence?.AgeDays,
|
||||
components.TrustKey,
|
||||
components.ReachabilityKey);
|
||||
}
|
||||
|
||||
private static double ApplyPenalty(double score, double penalty, ImmutableDictionary<string, double>.Builder inputs, string key)
|
||||
{
|
||||
if (penalty <= 0)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
|
||||
inputs[key] = penalty;
|
||||
return Math.Max(0, score - penalty);
|
||||
}
|
||||
|
||||
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
|
||||
{
|
||||
if (unknownConfidence is null)
|
||||
{
|
||||
return verdict;
|
||||
}
|
||||
|
||||
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in verdict.GetInputs())
|
||||
{
|
||||
inputsBuilder[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
|
||||
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
|
||||
|
||||
return verdict with
|
||||
{
|
||||
Inputs = inputsBuilder.ToImmutable(),
|
||||
UnknownConfidence = unknownConfidence.Value.Confidence,
|
||||
ConfidenceBand = unknownConfidence.Value.Band.Name,
|
||||
UnknownAgeDays = unknownConfidence.Value.AgeDays,
|
||||
};
|
||||
}
|
||||
|
||||
private static UnknownConfidenceResult? ComputeUnknownConfidence(PolicyUnknownConfidenceConfig config, PolicyFinding finding)
|
||||
{
|
||||
if (!IsUnknownFinding(finding))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ageDays = ResolveUnknownAgeDays(finding);
|
||||
var rawConfidence = config.Initial - (ageDays * config.DecayPerDay);
|
||||
var confidence = config.Clamp(rawConfidence);
|
||||
var band = config.ResolveBand(confidence);
|
||||
return new UnknownConfidenceResult(ageDays, confidence, band);
|
||||
}
|
||||
|
||||
private static bool IsUnknownFinding(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Severity == PolicySeverity.Unknown)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var tag in finding.Tags)
|
||||
{
|
||||
if (string.Equals(tag, "state:unknown", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double ResolveUnknownAgeDays(PolicyFinding finding)
|
||||
{
|
||||
var ageTag = TryGetTagValue(finding.Tags, "unknown-age-days:");
|
||||
if (!string.IsNullOrWhiteSpace(ageTag) &&
|
||||
double.TryParse(ageTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedAge) &&
|
||||
parsedAge >= 0)
|
||||
{
|
||||
return parsedAge;
|
||||
}
|
||||
|
||||
var sinceTag = TryGetTagValue(finding.Tags, "unknown-since:");
|
||||
if (string.IsNullOrWhiteSpace(sinceTag))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(sinceTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var since))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var observedTag = TryGetTagValue(finding.Tags, "observed-at:");
|
||||
if (!string.IsNullOrWhiteSpace(observedTag) &&
|
||||
DateTimeOffset.TryParse(observedTag, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var observed) &&
|
||||
observed > since)
|
||||
{
|
||||
return Math.Max(0, (observed - since).TotalDays);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? ResolveTrustKey(PolicyFinding finding)
|
||||
{
|
||||
if (!finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
var tagged = TryGetTagValue(finding.Tags, "trust:");
|
||||
if (!string.IsNullOrWhiteSpace(tagged))
|
||||
{
|
||||
return tagged;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Source))
|
||||
{
|
||||
return finding.Source;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(finding.Vendor))
|
||||
{
|
||||
return finding.Vendor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveTrustWeight(PolicyScoringConfig config, string? key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
return config.TrustOverrides.TryGetValue(key, out var weight) ? weight : 1.0;
|
||||
}
|
||||
|
||||
private static string? ResolveReachabilityKey(PolicyFinding finding)
|
||||
{
|
||||
if (finding.Tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var reachability = TryGetTagValue(finding.Tags, "reachability:");
|
||||
if (!string.IsNullOrWhiteSpace(reachability))
|
||||
{
|
||||
return reachability;
|
||||
}
|
||||
|
||||
var usage = TryGetTagValue(finding.Tags, "usage:");
|
||||
if (!string.IsNullOrWhiteSpace(usage))
|
||||
{
|
||||
return usage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ResolveReachabilityWeight(PolicyScoringConfig config, string? key, out string? resolvedKey)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(key) && config.ReachabilityBuckets.TryGetValue(key, out var weight))
|
||||
{
|
||||
resolvedKey = key;
|
||||
return weight;
|
||||
}
|
||||
|
||||
if (config.ReachabilityBuckets.TryGetValue("unknown", out var unknownWeight))
|
||||
{
|
||||
resolvedKey = "unknown";
|
||||
return unknownWeight;
|
||||
}
|
||||
|
||||
resolvedKey = key;
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static string? TryGetTagValue(ImmutableArray<string> tags, string prefix)
|
||||
{
|
||||
if (tags.IsDefaultOrEmpty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = tag[prefix.Length..].Trim();
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readonly record struct ScoringComponents(
|
||||
double SeverityWeight,
|
||||
double TrustWeight,
|
||||
double ReachabilityWeight,
|
||||
double BaseScore,
|
||||
string? TrustKey,
|
||||
string? ReachabilityKey);
|
||||
|
||||
private readonly struct UnknownConfidenceResult
|
||||
{
|
||||
public UnknownConfidenceResult(double ageDays, double confidence, PolicyUnknownConfidenceBand band)
|
||||
{
|
||||
AgeDays = ageDays;
|
||||
Confidence = confidence;
|
||||
Band = band;
|
||||
}
|
||||
|
||||
public double AgeDays { get; }
|
||||
|
||||
public double Confidence { get; }
|
||||
|
||||
public PolicyUnknownConfidenceBand Band { get; }
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
51
src/StellaOps.Policy/PolicyFinding.cs
Normal file
51
src/StellaOps.Policy/PolicyFinding.cs
Normal 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);
|
||||
}
|
||||
28
src/StellaOps.Policy/PolicyIssue.cs
Normal file
28
src/StellaOps.Policy/PolicyIssue.cs
Normal 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,
|
||||
}
|
||||
18
src/StellaOps.Policy/PolicyPreviewModels.cs
Normal file
18
src/StellaOps.Policy/PolicyPreviewModels.cs
Normal 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);
|
||||
142
src/StellaOps.Policy/PolicyPreviewService.cs
Normal file
142
src/StellaOps.Policy/PolicyPreviewService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/StellaOps.Policy/PolicySchemaResource.cs
Normal file
30
src/StellaOps.Policy/PolicySchemaResource.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
18
src/StellaOps.Policy/PolicyScoringConfig.cs
Normal file
18
src/StellaOps.Policy/PolicyScoringConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
ImmutableDictionary<string, double> ReachabilityBuckets,
|
||||
PolicyUnknownConfidenceConfig UnknownConfidence)
|
||||
{
|
||||
public static string BaselineVersion => "1.0";
|
||||
|
||||
public static PolicyScoringConfig Default { get; } = PolicyScoringConfigBinder.LoadDefault();
|
||||
}
|
||||
603
src/StellaOps.Policy/PolicyScoringConfigBinder.cs
Normal file
603
src/StellaOps.Policy/PolicyScoringConfigBinder.cs
Normal file
@@ -0,0 +1,603 @@
|
||||
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 Json.Schema;
|
||||
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 JsonSchema ScoringSchema = PolicyScoringSchema.Schema;
|
||||
|
||||
private static readonly ImmutableDictionary<string, double> DefaultReachabilityBuckets = CreateDefaultReachabilityBuckets();
|
||||
|
||||
private static readonly PolicyUnknownConfidenceConfig DefaultUnknownConfidence = CreateDefaultUnknownConfidence();
|
||||
|
||||
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 schemaIssues = ValidateAgainstSchema(root);
|
||||
issues.AddRange(schemaIssues);
|
||||
if (schemaIssues.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
|
||||
{
|
||||
return new PolicyScoringBindingResult(false, null, issues.ToImmutable());
|
||||
}
|
||||
|
||||
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 ImmutableArray<PolicyIssue> ValidateAgainstSchema(JsonNode root)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(root.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = false,
|
||||
}));
|
||||
|
||||
var result = ScoringSchema.Evaluate(document.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List,
|
||||
RequireFormatValidation = true,
|
||||
});
|
||||
|
||||
if (result.IsValid)
|
||||
{
|
||||
return ImmutableArray<PolicyIssue>.Empty;
|
||||
}
|
||||
|
||||
var issues = ImmutableArray.CreateBuilder<PolicyIssue>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
CollectSchemaIssues(result, issues, seen);
|
||||
return issues.ToImmutable();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return ImmutableArray.Create(PolicyIssue.Error("scoring.schema.normalize", $"Failed to normalize scoring configuration for schema validation: {ex.Message}", "$"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectSchemaIssues(EvaluationResults result, ImmutableArray<PolicyIssue>.Builder issues, HashSet<string> seen)
|
||||
{
|
||||
if (result.Errors is { Count: > 0 })
|
||||
{
|
||||
foreach (var pair in result.Errors)
|
||||
{
|
||||
var keyword = SanitizeKeyword(pair.Key);
|
||||
var path = ConvertPointerToPath(result.InstanceLocation?.ToString() ?? "#");
|
||||
var message = pair.Value ?? "Schema violation.";
|
||||
var key = $"{path}|{keyword}|{message}";
|
||||
if (seen.Add(key))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error($"scoring.schema.{keyword}", message, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Details is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var detail in result.Details)
|
||||
{
|
||||
CollectSchemaIssues(detail, issues, seen);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertPointerToPath(string pointer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pointer) || pointer == "#")
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
if (pointer[0] == '#')
|
||||
{
|
||||
pointer = pointer.Length > 1 ? pointer[1..] : string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(pointer))
|
||||
{
|
||||
return "$";
|
||||
}
|
||||
|
||||
var segments = pointer.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var builder = new StringBuilder("$");
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var unescaped = segment.Replace("~1", "/").Replace("~0", "~");
|
||||
if (int.TryParse(unescaped, out var index))
|
||||
{
|
||||
builder.Append('[').Append(index).Append(']');
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('.').Append(unescaped);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string SanitizeKeyword(string keyword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(keyword.Length);
|
||||
foreach (var ch in keyword)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(char.ToLowerInvariant(ch));
|
||||
}
|
||||
else if (ch is '.' or '_' or '-')
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append('_');
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length == 0 ? "unknown" : builder.ToString();
|
||||
}
|
||||
|
||||
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);
|
||||
var reachabilityBuckets = ReadReachabilityBuckets(obj, issues);
|
||||
var unknownConfidence = ReadUnknownConfidence(obj, issues);
|
||||
|
||||
return new PolicyScoringConfig(
|
||||
version,
|
||||
severityWeights,
|
||||
quietPenalty,
|
||||
warnPenalty,
|
||||
ignorePenalty,
|
||||
trustOverrides,
|
||||
reachabilityBuckets,
|
||||
unknownConfidence);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> CreateDefaultReachabilityBuckets()
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
builder["entrypoint"] = 1.0;
|
||||
builder["direct"] = 0.85;
|
||||
builder["indirect"] = 0.6;
|
||||
builder["runtime"] = 0.45;
|
||||
builder["unreachable"] = 0.25;
|
||||
builder["unknown"] = 0.5;
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig CreateDefaultUnknownConfidence()
|
||||
{
|
||||
var bands = ImmutableArray.Create(
|
||||
new PolicyUnknownConfidenceBand("high", 0.65, "Fresh unknowns with recent telemetry."),
|
||||
new PolicyUnknownConfidenceBand("medium", 0.35, "Unknowns aging toward action required."),
|
||||
new PolicyUnknownConfidenceBand("low", 0.0, "Stale unknowns that must be triaged."));
|
||||
return new PolicyUnknownConfidenceConfig(0.8, 0.05, 0.2, bands);
|
||||
}
|
||||
|
||||
private static ImmutableDictionary<string, double> ReadReachabilityBuckets(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("reachabilityBuckets", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.default", "reachabilityBuckets not specified; defaulting to baseline weights.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
if (node is not JsonObject bucketsObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.reachability.type", "reachabilityBuckets must be an object.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in bucketsObj)
|
||||
{
|
||||
if (pair.Value is null)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.null", $"Bucket '{pair.Key}' is null; defaulting to 0.", $"$.reachabilityBuckets.{pair.Key}"));
|
||||
builder[pair.Key] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = ExtractDouble(pair.Value, issues, $"$.reachabilityBuckets.{pair.Key}");
|
||||
builder[pair.Key] = value;
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.reachability.empty", "No reachability buckets defined; using defaults.", "$.reachabilityBuckets"));
|
||||
return DefaultReachabilityBuckets;
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static PolicyUnknownConfidenceConfig ReadUnknownConfidence(JsonObject obj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!obj.TryGetPropertyValue("unknownConfidence", out var node))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.default", "unknownConfidence not specified; defaulting to baseline decay settings.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
if (node is not JsonObject configObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.type", "unknownConfidence must be an object.", "$.unknownConfidence"));
|
||||
return DefaultUnknownConfidence;
|
||||
}
|
||||
|
||||
var initial = DefaultUnknownConfidence.Initial;
|
||||
if (configObj.TryGetPropertyValue("initial", out var initialNode))
|
||||
{
|
||||
initial = ExtractDouble(initialNode, issues, "$.unknownConfidence.initial");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.default", "initial not specified; using baseline value.", "$.unknownConfidence.initial"));
|
||||
}
|
||||
|
||||
var decay = DefaultUnknownConfidence.DecayPerDay;
|
||||
if (configObj.TryGetPropertyValue("decayPerDay", out var decayNode))
|
||||
{
|
||||
decay = ExtractDouble(decayNode, issues, "$.unknownConfidence.decayPerDay");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.default", "decayPerDay not specified; using baseline value.", "$.unknownConfidence.decayPerDay"));
|
||||
}
|
||||
|
||||
var floor = DefaultUnknownConfidence.Floor;
|
||||
if (configObj.TryGetPropertyValue("floor", out var floorNode))
|
||||
{
|
||||
floor = ExtractDouble(floorNode, issues, "$.unknownConfidence.floor");
|
||||
}
|
||||
else
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.default", "floor not specified; using baseline value.", "$.unknownConfidence.floor"));
|
||||
}
|
||||
|
||||
var bands = ReadConfidenceBands(configObj, issues);
|
||||
if (bands.IsDefaultOrEmpty)
|
||||
{
|
||||
bands = DefaultUnknownConfidence.Bands;
|
||||
}
|
||||
|
||||
if (initial < 0 || initial > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.initial.range", "initial confidence should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.initial"));
|
||||
initial = Math.Clamp(initial, 0, 1);
|
||||
}
|
||||
|
||||
if (decay < 0 || decay > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.decay.range", "decayPerDay should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.decayPerDay"));
|
||||
decay = Math.Clamp(decay, 0, 1);
|
||||
}
|
||||
|
||||
if (floor < 0 || floor > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.floor.range", "floor should be between 0 and 1. Clamping to valid range.", "$.unknownConfidence.floor"));
|
||||
floor = Math.Clamp(floor, 0, 1);
|
||||
}
|
||||
|
||||
return new PolicyUnknownConfidenceConfig(initial, decay, floor, bands);
|
||||
}
|
||||
|
||||
private static ImmutableArray<PolicyUnknownConfidenceBand> ReadConfidenceBands(JsonObject configObj, ImmutableArray<PolicyIssue>.Builder issues)
|
||||
{
|
||||
if (!configObj.TryGetPropertyValue("bands", out var node))
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
if (node is not JsonArray array)
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.bands.type", "unknownConfidence.bands must be an array.", "$.unknownConfidence.bands"));
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<PolicyUnknownConfidenceBand>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var index = 0; index < array.Count; index++)
|
||||
{
|
||||
var element = array[index];
|
||||
if (element is not JsonObject bandObj)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.type", "Band entry must be an object.", $"$.unknownConfidence.bands[{index}]"));
|
||||
continue;
|
||||
}
|
||||
|
||||
string? name = null;
|
||||
if (bandObj.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue nameValue && nameValue.TryGetValue(out string? text))
|
||||
{
|
||||
name = text?.Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.name", "Band entry requires a non-empty 'name'.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(name))
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.duplicate", $"Duplicate band '{name}' encountered.", $"$.unknownConfidence.bands[{index}].name"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bandObj.TryGetPropertyValue("min", out var minNode))
|
||||
{
|
||||
issues.Add(PolicyIssue.Error("scoring.unknown.band.min", $"Band '{name}' is missing 'min'.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var min = ExtractDouble(minNode, issues, $"$.unknownConfidence.bands[{index}].min");
|
||||
if (min < 0 || min > 1)
|
||||
{
|
||||
issues.Add(PolicyIssue.Warning("scoring.unknown.band.range", $"Band '{name}' min should be between 0 and 1. Clamping to valid range.", $"$.unknownConfidence.bands[{index}].min"));
|
||||
min = Math.Clamp(min, 0, 1);
|
||||
}
|
||||
|
||||
string? description = null;
|
||||
if (bandObj.TryGetPropertyValue("description", out var descriptionNode) && descriptionNode is JsonValue descriptionValue && descriptionValue.TryGetValue(out string? descriptionText))
|
||||
{
|
||||
description = descriptionText?.Trim();
|
||||
}
|
||||
|
||||
builder.Add(new PolicyUnknownConfidenceBand(name, min, description));
|
||||
}
|
||||
|
||||
if (builder.Count == 0)
|
||||
{
|
||||
return ImmutableArray<PolicyUnknownConfidenceBand>.Empty;
|
||||
}
|
||||
|
||||
return builder.ToImmutable()
|
||||
.OrderByDescending(static band => band.Min)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
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 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.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());
|
||||
}
|
||||
}
|
||||
}
|
||||
100
src/StellaOps.Policy/PolicyScoringConfigDigest.cs
Normal file
100
src/StellaOps.Policy/PolicyScoringConfigDigest.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
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 PolicyScoringConfigDigest
|
||||
{
|
||||
public static string Compute(PolicyScoringConfig config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
SkipValidation = true,
|
||||
}))
|
||||
{
|
||||
WriteConfig(writer, config);
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(buffer.WrittenSpan);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void WriteConfig(Utf8JsonWriter writer, PolicyScoringConfig config)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("version", config.Version);
|
||||
|
||||
writer.WritePropertyName("severityWeights");
|
||||
writer.WriteStartObject();
|
||||
foreach (var severity in Enum.GetValues<PolicySeverity>())
|
||||
{
|
||||
var key = severity.ToString();
|
||||
var value = config.SeverityWeights.TryGetValue(severity, out var weight) ? weight : 0;
|
||||
writer.WriteNumber(key, value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WriteNumber("quietPenalty", config.QuietPenalty);
|
||||
writer.WriteNumber("warnPenalty", config.WarnPenalty);
|
||||
writer.WriteNumber("ignorePenalty", config.IgnorePenalty);
|
||||
|
||||
if (!config.TrustOverrides.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("trustOverrides");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.TrustOverrides.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
if (!config.ReachabilityBuckets.IsEmpty)
|
||||
{
|
||||
writer.WritePropertyName("reachabilityBuckets");
|
||||
writer.WriteStartObject();
|
||||
foreach (var pair in config.ReachabilityBuckets.OrderBy(static kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteNumber(pair.Key, pair.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WritePropertyName("unknownConfidence");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteNumber("initial", config.UnknownConfidence.Initial);
|
||||
writer.WriteNumber("decayPerDay", config.UnknownConfidence.DecayPerDay);
|
||||
writer.WriteNumber("floor", config.UnknownConfidence.Floor);
|
||||
|
||||
if (!config.UnknownConfidence.Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
writer.WritePropertyName("bands");
|
||||
writer.WriteStartArray();
|
||||
foreach (var band in config.UnknownConfidence.Bands
|
||||
.OrderByDescending(static b => b.Min)
|
||||
.ThenBy(static b => b.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", band.Name);
|
||||
writer.WriteNumber("min", band.Min);
|
||||
if (!string.IsNullOrWhiteSpace(band.Description))
|
||||
{
|
||||
writer.WriteString("description", band.Description);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
27
src/StellaOps.Policy/PolicyScoringSchema.cs
Normal file
27
src/StellaOps.Policy/PolicyScoringSchema.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Json.Schema;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public static class PolicyScoringSchema
|
||||
{
|
||||
private const string SchemaResourceName = "StellaOps.Policy.Schemas.policy-scoring-schema@1.json";
|
||||
|
||||
private static readonly Lazy<JsonSchema> CachedSchema = new(LoadSchema, LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static JsonSchema Schema => CachedSchema.Value;
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using var stream = assembly.GetManifestResourceStream(SchemaResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{SchemaResourceName}' was not found.");
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var schemaJson = reader.ReadToEnd();
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
}
|
||||
29
src/StellaOps.Policy/PolicySnapshot.cs
Normal file
29
src/StellaOps.Policy/PolicySnapshot.cs
Normal 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);
|
||||
101
src/StellaOps.Policy/PolicySnapshotStore.cs
Normal file
101
src/StellaOps.Policy/PolicySnapshotStore.cs
Normal 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);
|
||||
}
|
||||
37
src/StellaOps.Policy/PolicyUnknownConfidenceConfig.cs
Normal file
37
src/StellaOps.Policy/PolicyUnknownConfidenceConfig.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyUnknownConfidenceConfig(
|
||||
double Initial,
|
||||
double DecayPerDay,
|
||||
double Floor,
|
||||
ImmutableArray<PolicyUnknownConfidenceBand> Bands)
|
||||
{
|
||||
public double Clamp(double value)
|
||||
=> Math.Clamp(value, Floor, 1.0);
|
||||
|
||||
public PolicyUnknownConfidenceBand ResolveBand(double value)
|
||||
{
|
||||
if (Bands.IsDefaultOrEmpty)
|
||||
{
|
||||
return PolicyUnknownConfidenceBand.Default;
|
||||
}
|
||||
|
||||
foreach (var band in Bands)
|
||||
{
|
||||
if (value >= band.Min)
|
||||
{
|
||||
return band;
|
||||
}
|
||||
}
|
||||
|
||||
return Bands[Bands.Length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PolicyUnknownConfidenceBand(string Name, double Min, string? Description = null)
|
||||
{
|
||||
public static PolicyUnknownConfidenceBand Default { get; } = new("unspecified", 0, null);
|
||||
}
|
||||
241
src/StellaOps.Policy/PolicyValidationCli.cs
Normal file
241
src/StellaOps.Policy/PolicyValidationCli.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/StellaOps.Policy/PolicyVerdict.cs
Normal file
112
src/StellaOps.Policy/PolicyVerdict.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
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,
|
||||
double? UnknownConfidence = null,
|
||||
string? ConfidenceBand = null,
|
||||
double? UnknownAgeDays = null,
|
||||
string? SourceTrust = null,
|
||||
string? Reachability = null)
|
||||
{
|
||||
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,
|
||||
UnknownConfidence: null,
|
||||
ConfidenceBand: null,
|
||||
UnknownAgeDays: null,
|
||||
SourceTrust: null,
|
||||
Reachability: null);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var baselineConfidence = Baseline.UnknownConfidence ?? 0;
|
||||
var projectedConfidence = Projected.UnknownConfidence ?? 0;
|
||||
if (Math.Abs(baselineConfidence - projectedConfidence) > 0.0001)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.ConfidenceBand, Projected.ConfidenceBand, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.SourceTrust, Projected.SourceTrust, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.Equals(Baseline.Reachability, Projected.Reachability, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/StellaOps.Policy/Schemas/policy-schema@1.json
Normal file
176
src/StellaOps.Policy/Schemas/policy-schema@1.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/StellaOps.Policy/Schemas/policy-scoring-default.json
Normal file
51
src/StellaOps.Policy/Schemas/policy-scoring-default.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"entrypoint": 1.0,
|
||||
"direct": 0.85,
|
||||
"indirect": 0.6,
|
||||
"runtime": 0.45,
|
||||
"unreachable": 0.25,
|
||||
"unknown": 0.5
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"initial": 0.8,
|
||||
"decayPerDay": 0.05,
|
||||
"floor": 0.2,
|
||||
"bands": [
|
||||
{
|
||||
"name": "high",
|
||||
"min": 0.65,
|
||||
"description": "Fresh unknowns with recent telemetry."
|
||||
},
|
||||
{
|
||||
"name": "medium",
|
||||
"min": 0.35,
|
||||
"description": "Unknowns aging toward action required."
|
||||
},
|
||||
{
|
||||
"name": "low",
|
||||
"min": 0.0,
|
||||
"description": "Stale unknowns that must be triaged."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
156
src/StellaOps.Policy/Schemas/policy-scoring-schema@1.json
Normal file
156
src/StellaOps.Policy/Schemas/policy-scoring-schema@1.json
Normal file
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://schemas.stella-ops.org/policy/policy-scoring-schema@1.json",
|
||||
"title": "StellaOps Policy Scoring Configuration v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"severityWeights"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"severityWeights": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"Critical",
|
||||
"High",
|
||||
"Medium",
|
||||
"Low",
|
||||
"Informational",
|
||||
"None",
|
||||
"Unknown"
|
||||
],
|
||||
"properties": {
|
||||
"Critical": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"High": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Medium": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Low": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Informational": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"None": {
|
||||
"$ref": "#/$defs/weight"
|
||||
},
|
||||
"Unknown": {
|
||||
"$ref": "#/$defs/weight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"quietPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"warnPenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"ignorePenalty": {
|
||||
"$ref": "#/$defs/penalty"
|
||||
},
|
||||
"trustOverrides": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/trustWeight"
|
||||
}
|
||||
},
|
||||
"reachabilityBuckets": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"$ref": "#/$defs/reachabilityWeight"
|
||||
}
|
||||
},
|
||||
"unknownConfidence": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"initial",
|
||||
"decayPerDay",
|
||||
"floor",
|
||||
"bands"
|
||||
],
|
||||
"properties": {
|
||||
"initial": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"decayPerDay": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"floor": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"bands": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"min"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_.-]*$"
|
||||
},
|
||||
"min": {
|
||||
"$ref": "#/$defs/confidence"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"weight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 100
|
||||
},
|
||||
"penalty": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200
|
||||
},
|
||||
"trustWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 5
|
||||
},
|
||||
"reachabilityWeight": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1.5
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/StellaOps.Policy/StellaOps.Policy.csproj
Normal file
22
src/StellaOps.Policy/StellaOps.Policy.csproj
Normal file
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<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" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
|
||||
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
14
src/StellaOps.Policy/Storage/IPolicySnapshotRepository.cs
Normal file
14
src/StellaOps.Policy/Storage/IPolicySnapshotRepository.cs
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/StellaOps.Policy/TASKS.md
Normal file
19
src/StellaOps.Policy/TASKS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Policy Engine Task Board (Sprint 9)
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| 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 | DOING (2025-10-19) | Policy Guild | — | Versioned scoring config with schema validation, trust table, and golden fixtures. | Scoring config documented; fixtures stored; validation CLI passes. |
|
||||
| POLICY-CORE-09-005 | DOING (2025-10-19) | Policy Guild | — | 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 | DOING (2025-10-19) | Policy Guild | — | Unknown state & confidence decay – deterministic bands surfaced in policy outputs. | Confidence decay tests pass; docs updated; preview endpoint displays banding. |
|
||||
| POLICY-CORE-09-004 | DONE | 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 | DONE | 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 | DONE | 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. |
|
||||
| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags. | Contract note published, sample payload agreed with Scanner team, dependencies captured in scanner/runtime task boards. |
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user