up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-26 07:47:08 +02:00
parent 56e2f64d07
commit 1c782897f7
184 changed files with 8991 additions and 649 deletions

View File

@@ -1,8 +1,8 @@
# StellaOps.Policy — Agent Charter
## Mission
Deliver the policy engine outlined in `docs/modules/scanner/ARCHITECTURE.md` and related prose:
- Define YAML schema (ignore rules, VEX inclusion/exclusion, vendor precedence, license gates).
Deliver the policy engine outlined in `docs/modules/policy/architecture.md`:
- Define SPL v1 schema (policy documents, statements, conditions) and scoring schema; keep fixtures and embedded resources current.
- Provide policy snapshot storage with revision digests and diagnostics.
- Offer preview APIs to compare policy impacts on existing reports.

View File

@@ -6,8 +6,12 @@ namespace StellaOps.Policy;
public static class PolicyEvaluation
{
public static PolicyVerdict EvaluateFinding(PolicyDocument document, PolicyScoringConfig scoringConfig, PolicyFinding finding)
{
public static PolicyVerdict EvaluateFinding(
PolicyDocument document,
PolicyScoringConfig scoringConfig,
PolicyFinding finding,
out PolicyExplanation? explanation)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
@@ -40,35 +44,49 @@ public static class PolicyEvaluation
resolvedReachabilityKey);
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, out explanation);
}
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Allowed,
null,
"No rule matched; baseline applied",
ImmutableArray.Create(PolicyExplanationNode.Leaf("rule", "No matching rule")));
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
return ApplyUnknownConfidence(baseline, unknownConfidence);
}
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence);
}
var baseline = PolicyVerdict.CreateBaseline(finding.FindingId, scoringConfig);
return ApplyUnknownConfidence(baseline, unknownConfidence);
}
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
ScoringComponents components,
UnknownConfidenceResult? unknownConfidence)
{
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
ScoringComponents components,
UnknownConfidenceResult? unknownConfidence,
out PolicyExplanation explanation)
{
var action = rule.Action;
var status = MapAction(action);
var notes = BuildNotes(action);
var notes = BuildNotes(action);
var explanationNodes = ImmutableArray.CreateBuilder<PolicyExplanationNode>();
explanationNodes.Add(PolicyExplanationNode.Leaf("rule", $"Matched rule '{rule.Name}'", rule.Identifier));
var inputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
inputs["severityWeight"] = components.SeverityWeight;
inputs["trustWeight"] = components.TrustWeight;
inputs["reachabilityWeight"] = components.ReachabilityWeight;
inputs["baseScore"] = components.BaseScore;
inputs["baseScore"] = components.BaseScore;
explanationNodes.Add(PolicyExplanationNode.Branch("score", "Base score", components.BaseScore.ToString(CultureInfo.InvariantCulture),
PolicyExplanationNode.Leaf("severityWeight", "Severity weight", components.SeverityWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("trustWeight", "Trust weight", components.TrustWeight.ToString(CultureInfo.InvariantCulture)),
PolicyExplanationNode.Leaf("reachabilityWeight", "Reachability weight", components.ReachabilityWeight.ToString(CultureInfo.InvariantCulture))));
if (!string.IsNullOrWhiteSpace(components.TrustKey))
{
inputs[$"trustWeight.{components.TrustKey}"] = components.TrustWeight;
@@ -79,13 +97,14 @@ public static class PolicyEvaluation
}
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
{
notes = AppendNote(notes, description);
}
if (unknownConfidence is { } unknownDetails)
{
inputs["unknownConfidence"] = unknownDetails.Confidence;
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
}
notes = AppendNote(notes, description);
explanationNodes.Add(PolicyExplanationNode.Leaf("unknown", description));
}
if (unknownConfidence is { } unknownDetails)
{
inputs["unknownConfidence"] = unknownDetails.Confidence;
inputs["unknownAgeDays"] = unknownDetails.AgeDays;
}
double score = components.BaseScore;
string? quietedBy = null;
@@ -94,8 +113,8 @@ public static class PolicyEvaluation
var quietRequested = action.Quiet;
var quietAllowed = quietRequested && (action.RequireVex is not null || action.Type == PolicyActionType.RequireVex);
if (quietRequested && !quietAllowed)
{
if (quietRequested && !quietAllowed)
{
var warnInputs = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in inputs)
{
@@ -112,10 +131,17 @@ public static class PolicyEvaluation
var warnScore = Math.Max(0, components.BaseScore - warnPenalty);
var warnNotes = AppendNote(notes, "Quiet flag ignored: rule must specify requireVex justifications.");
return new PolicyVerdict(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
"Quiet flag ignored; requireVex not provided",
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
PolicyVerdictStatus.Warned,
rule.Name,
action.Type.ToString(),
warnNotes,
warnScore,
@@ -130,33 +156,56 @@ public static class PolicyEvaluation
Reachability: components.ReachabilityKey);
}
switch (status)
{
case PolicyVerdictStatus.Ignored:
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
break;
case PolicyVerdictStatus.Warned:
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
break;
case PolicyVerdictStatus.Deferred:
var deferPenalty = config.WarnPenalty / 2;
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
break;
}
if (status != PolicyVerdictStatus.Allowed)
{
explanationNodes.Add(PolicyExplanationNode.Leaf("action", $"Action {action.Type}", status.ToString()));
}
switch (status)
{
case PolicyVerdictStatus.Ignored:
score = ApplyPenalty(score, config.IgnorePenalty, inputs, "ignorePenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Ignore penalty", config.IgnorePenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Warned:
score = ApplyPenalty(score, config.WarnPenalty, inputs, "warnPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Warn penalty", config.WarnPenalty.ToString(CultureInfo.InvariantCulture)));
break;
case PolicyVerdictStatus.Deferred:
var deferPenalty = config.WarnPenalty / 2;
score = ApplyPenalty(score, deferPenalty, inputs, "deferPenalty");
explanationNodes.Add(PolicyExplanationNode.Leaf("penalty", "Defer penalty", deferPenalty.ToString(CultureInfo.InvariantCulture)));
break;
}
if (quietAllowed)
{
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
quietedBy = rule.Name;
quiet = true;
}
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
if (quietAllowed)
{
score = ApplyPenalty(score, config.QuietPenalty, inputs, "quietPenalty");
quietedBy = rule.Name;
quiet = true;
explanationNodes.Add(PolicyExplanationNode.Leaf("quiet", "Quiet applied", config.QuietPenalty.ToString(CultureInfo.InvariantCulture)));
}
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes,
explanationNodes.ToImmutable());
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes,
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
score,
config.Version,
inputs.ToImmutable(),
@@ -180,12 +229,12 @@ public static class PolicyEvaluation
return Math.Max(0, score - penalty);
}
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
{
if (unknownConfidence is null)
{
return verdict;
}
private static PolicyVerdict ApplyUnknownConfidence(PolicyVerdict verdict, UnknownConfidenceResult? unknownConfidence)
{
if (unknownConfidence is null)
{
return verdict;
}
var inputsBuilder = ImmutableDictionary.CreateBuilder<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in verdict.GetInputs())
@@ -196,12 +245,12 @@ public static class PolicyEvaluation
inputsBuilder["unknownConfidence"] = unknownConfidence.Value.Confidence;
inputsBuilder["unknownAgeDays"] = unknownConfidence.Value.AgeDays;
return verdict with
{
Inputs = inputsBuilder.ToImmutable(),
UnknownConfidence = unknownConfidence.Value.Confidence,
ConfidenceBand = unknownConfidence.Value.Band.Name,
UnknownAgeDays = unknownConfidence.Value.AgeDays,
return verdict with
{
Inputs = inputsBuilder.ToImmutable(),
UnknownConfidence = unknownConfidence.Value.Confidence,
ConfidenceBand = unknownConfidence.Value.Band.Name,
UnknownAgeDays = unknownConfidence.Value.AgeDays,
};
}

View File

@@ -0,0 +1,48 @@
using System.Collections.Immutable;
namespace StellaOps.Policy;
/// <summary>
/// Structured explanation describing how a policy decision was reached.
/// </summary>
/// <param name="FindingId">Identifier of the evaluated finding.</param>
/// <param name="Decision">Final verdict status (e.g., Allow, Block, Warned).</param>
/// <param name="RuleName">Name of the rule that matched, if any.</param>
/// <param name="Reason">Human-readable summary.</param>
/// <param name="Nodes">Tree of evaluated nodes (rule, match, action, penalties, quieting, unknown confidence).</param>
public sealed record PolicyExplanation(
string FindingId,
PolicyVerdictStatus Decision,
string? RuleName,
string Reason,
ImmutableArray<PolicyExplanationNode> Nodes)
{
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Allowed, ruleName, reason, nodes.ToImmutableArray());
public static PolicyExplanation Block(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Blocked, ruleName, reason, nodes.ToImmutableArray());
public static PolicyExplanation Warn(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Warned, ruleName, reason, nodes.ToImmutableArray());
}
/// <summary>
/// A single explanation node with optional children to capture evaluation breadcrumbs.
/// </summary>
/// <param name="Kind">Short classifier (e.g., "rule", "match", "penalty", "quiet", "unknown").</param>
/// <param name="Label">Human-readable label.</param>
/// <param name="Detail">Optional detail (numeric or string rendered as text).</param>
/// <param name="Children">Nested explanation nodes.</param>
public sealed record PolicyExplanationNode(
string Kind,
string Label,
string? Detail,
ImmutableArray<PolicyExplanationNode> Children)
{
public static PolicyExplanationNode Leaf(string kind, string label, string? detail = null) =>
new(kind, label, detail, ImmutableArray<PolicyExplanationNode>.Empty);
public static PolicyExplanationNode Branch(string kind, string label, string? detail = null, params PolicyExplanationNode[] children) =>
new(kind, label, detail, children.ToImmutableArray());
}

