up
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
48
src/Policy/__Libraries/StellaOps.Policy/PolicyExplanation.cs
Normal file
48
src/Policy/__Libraries/StellaOps.Policy/PolicyExplanation.cs
Normal 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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
195
src/Policy/__Libraries/StellaOps.Policy/SplCanonicalizer.cs
Normal file
195
src/Policy/__Libraries/StellaOps.Policy/SplCanonicalizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
212
src/Policy/__Libraries/StellaOps.Policy/SplLayeringEngine.cs
Normal file
212
src/Policy/__Libraries/StellaOps.Policy/SplLayeringEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
168
src/Policy/__Libraries/StellaOps.Policy/SplMigrationTool.cs
Normal file
168
src/Policy/__Libraries/StellaOps.Policy/SplMigrationTool.cs
Normal 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('-');
|
||||
}
|
||||
}
|
||||
48
src/Policy/__Libraries/StellaOps.Policy/SplSchemaResource.cs
Normal file
48
src/Policy/__Libraries/StellaOps.Policy/SplSchemaResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user