up
This commit is contained in:
@@ -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