View File

@@ -93,7 +93,7 @@ public sealed class PolicyPreviewService
var results = ImmutableArray.CreateBuilder<PolicyVerdict>(findings.Length);
foreach (var finding in findings)
{
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding);
var verdict = PolicyEvaluation.EvaluateFinding(document, scoringConfig, finding, out _);
results.Add(verdict);
}

View File

@@ -40,8 +40,8 @@ public sealed class PolicyValidationCli
_error = error ?? Console.Error;
}
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
{
public async Task<int> RunAsync(PolicyValidationCliOptions options, CancellationToken cancellationToken = default)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
@@ -71,8 +71,18 @@ public sealed class PolicyValidationCli
var format = PolicySchema.DetectFormat(path);
var content = await File.ReadAllTextAsync(path, cancellationToken);
var bindingResult = PolicyBinder.Bind(content, format);
var diagnostics = PolicyDiagnostics.Create(bindingResult);
var bindingResult = PolicyBinder.Bind(content, format);
var diagnostics = PolicyDiagnostics.Create(bindingResult);
if (bindingResult.Success && bindingResult.Document is { } doc)
{
var splJson = SplMigrationTool.ToSplPolicyJson(doc);
var splHash = SplCanonicalizer.ComputeDigest(splJson);
diagnostics = diagnostics with
{
Recommendations = diagnostics.Recommendations.Add($"canonical.spl.digest:{splHash}"),
};
}
results.Add(new PolicyValidationFileResult(path, bindingResult, diagnostics));
}

View File

