up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,12 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,52 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +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);
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Policy;
|
||||
|
||||
public sealed record PolicyAuditEntry(
|
||||
Guid Id,
|
||||
DateTimeOffset CreatedAt,
|
||||
string Action,
|
||||
string RevisionId,
|
||||
string Digest,
|
||||
string? Actor,
|
||||
string Message);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
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");
|
||||
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)
|
||||
{
|
||||
@@ -90,143 +90,143 @@ public static class PolicyDigest
|
||||
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 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)
|
||||
|
||||
@@ -23,164 +23,164 @@ public sealed record PolicyDocument(
|
||||
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();
|
||||
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)
|
||||
|| lower.EndsWith(".stella", 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;
|
||||
}
|
||||
|
||||
|
||||
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 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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,51 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,28 +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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,18 +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);
|
||||
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);
|
||||
|
||||
@@ -1,142 +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)
|
||||
{
|
||||
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, out _);
|
||||
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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,100 +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();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +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);
|
||||
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);
|
||||
|
||||
@@ -1,101 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,37 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -83,170 +83,170 @@ public sealed class PolicyValidationCli
|
||||
Recommendations = diagnostics.Recommendations.Add($"canonical.spl.digest:{splHash}"),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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)
|
||||
|| extension.Equals(".stella", 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,65 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user