up
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
@@ -11,13 +11,13 @@ internal sealed record PolicyEvaluationRequest(
|
||||
PolicyIrDocument Document,
|
||||
PolicyEvaluationContext Context);
|
||||
|
||||
internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationSeverity Severity,
|
||||
PolicyEvaluationEnvironment Environment,
|
||||
PolicyEvaluationAdvisory Advisory,
|
||||
PolicyEvaluationVexEvidence Vex,
|
||||
PolicyEvaluationSbom Sbom,
|
||||
PolicyEvaluationExceptions Exceptions);
|
||||
internal sealed record PolicyEvaluationContext(
|
||||
PolicyEvaluationSeverity Severity,
|
||||
PolicyEvaluationEnvironment Environment,
|
||||
PolicyEvaluationAdvisory Advisory,
|
||||
PolicyEvaluationVexEvidence Vex,
|
||||
PolicyEvaluationSbom Sbom,
|
||||
PolicyEvaluationExceptions Exceptions);
|
||||
|
||||
internal sealed record PolicyEvaluationSeverity(string Normalized, decimal? Score = null);
|
||||
|
||||
@@ -43,28 +43,28 @@ internal sealed record PolicyEvaluationVexStatement(
|
||||
string StatementId,
|
||||
DateTimeOffset? Timestamp = null);
|
||||
|
||||
internal sealed record PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string> Tags,
|
||||
ImmutableArray<PolicyEvaluationComponent> Components)
|
||||
{
|
||||
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
|
||||
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public static readonly PolicyEvaluationSbom Empty = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray<PolicyEvaluationComponent>.Empty);
|
||||
|
||||
public bool HasTag(string tag) => Tags.Contains(tag);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationComponent(
|
||||
string Name,
|
||||
string Version,
|
||||
string Type,
|
||||
string? Purl,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
internal sealed record PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string> Tags,
|
||||
ImmutableArray<PolicyEvaluationComponent> Components)
|
||||
{
|
||||
public PolicyEvaluationSbom(ImmutableHashSet<string> Tags)
|
||||
: this(Tags, ImmutableArray<PolicyEvaluationComponent>.Empty)
|
||||
{
|
||||
}
|
||||
|
||||
public static readonly PolicyEvaluationSbom Empty = new(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray<PolicyEvaluationComponent>.Empty);
|
||||
|
||||
public bool HasTag(string tag) => Tags.Contains(tag);
|
||||
}
|
||||
|
||||
internal sealed record PolicyEvaluationComponent(
|
||||
string Name,
|
||||
string Version,
|
||||
string Type,
|
||||
string? Purl,
|
||||
ImmutableDictionary<string, string> Metadata);
|
||||
|
||||
internal sealed record PolicyEvaluationResult(
|
||||
bool Matched,
|
||||
|
||||
@@ -4,7 +4,7 @@ using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
@@ -98,20 +98,20 @@ internal sealed class PolicyExpressionEvaluator
|
||||
return sbom.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is RubyComponentScope rubyScope)
|
||||
{
|
||||
return rubyScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
|
||||
{
|
||||
return new EvaluationValue(value);
|
||||
}
|
||||
if (raw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is RubyComponentScope rubyScope)
|
||||
{
|
||||
return rubyScope.Get(member.Member);
|
||||
}
|
||||
|
||||
if (raw is ImmutableDictionary<string, object?> dict && dict.TryGetValue(member.Member, out var value))
|
||||
{
|
||||
return new EvaluationValue(value);
|
||||
}
|
||||
|
||||
if (raw is PolicyEvaluationVexStatement stmt)
|
||||
{
|
||||
@@ -139,51 +139,51 @@ internal sealed class PolicyExpressionEvaluator
|
||||
}
|
||||
}
|
||||
|
||||
if (invocation.Target is PolicyMemberAccessExpression member)
|
||||
{
|
||||
var targetValue = Evaluate(member.Target, scope);
|
||||
var targetRaw = targetValue.Raw;
|
||||
if (targetRaw is RubyComponentScope rubyScope)
|
||||
{
|
||||
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (targetRaw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (member.Target is PolicyIdentifierExpression root)
|
||||
{
|
||||
if (root.Name == "vex" && targetRaw is VexScope vexScope)
|
||||
{
|
||||
return member.Member switch
|
||||
{
|
||||
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
|
||||
"latest" => new EvaluationValue(vexScope.Latest()),
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
if (root.Name == "sbom" && targetRaw is SbomScope sbomScope)
|
||||
{
|
||||
return member.Member switch
|
||||
{
|
||||
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
|
||||
"any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this),
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope)
|
||||
{
|
||||
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
if (invocation.Target is PolicyMemberAccessExpression member)
|
||||
{
|
||||
var targetValue = Evaluate(member.Target, scope);
|
||||
var targetRaw = targetValue.Raw;
|
||||
if (targetRaw is RubyComponentScope rubyScope)
|
||||
{
|
||||
return rubyScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (targetRaw is ComponentScope componentScope)
|
||||
{
|
||||
return componentScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
|
||||
if (member.Target is PolicyIdentifierExpression root)
|
||||
{
|
||||
if (root.Name == "vex" && targetRaw is VexScope vexScope)
|
||||
{
|
||||
return member.Member switch
|
||||
{
|
||||
"any" => new EvaluationValue(vexScope.Any(invocation.Arguments, scope)),
|
||||
"latest" => new EvaluationValue(vexScope.Latest()),
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
if (root.Name == "sbom" && targetRaw is SbomScope sbomScope)
|
||||
{
|
||||
return member.Member switch
|
||||
{
|
||||
"has_tag" => sbomScope.HasTag(invocation.Arguments, scope, this),
|
||||
"any_component" => sbomScope.AnyComponent(invocation.Arguments, scope, this),
|
||||
_ => EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
if (root.Name == "advisory" && targetRaw is AdvisoryScope advisoryScope)
|
||||
{
|
||||
return advisoryScope.Invoke(member.Member, invocation.Arguments, scope, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
|
||||
private EvaluationValue EvaluateIndexer(PolicyIndexerExpression indexer, EvaluationScope scope)
|
||||
{
|
||||
@@ -442,322 +442,322 @@ internal sealed class PolicyExpressionEvaluator
|
||||
this.sbom = sbom;
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
|
||||
}
|
||||
|
||||
if (member.Equals("components", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new EvaluationValue(sbom.Components
|
||||
.Select(component => (object?)new ComponentScope(component))
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
|
||||
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return new EvaluationValue(sbom.HasTag(tag!));
|
||||
}
|
||||
|
||||
public EvaluationValue AnyComponent(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty)
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
var predicate = arguments[0];
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["component"] = new ComponentScope(component),
|
||||
};
|
||||
|
||||
if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locals["ruby"] = new RubyComponentScope(component);
|
||||
}
|
||||
|
||||
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
|
||||
if (evaluator.EvaluateBoolean(predicate, nestedScope))
|
||||
{
|
||||
return EvaluationValue.True;
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ComponentScope
|
||||
{
|
||||
private readonly PolicyEvaluationComponent component;
|
||||
|
||||
public ComponentScope(PolicyEvaluationComponent component)
|
||||
{
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
return member.ToLowerInvariant() switch
|
||||
{
|
||||
"name" => new EvaluationValue(component.Name),
|
||||
"version" => new EvaluationValue(component.Version),
|
||||
"type" => new EvaluationValue(component.Type),
|
||||
"purl" => new EvaluationValue(component.Purl),
|
||||
"metadata" => new EvaluationValue(component.Metadata),
|
||||
_ => component.Metadata.TryGetValue(member, out var value)
|
||||
? new EvaluationValue(value)
|
||||
: EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return new EvaluationValue(component.Metadata.ContainsKey(key!));
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RubyComponentScope
|
||||
{
|
||||
private readonly PolicyEvaluationComponent component;
|
||||
private readonly ImmutableHashSet<string> groups;
|
||||
|
||||
public RubyComponentScope(PolicyEvaluationComponent component)
|
||||
{
|
||||
this.component = component;
|
||||
groups = ParseGroups(component.Metadata);
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
return member.ToLowerInvariant() switch
|
||||
{
|
||||
"groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()),
|
||||
"declaredonly" => new EvaluationValue(IsDeclaredOnly()),
|
||||
"source" => new EvaluationValue(GetSource() ?? string.Empty),
|
||||
_ => component.Metadata.TryGetValue(member, out var value)
|
||||
? new EvaluationValue(value)
|
||||
: EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
switch (member.ToLowerInvariant())
|
||||
{
|
||||
case "group":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(name is not null && groups.Contains(name));
|
||||
}
|
||||
case "groups":
|
||||
return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray());
|
||||
case "declared_only":
|
||||
return new EvaluationValue(IsDeclaredOnly());
|
||||
case "source":
|
||||
{
|
||||
if (arguments.Length == 0)
|
||||
{
|
||||
return new EvaluationValue(GetSource() ?? string.Empty);
|
||||
}
|
||||
|
||||
var requested = evaluator.Evaluate(arguments[0], scope).AsString();
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
var kind = GetSourceKind();
|
||||
return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
case "capability":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(HasCapability(name));
|
||||
}
|
||||
case "capability_any":
|
||||
{
|
||||
var capabilities = EvaluateAsStringSet(arguments, scope, evaluator);
|
||||
return new EvaluationValue(capabilities.Any(HasCapability));
|
||||
}
|
||||
default:
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasCapability(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (component.Metadata.TryGetValue($"capability.{normalized}", out var value))
|
||||
{
|
||||
return IsTruthy(value);
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = normalized.Substring("scheduler.".Length);
|
||||
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
|
||||
? listValue
|
||||
: null;
|
||||
return ContainsDelimitedValue(schedulerList, group);
|
||||
}
|
||||
|
||||
if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
|
||||
? listValue
|
||||
: null;
|
||||
return !string.IsNullOrWhiteSpace(schedulerList);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsDeclaredOnly()
|
||||
{
|
||||
return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value);
|
||||
}
|
||||
|
||||
private string? GetSource()
|
||||
{
|
||||
return component.Metadata.TryGetValue("source", out var value) ? value : null;
|
||||
}
|
||||
|
||||
private string? GetSourceKind()
|
||||
{
|
||||
var source = GetSource();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
source = source.Trim();
|
||||
if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "git";
|
||||
}
|
||||
|
||||
if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "path";
|
||||
}
|
||||
|
||||
if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "vendor-cache";
|
||||
}
|
||||
|
||||
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "registry";
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> ParseGroups(ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
var groups = value
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(static g => !string.IsNullOrWhiteSpace(g))
|
||||
.Select(static g => g.Trim())
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static bool ContainsDelimitedValue(string? delimited, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return delimited
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
return value is not null
|
||||
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
var evaluated = evaluator.Evaluate(argument, scope).Raw;
|
||||
switch (evaluated)
|
||||
{
|
||||
case ImmutableArray<object?> array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is string text && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
builder.Add(text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case string text when !string.IsNullOrWhiteSpace(text):
|
||||
builder.Add(text.Trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VexScope
|
||||
{
|
||||
private readonly PolicyExpressionEvaluator evaluator;
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
if (member.Equals("tags", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new EvaluationValue(sbom.Tags.ToImmutableArray<object?>());
|
||||
}
|
||||
|
||||
if (member.Equals("components", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new EvaluationValue(sbom.Components
|
||||
.Select(component => (object?)new ComponentScope(component))
|
||||
.ToImmutableArray());
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
|
||||
public EvaluationValue HasTag(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
var tag = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return new EvaluationValue(sbom.HasTag(tag!));
|
||||
}
|
||||
|
||||
public EvaluationValue AnyComponent(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
if (arguments.Length == 0 || sbom.Components.IsDefaultOrEmpty)
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
var predicate = arguments[0];
|
||||
foreach (var component in sbom.Components)
|
||||
{
|
||||
var locals = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["component"] = new ComponentScope(component),
|
||||
};
|
||||
|
||||
if (component.Type.Equals("gem", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
locals["ruby"] = new RubyComponentScope(component);
|
||||
}
|
||||
|
||||
var nestedScope = EvaluationScope.FromLocals(scope.Globals, locals);
|
||||
if (evaluator.EvaluateBoolean(predicate, nestedScope))
|
||||
{
|
||||
return EvaluationValue.True;
|
||||
}
|
||||
}
|
||||
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ComponentScope
|
||||
{
|
||||
private readonly PolicyEvaluationComponent component;
|
||||
|
||||
public ComponentScope(PolicyEvaluationComponent component)
|
||||
{
|
||||
this.component = component;
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
return member.ToLowerInvariant() switch
|
||||
{
|
||||
"name" => new EvaluationValue(component.Name),
|
||||
"version" => new EvaluationValue(component.Version),
|
||||
"type" => new EvaluationValue(component.Type),
|
||||
"purl" => new EvaluationValue(component.Purl),
|
||||
"metadata" => new EvaluationValue(component.Metadata),
|
||||
_ => component.Metadata.TryGetValue(member, out var value)
|
||||
? new EvaluationValue(value)
|
||||
: EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
if (member.Equals("has_metadata", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var key = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
return new EvaluationValue(component.Metadata.ContainsKey(key!));
|
||||
}
|
||||
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RubyComponentScope
|
||||
{
|
||||
private readonly PolicyEvaluationComponent component;
|
||||
private readonly ImmutableHashSet<string> groups;
|
||||
|
||||
public RubyComponentScope(PolicyEvaluationComponent component)
|
||||
{
|
||||
this.component = component;
|
||||
groups = ParseGroups(component.Metadata);
|
||||
}
|
||||
|
||||
public EvaluationValue Get(string member)
|
||||
{
|
||||
return member.ToLowerInvariant() switch
|
||||
{
|
||||
"groups" => new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray()),
|
||||
"declaredonly" => new EvaluationValue(IsDeclaredOnly()),
|
||||
"source" => new EvaluationValue(GetSource() ?? string.Empty),
|
||||
_ => component.Metadata.TryGetValue(member, out var value)
|
||||
? new EvaluationValue(value)
|
||||
: EvaluationValue.Null,
|
||||
};
|
||||
}
|
||||
|
||||
public EvaluationValue Invoke(string member, ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
switch (member.ToLowerInvariant())
|
||||
{
|
||||
case "group":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(name is not null && groups.Contains(name));
|
||||
}
|
||||
case "groups":
|
||||
return new EvaluationValue(groups.Select(value => (object?)value).ToImmutableArray());
|
||||
case "declared_only":
|
||||
return new EvaluationValue(IsDeclaredOnly());
|
||||
case "source":
|
||||
{
|
||||
if (arguments.Length == 0)
|
||||
{
|
||||
return new EvaluationValue(GetSource() ?? string.Empty);
|
||||
}
|
||||
|
||||
var requested = evaluator.Evaluate(arguments[0], scope).AsString();
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return EvaluationValue.False;
|
||||
}
|
||||
|
||||
var kind = GetSourceKind();
|
||||
return new EvaluationValue(string.Equals(kind, requested, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
case "capability":
|
||||
{
|
||||
var name = arguments.Length > 0 ? evaluator.Evaluate(arguments[0], scope).AsString() : null;
|
||||
return new EvaluationValue(HasCapability(name));
|
||||
}
|
||||
case "capability_any":
|
||||
{
|
||||
var capabilities = EvaluateAsStringSet(arguments, scope, evaluator);
|
||||
return new EvaluationValue(capabilities.Any(HasCapability));
|
||||
}
|
||||
default:
|
||||
return EvaluationValue.Null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasCapability(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = name.Trim();
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (component.Metadata.TryGetValue($"capability.{normalized}", out var value))
|
||||
{
|
||||
return IsTruthy(value);
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("scheduler.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = normalized.Substring("scheduler.".Length);
|
||||
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
|
||||
? listValue
|
||||
: null;
|
||||
return ContainsDelimitedValue(schedulerList, group);
|
||||
}
|
||||
|
||||
if (normalized.Equals("scheduler", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var schedulerList = component.Metadata.TryGetValue("capability.scheduler", out var listValue)
|
||||
? listValue
|
||||
: null;
|
||||
return !string.IsNullOrWhiteSpace(schedulerList);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsDeclaredOnly()
|
||||
{
|
||||
return component.Metadata.TryGetValue("declaredOnly", out var value) && IsTruthy(value);
|
||||
}
|
||||
|
||||
private string? GetSource()
|
||||
{
|
||||
return component.Metadata.TryGetValue("source", out var value) ? value : null;
|
||||
}
|
||||
|
||||
private string? GetSourceKind()
|
||||
{
|
||||
var source = GetSource();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
source = source.Trim();
|
||||
if (source.StartsWith("git:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "git";
|
||||
}
|
||||
|
||||
if (source.StartsWith("path:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "path";
|
||||
}
|
||||
|
||||
if (source.StartsWith("vendor-cache", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "vendor-cache";
|
||||
}
|
||||
|
||||
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "registry";
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> ParseGroups(ImmutableDictionary<string, string> metadata)
|
||||
{
|
||||
if (!metadata.TryGetValue("groups", out var value) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return ImmutableHashSet<string>.Empty;
|
||||
}
|
||||
|
||||
var groups = value
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(static g => !string.IsNullOrWhiteSpace(g))
|
||||
.Select(static g => g.Trim())
|
||||
.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static bool ContainsDelimitedValue(string? delimited, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(delimited) || string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return delimited
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(entry => entry.Equals(value, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsTruthy(string? value)
|
||||
{
|
||||
return value is not null
|
||||
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|
||||
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static ImmutableHashSet<string> EvaluateAsStringSet(ImmutableArray<PolicyExpression> arguments, EvaluationScope scope, PolicyExpressionEvaluator evaluator)
|
||||
{
|
||||
var builder = ImmutableHashSet.CreateBuilder<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
var evaluated = evaluator.Evaluate(argument, scope).Raw;
|
||||
switch (evaluated)
|
||||
{
|
||||
case ImmutableArray<object?> array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is string text && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
builder.Add(text.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case string text when !string.IsNullOrWhiteSpace(text):
|
||||
builder.Add(text.Trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VexScope
|
||||
{
|
||||
private readonly PolicyExpressionEvaluator evaluator;
|
||||
private readonly PolicyEvaluationVexEvidence vex;
|
||||
|
||||
public VexScope(PolicyExpressionEvaluator evaluator, PolicyEvaluationVexEvidence vex)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
@@ -33,17 +34,17 @@ var policyEngineActivationConfigFiles = new[]
|
||||
"policy-engine.activation.yaml",
|
||||
"policy-engine.activation.local.yaml"
|
||||
};
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
@@ -59,12 +60,12 @@ builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
});
|
||||
|
||||
var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "STELLAOPS_POLICY_ENGINE_";
|
||||
options.BindingSection = PolicyEngineOptions.SectionName;
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
foreach (var relative in policyEngineConfigFiles)
|
||||
{
|
||||
var path = Path.Combine(builder.Environment.ContentRootPath, relative);
|
||||
@@ -79,35 +80,35 @@ var bootstrap = StellaOpsConfigurationBootstrapper.Build<PolicyEngineOptions>(op
|
||||
};
|
||||
options.PostBind = static (value, _) => value.Validate();
|
||||
});
|
||||
|
||||
|
||||
builder.Configuration.AddConfiguration(bootstrap.Configuration);
|
||||
|
||||
builder.Services.AddAirGapEgressPolicy(builder.Configuration, sectionName: "AirGap");
|
||||
|
||||
builder.Services.AddOptions<PolicyEngineOptions>()
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyEngineOptions.SectionName,
|
||||
typeof(PolicyEngineOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
.Bind(builder.Configuration.GetSection(PolicyEngineOptions.SectionName))
|
||||
.Validate(options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
PolicyEngineOptions.SectionName,
|
||||
typeof(PolicyEngineOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<PolicyEngineOptions>>().Value);
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<PolicyEngineStartupDiagnostics>();
|
||||
builder.Services.AddHostedService<PolicyEngineBootstrapWorker>();
|
||||
builder.Services.AddSingleton<PolicyCompiler>();
|
||||
builder.Services.AddSingleton<StellaOps.PolicyDsl.PolicyCompiler>();
|
||||
builder.Services.AddSingleton<PolicyCompilationService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Services.PathScopeMetrics>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationService>();
|
||||
@@ -140,36 +141,36 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
|
||||
|
||||
if (bootstrap.Options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
|
||||
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
|
||||
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
|
||||
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
|
||||
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
foreach (var scope in bootstrap.Options.Authority.Scopes)
|
||||
{
|
||||
clientOptions.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{PolicyEngineOptions.SectionName}:ResourceServer");
|
||||
|
||||
if (bootstrap.Options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsAuthClient(clientOptions =>
|
||||
{
|
||||
clientOptions.Authority = bootstrap.Options.Authority.Issuer;
|
||||
clientOptions.ClientId = bootstrap.Options.Authority.ClientId;
|
||||
clientOptions.ClientSecret = bootstrap.Options.Authority.ClientSecret;
|
||||
clientOptions.HttpTimeout = TimeSpan.FromSeconds(bootstrap.Options.Authority.BackchannelTimeoutSeconds);
|
||||
|
||||
clientOptions.DefaultScopes.Clear();
|
||||
foreach (var scope in bootstrap.Options.Authority.Scopes)
|
||||
{
|
||||
clientOptions.DefaultScopes.Add(scope);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
app.MapGet("/readyz", (PolicyEngineStartupDiagnostics diagnostics) =>
|
||||
diagnostics.IsReady
|
||||
|
||||
@@ -4,22 +4,34 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
|
||||
/// basic statistics consumed by API/CLI surfaces.
|
||||
/// </summary>
|
||||
using StellaOps.PolicyDsl;
|
||||
using DslCompiler = StellaOps.PolicyDsl.PolicyCompiler;
|
||||
using DslCompilationResult = StellaOps.PolicyDsl.PolicyCompilationResult;
|
||||
using IrDocument = StellaOps.PolicyDsl.PolicyIrDocument;
|
||||
using IrAction = StellaOps.PolicyDsl.PolicyIrAction;
|
||||
using IrAssignmentAction = StellaOps.PolicyDsl.PolicyIrAssignmentAction;
|
||||
using IrAnnotateAction = StellaOps.PolicyDsl.PolicyIrAnnotateAction;
|
||||
using IrIgnoreAction = StellaOps.PolicyDsl.PolicyIrIgnoreAction;
|
||||
using IrEscalateAction = StellaOps.PolicyDsl.PolicyIrEscalateAction;
|
||||
using IrRequireVexAction = StellaOps.PolicyDsl.PolicyIrRequireVexAction;
|
||||
using IrWarnAction = StellaOps.PolicyDsl.PolicyIrWarnAction;
|
||||
using IrDeferAction = StellaOps.PolicyDsl.PolicyIrDeferAction;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides deterministic compilation for <c>stella-dsl@1</c> policy documents and exposes
|
||||
/// basic statistics consumed by API/CLI surfaces.
|
||||
/// </summary>
|
||||
internal sealed class PolicyCompilationService
|
||||
{
|
||||
private readonly PolicyCompiler compiler;
|
||||
private readonly DslCompiler compiler;
|
||||
private readonly PolicyComplexityAnalyzer complexityAnalyzer;
|
||||
private readonly IOptionsMonitor<PolicyEngineOptions> optionsMonitor;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public PolicyCompilationService(
|
||||
PolicyCompiler compiler,
|
||||
DslCompiler compiler,
|
||||
PolicyComplexityAnalyzer complexityAnalyzer,
|
||||
IOptionsMonitor<PolicyEngineOptions> optionsMonitor,
|
||||
TimeProvider timeProvider)
|
||||
@@ -29,30 +41,30 @@ internal sealed class PolicyCompilationService
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Dsl is null || string.IsNullOrWhiteSpace(request.Dsl.Source))
|
||||
{
|
||||
throw new ArgumentException("Compilation requires DSL source.", nameof(request));
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
public PolicyCompilationResultDto Compile(PolicyCompileRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (request.Dsl is null || string.IsNullOrWhiteSpace(request.Dsl.Source))
|
||||
{
|
||||
throw new ArgumentException("Compilation requires DSL source.", nameof(request));
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Dsl.Syntax, "stella-dsl@1", StringComparison.Ordinal))
|
||||
{
|
||||
return PolicyCompilationResultDto.FromFailure(
|
||||
ImmutableArray.Create(PolicyIssue.Error(
|
||||
PolicyDslDiagnosticCodes.UnsupportedSyntaxVersion,
|
||||
DiagnosticCodes.UnsupportedSyntaxVersion,
|
||||
$"Unsupported syntax '{request.Dsl.Syntax ?? "null"}'. Expected 'stella-dsl@1'.",
|
||||
"dsl.syntax")),
|
||||
complexity: null,
|
||||
durationMilliseconds: 0);
|
||||
}
|
||||
|
||||
|
||||
var start = timeProvider.GetTimestamp();
|
||||
var result = compiler.Compile(request.Dsl.Source);
|
||||
var elapsed = timeProvider.GetElapsedTime(start, timeProvider.GetTimestamp());
|
||||
@@ -95,11 +107,11 @@ internal sealed class PolicyCompilationService
|
||||
? ImmutableArray.Create(diagnostic)
|
||||
: diagnostics.Add(diagnostic);
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
|
||||
|
||||
internal sealed record PolicyDslPayload(string Syntax, string Source);
|
||||
|
||||
|
||||
internal sealed record PolicyCompileRequest(PolicyDslPayload Dsl);
|
||||
|
||||
public sealed record PolicyDslPayload(string Syntax, string Source);
|
||||
|
||||
internal sealed record PolicyCompilationResultDto(
|
||||
bool Success,
|
||||
string? Digest,
|
||||
@@ -116,7 +128,7 @@ internal sealed record PolicyCompilationResultDto(
|
||||
new(false, null, null, ImmutableArray<byte>.Empty, diagnostics, complexity, durationMilliseconds);
|
||||
|
||||
public static PolicyCompilationResultDto FromSuccess(
|
||||
PolicyCompilationResult compilationResult,
|
||||
DslCompilationResult compilationResult,
|
||||
PolicyComplexityReport complexity,
|
||||
long durationMilliseconds)
|
||||
{
|
||||
@@ -136,45 +148,45 @@ internal sealed record PolicyCompilationResultDto(
|
||||
durationMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompilationStatistics(
|
||||
int RuleCount,
|
||||
ImmutableDictionary<string, int> ActionCounts)
|
||||
{
|
||||
public static PolicyCompilationStatistics Create(PolicyIrDocument document)
|
||||
{
|
||||
var actions = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Increment(string key)
|
||||
{
|
||||
actions[key] = actions.TryGetValue(key, out var existing) ? existing + 1 : 1;
|
||||
}
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
Increment(GetActionKey(action));
|
||||
}
|
||||
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
Increment($"else:{GetActionKey(action)}");
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyCompilationStatistics(document.Rules.Length, actions.ToImmutable());
|
||||
}
|
||||
|
||||
private static string GetActionKey(PolicyIrAction action) => action switch
|
||||
{
|
||||
PolicyIrAssignmentAction => "assign",
|
||||
PolicyIrAnnotateAction => "annotate",
|
||||
PolicyIrIgnoreAction => "ignore",
|
||||
PolicyIrEscalateAction => "escalate",
|
||||
PolicyIrRequireVexAction => "requireVex",
|
||||
PolicyIrWarnAction => "warn",
|
||||
PolicyIrDeferAction => "defer",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
internal sealed record PolicyCompilationStatistics(
|
||||
int RuleCount,
|
||||
ImmutableDictionary<string, int> ActionCounts)
|
||||
{
|
||||
public static PolicyCompilationStatistics Create(IrDocument document)
|
||||
{
|
||||
var actions = ImmutableDictionary.CreateBuilder<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Increment(string key)
|
||||
{
|
||||
actions[key] = actions.TryGetValue(key, out var existing) ? existing + 1 : 1;
|
||||
}
|
||||
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
Increment(GetActionKey(action));
|
||||
}
|
||||
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
Increment($"else:{GetActionKey(action)}");
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyCompilationStatistics(document.Rules.Length, actions.ToImmutable());
|
||||
}
|
||||
|
||||
private static string GetActionKey(IrAction action) => action switch
|
||||
{
|
||||
IrAssignmentAction => "assign",
|
||||
IrAnnotateAction => "annotate",
|
||||
IrIgnoreAction => "ignore",
|
||||
IrEscalateAction => "escalate",
|
||||
IrRequireVexAction => "requireVex",
|
||||
IrWarnAction => "warn",
|
||||
IrDeferAction => "defer",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Services;
|
||||
@@ -23,19 +23,19 @@ internal sealed partial class PolicyEvaluationService
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
internal PolicyEvaluationResult Evaluate(PolicyIrDocument document, PolicyEvaluationContext context)
|
||||
internal Evaluation.PolicyEvaluationResult Evaluate(PolicyIrDocument document, Evaluation.PolicyEvaluationContext context)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
throw new ArgumentNullException(nameof(document));
|
||||
}
|
||||
|
||||
var request = new PolicyEvaluationRequest(document, context);
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var request = new Evaluation.PolicyEvaluationRequest(document, context);
|
||||
return evaluator.Evaluate(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
internal static class PolicyDslDiagnosticCodes
|
||||
{
|
||||
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
|
||||
public const string UnterminatedString = "POLICY-DSL-LEX-002";
|
||||
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
|
||||
public const string InvalidNumber = "POLICY-DSL-LEX-004";
|
||||
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
|
||||
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
|
||||
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
|
||||
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
|
||||
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
|
||||
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
|
||||
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
|
||||
public const string InvalidAction = "POLICY-DSL-PARSE-008";
|
||||
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
|
||||
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
|
||||
}
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic codes for policy DSL lexing and parsing errors.
|
||||
/// </summary>
|
||||
public static class DiagnosticCodes
|
||||
{
|
||||
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
|
||||
public const string UnterminatedString = "POLICY-DSL-LEX-002";
|
||||
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
|
||||
public const string InvalidNumber = "POLICY-DSL-LEX-004";
|
||||
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
|
||||
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
|
||||
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
|
||||
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
|
||||
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
|
||||
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
|
||||
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
|
||||
public const string InvalidAction = "POLICY-DSL-PARSE-008";
|
||||
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
|
||||
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
|
||||
}
|
||||
70
src/Policy/StellaOps.PolicyDsl/DslToken.cs
Normal file
70
src/Policy/StellaOps.PolicyDsl/DslToken.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the kind of token in the policy DSL.
|
||||
/// </summary>
|
||||
public enum TokenKind
|
||||
{
|
||||
EndOfFile = 0,
|
||||
Identifier,
|
||||
StringLiteral,
|
||||
NumberLiteral,
|
||||
BooleanLiteral,
|
||||
LeftBrace,
|
||||
RightBrace,
|
||||
LeftParen,
|
||||
RightParen,
|
||||
LeftBracket,
|
||||
RightBracket,
|
||||
Comma,
|
||||
Semicolon,
|
||||
Colon,
|
||||
Arrow, // =>
|
||||
Assign, // =
|
||||
Define, // :=
|
||||
Dot,
|
||||
KeywordPolicy,
|
||||
KeywordSyntax,
|
||||
KeywordMetadata,
|
||||
KeywordProfile,
|
||||
KeywordRule,
|
||||
KeywordMap,
|
||||
KeywordSource,
|
||||
KeywordEnv,
|
||||
KeywordIf,
|
||||
KeywordThen,
|
||||
KeywordWhen,
|
||||
KeywordAnd,
|
||||
KeywordOr,
|
||||
KeywordNot,
|
||||
KeywordPriority,
|
||||
KeywordElse,
|
||||
KeywordBecause,
|
||||
KeywordSettings,
|
||||
KeywordIgnore,
|
||||
KeywordUntil,
|
||||
KeywordEscalate,
|
||||
KeywordTo,
|
||||
KeywordRequireVex,
|
||||
KeywordWarn,
|
||||
KeywordMessage,
|
||||
KeywordDefer,
|
||||
KeywordAnnotate,
|
||||
KeywordIn,
|
||||
EqualEqual,
|
||||
NotEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single token in the policy DSL.
|
||||
/// </summary>
|
||||
public readonly record struct DslToken(
|
||||
TokenKind Kind,
|
||||
string Text,
|
||||
SourceSpan Span,
|
||||
object? Value = null);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,169 +1,174 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
public sealed class PolicyCompiler
|
||||
{
|
||||
public PolicyCompilationResult Compile(string source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
var parseResult = PolicyParser.Parse(source);
|
||||
if (parseResult.Document is null)
|
||||
{
|
||||
return new PolicyCompilationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Checksum: null,
|
||||
CanonicalRepresentation: ImmutableArray<byte>.Empty,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
|
||||
{
|
||||
return new PolicyCompilationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Checksum: null,
|
||||
CanonicalRepresentation: ImmutableArray<byte>.Empty,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
|
||||
var canonical = PolicyIrSerializer.Serialize(irDocument);
|
||||
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
|
||||
|
||||
return new PolicyCompilationResult(
|
||||
Success: true,
|
||||
Document: irDocument,
|
||||
Checksum: checksum,
|
||||
CanonicalRepresentation: canonical,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
|
||||
{
|
||||
var metadata = node.Metadata
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
|
||||
|
||||
var settings = node.Settings
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
|
||||
|
||||
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
|
||||
foreach (var profile in node.Profiles)
|
||||
{
|
||||
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
|
||||
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
|
||||
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
|
||||
|
||||
foreach (var item in profile.Items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case PolicyProfileMapNode map:
|
||||
maps.Add(new PolicyIrProfileMap(
|
||||
map.Name,
|
||||
map.Entries
|
||||
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
|
||||
.ToImmutableArray()));
|
||||
break;
|
||||
case PolicyProfileEnvNode env:
|
||||
envs.Add(new PolicyIrProfileEnv(
|
||||
env.Name,
|
||||
env.Entries
|
||||
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
|
||||
.ToImmutableArray()));
|
||||
break;
|
||||
case PolicyProfileScalarNode scalar:
|
||||
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.Add(new PolicyIrProfile(
|
||||
profile.Name,
|
||||
maps.ToImmutable(),
|
||||
envs.ToImmutable(),
|
||||
scalars.ToImmutable()));
|
||||
}
|
||||
|
||||
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
|
||||
foreach (var rule in node.Rules)
|
||||
{
|
||||
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
var converted = ToIrAction(action);
|
||||
if (converted is not null)
|
||||
{
|
||||
thenActions.Add(converted);
|
||||
}
|
||||
}
|
||||
|
||||
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
var converted = ToIrAction(action);
|
||||
if (converted is not null)
|
||||
{
|
||||
elseActions.Add(converted);
|
||||
}
|
||||
}
|
||||
|
||||
rules.Add(new PolicyIrRule(
|
||||
rule.Name,
|
||||
rule.Priority,
|
||||
rule.When,
|
||||
thenActions.ToImmutable(),
|
||||
elseActions.ToImmutable(),
|
||||
rule.Because ?? string.Empty));
|
||||
}
|
||||
|
||||
return new PolicyIrDocument(
|
||||
node.Name,
|
||||
node.Syntax,
|
||||
metadata,
|
||||
profiles.ToImmutable(),
|
||||
settings,
|
||||
rules.ToImmutable());
|
||||
}
|
||||
|
||||
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
|
||||
{
|
||||
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
|
||||
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
|
||||
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
|
||||
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
|
||||
_ => new PolicyIrStringLiteral(string.Empty),
|
||||
};
|
||||
|
||||
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
|
||||
{
|
||||
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
|
||||
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
|
||||
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
|
||||
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
|
||||
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
|
||||
require.Conditions
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
|
||||
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
|
||||
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record PolicyCompilationResult(
|
||||
bool Success,
|
||||
PolicyIrDocument? Document,
|
||||
string? Checksum,
|
||||
ImmutableArray<byte> CanonicalRepresentation,
|
||||
ImmutableArray<PolicyIssue> Diagnostics);
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.Policy;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles policy DSL source code into an intermediate representation.
|
||||
/// </summary>
|
||||
public sealed class PolicyCompiler
|
||||
{
|
||||
public PolicyCompilationResult Compile(string source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
var parseResult = PolicyParser.Parse(source);
|
||||
if (parseResult.Document is null)
|
||||
{
|
||||
return new PolicyCompilationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Checksum: null,
|
||||
CanonicalRepresentation: ImmutableArray<byte>.Empty,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
|
||||
{
|
||||
return new PolicyCompilationResult(
|
||||
Success: false,
|
||||
Document: null,
|
||||
Checksum: null,
|
||||
CanonicalRepresentation: ImmutableArray<byte>.Empty,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
|
||||
var canonical = PolicyIrSerializer.Serialize(irDocument);
|
||||
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
|
||||
|
||||
return new PolicyCompilationResult(
|
||||
Success: true,
|
||||
Document: irDocument,
|
||||
Checksum: checksum,
|
||||
CanonicalRepresentation: canonical,
|
||||
Diagnostics: parseResult.Diagnostics);
|
||||
}
|
||||
|
||||
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
|
||||
{
|
||||
var metadata = node.Metadata
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
|
||||
|
||||
var settings = node.Settings
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
|
||||
|
||||
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
|
||||
foreach (var profile in node.Profiles)
|
||||
{
|
||||
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
|
||||
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
|
||||
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
|
||||
|
||||
foreach (var item in profile.Items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case PolicyProfileMapNode map:
|
||||
maps.Add(new PolicyIrProfileMap(
|
||||
map.Name,
|
||||
map.Entries
|
||||
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
|
||||
.ToImmutableArray()));
|
||||
break;
|
||||
case PolicyProfileEnvNode env:
|
||||
envs.Add(new PolicyIrProfileEnv(
|
||||
env.Name,
|
||||
env.Entries
|
||||
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
|
||||
.ToImmutableArray()));
|
||||
break;
|
||||
case PolicyProfileScalarNode scalar:
|
||||
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
profiles.Add(new PolicyIrProfile(
|
||||
profile.Name,
|
||||
maps.ToImmutable(),
|
||||
envs.ToImmutable(),
|
||||
scalars.ToImmutable()));
|
||||
}
|
||||
|
||||
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
|
||||
foreach (var rule in node.Rules)
|
||||
{
|
||||
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
var converted = ToIrAction(action);
|
||||
if (converted is not null)
|
||||
{
|
||||
thenActions.Add(converted);
|
||||
}
|
||||
}
|
||||
|
||||
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
var converted = ToIrAction(action);
|
||||
if (converted is not null)
|
||||
{
|
||||
elseActions.Add(converted);
|
||||
}
|
||||
}
|
||||
|
||||
rules.Add(new PolicyIrRule(
|
||||
rule.Name,
|
||||
rule.Priority,
|
||||
rule.When,
|
||||
thenActions.ToImmutable(),
|
||||
elseActions.ToImmutable(),
|
||||
rule.Because ?? string.Empty));
|
||||
}
|
||||
|
||||
return new PolicyIrDocument(
|
||||
node.Name,
|
||||
node.Syntax,
|
||||
metadata,
|
||||
profiles.ToImmutable(),
|
||||
settings,
|
||||
rules.ToImmutable());
|
||||
}
|
||||
|
||||
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
|
||||
{
|
||||
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
|
||||
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
|
||||
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
|
||||
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
|
||||
_ => new PolicyIrStringLiteral(string.Empty),
|
||||
};
|
||||
|
||||
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
|
||||
{
|
||||
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
|
||||
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
|
||||
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
|
||||
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
|
||||
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
|
||||
require.Conditions
|
||||
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
|
||||
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
|
||||
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
|
||||
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of compiling a policy DSL source.
|
||||
/// </summary>
|
||||
public sealed record PolicyCompilationResult(
|
||||
bool Success,
|
||||
PolicyIrDocument? Document,
|
||||
string? Checksum,
|
||||
ImmutableArray<byte> CanonicalRepresentation,
|
||||
ImmutableArray<PolicyIssue> Diagnostics);
|
||||
213
src/Policy/StellaOps.PolicyDsl/PolicyEngineFactory.cs
Normal file
213
src/Policy/StellaOps.PolicyDsl/PolicyEngineFactory.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating policy evaluation engines from compiled policy documents.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngineFactory
|
||||
{
|
||||
private readonly PolicyCompiler _compiler = new();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy engine from source code.
|
||||
/// </summary>
|
||||
/// <param name="source">The policy DSL source code.</param>
|
||||
/// <returns>A policy engine if compilation succeeds, otherwise null with diagnostics.</returns>
|
||||
public PolicyEngineResult CreateFromSource(string source)
|
||||
{
|
||||
var compilation = _compiler.Compile(source);
|
||||
if (!compilation.Success || compilation.Document is null)
|
||||
{
|
||||
return new PolicyEngineResult(null, compilation.Diagnostics);
|
||||
}
|
||||
|
||||
var engine = new PolicyEngine(compilation.Document, compilation.Checksum!);
|
||||
return new PolicyEngineResult(engine, compilation.Diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a policy engine from a pre-compiled IR document.
|
||||
/// </summary>
|
||||
/// <param name="document">The compiled policy IR document.</param>
|
||||
/// <param name="checksum">The policy checksum.</param>
|
||||
/// <returns>A policy engine.</returns>
|
||||
public PolicyEngine CreateFromDocument(PolicyIrDocument document, string checksum)
|
||||
{
|
||||
return new PolicyEngine(document, checksum);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of creating a policy engine.
|
||||
/// </summary>
|
||||
public sealed record PolicyEngineResult(
|
||||
PolicyEngine? Engine,
|
||||
System.Collections.Immutable.ImmutableArray<StellaOps.Policy.PolicyIssue> Diagnostics);
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight policy evaluation engine.
|
||||
/// </summary>
|
||||
public sealed class PolicyEngine
|
||||
{
|
||||
internal PolicyEngine(PolicyIrDocument document, string checksum)
|
||||
{
|
||||
Document = document;
|
||||
Checksum = checksum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the compiled policy document.
|
||||
/// </summary>
|
||||
public PolicyIrDocument Document { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy checksum (SHA-256 of canonical representation).
|
||||
/// </summary>
|
||||
public string Checksum { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy name.
|
||||
/// </summary>
|
||||
public string Name => Document.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the policy syntax version.
|
||||
/// </summary>
|
||||
public string Syntax => Document.Syntax;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of rules in the policy.
|
||||
/// </summary>
|
||||
public int RuleCount => Document.Rules.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates the policy against the given signal context.
|
||||
/// </summary>
|
||||
/// <param name="context">The signal context to evaluate against.</param>
|
||||
/// <returns>The evaluation result.</returns>
|
||||
public PolicyEvaluationResult Evaluate(SignalContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
var matchedRules = new List<string>();
|
||||
var actions = new List<EvaluatedAction>();
|
||||
|
||||
foreach (var rule in Document.Rules.OrderByDescending(r => r.Priority))
|
||||
{
|
||||
var matched = EvaluateExpression(rule.When, context);
|
||||
if (matched)
|
||||
{
|
||||
matchedRules.Add(rule.Name);
|
||||
foreach (var action in rule.ThenActions)
|
||||
{
|
||||
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: false));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var action in rule.ElseActions)
|
||||
{
|
||||
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new PolicyEvaluationResult(
|
||||
PolicyName: Name,
|
||||
PolicyChecksum: Checksum,
|
||||
MatchedRules: matchedRules.ToArray(),
|
||||
Actions: actions.ToArray());
|
||||
}
|
||||
|
||||
private static bool EvaluateExpression(PolicyExpression expression, SignalContext context)
|
||||
{
|
||||
return expression switch
|
||||
{
|
||||
PolicyBinaryExpression binary => EvaluateBinary(binary, context),
|
||||
PolicyUnaryExpression unary => EvaluateUnary(unary, context),
|
||||
PolicyLiteralExpression literal => literal.Value is bool b && b,
|
||||
PolicyIdentifierExpression identifier => context.HasSignal(identifier.Name),
|
||||
PolicyMemberAccessExpression member => EvaluateMemberAccess(member, context),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateBinary(PolicyBinaryExpression binary, SignalContext context)
|
||||
{
|
||||
return binary.Operator switch
|
||||
{
|
||||
PolicyBinaryOperator.And => EvaluateExpression(binary.Left, context) && EvaluateExpression(binary.Right, context),
|
||||
PolicyBinaryOperator.Or => EvaluateExpression(binary.Left, context) || EvaluateExpression(binary.Right, context),
|
||||
PolicyBinaryOperator.Equal => EvaluateEquality(binary.Left, binary.Right, context, negate: false),
|
||||
PolicyBinaryOperator.NotEqual => EvaluateEquality(binary.Left, binary.Right, context, negate: true),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateUnary(PolicyUnaryExpression unary, SignalContext context)
|
||||
{
|
||||
return unary.Operator switch
|
||||
{
|
||||
PolicyUnaryOperator.Not => !EvaluateExpression(unary.Operand, context),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool EvaluateMemberAccess(PolicyMemberAccessExpression member, SignalContext context)
|
||||
{
|
||||
var value = ResolveValue(member.Target, context);
|
||||
if (value is IDictionary<string, object?> dict)
|
||||
{
|
||||
return dict.TryGetValue(member.Member, out var v) && v is bool b && b;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool EvaluateEquality(PolicyExpression left, PolicyExpression right, SignalContext context, bool negate)
|
||||
{
|
||||
var leftValue = ResolveValue(left, context);
|
||||
var rightValue = ResolveValue(right, context);
|
||||
var equal = Equals(leftValue, rightValue);
|
||||
return negate ? !equal : equal;
|
||||
}
|
||||
|
||||
private static object? ResolveValue(PolicyExpression expression, SignalContext context)
|
||||
{
|
||||
return expression switch
|
||||
{
|
||||
PolicyLiteralExpression literal => literal.Value,
|
||||
PolicyIdentifierExpression identifier => context.GetSignal(identifier.Name),
|
||||
PolicyMemberAccessExpression member => ResolveMemberValue(member, context),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static object? ResolveMemberValue(PolicyMemberAccessExpression member, SignalContext context)
|
||||
{
|
||||
var target = ResolveValue(member.Target, context);
|
||||
if (target is IDictionary<string, object?> dict)
|
||||
{
|
||||
return dict.TryGetValue(member.Member, out var v) ? v : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of evaluating a policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyEvaluationResult(
|
||||
string PolicyName,
|
||||
string PolicyChecksum,
|
||||
string[] MatchedRules,
|
||||
EvaluatedAction[] Actions);
|
||||
|
||||
/// <summary>
|
||||
/// An action that was evaluated as part of policy execution.
|
||||
/// </summary>
|
||||
public sealed record EvaluatedAction(
|
||||
string RuleName,
|
||||
PolicyIrAction Action,
|
||||
bool WasElseBranch);
|
||||
@@ -1,61 +1,64 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
public sealed record PolicyIrDocument(
|
||||
string Name,
|
||||
string Syntax,
|
||||
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
|
||||
ImmutableArray<PolicyIrProfile> Profiles,
|
||||
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
|
||||
ImmutableArray<PolicyIrRule> Rules);
|
||||
|
||||
public abstract record PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrProfile(
|
||||
string Name,
|
||||
ImmutableArray<PolicyIrProfileMap> Maps,
|
||||
ImmutableArray<PolicyIrProfileEnv> Environments,
|
||||
ImmutableArray<PolicyIrProfileScalar> Scalars);
|
||||
|
||||
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
|
||||
|
||||
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
|
||||
|
||||
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
|
||||
|
||||
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
|
||||
|
||||
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
|
||||
|
||||
public sealed record PolicyIrRule(
|
||||
string Name,
|
||||
int Priority,
|
||||
PolicyExpression When,
|
||||
ImmutableArray<PolicyIrAction> ThenActions,
|
||||
ImmutableArray<PolicyIrAction> ElseActions,
|
||||
string Because);
|
||||
|
||||
public abstract record PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate representation of a compiled policy document.
|
||||
/// </summary>
|
||||
public sealed record PolicyIrDocument(
|
||||
string Name,
|
||||
string Syntax,
|
||||
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
|
||||
ImmutableArray<PolicyIrProfile> Profiles,
|
||||
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
|
||||
ImmutableArray<PolicyIrRule> Rules);
|
||||
|
||||
public abstract record PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
|
||||
|
||||
public sealed record PolicyIrProfile(
|
||||
string Name,
|
||||
ImmutableArray<PolicyIrProfileMap> Maps,
|
||||
ImmutableArray<PolicyIrProfileEnv> Environments,
|
||||
ImmutableArray<PolicyIrProfileScalar> Scalars);
|
||||
|
||||
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
|
||||
|
||||
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
|
||||
|
||||
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
|
||||
|
||||
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
|
||||
|
||||
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
|
||||
|
||||
public sealed record PolicyIrRule(
|
||||
string Name,
|
||||
int Priority,
|
||||
PolicyExpression When,
|
||||
ImmutableArray<PolicyIrAction> ThenActions,
|
||||
ImmutableArray<PolicyIrAction> ElseActions,
|
||||
string Because);
|
||||
|
||||
public abstract record PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
|
||||
|
||||
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;
|
||||
@@ -1,415 +1,418 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
internal static class PolicyIrSerializer
|
||||
{
|
||||
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
});
|
||||
|
||||
WriteDocument(writer, document);
|
||||
writer.Flush();
|
||||
|
||||
return buffer.WrittenSpan.ToArray().ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", document.Name);
|
||||
writer.WriteString("syntax", document.Syntax);
|
||||
|
||||
writer.WritePropertyName("metadata");
|
||||
WriteLiteralDictionary(writer, document.Metadata);
|
||||
|
||||
writer.WritePropertyName("profiles");
|
||||
writer.WriteStartArray();
|
||||
foreach (var profile in document.Profiles)
|
||||
{
|
||||
WriteProfile(writer, profile);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("settings");
|
||||
WriteLiteralDictionary(writer, document.Settings);
|
||||
|
||||
writer.WritePropertyName("rules");
|
||||
writer.WriteStartArray();
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
WriteRule(writer, rule);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", profile.Name);
|
||||
|
||||
writer.WritePropertyName("maps");
|
||||
writer.WriteStartArray();
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", map.Name);
|
||||
writer.WritePropertyName("entries");
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("source", entry.Source);
|
||||
writer.WriteNumber("weight", entry.Weight);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("env");
|
||||
writer.WriteStartArray();
|
||||
foreach (var env in profile.Environments)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", env.Name);
|
||||
writer.WritePropertyName("entries");
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in env.Entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("condition");
|
||||
WriteExpression(writer, entry.Condition);
|
||||
writer.WriteNumber("weight", entry.Weight);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("scalars");
|
||||
writer.WriteStartArray();
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", scalar.Name);
|
||||
writer.WritePropertyName("value");
|
||||
WriteLiteral(writer, scalar.Value);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", rule.Name);
|
||||
writer.WriteNumber("priority", rule.Priority);
|
||||
writer.WritePropertyName("when");
|
||||
WriteExpression(writer, rule.When);
|
||||
|
||||
writer.WritePropertyName("then");
|
||||
WriteActions(writer, rule.ThenActions);
|
||||
|
||||
writer.WritePropertyName("else");
|
||||
WriteActions(writer, rule.ElseActions);
|
||||
|
||||
writer.WriteString("because", rule.Because);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var action in actions)
|
||||
{
|
||||
WriteAction(writer, action);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assign:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "assign");
|
||||
WriteReference(writer, assign.Target);
|
||||
writer.WritePropertyName("value");
|
||||
WriteExpression(writer, assign.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "annotate");
|
||||
WriteReference(writer, annotate.Target);
|
||||
writer.WritePropertyName("value");
|
||||
WriteExpression(writer, annotate.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "ignore");
|
||||
writer.WritePropertyName("until");
|
||||
WriteOptionalExpression(writer, ignore.Until);
|
||||
writer.WriteString("because", ignore.Because ?? string.Empty);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "escalate");
|
||||
writer.WritePropertyName("to");
|
||||
WriteOptionalExpression(writer, escalate.To);
|
||||
writer.WritePropertyName("when");
|
||||
WriteOptionalExpression(writer, escalate.When);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrRequireVexAction require:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "requireVex");
|
||||
writer.WritePropertyName("conditions");
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in require.Conditions)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
WriteExpression(writer, kvp.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrWarnAction warn:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "warn");
|
||||
writer.WritePropertyName("message");
|
||||
WriteOptionalExpression(writer, warn.Message);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrDeferAction defer:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "defer");
|
||||
writer.WritePropertyName("until");
|
||||
WriteOptionalExpression(writer, defer.Until);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
|
||||
{
|
||||
writer.WritePropertyName("target");
|
||||
writer.WriteStartArray();
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
writer.WriteStringValue(segment);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
WriteExpression(writer, expression);
|
||||
}
|
||||
|
||||
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
|
||||
{
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression literal:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "literal");
|
||||
writer.WritePropertyName("value");
|
||||
WriteLiteralValue(writer, literal.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyListExpression list:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "list");
|
||||
writer.WritePropertyName("items");
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
WriteExpression(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIdentifierExpression identifier:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "identifier");
|
||||
writer.WriteString("name", identifier.Name);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "member");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, member.Target);
|
||||
writer.WriteString("member", member.Member);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "call");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, invocation.Target);
|
||||
writer.WritePropertyName("args");
|
||||
writer.WriteStartArray();
|
||||
foreach (var arg in invocation.Arguments)
|
||||
{
|
||||
WriteExpression(writer, arg);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "indexer");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, indexer.Target);
|
||||
writer.WritePropertyName("index");
|
||||
WriteExpression(writer, indexer.Index);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "unary");
|
||||
writer.WriteString("op", unary.Operator switch
|
||||
{
|
||||
PolicyUnaryOperator.Not => "not",
|
||||
_ => unary.Operator.ToString().ToLowerInvariant(),
|
||||
});
|
||||
writer.WritePropertyName("operand");
|
||||
WriteExpression(writer, unary.Operand);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "binary");
|
||||
writer.WriteString("op", GetBinaryOperator(binary.Operator));
|
||||
writer.WritePropertyName("left");
|
||||
WriteExpression(writer, binary.Left);
|
||||
writer.WritePropertyName("right");
|
||||
WriteExpression(writer, binary.Right);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
default:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "unknown");
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
|
||||
{
|
||||
PolicyBinaryOperator.And => "and",
|
||||
PolicyBinaryOperator.Or => "or",
|
||||
PolicyBinaryOperator.Equal => "eq",
|
||||
PolicyBinaryOperator.NotEqual => "neq",
|
||||
PolicyBinaryOperator.LessThan => "lt",
|
||||
PolicyBinaryOperator.LessThanOrEqual => "lte",
|
||||
PolicyBinaryOperator.GreaterThan => "gt",
|
||||
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
|
||||
PolicyBinaryOperator.In => "in",
|
||||
PolicyBinaryOperator.NotIn => "not_in",
|
||||
_ => op.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in dictionary)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
WriteLiteral(writer, kvp.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrStringLiteral s:
|
||||
writer.WriteStringValue(s.Value);
|
||||
break;
|
||||
case PolicyIrNumberLiteral n:
|
||||
writer.WriteNumberValue(n.Value);
|
||||
break;
|
||||
case PolicyIrBooleanLiteral b:
|
||||
writer.WriteBooleanValue(b.Value);
|
||||
break;
|
||||
case PolicyIrListLiteral list:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
WriteLiteral(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
default:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
case string s:
|
||||
writer.WriteStringValue(s);
|
||||
break;
|
||||
case bool b:
|
||||
writer.WriteBooleanValue(b);
|
||||
break;
|
||||
case decimal dec:
|
||||
writer.WriteNumberValue(dec);
|
||||
break;
|
||||
case double dbl:
|
||||
writer.WriteNumberValue(dbl);
|
||||
break;
|
||||
case int i:
|
||||
writer.WriteNumberValue(i);
|
||||
break;
|
||||
default:
|
||||
writer.WriteStringValue(value.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Buffers;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes policy IR documents to a canonical JSON representation for hashing.
|
||||
/// </summary>
|
||||
public static class PolicyIrSerializer
|
||||
{
|
||||
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
|
||||
{
|
||||
var buffer = new ArrayBufferWriter<byte>();
|
||||
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
|
||||
{
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
});
|
||||
|
||||
WriteDocument(writer, document);
|
||||
writer.Flush();
|
||||
|
||||
return buffer.WrittenSpan.ToArray().ToImmutableArray();
|
||||
}
|
||||
|
||||
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", document.Name);
|
||||
writer.WriteString("syntax", document.Syntax);
|
||||
|
||||
writer.WritePropertyName("metadata");
|
||||
WriteLiteralDictionary(writer, document.Metadata);
|
||||
|
||||
writer.WritePropertyName("profiles");
|
||||
writer.WriteStartArray();
|
||||
foreach (var profile in document.Profiles)
|
||||
{
|
||||
WriteProfile(writer, profile);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("settings");
|
||||
WriteLiteralDictionary(writer, document.Settings);
|
||||
|
||||
writer.WritePropertyName("rules");
|
||||
writer.WriteStartArray();
|
||||
foreach (var rule in document.Rules)
|
||||
{
|
||||
WriteRule(writer, rule);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", profile.Name);
|
||||
|
||||
writer.WritePropertyName("maps");
|
||||
writer.WriteStartArray();
|
||||
foreach (var map in profile.Maps)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", map.Name);
|
||||
writer.WritePropertyName("entries");
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in map.Entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("source", entry.Source);
|
||||
writer.WriteNumber("weight", entry.Weight);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("env");
|
||||
writer.WriteStartArray();
|
||||
foreach (var env in profile.Environments)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", env.Name);
|
||||
writer.WritePropertyName("entries");
|
||||
writer.WriteStartArray();
|
||||
foreach (var entry in env.Entries)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WritePropertyName("condition");
|
||||
WriteExpression(writer, entry.Condition);
|
||||
writer.WriteNumber("weight", entry.Weight);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("scalars");
|
||||
writer.WriteStartArray();
|
||||
foreach (var scalar in profile.Scalars)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", scalar.Name);
|
||||
writer.WritePropertyName("value");
|
||||
WriteLiteral(writer, scalar.Value);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", rule.Name);
|
||||
writer.WriteNumber("priority", rule.Priority);
|
||||
writer.WritePropertyName("when");
|
||||
WriteExpression(writer, rule.When);
|
||||
|
||||
writer.WritePropertyName("then");
|
||||
WriteActions(writer, rule.ThenActions);
|
||||
|
||||
writer.WritePropertyName("else");
|
||||
WriteActions(writer, rule.ElseActions);
|
||||
|
||||
writer.WriteString("because", rule.Because);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
|
||||
{
|
||||
writer.WriteStartArray();
|
||||
foreach (var action in actions)
|
||||
{
|
||||
WriteAction(writer, action);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PolicyIrAssignmentAction assign:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "assign");
|
||||
WriteReference(writer, assign.Target);
|
||||
writer.WritePropertyName("value");
|
||||
WriteExpression(writer, assign.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrAnnotateAction annotate:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "annotate");
|
||||
WriteReference(writer, annotate.Target);
|
||||
writer.WritePropertyName("value");
|
||||
WriteExpression(writer, annotate.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrIgnoreAction ignore:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "ignore");
|
||||
writer.WritePropertyName("until");
|
||||
WriteOptionalExpression(writer, ignore.Until);
|
||||
writer.WriteString("because", ignore.Because ?? string.Empty);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrEscalateAction escalate:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "escalate");
|
||||
writer.WritePropertyName("to");
|
||||
WriteOptionalExpression(writer, escalate.To);
|
||||
writer.WritePropertyName("when");
|
||||
WriteOptionalExpression(writer, escalate.When);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrRequireVexAction require:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "requireVex");
|
||||
writer.WritePropertyName("conditions");
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in require.Conditions)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
WriteExpression(writer, kvp.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrWarnAction warn:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "warn");
|
||||
writer.WritePropertyName("message");
|
||||
WriteOptionalExpression(writer, warn.Message);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIrDeferAction defer:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "defer");
|
||||
writer.WritePropertyName("until");
|
||||
WriteOptionalExpression(writer, defer.Until);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
|
||||
{
|
||||
writer.WritePropertyName("target");
|
||||
writer.WriteStartArray();
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
writer.WriteStringValue(segment);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
|
||||
{
|
||||
if (expression is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
WriteExpression(writer, expression);
|
||||
}
|
||||
|
||||
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
|
||||
{
|
||||
switch (expression)
|
||||
{
|
||||
case PolicyLiteralExpression literal:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "literal");
|
||||
writer.WritePropertyName("value");
|
||||
WriteLiteralValue(writer, literal.Value);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyListExpression list:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "list");
|
||||
writer.WritePropertyName("items");
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
WriteExpression(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIdentifierExpression identifier:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "identifier");
|
||||
writer.WriteString("name", identifier.Name);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyMemberAccessExpression member:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "member");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, member.Target);
|
||||
writer.WriteString("member", member.Member);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyInvocationExpression invocation:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "call");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, invocation.Target);
|
||||
writer.WritePropertyName("args");
|
||||
writer.WriteStartArray();
|
||||
foreach (var arg in invocation.Arguments)
|
||||
{
|
||||
WriteExpression(writer, arg);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyIndexerExpression indexer:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "indexer");
|
||||
writer.WritePropertyName("target");
|
||||
WriteExpression(writer, indexer.Target);
|
||||
writer.WritePropertyName("index");
|
||||
WriteExpression(writer, indexer.Index);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyUnaryExpression unary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "unary");
|
||||
writer.WriteString("op", unary.Operator switch
|
||||
{
|
||||
PolicyUnaryOperator.Not => "not",
|
||||
_ => unary.Operator.ToString().ToLowerInvariant(),
|
||||
});
|
||||
writer.WritePropertyName("operand");
|
||||
WriteExpression(writer, unary.Operand);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
case PolicyBinaryExpression binary:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "binary");
|
||||
writer.WriteString("op", GetBinaryOperator(binary.Operator));
|
||||
writer.WritePropertyName("left");
|
||||
WriteExpression(writer, binary.Left);
|
||||
writer.WritePropertyName("right");
|
||||
WriteExpression(writer, binary.Right);
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
default:
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("type", "unknown");
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
|
||||
{
|
||||
PolicyBinaryOperator.And => "and",
|
||||
PolicyBinaryOperator.Or => "or",
|
||||
PolicyBinaryOperator.Equal => "eq",
|
||||
PolicyBinaryOperator.NotEqual => "neq",
|
||||
PolicyBinaryOperator.LessThan => "lt",
|
||||
PolicyBinaryOperator.LessThanOrEqual => "lte",
|
||||
PolicyBinaryOperator.GreaterThan => "gt",
|
||||
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
|
||||
PolicyBinaryOperator.In => "in",
|
||||
PolicyBinaryOperator.NotIn => "not_in",
|
||||
_ => op.ToString().ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach (var kvp in dictionary)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
WriteLiteral(writer, kvp.Value);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
|
||||
{
|
||||
switch (literal)
|
||||
{
|
||||
case PolicyIrStringLiteral s:
|
||||
writer.WriteStringValue(s.Value);
|
||||
break;
|
||||
case PolicyIrNumberLiteral n:
|
||||
writer.WriteNumberValue(n.Value);
|
||||
break;
|
||||
case PolicyIrBooleanLiteral b:
|
||||
writer.WriteBooleanValue(b.Value);
|
||||
break;
|
||||
case PolicyIrListLiteral list:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list.Items)
|
||||
{
|
||||
WriteLiteral(writer, item);
|
||||
}
|
||||
|
||||
writer.WriteEndArray();
|
||||
break;
|
||||
default:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
writer.WriteNullValue();
|
||||
break;
|
||||
case string s:
|
||||
writer.WriteStringValue(s);
|
||||
break;
|
||||
case bool b:
|
||||
writer.WriteBooleanValue(b);
|
||||
break;
|
||||
case decimal dec:
|
||||
writer.WriteNumberValue(dec);
|
||||
break;
|
||||
case double dbl:
|
||||
writer.WriteNumberValue(dbl);
|
||||
break;
|
||||
case int i:
|
||||
writer.WriteNumberValue(i);
|
||||
break;
|
||||
default:
|
||||
writer.WriteStringValue(value.ToString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,141 +1,141 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
public abstract record SyntaxNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyDocumentNode(
|
||||
string Name,
|
||||
string Syntax,
|
||||
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
|
||||
ImmutableArray<PolicyProfileNode> Profiles,
|
||||
ImmutableDictionary<string, PolicyLiteralValue> Settings,
|
||||
ImmutableArray<PolicyRuleNode> Rules,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileItemNode> Items,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public abstract record PolicyProfileItemNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyProfileMapNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileMapEntryNode> Entries,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyProfileMapEntryNode(
|
||||
string Source,
|
||||
decimal Weight,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileEnvNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyProfileEnvEntryNode(
|
||||
PolicyExpression Condition,
|
||||
decimal Weight,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileScalarNode(
|
||||
string Name,
|
||||
PolicyLiteralValue Value,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyRuleNode(
|
||||
string Name,
|
||||
int Priority,
|
||||
PolicyExpression When,
|
||||
ImmutableArray<PolicyActionNode> ThenActions,
|
||||
ImmutableArray<PolicyActionNode> ElseActions,
|
||||
string? Because,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public abstract record PolicyActionNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyAssignmentActionNode(
|
||||
PolicyReference Target,
|
||||
PolicyExpression Value,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyAnnotateActionNode(
|
||||
PolicyReference Target,
|
||||
PolicyExpression Value,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyIgnoreActionNode(
|
||||
PolicyExpression? Until,
|
||||
string? Because,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyEscalateActionNode(
|
||||
PolicyExpression? To,
|
||||
PolicyExpression? When,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyRequireVexActionNode(
|
||||
ImmutableDictionary<string, PolicyExpression> Conditions,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyWarnActionNode(
|
||||
PolicyExpression? Message,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyDeferActionNode(
|
||||
PolicyExpression? Until,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public abstract record PolicyExpression(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public enum PolicyUnaryOperator
|
||||
{
|
||||
Not,
|
||||
}
|
||||
|
||||
public enum PolicyBinaryOperator
|
||||
{
|
||||
And,
|
||||
Or,
|
||||
Equal,
|
||||
NotEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
In,
|
||||
NotIn,
|
||||
}
|
||||
|
||||
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
|
||||
{
|
||||
public override string ToString() => string.Join(".", Segments);
|
||||
}
|
||||
|
||||
public abstract record PolicyLiteralValue(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
public abstract record SyntaxNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyDocumentNode(
|
||||
string Name,
|
||||
string Syntax,
|
||||
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
|
||||
ImmutableArray<PolicyProfileNode> Profiles,
|
||||
ImmutableDictionary<string, PolicyLiteralValue> Settings,
|
||||
ImmutableArray<PolicyRuleNode> Rules,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileItemNode> Items,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public abstract record PolicyProfileItemNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyProfileMapNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileMapEntryNode> Entries,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyProfileMapEntryNode(
|
||||
string Source,
|
||||
decimal Weight,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileEnvNode(
|
||||
string Name,
|
||||
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyProfileEnvEntryNode(
|
||||
PolicyExpression Condition,
|
||||
decimal Weight,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public sealed record PolicyProfileScalarNode(
|
||||
string Name,
|
||||
PolicyLiteralValue Value,
|
||||
SourceSpan Span) : PolicyProfileItemNode(Span);
|
||||
|
||||
public sealed record PolicyRuleNode(
|
||||
string Name,
|
||||
int Priority,
|
||||
PolicyExpression When,
|
||||
ImmutableArray<PolicyActionNode> ThenActions,
|
||||
ImmutableArray<PolicyActionNode> ElseActions,
|
||||
string? Because,
|
||||
SourceSpan Span) : SyntaxNode(Span);
|
||||
|
||||
public abstract record PolicyActionNode(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyAssignmentActionNode(
|
||||
PolicyReference Target,
|
||||
PolicyExpression Value,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyAnnotateActionNode(
|
||||
PolicyReference Target,
|
||||
PolicyExpression Value,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyIgnoreActionNode(
|
||||
PolicyExpression? Until,
|
||||
string? Because,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyEscalateActionNode(
|
||||
PolicyExpression? To,
|
||||
PolicyExpression? When,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyRequireVexActionNode(
|
||||
ImmutableDictionary<string, PolicyExpression> Conditions,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyWarnActionNode(
|
||||
PolicyExpression? Message,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public sealed record PolicyDeferActionNode(
|
||||
PolicyExpression? Until,
|
||||
SourceSpan Span) : PolicyActionNode(Span);
|
||||
|
||||
public abstract record PolicyExpression(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
|
||||
|
||||
public enum PolicyUnaryOperator
|
||||
{
|
||||
Not,
|
||||
}
|
||||
|
||||
public enum PolicyBinaryOperator
|
||||
{
|
||||
And,
|
||||
Or,
|
||||
Equal,
|
||||
NotEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
In,
|
||||
NotIn,
|
||||
}
|
||||
|
||||
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
|
||||
{
|
||||
public override string ToString() => string.Join(".", Segments);
|
||||
}
|
||||
|
||||
public abstract record PolicyLiteralValue(SourceSpan Span);
|
||||
|
||||
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
|
||||
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);
|
||||
216
src/Policy/StellaOps.PolicyDsl/SignalContext.cs
Normal file
216
src/Policy/StellaOps.PolicyDsl/SignalContext.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Provides signal values for policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class SignalContext
|
||||
{
|
||||
private readonly Dictionary<string, object?> _signals;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty signal context.
|
||||
/// </summary>
|
||||
public SignalContext()
|
||||
{
|
||||
_signals = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signal context with initial values.
|
||||
/// </summary>
|
||||
/// <param name="signals">Initial signal values.</param>
|
||||
public SignalContext(IDictionary<string, object?> signals)
|
||||
{
|
||||
_signals = new Dictionary<string, object?>(signals, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a signal exists.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <returns>True if the signal exists.</returns>
|
||||
public bool HasSignal(string name) => _signals.ContainsKey(name);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signal value.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <returns>The signal value, or null if not found.</returns>
|
||||
public object? GetSignal(string name) => _signals.TryGetValue(name, out var value) ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signal value as a specific type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The expected type.</typeparam>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <returns>The signal value, or default if not found or wrong type.</returns>
|
||||
public T? GetSignal<T>(string name) => _signals.TryGetValue(name, out var value) && value is T t ? t : default;
|
||||
|
||||
/// <summary>
|
||||
/// Sets a signal value.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="value">The signal value.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public SignalContext SetSignal(string name, object? value)
|
||||
{
|
||||
_signals[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a signal.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <returns>This context for chaining.</returns>
|
||||
public SignalContext RemoveSignal(string name)
|
||||
{
|
||||
_signals.Remove(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all signal names.
|
||||
/// </summary>
|
||||
public IEnumerable<string> SignalNames => _signals.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Gets all signals as a read-only dictionary.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object?> Signals => _signals;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of this context.
|
||||
/// </summary>
|
||||
/// <returns>A new context with the same signals.</returns>
|
||||
public SignalContext Clone() => new(_signals);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a signal context builder for fluent construction.
|
||||
/// </summary>
|
||||
/// <returns>A new builder.</returns>
|
||||
public static SignalContextBuilder Builder() => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builder for creating signal contexts with fluent API.
|
||||
/// </summary>
|
||||
public sealed class SignalContextBuilder
|
||||
{
|
||||
private readonly Dictionary<string, object?> _signals = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a signal to the context.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="value">The signal value.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithSignal(string name, object? value)
|
||||
{
|
||||
_signals[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a boolean signal to the context.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="value">The boolean value.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithFlag(string name, bool value = true)
|
||||
{
|
||||
_signals[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a numeric signal to the context.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="value">The numeric value.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithNumber(string name, decimal value)
|
||||
{
|
||||
_signals[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a string signal to the context.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="value">The string value.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithString(string name, string value)
|
||||
{
|
||||
_signals[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a nested object signal to the context.
|
||||
/// </summary>
|
||||
/// <param name="name">The signal name.</param>
|
||||
/// <param name="properties">The nested properties.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithObject(string name, IDictionary<string, object?> properties)
|
||||
{
|
||||
_signals[name] = new Dictionary<string, object?>(properties, StringComparer.Ordinal);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds common finding signals.
|
||||
/// </summary>
|
||||
/// <param name="severity">The finding severity (e.g., "critical", "high", "medium", "low").</param>
|
||||
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
|
||||
/// <param name="cveId">Optional CVE identifier.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithFinding(string severity, decimal confidence, string? cveId = null)
|
||||
{
|
||||
_signals["finding"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["severity"] = severity,
|
||||
["confidence"] = confidence,
|
||||
["cve_id"] = cveId,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds common reachability signals.
|
||||
/// </summary>
|
||||
/// <param name="state">The reachability state (e.g., "reachable", "unreachable", "unknown").</param>
|
||||
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
|
||||
/// <param name="hasRuntimeEvidence">Whether there is runtime evidence.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithReachability(string state, decimal confidence, bool hasRuntimeEvidence = false)
|
||||
{
|
||||
_signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["state"] = state,
|
||||
["confidence"] = confidence,
|
||||
["has_runtime_evidence"] = hasRuntimeEvidence,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds common trust score signals.
|
||||
/// </summary>
|
||||
/// <param name="score">The trust score (0.0 to 1.0).</param>
|
||||
/// <param name="verified">Whether the source is verified.</param>
|
||||
/// <returns>This builder for chaining.</returns>
|
||||
public SignalContextBuilder WithTrustScore(decimal score, bool verified = false)
|
||||
{
|
||||
_signals["trust_score"] = score;
|
||||
_signals["trust_verified"] = verified;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the signal context.
|
||||
/// </summary>
|
||||
/// <returns>A new signal context with the configured signals.</returns>
|
||||
public SignalContext Build() => new(_signals);
|
||||
}
|
||||
@@ -1,160 +1,97 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Compilation;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a precise source location within a policy DSL document.
|
||||
/// </summary>
|
||||
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
|
||||
{
|
||||
public SourceLocation(int offset, int line, int column)
|
||||
{
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
}
|
||||
|
||||
if (line < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(line));
|
||||
}
|
||||
|
||||
if (column < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(column));
|
||||
}
|
||||
|
||||
Offset = offset;
|
||||
Line = line;
|
||||
Column = column;
|
||||
}
|
||||
|
||||
public int Offset { get; }
|
||||
|
||||
public int Line { get; }
|
||||
|
||||
public int Column { get; }
|
||||
|
||||
public override string ToString() => $"(L{Line}, C{Column})";
|
||||
|
||||
public bool Equals(SourceLocation other) =>
|
||||
Offset == other.Offset && Line == other.Line && Column == other.Column;
|
||||
|
||||
public override bool Equals([NotNullWhen(true)] object? obj) =>
|
||||
obj is SourceLocation other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
|
||||
|
||||
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
|
||||
|
||||
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
|
||||
|
||||
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
|
||||
|
||||
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
|
||||
|
||||
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a start/end location pair within a policy DSL source document.
|
||||
/// </summary>
|
||||
public readonly struct SourceSpan : IEquatable<SourceSpan>
|
||||
{
|
||||
public SourceSpan(SourceLocation start, SourceLocation end)
|
||||
{
|
||||
if (start.Offset > end.Offset)
|
||||
{
|
||||
throw new ArgumentException("Start must not be after end.", nameof(start));
|
||||
}
|
||||
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
|
||||
public SourceLocation Start { get; }
|
||||
|
||||
public SourceLocation End { get; }
|
||||
|
||||
public override string ToString() => $"{Start}->{End}";
|
||||
|
||||
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
|
||||
|
||||
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Start, End);
|
||||
|
||||
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
|
||||
{
|
||||
var start = first.Start <= second.Start ? first.Start : second.Start;
|
||||
var end = first.End >= second.End ? first.End : second.End;
|
||||
return new SourceSpan(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
internal enum TokenKind
|
||||
{
|
||||
EndOfFile = 0,
|
||||
Identifier,
|
||||
StringLiteral,
|
||||
NumberLiteral,
|
||||
BooleanLiteral,
|
||||
LeftBrace,
|
||||
RightBrace,
|
||||
LeftParen,
|
||||
RightParen,
|
||||
LeftBracket,
|
||||
RightBracket,
|
||||
Comma,
|
||||
Semicolon,
|
||||
Colon,
|
||||
Arrow, // =>
|
||||
Assign, // =
|
||||
Define, // :=
|
||||
Dot,
|
||||
KeywordPolicy,
|
||||
KeywordSyntax,
|
||||
KeywordMetadata,
|
||||
KeywordProfile,
|
||||
KeywordRule,
|
||||
KeywordMap,
|
||||
KeywordSource,
|
||||
KeywordEnv,
|
||||
KeywordIf,
|
||||
KeywordThen,
|
||||
KeywordWhen,
|
||||
KeywordAnd,
|
||||
KeywordOr,
|
||||
KeywordNot,
|
||||
KeywordPriority,
|
||||
KeywordElse,
|
||||
KeywordBecause,
|
||||
KeywordSettings,
|
||||
KeywordIgnore,
|
||||
KeywordUntil,
|
||||
KeywordEscalate,
|
||||
KeywordTo,
|
||||
KeywordRequireVex,
|
||||
KeywordWarn,
|
||||
KeywordMessage,
|
||||
KeywordDefer,
|
||||
KeywordAnnotate,
|
||||
KeywordIn,
|
||||
EqualEqual,
|
||||
NotEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
internal readonly record struct DslToken(
|
||||
TokenKind Kind,
|
||||
string Text,
|
||||
SourceSpan Span,
|
||||
object? Value = null);
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace StellaOps.PolicyDsl;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a precise source location within a policy DSL document.
|
||||
/// </summary>
|
||||
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
|
||||
{
|
||||
public SourceLocation(int offset, int line, int column)
|
||||
{
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset));
|
||||
}
|
||||
|
||||
if (line < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(line));
|
||||
}
|
||||
|
||||
if (column < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(column));
|
||||
}
|
||||
|
||||
Offset = offset;
|
||||
Line = line;
|
||||
Column = column;
|
||||
}
|
||||
|
||||
public int Offset { get; }
|
||||
|
||||
public int Line { get; }
|
||||
|
||||
public int Column { get; }
|
||||
|
||||
public override string ToString() => $"(L{Line}, C{Column})";
|
||||
|
||||
public bool Equals(SourceLocation other) =>
|
||||
Offset == other.Offset && Line == other.Line && Column == other.Column;
|
||||
|
||||
public override bool Equals([NotNullWhen(true)] object? obj) =>
|
||||
obj is SourceLocation other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
|
||||
|
||||
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
|
||||
|
||||
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
|
||||
|
||||
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
|
||||
|
||||
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
|
||||
|
||||
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a start/end location pair within a policy DSL source document.
|
||||
/// </summary>
|
||||
public readonly struct SourceSpan : IEquatable<SourceSpan>
|
||||
{
|
||||
public SourceSpan(SourceLocation start, SourceLocation end)
|
||||
{
|
||||
if (start.Offset > end.Offset)
|
||||
{
|
||||
throw new ArgumentException("Start must not be after end.", nameof(start));
|
||||
}
|
||||
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
|
||||
public SourceLocation Start { get; }
|
||||
|
||||
public SourceLocation End { get; }
|
||||
|
||||
public override string ToString() => $"{Start}->{End}";
|
||||
|
||||
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
|
||||
|
||||
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Start, End);
|
||||
|
||||
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
|
||||
{
|
||||
var start = first.Start <= second.Start ? first.Start : second.Start;
|
||||
var end = first.End >= second.End ? first.End : second.End;
|
||||
return new SourceSpan(start, end);
|
||||
}
|
||||
}
|
||||
20
src/Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj
Normal file
20
src/Policy/StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="StellaOps.PolicyDsl.Tests" />
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine" />
|
||||
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
using Xunit.Sdk;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Policy;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
using StellaOps.Policy.Engine.Evaluation;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using Xunit;
|
||||
@@ -51,26 +51,26 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
because "Respect strong vendor VEX claims."
|
||||
}
|
||||
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
|
||||
rule block_ruby_dev priority 4 {
|
||||
when sbom.any_component(ruby.group("development") and ruby.declared_only())
|
||||
then status := "blocked"
|
||||
because "Development-only Ruby gems without install evidence cannot ship."
|
||||
}
|
||||
|
||||
rule warn_ruby_git_sources {
|
||||
when sbom.any_component(ruby.source("git"))
|
||||
then warn message "Git-sourced Ruby gem present; review required."
|
||||
because "Git-sourced Ruby dependencies require explicit review."
|
||||
}
|
||||
}
|
||||
""";
|
||||
rule alert_warn_eol_runtime priority 1 {
|
||||
when severity.normalized <= "Medium"
|
||||
and sbom.has_tag("runtime:eol")
|
||||
then warn message "Runtime marked as EOL; upgrade recommended."
|
||||
because "Deprecated runtime should be upgraded."
|
||||
}
|
||||
|
||||
rule block_ruby_dev priority 4 {
|
||||
when sbom.any_component(ruby.group("development") and ruby.declared_only())
|
||||
then status := "blocked"
|
||||
because "Development-only Ruby gems without install evidence cannot ship."
|
||||
}
|
||||
|
||||
rule warn_ruby_git_sources {
|
||||
when sbom.any_component(ruby.source("git"))
|
||||
then warn message "Git-sourced Ruby gem present; review required."
|
||||
because "Git-sourced Ruby dependencies require explicit review."
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private readonly PolicyCompiler compiler = new();
|
||||
private readonly PolicyEvaluationService evaluationService = new();
|
||||
@@ -125,11 +125,11 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
public void Evaluate_WarnRuleEmitsWarning()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var tags = ImmutableHashSet.Create("runtime:eol");
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(tags)
|
||||
};
|
||||
var tags = ImmutableHashSet.Create("runtime:eol");
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(tags)
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
@@ -273,74 +273,74 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
Assert.NotNull(result.AppliedException);
|
||||
Assert.Equal("exc-rule", result.AppliedException!.ExceptionId);
|
||||
Assert.Equal("Rule Critical Suppress", result.AppliedException!.Metadata["effectName"]);
|
||||
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
|
||||
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RubyDevComponentBlocked()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var component = CreateRubyComponent(
|
||||
name: "dev-only",
|
||||
version: "1.0.0",
|
||||
groups: "development;test",
|
||||
declaredOnly: true,
|
||||
source: "https://rubygems.org/",
|
||||
capabilities: new[] { "exec" });
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_ruby_dev", result.RuleName);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RubyGitComponentWarns()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var component = CreateRubyComponent(
|
||||
name: "git-gem",
|
||||
version: "0.5.0",
|
||||
groups: "default",
|
||||
declaredOnly: false,
|
||||
source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567",
|
||||
capabilities: Array.Empty<string>(),
|
||||
schedulerCapabilities: new[] { "sidekiq" });
|
||||
|
||||
var context = CreateContext("Low", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("warn_ruby_git_sources", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private PolicyIrDocument CompileBaseline()
|
||||
{
|
||||
var compilation = compiler.Compile(BaselinePolicy);
|
||||
if (!compilation.Success)
|
||||
{
|
||||
Console.WriteLine(Describe(compilation.Diagnostics));
|
||||
}
|
||||
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
|
||||
return Assert.IsType<PolicyIrDocument>(compilation.Document);
|
||||
}
|
||||
Assert.Equal("alice", result.AppliedException!.Metadata["requestedBy"]);
|
||||
Assert.Equal("alice", result.Annotations["exception.meta.requestedBy"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RubyDevComponentBlocked()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var component = CreateRubyComponent(
|
||||
name: "dev-only",
|
||||
version: "1.0.0",
|
||||
groups: "development;test",
|
||||
declaredOnly: true,
|
||||
source: "https://rubygems.org/",
|
||||
capabilities: new[] { "exec" });
|
||||
|
||||
var context = CreateContext("Medium", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("block_ruby_dev", result.RuleName);
|
||||
Assert.Equal("blocked", result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RubyGitComponentWarns()
|
||||
{
|
||||
var document = CompileBaseline();
|
||||
var component = CreateRubyComponent(
|
||||
name: "git-gem",
|
||||
version: "0.5.0",
|
||||
groups: "default",
|
||||
declaredOnly: false,
|
||||
source: "git:https://github.com/example/git-gem.git@0123456789abcdef0123456789abcdef01234567",
|
||||
capabilities: Array.Empty<string>(),
|
||||
schedulerCapabilities: new[] { "sidekiq" });
|
||||
|
||||
var context = CreateContext("Low", "internal") with
|
||||
{
|
||||
Sbom = new PolicyEvaluationSbom(
|
||||
ImmutableHashSet<string>.Empty.WithComparer(StringComparer.OrdinalIgnoreCase),
|
||||
ImmutableArray.Create(component))
|
||||
};
|
||||
|
||||
var result = evaluationService.Evaluate(document, context);
|
||||
|
||||
Assert.True(result.Matched);
|
||||
Assert.Equal("warn_ruby_git_sources", result.RuleName);
|
||||
Assert.Equal("warned", result.Status);
|
||||
Assert.Contains(result.Warnings, warning => warning.Contains("Git-sourced", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private PolicyIrDocument CompileBaseline()
|
||||
{
|
||||
var compilation = compiler.Compile(BaselinePolicy);
|
||||
if (!compilation.Success)
|
||||
{
|
||||
Console.WriteLine(Describe(compilation.Diagnostics));
|
||||
}
|
||||
Assert.True(compilation.Success, Describe(compilation.Diagnostics));
|
||||
return Assert.IsType<PolicyIrDocument>(compilation.Document);
|
||||
}
|
||||
|
||||
private static PolicyEvaluationContext CreateContext(string severity, string exposure, PolicyEvaluationExceptions? exceptions = null)
|
||||
{
|
||||
@@ -352,67 +352,67 @@ policy "Baseline Production Policy" syntax "stella-dsl@1" {
|
||||
}.ToImmutableDictionary(StringComparer.OrdinalIgnoreCase)),
|
||||
new PolicyEvaluationAdvisory("GHSA", ImmutableDictionary<string, string>.Empty),
|
||||
PolicyEvaluationVexEvidence.Empty,
|
||||
PolicyEvaluationSbom.Empty,
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty);
|
||||
}
|
||||
PolicyEvaluationSbom.Empty,
|
||||
exceptions ?? PolicyEvaluationExceptions.Empty);
|
||||
}
|
||||
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
|
||||
private static PolicyEvaluationComponent CreateRubyComponent(
|
||||
string name,
|
||||
string version,
|
||||
string groups,
|
||||
bool declaredOnly,
|
||||
string source,
|
||||
IEnumerable<string>? capabilities = null,
|
||||
IEnumerable<string>? schedulerCapabilities = null)
|
||||
{
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(groups))
|
||||
{
|
||||
metadataBuilder["groups"] = groups;
|
||||
}
|
||||
|
||||
metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
metadataBuilder["source"] = source.Trim();
|
||||
}
|
||||
|
||||
if (capabilities is not null)
|
||||
{
|
||||
foreach (var capability in capabilities)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
metadataBuilder[$"capability.{capability.Trim()}"] = "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schedulerCapabilities is not null)
|
||||
{
|
||||
var schedulerList = string.Join(
|
||||
';',
|
||||
schedulerCapabilities
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(schedulerList))
|
||||
{
|
||||
metadataBuilder["capability.scheduler"] = schedulerList;
|
||||
}
|
||||
}
|
||||
|
||||
metadataBuilder["lockfile"] = "Gemfile.lock";
|
||||
|
||||
return new PolicyEvaluationComponent(
|
||||
name,
|
||||
version,
|
||||
"gem",
|
||||
$"pkg:gem/{name}@{version}",
|
||||
metadataBuilder.ToImmutable());
|
||||
}
|
||||
}
|
||||
private static string Describe(ImmutableArray<PolicyIssue> issues) =>
|
||||
string.Join(" | ", issues.Select(issue => $"{issue.Severity}:{issue.Code}:{issue.Message}"));
|
||||
|
||||
private static PolicyEvaluationComponent CreateRubyComponent(
|
||||
string name,
|
||||
string version,
|
||||
string groups,
|
||||
bool declaredOnly,
|
||||
string source,
|
||||
IEnumerable<string>? capabilities = null,
|
||||
IEnumerable<string>? schedulerCapabilities = null)
|
||||
{
|
||||
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (!string.IsNullOrWhiteSpace(groups))
|
||||
{
|
||||
metadataBuilder["groups"] = groups;
|
||||
}
|
||||
|
||||
metadataBuilder["declaredOnly"] = declaredOnly ? "true" : "false";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
metadataBuilder["source"] = source.Trim();
|
||||
}
|
||||
|
||||
if (capabilities is not null)
|
||||
{
|
||||
foreach (var capability in capabilities)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(capability))
|
||||
{
|
||||
metadataBuilder[$"capability.{capability.Trim()}"] = "true";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schedulerCapabilities is not null)
|
||||
{
|
||||
var schedulerList = string.Join(
|
||||
';',
|
||||
schedulerCapabilities
|
||||
.Where(static s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(static s => s.Trim()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(schedulerList))
|
||||
{
|
||||
metadataBuilder["capability.scheduler"] = schedulerList;
|
||||
}
|
||||
}
|
||||
|
||||
metadataBuilder["lockfile"] = "Gemfile.lock";
|
||||
|
||||
return new PolicyEvaluationComponent(
|
||||
name,
|
||||
version,
|
||||
"gem",
|
||||
$"pkg:gem/{name}@{version}",
|
||||
metadataBuilder.ToImmutable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Policy.Engine.Domain;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Xunit;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the policy DSL compiler.
|
||||
/// </summary>
|
||||
public class PolicyCompilerTests
|
||||
{
|
||||
private readonly PolicyCompiler _compiler = new();
|
||||
|
||||
[Fact]
|
||||
public void Compile_MinimalPolicy_Succeeds()
|
||||
{
|
||||
// Arrange - rule name is an identifier, not a string; then block has no braces; := for assignment
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule always priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "info"
|
||||
because "always applies"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
result.Document.Should().NotBeNull();
|
||||
result.Document!.Name.Should().Be("test");
|
||||
result.Document.Syntax.Should().Be("stella-dsl@1");
|
||||
result.Document.Rules.Should().HaveCount(1);
|
||||
result.Checksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_WithMetadata_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "with-meta" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
version = "1.0.0"
|
||||
author = "test"
|
||||
}
|
||||
rule r1 priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "low"
|
||||
because "required"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
result.Document!.Metadata.Should().ContainKey("version");
|
||||
result.Document.Metadata.Should().ContainKey("author");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_WithProfile_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "with-profile" syntax "stella-dsl@1" {
|
||||
profile standard {
|
||||
trust_score = 0.85
|
||||
}
|
||||
rule r1 priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "low"
|
||||
because "required"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
result.Document!.Profiles.Should().HaveCount(1);
|
||||
result.Document.Profiles[0].Name.Should().Be("standard");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_EmptySource_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var source = "";
|
||||
|
||||
// Act
|
||||
var result = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_InvalidSyntax_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "bad" syntax "invalid@1" {
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_SameSource_ProducesSameChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "deterministic" syntax "stella-dsl@1" {
|
||||
rule r1 priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "info"
|
||||
because "always"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result1 = _compiler.Compile(source);
|
||||
var result2 = _compiler.Compile(source);
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue(string.Join("; ", result1.Diagnostics.Select(d => d.Message)));
|
||||
result2.Success.Should().BeTrue(string.Join("; ", result2.Diagnostics.Select(d => d.Message)));
|
||||
result1.Checksum.Should().Be(result2.Checksum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_DifferentSource_ProducesDifferentChecksum()
|
||||
{
|
||||
// Arrange
|
||||
var source1 = """
|
||||
policy "test1" syntax "stella-dsl@1" {
|
||||
rule r1 priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "info"
|
||||
because "always"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var source2 = """
|
||||
policy "test2" syntax "stella-dsl@1" {
|
||||
rule r1 priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "info"
|
||||
because "always"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result1 = _compiler.Compile(source1);
|
||||
var result2 = _compiler.Compile(source2);
|
||||
|
||||
// Assert
|
||||
result1.Checksum.Should().NotBe(result2.Checksum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the policy evaluation engine.
|
||||
/// </summary>
|
||||
public class PolicyEngineTests
|
||||
{
|
||||
private readonly PolicyEngineFactory _factory = new();
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RuleMatches_ReturnsMatchedRules()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule critical_rule priority 100 {
|
||||
when finding.severity == "critical"
|
||||
then
|
||||
severity := "critical"
|
||||
because "critical finding detected"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = SignalContext.Builder()
|
||||
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "critical" })
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().Contain("critical_rule");
|
||||
evalResult.PolicyChecksum.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_RuleDoesNotMatch_ExecutesElseBranch()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule critical_only priority 100 {
|
||||
when finding.severity == "critical"
|
||||
then
|
||||
severity := "critical"
|
||||
else
|
||||
severity := "info"
|
||||
because "classify by severity"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = SignalContext.Builder()
|
||||
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "low" })
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().BeEmpty();
|
||||
evalResult.Actions.Should().NotBeEmpty();
|
||||
evalResult.Actions[0].WasElseBranch.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_MultipleRules_EvaluatesInPriorityOrder()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule low_priority priority 10 {
|
||||
when true
|
||||
then
|
||||
severity := "low"
|
||||
because "low priority rule"
|
||||
}
|
||||
rule high_priority priority 100 {
|
||||
when true
|
||||
then
|
||||
severity := "high"
|
||||
because "high priority rule"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = new SignalContext();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().HaveCount(2);
|
||||
evalResult.MatchedRules[0].Should().Be("high_priority");
|
||||
evalResult.MatchedRules[1].Should().Be("low_priority");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithAndCondition_MatchesWhenBothTrue()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule combined priority 100 {
|
||||
when finding.severity == "critical" and reachability.state == "reachable"
|
||||
then
|
||||
severity := "critical"
|
||||
because "critical and reachable"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = SignalContext.Builder()
|
||||
.WithFinding("critical", 0.95m)
|
||||
.WithReachability("reachable", 0.9m)
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().Contain("combined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithOrCondition_MatchesWhenEitherTrue()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule either priority 100 {
|
||||
when finding.severity == "critical" or finding.severity == "high"
|
||||
then
|
||||
severity := "elevated"
|
||||
because "elevated severity"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = SignalContext.Builder()
|
||||
.WithObject("finding", new Dictionary<string, object?> { ["severity"] = "high" })
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().Contain("either");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_WithNotCondition_InvertsResult()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "test" syntax "stella-dsl@1" {
|
||||
rule not_critical priority 100 {
|
||||
when not finding.is_critical
|
||||
then
|
||||
severity := "low"
|
||||
because "not critical"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _factory.CreateFromSource(source);
|
||||
result.Engine.Should().NotBeNull(string.Join("; ", result.Diagnostics.Select(d => d.Message)));
|
||||
var engine = result.Engine!;
|
||||
var context = SignalContext.Builder()
|
||||
.WithObject("finding", new Dictionary<string, object?> { ["is_critical"] = false })
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var evalResult = engine.Evaluate(context);
|
||||
|
||||
// Assert
|
||||
evalResult.MatchedRules.Should().Contain("not_critical");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.PolicyDsl.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the signal context API.
|
||||
/// </summary>
|
||||
public class SignalContextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Builder_WithSignal_SetsSignalValue()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithSignal("test", "value")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.GetSignal("test").Should().Be("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithFlag_SetsBooleanSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithFlag("enabled")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.GetSignal<bool>("enabled").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithNumber_SetsDecimalSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithNumber("score", 0.95m)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.GetSignal<decimal>("score").Should().Be(0.95m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithString_SetsStringSignal()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithString("name", "test")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.GetSignal<string>("name").Should().Be("test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithFinding_SetsNestedFindingObject()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithFinding("critical", 0.95m, "CVE-2024-1234")
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.HasSignal("finding").Should().BeTrue();
|
||||
var finding = context.GetSignal("finding") as IDictionary<string, object?>;
|
||||
finding.Should().NotBeNull();
|
||||
finding!["severity"].Should().Be("critical");
|
||||
finding["confidence"].Should().Be(0.95m);
|
||||
finding["cve_id"].Should().Be("CVE-2024-1234");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithReachability_SetsNestedReachabilityObject()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithReachability("reachable", 0.9m, hasRuntimeEvidence: true)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.HasSignal("reachability").Should().BeTrue();
|
||||
var reachability = context.GetSignal("reachability") as IDictionary<string, object?>;
|
||||
reachability.Should().NotBeNull();
|
||||
reachability!["state"].Should().Be("reachable");
|
||||
reachability["confidence"].Should().Be(0.9m);
|
||||
reachability["has_runtime_evidence"].Should().Be(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_WithTrustScore_SetsTrustSignals()
|
||||
{
|
||||
// Arrange & Act
|
||||
var context = SignalContext.Builder()
|
||||
.WithTrustScore(0.85m, verified: true)
|
||||
.Build();
|
||||
|
||||
// Assert
|
||||
context.GetSignal<decimal>("trust_score").Should().Be(0.85m);
|
||||
context.GetSignal<bool>("trust_verified").Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSignal_UpdatesExistingValue()
|
||||
{
|
||||
// Arrange
|
||||
var context = new SignalContext();
|
||||
context.SetSignal("key", "value1");
|
||||
|
||||
// Act
|
||||
context.SetSignal("key", "value2");
|
||||
|
||||
// Assert
|
||||
context.GetSignal("key").Should().Be("value2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveSignal_RemovesExistingSignal()
|
||||
{
|
||||
// Arrange
|
||||
var context = new SignalContext();
|
||||
context.SetSignal("key", "value");
|
||||
|
||||
// Act
|
||||
context.RemoveSignal("key");
|
||||
|
||||
// Assert
|
||||
context.HasSignal("key").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clone_CreatesIndependentCopy()
|
||||
{
|
||||
// Arrange
|
||||
var original = SignalContext.Builder()
|
||||
.WithSignal("key", "value")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var clone = original.Clone();
|
||||
clone.SetSignal("key", "modified");
|
||||
|
||||
// Assert
|
||||
original.GetSignal("key").Should().Be("value");
|
||||
clone.GetSignal("key").Should().Be("modified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalNames_ReturnsAllSignalKeys()
|
||||
{
|
||||
// Arrange
|
||||
var context = SignalContext.Builder()
|
||||
.WithSignal("a", 1)
|
||||
.WithSignal("b", 2)
|
||||
.WithSignal("c", 3)
|
||||
.Build();
|
||||
|
||||
// Act & Assert
|
||||
context.SignalNames.Should().BeEquivalentTo(new[] { "a", "b", "c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signals_ReturnsReadOnlyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var context = SignalContext.Builder()
|
||||
.WithSignal("key", "value")
|
||||
.Build();
|
||||
|
||||
// Act
|
||||
var signals = context.Signals;
|
||||
|
||||
// Assert
|
||||
signals.Should().ContainKey("key");
|
||||
signals["key"].Should().Be("value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<!-- Disable Concelier test infra to avoid duplicate package references -->
|
||||
<UseConcelierTestInfra>false</UseConcelierTestInfra>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="TestData\*.dsl">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,56 @@
|
||||
// Default reachability-aware policy
|
||||
// syntax: stella-dsl@1
|
||||
|
||||
policy "default-reachability" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
version = "1.0.0"
|
||||
description = "Default policy with reachability-aware rules"
|
||||
author = "StellaOps"
|
||||
}
|
||||
|
||||
settings {
|
||||
default_action = "warn"
|
||||
fail_on_critical = true
|
||||
}
|
||||
|
||||
profile standard {
|
||||
trust_score = 0.85
|
||||
}
|
||||
|
||||
// Critical vulnerabilities with confirmed reachability
|
||||
rule critical_reachable priority 100 {
|
||||
when finding.severity == "critical" and reachability.state == "reachable"
|
||||
then
|
||||
severity := "critical"
|
||||
annotate finding.priority := "immediate"
|
||||
escalate to "security-team" when reachability.confidence > 0.9
|
||||
because "Critical vulnerabilities with confirmed reachability require immediate action"
|
||||
}
|
||||
|
||||
// High severity with runtime evidence
|
||||
rule high_with_evidence priority 90 {
|
||||
when finding.severity == "high" and reachability.has_runtime_evidence
|
||||
then
|
||||
severity := "high"
|
||||
annotate finding.evidence := "runtime-confirmed"
|
||||
else
|
||||
defer until "reachability-assessment"
|
||||
because "High severity findings need runtime evidence for prioritization"
|
||||
}
|
||||
|
||||
// Low severity unreachable can be ignored
|
||||
rule low_unreachable priority 50 {
|
||||
when finding.severity == "low" and reachability.state == "unreachable"
|
||||
then
|
||||
ignore until "next-scan" because "Low severity unreachable code"
|
||||
because "Low severity unreachable vulnerabilities can be safely deferred"
|
||||
}
|
||||
|
||||
// Unknown reachability requires VEX
|
||||
rule unknown_reachability priority 40 {
|
||||
when not reachability.state
|
||||
then
|
||||
warn message "Reachability assessment pending"
|
||||
because "Unknown reachability requires manual assessment"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Minimal valid policy
|
||||
// syntax: stella-dsl@1
|
||||
|
||||
policy "minimal" syntax "stella-dsl@1" {
|
||||
rule always_pass priority 1 {
|
||||
when true
|
||||
then
|
||||
severity := "info"
|
||||
because "always applies"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user