@@ -0,0 +1,42 @@
{
"apiVersion": "spl.stellaops/v1",
"kind": "Policy",
"metadata": {
"name": "demo-access",
"description": "Sample SPL policy allowing read access to demo resources",
"labels": {
"env": "demo",
"owner": "policy-guild"
}
},
"spec": {
"defaultEffect": "deny",
"statements": [
{
"id": "allow-read-demo",
"effect": "allow",
"description": "Allow read on demo resources",
"match": {
"resource": "demo/*",
"actions": ["read"],
"reachability": "direct",
"exploitability": {
"epss": 0.42,
"kev": false
},
"conditions": [
{
"field": "request.tenant",
"operator": "eq",
"value": "demo"
}
]
},
"audit": {
"message": "demo read granted",
"severity": "info"
}
}
]
}
}

View File

@@ -0,0 +1,168 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.stellaops.io/policy/spl-schema@1.json",
"title": "Stella Policy Language (SPL) v1",
"type": "object",
"additionalProperties": false,
"required": ["apiVersion", "kind", "metadata", "spec"],
"properties": {
"apiVersion": {
"type": "string",
"const": "spl.stellaops/v1"
},
"kind": {
"type": "string",
"const": "Policy"
},
"metadata": {
"type": "object",
"additionalProperties": false,
"required": ["name"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$",
"description": "DNS-style name, 1-64 chars, lowercase, hyphen separated"
},
"description": {
"type": "string",
"maxLength": 512
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string",
"maxLength": 128
}
},
"annotations": {
"type": "object",
"additionalProperties": {
"type": "string",
"maxLength": 2048
}
}
}
},
"spec": {
"type": "object",
"additionalProperties": false,
"required": ["statements"],
"properties": {
"defaultEffect": {
"type": "string",
"enum": ["allow", "deny"],
"default": "deny"
},
"statements": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "effect", "match"],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]{1,64}$"
},
"effect": {
"type": "string",
"enum": ["allow", "deny"]
},
"description": {
"type": "string",
"maxLength": 512
},
"match": {
"type": "object",
"additionalProperties": false,
"required": ["resource", "actions"],
"properties": {
"resource": {
"type": "string",
"maxLength": 256
},
"actions": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"maxLength": 128
}
},
"reachability": {
"type": "string",
"enum": ["none", "indirect", "direct"],
"description": "Optional reachability asserted for the matched resource (e.g., entrypoint usage)."
},
"exploitability": {
"type": "object",
"additionalProperties": false,
"properties": {
"epss": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"kev": {
"type": "boolean",
"description": "Known exploited vulnerability flag."
}
}
},
"conditions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["field", "operator", "value"],
"properties": {
"field": {
"type": "string",
"maxLength": 256
},
"operator": {
"type": "string",
"enum": [
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"in",
"nin",
"contains",
"startsWith",
"endsWith"
]
},
"value": {}
}
}
}
}
},
"audit": {
"type": "object",
"additionalProperties": false,
"properties": {
"message": {
"type": "string",
"maxLength": 512
},
"severity": {
"type": "string",
"enum": ["info", "warn", "error"],
"default": "info"
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,195 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Policy;
/// <summary>
/// Canonicalizes SPL (Stella Policy Language) documents and produces stable digests.
/// Sorting is applied where order is not semantically meaningful (statements, actions, conditions)
/// so the same policy yields identical hashes regardless of authoring order or whitespace.
/// </summary>
public static class SplCanonicalizer
{
private static readonly JsonDocumentOptions DocumentOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
private static readonly JsonWriterOptions WriterOptions = new()
{
Indented = false,
SkipValidation = false,
};
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> json)
{
using var document = JsonDocument.Parse(json, DocumentOptions);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))
{
WriteCanonicalValue(writer, document.RootElement, Array.Empty<string>());
}
return buffer.WrittenSpan.ToArray();
}
public static string CanonicalizeToString(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
return Encoding.UTF8.GetString(CanonicalizeToUtf8(bytes));
}
public static string ComputeDigest(string json)
{
var bytes = Encoding.UTF8.GetBytes(json);
return ComputeDigest(bytes);
}
public static string ComputeDigest(ReadOnlySpan<byte> json)
{
var canonical = CanonicalizeToUtf8(json);
var hash = SHA256.HashData(canonical);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void WriteCanonicalValue(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
WriteCanonicalObject(writer, element, path);
break;
case JsonValueKind.Array:
WriteCanonicalArray(writer, element, path);
break;
default:
element.WriteTo(writer);
break;
}
}
private static void WriteCanonicalObject(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
{
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(static p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonicalValue(writer, property.Value, Append(path, property.Name));
}
writer.WriteEndObject();
}
private static void WriteCanonicalArray(Utf8JsonWriter writer, JsonElement element, IReadOnlyList<string> path)
{
writer.WriteStartArray();
IEnumerable<JsonElement> sequence = element.EnumerateArray();
if (IsStatementsPath(path))
{
sequence = sequence.OrderBy(GetStatementSortKey, StringComparer.Ordinal);
}
else if (IsActionsPath(path))
{
sequence = sequence.OrderBy(static v => v.GetString(), StringComparer.Ordinal);
}
else if (IsConditionsPath(path))
{
sequence = sequence.OrderBy(GetConditionSortKey, StringComparer.Ordinal);
}
foreach (var item in sequence)
{
WriteCanonicalValue(writer, item, path);
}
writer.WriteEndArray();
}
private static bool IsStatementsPath(IReadOnlyList<string> path)
=> path.Count >= 1 && path[^1] == "statements";
private static bool IsActionsPath(IReadOnlyList<string> path)
=> path.Count >= 1 && path[^1] == "actions";
private static bool IsConditionsPath(IReadOnlyList<string> path)
=> path.Count >= 1 && path[^1] == "conditions";
private static string GetStatementSortKey(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
{
return id.GetString() ?? string.Empty;
}
return string.Empty;
}
private static string GetConditionSortKey(JsonElement element)
{
var field = element.TryGetProperty("field", out var f) && f.ValueKind == JsonValueKind.String
? f.GetString() ?? string.Empty
: string.Empty;
var op = element.TryGetProperty("operator", out var o) && o.ValueKind == JsonValueKind.String
? o.GetString() ?? string.Empty
: string.Empty;
var value = element.TryGetProperty("value", out var v)
? CanonicalScalar(v)
: string.Empty;
return string.Create(field.Length + op.Length + value.Length + 2, (field, op, value),
static (span, state) =>
{
var (field, op, value) = state;
var offset = 0;
field.AsSpan().CopyTo(span);
offset += field.Length;
span[offset++] = '\u0001';
op.AsSpan().CopyTo(span[offset..]);
offset += op.Length;
span[offset++] = '\u0001';
value.AsSpan().CopyTo(span[offset..]);
});
}
private static string CanonicalScalar(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString() ?? string.Empty,
JsonValueKind.Number => element.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => "null",
_ => element.GetRawText(),
};
}
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
{
if (path.Count == 0)
{
return new[] { segment };
}
var next = new string[path.Count + 1];
for (var i = 0; i < path.Count; i++)
{
next[i] = path[i];
}
next[^1] = segment;
return next;
}
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Policy;
/// <summary>
/// Provides deterministic layering/override semantics for SPL (Stella Policy Language) documents.
/// Overlay statements replace base statements with the same <c>id</c>; metadata labels/annotations merge with overlay precedence.
/// The merged output is returned in canonicalized JSON form so hashes remain stable.
/// </summary>
public static class SplLayeringEngine
{
private static readonly JsonDocumentOptions DocumentOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// Merge two SPL documents and return canonical JSON (sorted properties/statements/actions/conditions).
/// </summary>
public static string Merge(string basePolicyJson, string overlayPolicyJson)
{
if (basePolicyJson is null) throw new ArgumentNullException(nameof(basePolicyJson));
if (overlayPolicyJson is null) throw new ArgumentNullException(nameof(overlayPolicyJson));
var merged = MergeToUtf8(Encoding.UTF8.GetBytes(basePolicyJson), Encoding.UTF8.GetBytes(overlayPolicyJson));
return Encoding.UTF8.GetString(merged);
}
/// <summary>
/// Merge two SPL documents and return canonical UTF-8 bytes.
/// </summary>
public static byte[] MergeToUtf8(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
{
var merged = MergeToJsonNode(basePolicyUtf8, overlayPolicyUtf8);
var raw = Encoding.UTF8.GetBytes(merged.ToJsonString(new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = null,
}));
return SplCanonicalizer.CanonicalizeToUtf8(raw);
}
private static JsonNode MergeToJsonNode(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
{
using var baseDoc = JsonDocument.Parse(basePolicyUtf8, DocumentOptions);
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8, DocumentOptions);
var baseRoot = baseDoc.RootElement;
var overlayRoot = overlayDoc.RootElement;
var result = new JsonObject();
// apiVersion/kind: overlay wins if present, else base.
result["apiVersion"] = overlayRoot.TryGetProperty("apiVersion", out var apiVersion)
? apiVersion.GetString()
: baseRoot.GetPropertyOrNull("apiVersion")?.GetString();
result["kind"] = overlayRoot.TryGetProperty("kind", out var kind)
? kind.GetString()
: baseRoot.GetPropertyOrNull("kind")?.GetString();
result["metadata"] = MergeMetadata(baseRoot.GetPropertyOrNull("metadata"), overlayRoot.GetPropertyOrNull("metadata"));
var mergedSpec = MergeSpec(baseRoot.GetPropertyOrNull("spec"), overlayRoot.GetPropertyOrNull("spec"));
if (mergedSpec is not null)
{
result["spec"] = mergedSpec;
}
// Preserve any other top-level fields with overlay precedence.
CopyUnknownProperties(baseRoot, result, skipNames: new[] { "apiVersion", "kind", "metadata", "spec" });
CopyUnknownProperties(overlayRoot, result, skipNames: new[] { "apiVersion", "kind", "metadata", "spec" });
return result;
}
private static JsonObject MergeSpec(JsonElement? baseSpec, JsonElement? overlaySpec)
{
var spec = new JsonObject();
if (baseSpec is { ValueKind: JsonValueKind.Object } b)
{
CopyAllProperties(b, spec);
}
if (overlaySpec is { ValueKind: JsonValueKind.Object } o)
{
CopyAllProperties(o, spec);
}
// defaultEffect: overlay wins, else base, else schema default "deny".
spec["defaultEffect"] = overlaySpec?.GetPropertyOrNull("defaultEffect")?.GetString()
?? baseSpec?.GetPropertyOrNull("defaultEffect")?.GetString()
?? "deny";
var mergedStatements = MergeStatements(baseSpec, overlaySpec);
spec["statements"] = mergedStatements;
return spec;
}
private static JsonArray MergeStatements(JsonElement? baseSpec, JsonElement? overlaySpec)
{
var statements = new Dictionary<string, JsonNode>(StringComparer.Ordinal);
void AddRange(JsonElement? spec)
{
if (spec is not { ValueKind: JsonValueKind.Object }) return;
if (!spec.Value.TryGetProperty("statements", out var stmts) || stmts.ValueKind != JsonValueKind.Array) return;
foreach (var statement in stmts.EnumerateArray())
{
if (statement.ValueKind != JsonValueKind.Object) continue;
if (!statement.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) continue;
var id = idProp.GetString() ?? string.Empty;
statements[id] = JsonNode.Parse(statement.GetRawText())!; // replace if already present
}
}
AddRange(baseSpec);
AddRange(overlaySpec);
var merged = new JsonArray();
foreach (var kvp in statements.OrderBy(k => k.Key, StringComparer.Ordinal))
{
merged.Add(kvp.Value);
}
return merged;
}
private static JsonObject MergeMetadata(JsonElement? baseMeta, JsonElement? overlayMeta)
{
var meta = new JsonObject();
if (baseMeta is { ValueKind: JsonValueKind.Object } b)
{
CopyAllProperties(b, meta);
}
if (overlayMeta is { ValueKind: JsonValueKind.Object } o)
{
CopyAllProperties(o, meta);
}
meta["labels"] = MergeStringMap(
baseMeta.GetPropertyOrNull("labels"),
overlayMeta.GetPropertyOrNull("labels"));
meta["annotations"] = MergeStringMap(
baseMeta.GetPropertyOrNull("annotations"),
overlayMeta.GetPropertyOrNull("annotations"));
return meta;
}
private static JsonObject MergeStringMap(JsonElement? baseMap, JsonElement? overlayMap)
{
var map = new JsonObject();
if (baseMap is { ValueKind: JsonValueKind.Object } b)
{
CopyAllProperties(b, map);
}
if (overlayMap is { ValueKind: JsonValueKind.Object } o)
{
CopyAllProperties(o, map);
}
return map;
}
private static void CopyAllProperties(JsonElement element, JsonObject target)
{
foreach (var property in element.EnumerateObject())
{
target[property.Name] = JsonNode.Parse(property.Value.GetRawText());
}
}
private static void CopyUnknownProperties(JsonElement element, JsonObject target, string[] skipNames)
{
var skip = new HashSet<string>(skipNames, StringComparer.Ordinal);
foreach (var property in element.EnumerateObject())
{
if (skip.Contains(property.Name))
{
continue;
}
target[property.Name] = JsonNode.Parse(property.Value.GetRawText());
}
}
private static JsonElement? GetPropertyOrNull(this JsonElement? element, string name)
{
if (element is not { ValueKind: JsonValueKind.Object })
{
return null;
}
return element.Value.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
}
}

View File

@@ -0,0 +1,168 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.Policy;
/// <summary>
/// Converts legacy <see cref="PolicyDocument"/> instances to SPL (Stella Policy Language) JSON packs.
/// Output is canonicalised for deterministic hashing and downstream packaging.
/// </summary>
public static class SplMigrationTool
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = null,
};
public static string ToSplPolicyJson(PolicyDocument document)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
}
var node = BuildNode(document);
var utf8 = Encoding.UTF8.GetBytes(node.ToJsonString(SerializerOptions));
var canonical = SplCanonicalizer.CanonicalizeToUtf8(utf8);
return Encoding.UTF8.GetString(canonical);
}
private static JsonNode BuildNode(PolicyDocument document)
{
var root = new JsonObject
{
["apiVersion"] = "spl.stellaops/v1",
["kind"] = "Policy",
["metadata"] = BuildMetadata(document.Metadata),
["spec"] = BuildSpec(document)
};
return root;
}
private static JsonObject BuildMetadata(ImmutableDictionary<string, string> metadata)
{
var labels = new JsonObject();
foreach (var pair in metadata.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
labels[pair.Key] = pair.Value;
}
return new JsonObject
{
["name"] = labels.TryGetPropertyValue("name", out var nameNode) && nameNode is JsonValue ? nameNode : null,
["labels"] = labels
};
}
private static JsonObject BuildSpec(PolicyDocument document)
{
var statements = new JsonArray();
foreach (var rule in document.Rules.OrderBy(static r => r.Identifier ?? r.Name, StringComparer.Ordinal))
{
statements.Add(BuildStatement(rule));
}
var spec = new JsonObject
{
["defaultEffect"] = "deny",
["statements"] = statements
};
return spec;
}
private static JsonObject BuildStatement(PolicyRule rule)
{
var id = rule.Identifier ?? Slug(rule.Name);
var effect = MapEffect(rule.Action.Type);
var statement = new JsonObject
{
["id"] = id,
["effect"] = effect,
["match"] = BuildMatch(rule.Match)
};
if (!string.IsNullOrWhiteSpace(rule.Description))
{
statement["description"] = rule.Description;
}
if (rule.Action.Type is PolicyActionType.Warn or PolicyActionType.Defer or PolicyActionType.Ignore)
{
statement["audit"] = new JsonObject
{
["message"] = rule.Justification ?? rule.Name,
["severity"] = rule.Action.Type == PolicyActionType.Warn ? "warn" : "info"
};
}
return statement;
}
private static JsonObject BuildMatch(PolicyRuleMatchCriteria match)
{
var actions = new JsonArray();
var resources = new JsonArray();
foreach (var pkg in match.Packages)
{
resources.Add(pkg);
actions.Add("use");
}
foreach (var path in match.Paths)
{
resources.Add(path);
actions.Add("access");
}
// Ensure at least one action + resource to satisfy SPL schema.
if (resources.Count == 0)
{
resources.Add("*");
actions.Add("read");
}
return new JsonObject
{
["resource"] = resources[0],
["actions"] = actions
};
}
private static string MapEffect(PolicyActionType type) => type switch
{
PolicyActionType.Block => "deny",
PolicyActionType.Escalate => "deny",
PolicyActionType.RequireVex => "deny",
_ => "allow",
};
private static string Slug(string name)
{
if (string.IsNullOrWhiteSpace(name))
{
return "unnamed";
}
var chars = name.ToLowerInvariant()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')
.ToArray();
var slug = new string(chars);
while (slug.Contains("--", StringComparison.Ordinal))
{
slug = slug.Replace("--", "-", StringComparison.Ordinal);
}
return slug.Trim('-');
}
}

