This commit is contained in:
StellaOps Bot
2025-11-27 21:10:06 +02:00
parent cfa2274d31
commit 8abbf9574d
106 changed files with 7078 additions and 3197 deletions

View File

@@ -6,12 +6,12 @@ namespace StellaOps.Policy;
public static class PolicyEvaluation
{
public static PolicyVerdict EvaluateFinding(
PolicyDocument document,
PolicyScoringConfig scoringConfig,
PolicyFinding finding,
out PolicyExplanation? explanation)
{
public static PolicyVerdict EvaluateFinding(
PolicyDocument document,
PolicyScoringConfig scoringConfig,
PolicyFinding finding,
out PolicyExplanation? explanation)
{
if (document is null)
{
throw new ArgumentNullException(nameof(document));
@@ -44,49 +44,49 @@ public static class PolicyEvaluation
resolvedReachabilityKey);
var unknownConfidence = ComputeUnknownConfidence(scoringConfig.UnknownConfidence, finding);
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, 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);
}
foreach (var rule in document.Rules)
{
if (!RuleMatches(rule, finding))
{
continue;
}
private static PolicyVerdict BuildVerdict(
PolicyRule rule,
PolicyFinding finding,
PolicyScoringConfig config,
ScoringComponents components,
UnknownConfidenceResult? unknownConfidence,
out PolicyExplanation explanation)
{
return BuildVerdict(rule, finding, scoringConfig, components, unknownConfidence, out explanation);
}
explanation = new PolicyExplanation(
finding.FindingId,
PolicyVerdictStatus.Pass,
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);
}
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 explanationNodes = ImmutableArray.CreateBuilder<PolicyExplanationNode>();
explanationNodes.Add(PolicyExplanationNode.Leaf("rule", $"Matched rule '{rule.Name}'", rule.Identifier));
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;
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))));
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;
@@ -97,14 +97,14 @@ public static class PolicyEvaluation
}
if (unknownConfidence is { Band.Description: { Length: > 0 } description })
{
notes = AppendNote(notes, description);
explanationNodes.Add(PolicyExplanationNode.Leaf("unknown", 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;
@@ -113,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)
{
@@ -131,17 +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.");
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,
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,
@@ -156,56 +156,49 @@ public static class PolicyEvaluation
Reachability: components.ReachabilityKey);
}
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 (status != PolicyVerdictStatus.Pass)
{
explanationNodes.Add(PolicyExplanationNode.Leaf("action", $"Action {action.Type}", status.ToString()));
}
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,
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;
explanationNodes.Add(PolicyExplanationNode.Leaf("quiet", "Quiet applied", config.QuietPenalty.ToString(CultureInfo.InvariantCulture)));
}
explanation = new PolicyExplanation(
finding.FindingId,
status,
rule.Name,
notes ?? string.Empty,
explanationNodes.ToImmutable());
return new PolicyVerdict(
finding.FindingId,
status,
rule.Name,
action.Type.ToString(),
notes,
score,
config.Version,
inputs.ToImmutable(),
@@ -229,12 +222,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())
@@ -245,12 +238,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

@@ -18,7 +18,7 @@ public sealed record PolicyExplanation(
ImmutableArray<PolicyExplanationNode> Nodes)
{
public static PolicyExplanation Allow(string findingId, string? ruleName, string reason, params PolicyExplanationNode[] nodes) =>
new(findingId, PolicyVerdictStatus.Allowed, ruleName, reason, nodes.ToImmutableArray());
new(findingId, PolicyVerdictStatus.Pass, 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());

View File

@@ -29,7 +29,7 @@ public static class SplCanonicalizer
public static byte[] CanonicalizeToUtf8(ReadOnlySpan<byte> json)
{
using var document = JsonDocument.Parse(json, DocumentOptions);
using var document = JsonDocument.Parse(json.ToArray().AsMemory(), DocumentOptions);
var buffer = new ArrayBufferWriter<byte>();
using (var writer = new Utf8JsonWriter(buffer, WriterOptions))

View File

@@ -49,8 +49,8 @@ public static class SplLayeringEngine
private static JsonNode MergeToJsonNode(ReadOnlySpan<byte> basePolicyUtf8, ReadOnlySpan<byte> overlayPolicyUtf8)
{
using var baseDoc = JsonDocument.Parse(basePolicyUtf8, DocumentOptions);
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8, DocumentOptions);
using var baseDoc = JsonDocument.Parse(basePolicyUtf8.ToArray().AsMemory(), DocumentOptions);
using var overlayDoc = JsonDocument.Parse(overlayPolicyUtf8.ToArray().AsMemory(), DocumentOptions);
var baseRoot = baseDoc.RootElement;
var overlayRoot = overlayDoc.RootElement;
@@ -209,4 +209,14 @@ public static class SplLayeringEngine
return element.Value.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
}
private static JsonElement? GetPropertyOrNull(this JsonElement element, string name)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
return element.TryGetProperty(name, out var value) ? value : (JsonElement?)null;
}
}