View File

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

View File

@@ -14,9 +14,11 @@
<PackageReference Include="JsonSchema.Net" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
</ItemGroup>
</Project>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-schema@1.json" />
<EmbeddedResource Include="Schemas\policy-scoring-default.json" />
<EmbeddedResource Include="Schemas\policy-scoring-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-schema@1.json" />
<EmbeddedResource Include="Schemas\spl-sample@1.json" />
</ItemGroup>
</Project>

View File

@@ -3,3 +3,8 @@
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| POLICY-EXC-25-001 | DONE (2025-10-27) | Policy Guild, Governance Guild | POLICY-SPL-23-001 | Extend SPL schema/spec to reference exception effects and routing templates; publish updated docs and validation fixtures. | Schema updated with exception references; validation tests cover effect types; docs draft ready. |
| POLICY-SPL-23-001 | DONE (2025-11-25) | Policy Guild | — | Define SPL v1 schema + fixtures; embed schema/sample in `StellaOps.Policy` with loader helper. | `spl-schema@1.json` and `spl-sample@1.json` embedded; `SplSchemaResource` exposes schema/sample; sprint 0128 task closed. |
| POLICY-SPL-23-002 | DONE (2025-11-26) | Policy Guild | POLICY-SPL-23-001 | Canonicalizer + content hashing for SPL policies. | Order-stable canonicalizer (statements/actions/conditions), SHA-256 digest helper, and unit tests in `SplCanonicalizerTests`. |
| POLICY-SPL-23-003 | DONE (2025-11-26) | Policy Guild | POLICY-SPL-23-002 | Layering/override engine + tests. | `SplLayeringEngine` merges base/overlay with deterministic output and metadata merge; covered by `SplLayeringEngineTests`. |
| POLICY-SPL-23-004 | DONE (2025-11-26) | Policy Guild, Audit Guild | POLICY-SPL-23-003 | Explanation tree model + persistence hooks. | `PolicyExplanation`/`PolicyExplanationNode` produced from evaluation with structured nodes; persistence ready for follow-on wiring. |
| POLICY-SPL-23-005 | DONE (2025-11-26) | Policy Guild, DevEx Guild | POLICY-SPL-23-004 | Migration tool to baseline SPL packs. | `SplMigrationTool` converts PolicyDocument to canonical SPL JSON; covered by `SplMigrationToolTests`. |

View File

@@ -34,16 +34,20 @@ public sealed class PolicyEvaluationTests
source: "community",
tags: ImmutableArray.Create("reachability:indirect"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(19.5, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(50, inputs["severityWeight"]);
Assert.Equal(0.65, inputs["trustWeight"], 3);
Assert.Equal(0.6, inputs["reachabilityWeight"], 3);
Assert.Equal(19.5, inputs["baseScore"], 3);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(19.5, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(50, inputs["severityWeight"]);
Assert.Equal(0.65, inputs["trustWeight"], 3);
Assert.Equal(0.6, inputs["reachabilityWeight"], 3);
Assert.Equal(19.5, inputs["baseScore"], 3);
Assert.NotNull(explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, explanation!.Decision);
Assert.Equal("BlockMedium", explanation.RuleName);
}
[Fact]
@@ -79,17 +83,20 @@ public sealed class PolicyEvaluationTests
PolicySeverity.Critical,
tags: ImmutableArray.Create("reachability:entrypoint"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
Assert.True(verdict.Quiet);
Assert.Equal("QuietIgnore", verdict.QuietedBy);
Assert.Equal(10, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(90, inputs["baseScore"], 3);
Assert.Equal(config.IgnorePenalty, inputs["ignorePenalty"]);
Assert.Equal(config.QuietPenalty, inputs["quietPenalty"]);
Assert.Equal(PolicyVerdictStatus.Ignored, verdict.Status);
Assert.True(verdict.Quiet);
Assert.Equal("QuietIgnore", verdict.QuietedBy);
Assert.Equal(10, verdict.Score, 3);
var inputs = verdict.GetInputs();
Assert.Equal(90, inputs["baseScore"], 3);
Assert.Equal(config.IgnorePenalty, inputs["ignorePenalty"]);
Assert.Equal(config.QuietPenalty, inputs["quietPenalty"]);
Assert.NotNull(explanation);
Assert.Equal(PolicyVerdictStatus.Ignored, explanation!.Decision);
}
[Fact]
@@ -121,16 +128,19 @@ public sealed class PolicyEvaluationTests
PolicySeverity.Unknown,
tags: ImmutableArray.Create("reachability:unknown", "unknown-age-days:5"));
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding);
var verdict = PolicyEvaluation.EvaluateFinding(document, config, finding, out var explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
Assert.Equal("medium", verdict.ConfidenceBand);
Assert.Equal(5, verdict.UnknownAgeDays ?? 0, 3);
var inputs = verdict.GetInputs();
Assert.Equal(0.55, inputs["unknownConfidence"], 3);
Assert.Equal(5, inputs["unknownAgeDays"], 3);
}
}
Assert.Equal(PolicyVerdictStatus.Blocked, verdict.Status);
Assert.Equal(30, verdict.Score, 3); // 60 * 1 * 0.5
Assert.Equal(0.55, verdict.UnknownConfidence ?? 0, 3);
Assert.Equal("medium", verdict.ConfidenceBand);
Assert.Equal(5, verdict.UnknownAgeDays ?? 0, 3);
var inputs = verdict.GetInputs();
Assert.Equal(0.55, inputs["unknownConfidence"], 3);
Assert.Equal(5, inputs["unknownAgeDays"], 3);
Assert.NotNull(explanation);
Assert.Equal(PolicyVerdictStatus.Blocked, explanation!.Decision);
}
}

View File

@@ -162,7 +162,7 @@ rules:
Assert.True(snapshot!.Document.Rules[0].Action.Quiet);
Assert.Null(snapshot.Document.Rules[0].Action.RequireVex);
Assert.Equal(PolicyActionType.Ignore, snapshot.Document.Rules[0].Action.Type);
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low));
var manualVerdict = PolicyEvaluation.EvaluateFinding(snapshot.Document, snapshot.ScoringConfig, PolicyFinding.Create("finding-quiet", PolicySeverity.Low), out _);
Assert.Equal(PolicyVerdictStatus.Warned, manualVerdict.Status);
var service = new PolicyPreviewService(store, NullLogger<PolicyPreviewService>.Instance);

View File

@@ -0,0 +1,55 @@
using System;
using System.IO;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Policy;
using Xunit;
namespace StellaOps.Policy.Tests;
public class PolicyValidationCliTests
{
[Fact]
public async Task RunAsync_EmitsCanonicalDigest_OnValidPolicy()
{
var tmp = Path.GetTempFileName();
try
{
await File.WriteAllTextAsync(tmp, """
{
"apiVersion": "spl.stellaops/v1",
"kind": "Policy",
"metadata": { "name": "demo" },
"spec": {
"defaultEffect": "deny",
"statements": [
{ "id": "ALLOW", "effect": "allow", "match": { "resource": "*", "actions": ["read"] } }
]
}
}
""");
var options = new PolicyValidationCliOptions
{
Inputs = new[] { tmp },
OutputJson = false,
Strict = false,
};
using var output = new StringWriter();
using var error = new StringWriter();
var cli = new PolicyValidationCli(output, error);
var exit = await cli.RunAsync(options);
exit.Should().Be(0);
var text = output.ToString();
text.Should().Contain("OK");
text.Should().Contain("canonical.spl.digest:");
}
finally
{
File.Delete(tmp);
}
}
}

View File

@@ -0,0 +1,90 @@
using StellaOps.Policy;
using Xunit;
namespace StellaOps.Policy.Tests;
public class SplCanonicalizerTests
{
[Fact]
public void Canonicalize_SortsStatementsActionsAndConditions()
{
const string input = """
{
"kind": "Policy",
"apiVersion": "spl.stellaops/v1",
"spec": {
"statements": [
{
"effect": "deny",
"id": "B-2",
"match": {
"resource": "/accounts/*",
"actions": ["delete", "read"]
}
},
{
"description": "desc",
"effect": "allow",
"id": "A-1",
"match": {
"actions": ["write", "read"],
"resource": "/accounts/*",
"conditions": [
{"operator": "gte", "value": 2, "field": "tier"},
{"field": "env", "value": "prod", "operator": "eq"}
]
},
"audit": {"severity": "warn", "message": "audit msg"}
}
],
"defaultEffect": "deny"
},
"metadata": {
"labels": {"env": "prod"},
"annotations": {"a": "1"},
"name": "demo"
}
}
""";
var canonical = SplCanonicalizer.CanonicalizeToString(input);
const string expected = "{\"apiVersion\":\"spl.stellaops/v1\",\"kind\":\"Policy\",\"metadata\":{\"annotations\":{\"a\":\"1\"},\"labels\":{\"env\":\"prod\"},\"name\":\"demo\"},\"spec\":{\"defaultEffect\":\"deny\",\"statements\":[{\"audit\":{\"message\":\"audit msg\",\"severity\":\"warn\"},\"description\":\"desc\",\"effect\":\"allow\",\"id\":\"A-1\",\"match\":{\"actions\":[\"read\",\"write\"],\"conditions\":[{\"field\":\"env\",\"operator\":\"eq\",\"value\":\"prod\"},{\"field\":\"tier\",\"operator\":\"gte\",\"value\":2}],\"resource\":\"/accounts/*\"}},{\"effect\":\"deny\",\"id\":\"B-2\",\"match\":{\"actions\":[\"delete\",\"read\"],\"resource\":\"/accounts/*\"}}]}}}";
Assert.Equal(expected, canonical);
}
[Fact]
public void ComputeDigest_IgnoresOrderingNoise()
{
const string versionA = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"defaultEffect":"deny","statements":[{"id":"B","effect":"deny","match":{"resource":"/r","actions":["write","read"]}},{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read"],"conditions":[{"field":"env","operator":"eq","value":"prod"}]}}]}}
""";
const string versionB = """
{"spec":{"statements":[{"match":{"actions":["read"],"resource":"/r","conditions":[{"value":"prod","operator":"eq","field":"env"}]},"effect":"allow","id":"A"},{"match":{"actions":["read","write"],"resource":"/r"},"effect":"deny","id":"B"}],"defaultEffect":"deny"},"kind":"Policy","metadata":{"name":"demo"},"apiVersion":"spl.stellaops/v1"}
""";
var hashA = SplCanonicalizer.ComputeDigest(versionA);
var hashB = SplCanonicalizer.ComputeDigest(versionB);
Assert.Equal(hashA, hashB);
}
[Fact]
public void ComputeDigest_DetectsContentChange()
{
const string baseDoc = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"statements":[{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read"]}}]}}
""";
const string changedDoc = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"statements":[{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read","write"]}}]}}
""";
var original = SplCanonicalizer.ComputeDigest(baseDoc);
var changed = SplCanonicalizer.ComputeDigest(changedDoc);
Assert.NotEqual(original, changed);
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
using StellaOps.Policy;
using Xunit;
namespace StellaOps.Policy.Tests;
public class SplLayeringEngineTests
{
[Fact]
public void Merge_ReplacesStatementsById_AndKeepsBaseOnes()
{
const string baseDoc = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"defaultEffect":"deny","statements":[{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read"]}}, {"id":"B","effect":"deny","match":{"resource":"/r","actions":["write"]}}]}}
""";
const string overlay = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"statements":[{"id":"A","effect":"deny","match":{"resource":"/r","actions":["read","write"]}}, {"id":"C","effect":"allow","match":{"resource":"/r","actions":["read"]}}]}}
""";
var merged = SplLayeringEngine.Merge(baseDoc, overlay);
const string expected = "{\"apiVersion\":\"spl.stellaops/v1\",\"kind\":\"Policy\",\"metadata\":{\"name\":\"demo\"},\"spec\":{\"defaultEffect\":\"deny\",\"statements\":[{\"effect\":\"deny\",\"id\":\"A\",\"match\":{\"actions\":[\"read\",\"write\"],\"resource\":\"/r\"}},{\"effect\":\"deny\",\"id\":\"B\",\"match\":{\"actions\":[\"write\"],\"resource\":\"/r\"}},{\"effect\":\"allow\",\"id\":\"C\",\"match\":{\"actions\":[\"read\"],\"resource\":\"/r\"}}]}}";
Assert.Equal(expected, merged);
}
[Fact]
public void Merge_MergesMetadataAndDefaultEffect()
{
const string baseDoc = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo","labels":{"env":"dev"}},"spec":{"defaultEffect":"deny","statements":[{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read"]}}]}}
""";
const string overlay = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"labels":{"env":"prod","tier":"gold"}},"spec":{"defaultEffect":"allow","statements":[{"id":"B","effect":"deny","match":{"resource":"/r","actions":["write"]}}]}}
""";
var merged = SplLayeringEngine.Merge(baseDoc, overlay);
const string expected = "{\"apiVersion\":\"spl.stellaops/v1\",\"kind\":\"Policy\",\"metadata\":{\"labels\":{\"env\":\"prod\",\"tier\":\"gold\"},\"name\":\"demo\"},\"spec\":{\"defaultEffect\":\"allow\",\"statements\":[{\"effect\":\"allow\",\"id\":\"A\",\"match\":{\"actions\":[\"read\"],\"resource\":\"/r\"}},{\"effect\":\"deny\",\"id\":\"B\",\"match\":{\"actions\":[\"write\"],\"resource\":\"/r\"}}]}}";
Assert.Equal(expected, merged);
}
[Fact]
public void Merge_PreservesUnknownTopLevelAndSpecFields()
{
const string baseDoc = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"extras":{"foo":1},"spec":{"defaultEffect":"deny","statements":[{"id":"A","effect":"allow","match":{"resource":"/r","actions":["read"]}}],"extensions":{"bar":true}}}
""";
const string overlay = """
{"apiVersion":"spl.stellaops/v1","kind":"Policy","metadata":{"name":"demo"},"spec":{"statements":[{"id":"B","effect":"deny","match":{"resource":"/r","actions":["write"]}}]}}
""";
var merged = SplLayeringEngine.Merge(baseDoc, overlay);
using var doc = JsonDocument.Parse(merged);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("extras", out var extras) && extras.TryGetProperty("foo", out var foo) && foo.GetInt32() == 1);
Assert.True(root.GetProperty("spec").TryGetProperty("extensions", out var extensions) && extensions.TryGetProperty("bar", out var bar) && bar.GetBoolean());
}
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Immutable;
using StellaOps.Policy;
using Xunit;
namespace StellaOps.Policy.Tests;
public class SplMigrationToolTests
{
[Fact]
public void ToSplPolicyJson_ConvertsRulesAndMetadata()
{
var rule = PolicyRule.Create(
name: "Block CVE",
action: new PolicyAction(PolicyActionType.Block, null, null, null, false),
severities: ImmutableArray.Create(PolicySeverity.Critical),
environments: ImmutableArray<string>.Empty,
sources: ImmutableArray<string>.Empty,
vendors: ImmutableArray<string>.Empty,
licenses: ImmutableArray<string>.Empty,
tags: ImmutableArray<string>.Empty,
match: PolicyRuleMatchCriteria.Create(
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray.Create("/app"),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty),
expires: null,
justification: "block it",
identifier: "RULE-1");
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty.Add("name", "demo"),
PolicyExceptionConfiguration.Empty);
var spl = SplMigrationTool.ToSplPolicyJson(document);
const string expected = "{\"apiVersion\":\"spl.stellaops/v1\",\"kind\":\"Policy\",\"metadata\":{\"labels\":{\"name\":\"demo\"},\"name\":\"demo\"},\"spec\":{\"defaultEffect\":\"deny\",\"statements\":[{\"effect\":\"deny\",\"id\":\"RULE-1\",\"match\":{\"actions\":[\"access\"],\"resource\":\"/app\"}}]}}";
Assert.Equal(expected, spl);
}
[Fact]
public void ToSplPolicyJson_UsesOverlaySafeIdsAndAudits()
{
var rule = PolicyRule.Create(
name: "Warn entrypoint",
action: new PolicyAction(PolicyActionType.Warn, null, null, null, true),
severities: ImmutableArray.Create(PolicySeverity.Low),
environments: ImmutableArray<string>.Empty,
sources: ImmutableArray<string>.Empty,
vendors: ImmutableArray<string>.Empty,
licenses: ImmutableArray<string>.Empty,
tags: ImmutableArray<string>.Empty,
match: PolicyRuleMatchCriteria.Empty,
expires: null,
justification: "soft warning");
var document = new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
var spl = SplMigrationTool.ToSplPolicyJson(document);
const string expectedId = "warn-entrypoint";
Assert.Contains(expectedId, spl);
Assert.Contains("\"audit\":{\"message\":\"soft warning\",\"severity\":\"warn\"}", spl);
}
}

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using StellaOps.Policy;
using Xunit;
namespace StellaOps.Policy.Tests;
public class SplSchemaResourceTests
{
[Fact]
public void Schema_IncludesReachabilityAndExploitability()
{
var schema = SplSchemaResource.GetSchema();
using var doc = JsonDocument.Parse(schema);
var match = doc.RootElement
.GetProperty("properties")
.GetProperty("spec")
.GetProperty("properties")
.GetProperty("statements")
.GetProperty("items")
.GetProperty("properties")
.GetProperty("match")
.GetProperty("properties");
Assert.True(match.TryGetProperty("reachability", out var reachability));
Assert.Equal(JsonValueKind.Object, reachability.ValueKind);
Assert.True(match.TryGetProperty("exploitability", out var exploitability));
Assert.Equal(JsonValueKind.Object, exploitability.ValueKind);
}
}