finish off sprint advisories and sprints
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Abstractions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Evaluation;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Evaluation;
|
||||
|
||||
public class RemediationResolverTests
|
||||
{
|
||||
private readonly RemediationResolver _resolver = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(PolicyGateTypes.CvssThreshold, RemediationCodes.CvssExceed)]
|
||||
[InlineData(PolicyGateTypes.SignatureRequired, RemediationCodes.SignatureMissing)]
|
||||
[InlineData(PolicyGateTypes.EvidenceFreshness, RemediationCodes.FreshnessExpired)]
|
||||
[InlineData(PolicyGateTypes.SbomPresence, RemediationCodes.SbomMissing)]
|
||||
[InlineData(PolicyGateTypes.MinimumConfidence, RemediationCodes.ConfidenceLow)]
|
||||
[InlineData(PolicyGateTypes.UnknownsBudget, RemediationCodes.UnknownsBudgetExceeded)]
|
||||
[InlineData(PolicyGateTypes.ReachabilityRequirement, RemediationCodes.ReachabilityRequired)]
|
||||
public void GetDefaultForGateType_ReturnsCorrectCode(string gateType, string expectedCode)
|
||||
{
|
||||
var hint = _resolver.GetDefaultForGateType(gateType);
|
||||
|
||||
hint.Should().NotBeNull();
|
||||
hint!.Code.Should().Be(expectedCode);
|
||||
hint.Title.Should().NotBeNullOrWhiteSpace();
|
||||
hint.Severity.Should().NotBeNullOrWhiteSpace();
|
||||
hint.Actions.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDefaultForGateType_UnknownType_ReturnsNull()
|
||||
{
|
||||
var hint = _resolver.GetDefaultForGateType("NonExistentGate");
|
||||
hint.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GateWithCustomRemediation_ReturnsCustomHint()
|
||||
{
|
||||
var customHint = new RemediationHint
|
||||
{
|
||||
Code = "CUSTOM_CODE",
|
||||
Title = "Custom remediation",
|
||||
Severity = RemediationSeverity.Low,
|
||||
Actions = [new RemediationAction { Type = RemediationActionTypes.Investigate, Description = "Custom action" }]
|
||||
};
|
||||
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "test-gate",
|
||||
Type = PolicyGateTypes.CvssThreshold,
|
||||
Remediation = customHint
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "some failure");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Code.Should().Be("CUSTOM_CODE");
|
||||
result.Title.Should().Be("Custom remediation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GateWithoutRemediation_FallsBackToDefault()
|
||||
{
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "test-gate",
|
||||
Type = PolicyGateTypes.CvssThreshold,
|
||||
Remediation = null
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "CVSS exceeded");
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Code.Should().Be(RemediationCodes.CvssExceed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_GateWithUnknownType_NoRemediation_ReturnsNull()
|
||||
{
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "unknown-gate",
|
||||
Type = "UnknownGateType",
|
||||
Remediation = null
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "some failure");
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithContext_ResolvesPlaceholders()
|
||||
{
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "test-gate",
|
||||
Type = PolicyGateTypes.SignatureRequired,
|
||||
Remediation = null // will use default
|
||||
};
|
||||
|
||||
var context = new RemediationContext
|
||||
{
|
||||
Image = "registry.example.com/app:v1.2.3",
|
||||
Purl = "pkg:npm/express@4.18.0"
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "signature missing", context);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Actions.Should().Contain(a =>
|
||||
a.Command != null && a.Command.Contains("registry.example.com/app:v1.2.3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithContext_ResolvesAllPlaceholderTypes()
|
||||
{
|
||||
var hint = new RemediationHint
|
||||
{
|
||||
Code = "TEST",
|
||||
Title = "Test",
|
||||
Severity = RemediationSeverity.Medium,
|
||||
Actions =
|
||||
[
|
||||
new RemediationAction
|
||||
{
|
||||
Type = RemediationActionTypes.Upgrade,
|
||||
Description = "Test action",
|
||||
Command = "stella fix --image {image} --purl {purl} --cve {cveId} --env {environment} --reason {reason}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "test-gate",
|
||||
Type = "CustomGate",
|
||||
Remediation = hint
|
||||
};
|
||||
|
||||
var context = new RemediationContext
|
||||
{
|
||||
Image = "myimage:latest",
|
||||
Purl = "pkg:npm/lodash@4.17.21",
|
||||
CveId = "CVE-2021-23337",
|
||||
Environment = "production",
|
||||
Justification = "accepted risk"
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "test", context);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
var command = result!.Actions[0].Command;
|
||||
command.Should().Contain("myimage:latest");
|
||||
command.Should().Contain("pkg:npm/lodash@4.17.21");
|
||||
command.Should().Contain("CVE-2021-23337");
|
||||
command.Should().Contain("production");
|
||||
command.Should().Contain("accepted risk");
|
||||
command.Should().NotContain("{");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_Rule_ReturnsRuleRemediation()
|
||||
{
|
||||
var rule = new PolicyRuleDefinition
|
||||
{
|
||||
Name = "require-dsse",
|
||||
Action = PolicyActions.Block,
|
||||
Remediation = new RemediationHint
|
||||
{
|
||||
Code = RemediationCodes.DsseMissing,
|
||||
Title = "DSSE missing",
|
||||
Severity = RemediationSeverity.Critical,
|
||||
Actions = [new RemediationAction { Type = RemediationActionTypes.Sign, Description = "Sign it", Command = "stella attest attach --sign --image {image}" }]
|
||||
}
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(rule);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Code.Should().Be(RemediationCodes.DsseMissing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_RuleWithoutRemediation_ReturnsNull()
|
||||
{
|
||||
var rule = new PolicyRuleDefinition
|
||||
{
|
||||
Name = "some-rule",
|
||||
Action = PolicyActions.Warn,
|
||||
Remediation = null
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(rule);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_WithNullContext_ReturnsUnresolvedTemplates()
|
||||
{
|
||||
var gate = new PolicyGateDefinition
|
||||
{
|
||||
Id = "test-gate",
|
||||
Type = PolicyGateTypes.SignatureRequired,
|
||||
Remediation = null
|
||||
};
|
||||
|
||||
var result = _resolver.Resolve(gate, "test", context: null);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
// Templates should remain unresolved
|
||||
result!.Actions.Should().Contain(a =>
|
||||
a.Command != null && a.Command.Contains("{image}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllDefaultHints_HaveValidSeverity()
|
||||
{
|
||||
var validSeverities = new[] { RemediationSeverity.Critical, RemediationSeverity.High, RemediationSeverity.Medium, RemediationSeverity.Low };
|
||||
var gateTypes = new[]
|
||||
{
|
||||
PolicyGateTypes.CvssThreshold,
|
||||
PolicyGateTypes.SignatureRequired,
|
||||
PolicyGateTypes.EvidenceFreshness,
|
||||
PolicyGateTypes.SbomPresence,
|
||||
PolicyGateTypes.MinimumConfidence,
|
||||
PolicyGateTypes.UnknownsBudget,
|
||||
PolicyGateTypes.ReachabilityRequirement
|
||||
};
|
||||
|
||||
foreach (var gateType in gateTypes)
|
||||
{
|
||||
var hint = _resolver.GetDefaultForGateType(gateType);
|
||||
hint.Should().NotBeNull(because: $"gate type '{gateType}' should have a default hint");
|
||||
hint!.Severity.Should().BeOneOf(validSeverities,
|
||||
because: $"gate type '{gateType}' severity must be valid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllDefaultHints_HaveAtLeastOneAction()
|
||||
{
|
||||
var gateTypes = new[]
|
||||
{
|
||||
PolicyGateTypes.CvssThreshold,
|
||||
PolicyGateTypes.SignatureRequired,
|
||||
PolicyGateTypes.EvidenceFreshness,
|
||||
PolicyGateTypes.SbomPresence,
|
||||
PolicyGateTypes.MinimumConfidence,
|
||||
PolicyGateTypes.UnknownsBudget,
|
||||
PolicyGateTypes.ReachabilityRequirement
|
||||
};
|
||||
|
||||
foreach (var gateType in gateTypes)
|
||||
{
|
||||
var hint = _resolver.GetDefaultForGateType(gateType);
|
||||
hint!.Actions.Should().NotBeEmpty(
|
||||
because: $"gate type '{gateType}' must have actionable remediation");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemediationContext_ResolveTemplate_WithAdditionalValues()
|
||||
{
|
||||
var context = new RemediationContext
|
||||
{
|
||||
AdditionalValues = new Dictionary<string, string>
|
||||
{
|
||||
["scanId"] = "scan-12345",
|
||||
["component"] = "billing-service"
|
||||
}
|
||||
};
|
||||
|
||||
var result = context.ResolveTemplate("stella scan --id {scanId} --component {component}");
|
||||
|
||||
result.Should().Be("stella scan --id scan-12345 --component billing-service");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Export;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Export;
|
||||
|
||||
public class JsonPolicyExporterTests
|
||||
{
|
||||
private readonly JsonPolicyExporter _exporter = new();
|
||||
|
||||
private static PolicyPackDocument LoadGoldenFixture()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var json = File.ReadAllText(fixturePath);
|
||||
return JsonSerializer.Deserialize<PolicyPackDocument>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToJson_ProducesValidDocument()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var request = new PolicyExportRequest { Format = PolicyFormats.Json };
|
||||
|
||||
var result = await _exporter.ExportToJsonAsync(doc, request);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.ApiVersion.Should().Be(PolicyPackDocument.ApiVersionV2);
|
||||
result.Metadata.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToJson_IsDeterministic()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var request = new PolicyExportRequest { Format = PolicyFormats.Json };
|
||||
|
||||
var result1 = await _exporter.ExportToJsonAsync(doc, request);
|
||||
var result2 = await _exporter.ExportToJsonAsync(doc, request);
|
||||
|
||||
result1.Metadata.Digest.Should().Be(result2.Metadata.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToJson_WithEnvironment_MergesConfig()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var request = new PolicyExportRequest { Format = PolicyFormats.Json, Environment = "staging" };
|
||||
|
||||
var result = await _exporter.ExportToJsonAsync(doc, request);
|
||||
|
||||
// Environment-specific config should be merged into base config
|
||||
var cvssGate = result.Spec.Gates.First(g => g.Id == "cvss-threshold");
|
||||
cvssGate.Environments.Should().BeNull(because: "environments are merged for single-env export");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportToJson_WithoutRemediation_StripsHints()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var request = new PolicyExportRequest { Format = PolicyFormats.Json, IncludeRemediation = false };
|
||||
|
||||
var result = await _exporter.ExportToJsonAsync(doc, request);
|
||||
|
||||
result.Spec.Gates.Should().AllSatisfy(g => g.Remediation.Should().BeNull());
|
||||
result.Spec.Rules.Should().AllSatisfy(r => r.Remediation.Should().BeNull());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeCanonical_ProducesDeterministicOutput()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
|
||||
var bytes1 = JsonPolicyExporter.SerializeCanonical(doc);
|
||||
var bytes2 = JsonPolicyExporter.SerializeCanonical(doc);
|
||||
|
||||
bytes1.Should().BeEquivalentTo(bytes2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoundTrip_ExportImport_ProducesEquivalent()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var request = new PolicyExportRequest { Format = PolicyFormats.Json };
|
||||
|
||||
var exported = await _exporter.ExportToJsonAsync(doc, request);
|
||||
var json = JsonPolicyExporter.SerializeToString(exported);
|
||||
|
||||
// Re-import
|
||||
var reimported = JsonSerializer.Deserialize<PolicyPackDocument>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
reimported.Should().NotBeNull();
|
||||
reimported!.Spec.Gates.Should().HaveCount(doc.Spec.Gates.Count);
|
||||
reimported.Spec.Rules.Should().HaveCount(doc.Spec.Rules.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": {
|
||||
"name": "production-baseline",
|
||||
"version": "1.0.0",
|
||||
"description": "Production release gate policy with evidence-based verification.",
|
||||
"createdAt": "2026-01-23T00:00:00Z",
|
||||
"exportedFrom": {
|
||||
"engine": "stella-policy-engine",
|
||||
"engineVersion": "10.0.0",
|
||||
"exportedAt": "2026-01-23T00:00:00Z"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"settings": {
|
||||
"defaultAction": "block",
|
||||
"unknownsThreshold": 0.6,
|
||||
"stopOnFirstFailure": true,
|
||||
"deterministicMode": true
|
||||
},
|
||||
"gates": [
|
||||
{
|
||||
"id": "cvss-threshold",
|
||||
"type": "CvssThresholdGate",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"threshold": 7.0,
|
||||
"cvssVersion": "highest",
|
||||
"failOnMissing": false
|
||||
},
|
||||
"environments": {
|
||||
"production": { "threshold": 7.0 },
|
||||
"staging": { "threshold": 8.0 },
|
||||
"development": { "threshold": 9.0 }
|
||||
},
|
||||
"remediation": {
|
||||
"code": "CVSS_EXCEED",
|
||||
"title": "CVSS score exceeds threshold",
|
||||
"description": "One or more vulnerabilities exceed the configured CVSS severity threshold for this environment.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "upgrade",
|
||||
"description": "Upgrade the affected package to a patched version.",
|
||||
"command": "stella advisory patch --purl {purl}"
|
||||
},
|
||||
{
|
||||
"type": "vex",
|
||||
"description": "Provide a VEX not_affected statement if the vulnerability is unreachable.",
|
||||
"command": "stella vex emit --status not_affected --purl {purl} --justification {reason}"
|
||||
},
|
||||
{
|
||||
"type": "override",
|
||||
"description": "Request a policy override with documented justification.",
|
||||
"command": "stella gate evaluate --allow-override --justification '{reason}'"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{ "title": "CVSS v3.1 Specification", "url": "https://www.first.org/cvss/v3.1/specification-document" }
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "signature-required",
|
||||
"type": "SignatureRequiredGate",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"requireDsse": true,
|
||||
"requireRekor": true,
|
||||
"acceptedAlgorithms": ["ES256", "RS256", "EdDSA"]
|
||||
},
|
||||
"remediation": {
|
||||
"code": "SIG_MISS",
|
||||
"title": "Required signature missing",
|
||||
"description": "The artifact is missing a required DSSE signature or Rekor transparency log entry.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "sign",
|
||||
"description": "Sign the attestation with DSSE and attach to the artifact.",
|
||||
"command": "stella attest attach --sign --image {image}"
|
||||
},
|
||||
{
|
||||
"type": "anchor",
|
||||
"description": "Anchor the attestation in the Rekor transparency log.",
|
||||
"command": "stella attest attach --rekor --image {image}"
|
||||
}
|
||||
],
|
||||
"severity": "critical"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "evidence-freshness",
|
||||
"type": "EvidenceFreshnessGate",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"maxAgeHours": 24,
|
||||
"requireTst": false
|
||||
},
|
||||
"environments": {
|
||||
"production": { "maxAgeHours": 24, "requireTst": true },
|
||||
"staging": { "maxAgeHours": 72 }
|
||||
},
|
||||
"remediation": {
|
||||
"code": "FRESH_EXPIRED",
|
||||
"title": "Evidence freshness expired",
|
||||
"description": "The attestation evidence exceeds the maximum age threshold for this environment.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "generate",
|
||||
"description": "Re-generate attestation with current timestamp.",
|
||||
"command": "stella attest build --image {image}"
|
||||
},
|
||||
{
|
||||
"type": "sign",
|
||||
"description": "Request an RFC-3161 timestamp for freshness proof.",
|
||||
"command": "stella attest attach --tst --image {image}"
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{ "title": "RFC 3161 - TSA Protocol", "url": "https://datatracker.ietf.org/doc/html/rfc3161" }
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sbom-presence",
|
||||
"type": "SbomPresenceGate",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"requireCanonicalDigest": true,
|
||||
"acceptedFormats": ["cyclonedx-1.5", "cyclonedx-1.6", "spdx-2.3"]
|
||||
},
|
||||
"remediation": {
|
||||
"code": "SBOM_MISS",
|
||||
"title": "SBOM missing or invalid",
|
||||
"description": "A canonical SBOM with verified digest is required for release verification.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "generate",
|
||||
"description": "Generate an SBOM and include its digest in the attestation.",
|
||||
"command": "stella sbom generate --format cyclonedx --output sbom.cdx.json"
|
||||
}
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "minimum-confidence",
|
||||
"type": "MinimumConfidenceGate",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"threshold": 0.75
|
||||
},
|
||||
"environments": {
|
||||
"production": { "threshold": 0.75 },
|
||||
"staging": { "threshold": 0.60 },
|
||||
"development": { "threshold": 0.40 }
|
||||
},
|
||||
"remediation": {
|
||||
"code": "CONF_LOW",
|
||||
"title": "Confidence score below threshold",
|
||||
"description": "The reachability confidence score is below the minimum required for this environment.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "investigate",
|
||||
"description": "Provide additional reachability evidence to increase confidence.",
|
||||
"command": "stella scan reachability --purl {purl} --deep"
|
||||
}
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"name": "require-dsse-signature",
|
||||
"action": "block",
|
||||
"priority": 10,
|
||||
"match": { "dsse.verified": false },
|
||||
"remediation": {
|
||||
"code": "DSSE_MISS",
|
||||
"title": "DSSE signature missing or invalid",
|
||||
"actions": [
|
||||
{
|
||||
"type": "sign",
|
||||
"description": "Sign attestation with DSSE.",
|
||||
"command": "stella attest attach --sign --image {image}"
|
||||
}
|
||||
],
|
||||
"severity": "critical"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "require-rekor-proof",
|
||||
"action": "block",
|
||||
"priority": 20,
|
||||
"match": { "rekor.verified": false },
|
||||
"remediation": {
|
||||
"code": "REKOR_MISS",
|
||||
"title": "Rekor v2 inclusion proof missing or invalid",
|
||||
"actions": [
|
||||
{
|
||||
"type": "anchor",
|
||||
"description": "Anchor attestation in Rekor transparency log.",
|
||||
"command": "stella attest attach --rekor --image {image}"
|
||||
}
|
||||
],
|
||||
"severity": "critical"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "require-sbom-digest",
|
||||
"action": "block",
|
||||
"priority": 30,
|
||||
"match": { "sbom.canonicalDigest": null },
|
||||
"remediation": {
|
||||
"code": "SBOM_MISS",
|
||||
"title": "Canonical SBOM digest missing",
|
||||
"actions": [
|
||||
{
|
||||
"type": "generate",
|
||||
"description": "Generate SBOM and include canonical digest in attestation.",
|
||||
"command": "stella sbom generate --format cyclonedx --output sbom.cdx.json"
|
||||
}
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "require-freshness-tst",
|
||||
"action": "warn",
|
||||
"priority": 40,
|
||||
"match": { "freshness.tstVerified": false },
|
||||
"remediation": {
|
||||
"code": "TST_MISS",
|
||||
"title": "RFC-3161 timestamp missing",
|
||||
"description": "Timestamp verification is recommended for freshness assurance.",
|
||||
"actions": [
|
||||
{
|
||||
"type": "sign",
|
||||
"description": "Request a TSA timestamp.",
|
||||
"command": "stella attest attach --tst --image {image}"
|
||||
}
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package stella.release
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
# Gate: cvss-threshold (CvssThresholdGate)
|
||||
deny contains msg if {
|
||||
input.cvss.score >= 7.0
|
||||
msg := "CVSS score exceeds threshold"
|
||||
}
|
||||
|
||||
# Gate: signature-required (SignatureRequiredGate)
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "Required signature missing"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.rekor.verified
|
||||
msg := "Required signature missing"
|
||||
}
|
||||
|
||||
# Gate: evidence-freshness (EvidenceFreshnessGate)
|
||||
deny contains msg if {
|
||||
input.freshness.maxAgeHours <= 24
|
||||
not input.freshness.tstVerified
|
||||
msg := "Evidence freshness expired"
|
||||
}
|
||||
|
||||
# Gate: sbom-presence (SbomPresenceGate)
|
||||
deny contains msg if {
|
||||
not input.sbom.canonicalDigest
|
||||
msg := "SBOM missing or invalid"
|
||||
}
|
||||
|
||||
# Gate: minimum-confidence (MinimumConfidenceGate)
|
||||
deny contains msg if {
|
||||
input.confidence < 0.75
|
||||
msg := "Confidence score below threshold"
|
||||
}
|
||||
|
||||
# Rule: require-dsse-signature
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "DSSE signature missing or invalid"
|
||||
}
|
||||
|
||||
# Rule: require-rekor-proof
|
||||
deny contains msg if {
|
||||
not input.rekor.verified
|
||||
msg := "Rekor v2 inclusion proof missing or invalid"
|
||||
}
|
||||
|
||||
# Rule: require-sbom-digest
|
||||
deny contains msg if {
|
||||
not input.sbom.canonicalDigest
|
||||
msg := "Canonical SBOM digest missing"
|
||||
}
|
||||
|
||||
# Rule: require-freshness-tst
|
||||
deny contains msg if {
|
||||
not input.freshness.tstVerified
|
||||
msg := "RFC-3161 timestamp missing"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
|
||||
# Remediation hints (structured output)
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "CVSS score exceeds threshold"
|
||||
hint := {"code": "CVSS_EXCEED", "fix": "Run: stella advisory patch --purl {purl}", "severity": "high"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "Required signature missing"
|
||||
hint := {"code": "SIG_MISS", "fix": "Run: stella attest attach --sign --image {image}", "severity": "critical"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "Evidence freshness expired"
|
||||
hint := {"code": "FRESH_EXPIRED", "fix": "Run: stella attest build --image {image}", "severity": "high"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "SBOM missing or invalid"
|
||||
hint := {"code": "SBOM_MISS", "fix": "Run: stella sbom generate --format cyclonedx --output sbom.cdx.json", "severity": "high"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "Confidence score below threshold"
|
||||
hint := {"code": "CONF_LOW", "fix": "Run: stella scan reachability --purl {purl} --deep", "severity": "medium"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "DSSE signature missing or invalid"
|
||||
hint := {"code": "DSSE_MISS", "fix": "Run: stella attest attach --sign --image {image}", "severity": "critical"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "Rekor v2 inclusion proof missing or invalid"
|
||||
hint := {"code": "REKOR_MISS", "fix": "Run: stella attest attach --rekor --image {image}", "severity": "critical"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "Canonical SBOM digest missing"
|
||||
hint := {"code": "SBOM_MISS", "fix": "Run: stella sbom generate --format cyclonedx --output sbom.cdx.json", "severity": "high"}
|
||||
}
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "RFC-3161 timestamp missing"
|
||||
hint := {"code": "TST_MISS", "fix": "Run: stella attest attach --tst --image {image}", "severity": "medium"}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Import;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Import;
|
||||
|
||||
public class FormatDetectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Detect_JsonWithApiVersion_ReturnsJson()
|
||||
{
|
||||
var content = """{ "apiVersion": "policy.stellaops.io/v2", "kind": "PolicyPack" }""";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_JsonWithKind_ReturnsJson()
|
||||
{
|
||||
var content = """{ "kind": "PolicyPack", "metadata": {} }""";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_GenericJson_ReturnsJson()
|
||||
{
|
||||
var content = """{ "foo": "bar" }""";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_RegoWithPackage_ReturnsRego()
|
||||
{
|
||||
var content = "package stella.release\n\ndefault allow := false\n";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Rego);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_RegoWithComment_ThenPackage_ReturnsRego()
|
||||
{
|
||||
var content = "# Policy file\npackage stella.release\n\ndefault allow := false\n";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Rego);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_RegoWithDenyContains_ReturnsRego()
|
||||
{
|
||||
var content = "deny contains msg if {\n not input.dsse.verified\n}\n";
|
||||
FormatDetector.Detect(content).Should().Be(PolicyFormats.Rego);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_EmptyContent_ReturnsNull()
|
||||
{
|
||||
FormatDetector.Detect("").Should().BeNull();
|
||||
FormatDetector.Detect(" ").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_UnrecognizableContent_ReturnsNull()
|
||||
{
|
||||
FormatDetector.Detect("hello world").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectFromExtension_JsonFile_ReturnsJson()
|
||||
{
|
||||
FormatDetector.DetectFromExtension("policy.json").Should().Be(PolicyFormats.Json);
|
||||
FormatDetector.DetectFromExtension("/path/to/my-policy.json").Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectFromExtension_RegoFile_ReturnsRego()
|
||||
{
|
||||
FormatDetector.DetectFromExtension("policy.rego").Should().Be(PolicyFormats.Rego);
|
||||
FormatDetector.DetectFromExtension("/path/to/release.rego").Should().Be(PolicyFormats.Rego);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectFromExtension_UnknownExtension_ReturnsNull()
|
||||
{
|
||||
FormatDetector.DetectFromExtension("policy.yaml").Should().BeNull();
|
||||
FormatDetector.DetectFromExtension("policy.txt").Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_WithFilePath_ExtensionTakesPriority()
|
||||
{
|
||||
// Content looks like Rego but extension is .json
|
||||
var content = "package stella.release\ndefault allow := false\n";
|
||||
FormatDetector.Detect("policy.json", content).Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detect_WithFilePath_FallsBackToContent()
|
||||
{
|
||||
var content = """{ "apiVersion": "policy.stellaops.io/v2" }""";
|
||||
FormatDetector.Detect("policy.unknown", content).Should().Be(PolicyFormats.Json);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(" { \"apiVersion\": \"policy.stellaops.io/v2\" }", PolicyFormats.Json)]
|
||||
[InlineData("\n\n{ \"kind\": \"PolicyPack\" }", PolicyFormats.Json)]
|
||||
[InlineData(" package stella.release\n", PolicyFormats.Rego)]
|
||||
[InlineData("\n# comment\npackage foo\n", PolicyFormats.Rego)]
|
||||
public void Detect_WithLeadingWhitespace_DetectsCorrectly(string content, string expected)
|
||||
{
|
||||
FormatDetector.Detect(content).Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Import;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Import;
|
||||
|
||||
public class JsonPolicyImporterTests
|
||||
{
|
||||
private readonly JsonPolicyImporter _importer = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Import_GoldenFixture_Succeeds()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var content = await File.ReadAllTextAsync(fixturePath);
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(content, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document.Should().NotBeNull();
|
||||
result.DetectedFormat.Should().Be(PolicyFormats.Json);
|
||||
result.GateCount.Should().Be(5);
|
||||
result.RuleCount.Should().Be(4);
|
||||
result.Diagnostics.Should().NotContain(d => d.Severity == PolicyDiagnostic.Severities.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_InvalidJson_ReturnsParseError()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync("{ invalid json }", new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "JSON_PARSE_ERROR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_UnknownApiVersion_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v99",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": { "defaultAction": "block" }, "gates": [], "rules": [] }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(json, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "VERSION_UNKNOWN");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_V1ApiVersion_ReturnsWarning()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v1",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": { "defaultAction": "block" }, "gates": [], "rules": [] }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(json, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "VERSION_V1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_DuplicateGateIds_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [
|
||||
{ "id": "dup-gate", "type": "SomeGate" },
|
||||
{ "id": "dup-gate", "type": "AnotherGate" }
|
||||
],
|
||||
"rules": []
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(json, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "GATE_ID_DUPLICATE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_DuplicateRuleNames_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [],
|
||||
"rules": [
|
||||
{ "name": "dup-rule", "action": "block" },
|
||||
{ "name": "dup-rule", "action": "warn" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(json, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "RULE_NAME_DUPLICATE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_EmptyContent_ReturnsError()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync("", new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_RegoContent_ReturnsRegoError()
|
||||
{
|
||||
var rego = "package stella.release\ndefault allow := false\n";
|
||||
var result = await _importer.ImportFromStringAsync(rego, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.DetectedFormat.Should().Be(PolicyFormats.Rego);
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "REGO_USE_IMPORTER");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ValidateOnly_DoesNotPersist()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var content = await File.ReadAllTextAsync(fixturePath);
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(content,
|
||||
new PolicyImportOptions { ValidateOnly = true });
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document.Should().NotBeNull(); // Document returned even in validate-only
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_Stream_WorksLikeString()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
await using var stream = File.OpenRead(fixturePath);
|
||||
|
||||
var result = await _importer.ImportAsync(stream, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.GateCount.Should().Be(5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-05 - Rego Import & Embedded OPA Evaluator
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Import;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Import;
|
||||
|
||||
public class RegoPolicyImporterTests
|
||||
{
|
||||
private readonly RegoPolicyImporter _importer = new();
|
||||
|
||||
private const string SampleRegoWithAllGates = """
|
||||
package stella.release
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
input.cvss.score >= 7.0
|
||||
msg := "CVSS score exceeds threshold"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "DSSE signature missing"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.rekor.verified
|
||||
msg := "Rekor proof missing"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.sbom.canonicalDigest
|
||||
msg := "SBOM digest missing"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
input.confidence < 0.75
|
||||
msg := "Confidence too low"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.freshness.tstVerified
|
||||
msg := "Evidence freshness expired"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.reachability.status
|
||||
msg := "Reachability proof required"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
input.unknownsRatio > 0.6
|
||||
msg := "Unknowns budget exceeded"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ValidRego_ReturnsSuccess()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document.Should().NotBeNull();
|
||||
result.DetectedFormat.Should().Be(PolicyFormats.Rego);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsCvssGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.CvssThreshold);
|
||||
var cvssGate = result.Document.Spec.Gates.First(g => g.Type == PolicyGateTypes.CvssThreshold);
|
||||
cvssGate.Config.Should().ContainKey("threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsSignatureGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.SignatureRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsSbomGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.SbomPresence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsConfidenceGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.MinimumConfidence);
|
||||
var confGate = result.Document.Spec.Gates.First(g => g.Type == PolicyGateTypes.MinimumConfidence);
|
||||
confGate.Config.Should().ContainKey("threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsFreshnessGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.EvidenceFreshness);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsReachabilityGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.ReachabilityRequirement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_MapsUnknownsGateToNative()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Gates.Should().Contain(g => g.Type == PolicyGateTypes.UnknownsBudget);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_AllGatesMappedNatively()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Mapping.Should().NotBeNull();
|
||||
result.Mapping!.NativeMapped.Should().NotBeEmpty();
|
||||
result.Mapping.OpaEvaluated.Should().BeEmpty();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "NATIVE_MAPPED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_UnknownPattern_CreatesCustomRule()
|
||||
{
|
||||
var regoWithCustom = """
|
||||
package stella.release
|
||||
import rego.v1
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
input.custom.field == "dangerous"
|
||||
msg := "Custom check failed"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(regoWithCustom, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document!.Spec!.Rules.Should().NotBeEmpty();
|
||||
result.Mapping!.OpaEvaluated.Should().NotBeEmpty();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "UNMAPPED_RULE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_WithEnvironment_CapturesEnvironment()
|
||||
{
|
||||
var regoWithEnv = """
|
||||
package stella.release
|
||||
import rego.v1
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
input.environment == "production"
|
||||
input.cvss.score >= 7.0
|
||||
msg := "CVSS exceeds production threshold"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(regoWithEnv, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var cvssGate = result.Document!.Spec!.Gates.First(g => g.Type == PolicyGateTypes.CvssThreshold);
|
||||
cvssGate.Environments.Should().ContainKey("production");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_JsonContent_RejectsWithFormatMismatch()
|
||||
{
|
||||
var jsonContent = """{"apiVersion": "policy.stellaops.io/v2"}""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(jsonContent, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Code == "FORMAT_MISMATCH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_ExtractsPackageName()
|
||||
{
|
||||
var regoWithCustomPkg = """
|
||||
package myorg.custom.policy
|
||||
import rego.v1
|
||||
default allow := false
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "unsigned"
|
||||
}
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(regoWithCustomPkg, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Document!.Metadata!.Name.Should().Be("myorg-custom-policy");
|
||||
result.Document.Metadata.Description.Should().Contain("myorg.custom.policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_WithRemediation_AttachesToGates()
|
||||
{
|
||||
var regoWithRemediation = """
|
||||
package stella.release
|
||||
import rego.v1
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "DSSE signature missing"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
|
||||
remediation contains hint if {
|
||||
some msg in deny
|
||||
msg == "DSSE signature missing"
|
||||
hint := {"code": "SIG_MISS", "fix": "Run: stella attest attach --sign", "severity": "critical"}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = await _importer.ImportFromStringAsync(regoWithRemediation, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var sigGate = result.Document!.Spec!.Gates.First(g => g.Type == PolicyGateTypes.SignatureRequired);
|
||||
sigGate.Remediation.Should().NotBeNull();
|
||||
sigGate.Remediation!.Code.Should().Be("SIG_MISS");
|
||||
sigGate.Remediation.Severity.Should().Be("critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_SetsApiVersionAndKind()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.ApiVersion.Should().Be(PolicyPackDocument.ApiVersionV2);
|
||||
result.Document.Kind.Should().Be(PolicyPackDocument.KindPolicyPack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_SetsDefaultActionToBlock()
|
||||
{
|
||||
var result = await _importer.ImportFromStringAsync(SampleRegoWithAllGates, new PolicyImportOptions());
|
||||
|
||||
result.Document!.Spec!.Settings!.DefaultAction.Should().Be(PolicyActions.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_EmptyStream_ReturnsFailure()
|
||||
{
|
||||
using var stream = new MemoryStream(Array.Empty<byte>());
|
||||
var result = await _importer.ImportAsync(stream, new PolicyImportOptions());
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.Diagnostics.Should().Contain(d => d.Severity == "error");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Interop.Abstractions;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using StellaOps.Policy.Interop.Rego;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Rego;
|
||||
|
||||
public class RegoCodeGeneratorTests
|
||||
{
|
||||
private readonly RegoCodeGenerator _generator = new();
|
||||
|
||||
private static PolicyPackDocument LoadGoldenFixture()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var json = File.ReadAllText(fixturePath);
|
||||
return JsonSerializer.Deserialize<PolicyPackDocument>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ProducesValidRegoHeader()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.RegoSource.Should().StartWith("package stella.release");
|
||||
result.RegoSource.Should().Contain("import rego.v1");
|
||||
result.RegoSource.Should().Contain("default allow := false");
|
||||
result.PackageName.Should().Be("stella.release");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ContainsDenyRules()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("deny contains msg if {");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ContainsAllowRule()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("allow if { count(deny) == 0 }");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_CvssGate_ProducesScoreComparison()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("input.cvss.score >= 7.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_SignatureGate_ProducesDsseAndRekorChecks()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("not input.dsse.verified");
|
||||
result.RegoSource.Should().Contain("not input.rekor.verified");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_SbomGate_ProducesDigestCheck()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("not input.sbom.canonicalDigest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ConfidenceGate_ProducesThresholdCheck()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("input.confidence < 0.75");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithRemediation_ProducesRemediationRules()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { IncludeRemediation = true });
|
||||
|
||||
result.RegoSource.Should().Contain("remediation contains hint if {");
|
||||
result.RegoSource.Should().Contain("\"code\":");
|
||||
result.RegoSource.Should().Contain("\"fix\":");
|
||||
result.RegoSource.Should().Contain("\"severity\":");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithoutRemediation_OmitsRemediationRules()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { IncludeRemediation = false });
|
||||
|
||||
result.RegoSource.Should().NotContain("remediation contains hint if {");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithEnvironment_UsesEnvironmentThresholds()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { Environment = "staging" });
|
||||
|
||||
// Staging CVSS threshold is 8.0
|
||||
result.RegoSource.Should().Contain("input.cvss.score >= 8.0");
|
||||
result.RegoSource.Should().Contain("input.environment == \"staging\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_CustomPackageName_UsesIt()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { PackageName = "myorg.policy" });
|
||||
|
||||
result.RegoSource.Should().StartWith("package myorg.policy");
|
||||
result.PackageName.Should().Be("myorg.policy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithComments_IncludesGateComments()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { IncludeComments = true });
|
||||
|
||||
result.RegoSource.Should().Contain("# Gate: cvss-threshold (CvssThresholdGate)");
|
||||
result.RegoSource.Should().Contain("# Rule: require-dsse-signature");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_WithoutComments_OmitsComments()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions { IncludeComments = false });
|
||||
|
||||
result.RegoSource.Should().NotContain("# Gate:");
|
||||
result.RegoSource.Should().NotContain("# Rule:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_ProducesDigest()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.Digest.Should().NotBeNull();
|
||||
result.Digest.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_IsDeterministic()
|
||||
{
|
||||
var doc = LoadGoldenFixture();
|
||||
var options = new RegoGenerationOptions();
|
||||
|
||||
var result1 = _generator.Generate(doc, options);
|
||||
var result2 = _generator.Generate(doc, options);
|
||||
|
||||
result1.Digest.Should().Be(result2.Digest);
|
||||
result1.RegoSource.Should().Be(result2.RegoSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_DisabledGate_IsSkipped()
|
||||
{
|
||||
var doc = new PolicyPackDocument
|
||||
{
|
||||
ApiVersion = PolicyPackDocument.ApiVersionV2,
|
||||
Kind = PolicyPackDocument.KindPolicyPack,
|
||||
Metadata = new PolicyPackMetadata { Name = "test", Version = "1.0.0" },
|
||||
Spec = new PolicyPackSpec
|
||||
{
|
||||
Settings = new PolicyPackSettings { DefaultAction = PolicyActions.Block },
|
||||
Gates =
|
||||
[
|
||||
new PolicyGateDefinition { Id = "disabled-gate", Type = PolicyGateTypes.CvssThreshold, Enabled = false }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().NotContain("input.cvss.score");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_UnknownGateType_ProducesWarning()
|
||||
{
|
||||
var doc = new PolicyPackDocument
|
||||
{
|
||||
ApiVersion = PolicyPackDocument.ApiVersionV2,
|
||||
Kind = PolicyPackDocument.KindPolicyPack,
|
||||
Metadata = new PolicyPackMetadata { Name = "test", Version = "1.0.0" },
|
||||
Spec = new PolicyPackSpec
|
||||
{
|
||||
Settings = new PolicyPackSettings { DefaultAction = PolicyActions.Block },
|
||||
Gates =
|
||||
[
|
||||
new PolicyGateDefinition { Id = "unknown-gate", Type = "CustomUnknownGate", Enabled = true }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.Warnings.Should().Contain(w => w.Contains("CustomUnknownGate"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_AllowRule_IsSkipped()
|
||||
{
|
||||
var doc = new PolicyPackDocument
|
||||
{
|
||||
ApiVersion = PolicyPackDocument.ApiVersionV2,
|
||||
Kind = PolicyPackDocument.KindPolicyPack,
|
||||
Metadata = new PolicyPackMetadata { Name = "test", Version = "1.0.0" },
|
||||
Spec = new PolicyPackSpec
|
||||
{
|
||||
Settings = new PolicyPackSettings { DefaultAction = PolicyActions.Block },
|
||||
Rules =
|
||||
[
|
||||
new PolicyRuleDefinition { Name = "allow-rule", Action = PolicyActions.Allow }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
// Allow rules don't generate deny rules
|
||||
result.RegoSource.Should().NotContain("allow-rule");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_RuleWithNullMatch_ProducesNotCheck()
|
||||
{
|
||||
var doc = new PolicyPackDocument
|
||||
{
|
||||
ApiVersion = PolicyPackDocument.ApiVersionV2,
|
||||
Kind = PolicyPackDocument.KindPolicyPack,
|
||||
Metadata = new PolicyPackMetadata { Name = "test", Version = "1.0.0" },
|
||||
Spec = new PolicyPackSpec
|
||||
{
|
||||
Settings = new PolicyPackSettings { DefaultAction = PolicyActions.Block },
|
||||
Rules =
|
||||
[
|
||||
new PolicyRuleDefinition
|
||||
{
|
||||
Name = "null-check",
|
||||
Action = PolicyActions.Block,
|
||||
Match = new Dictionary<string, object?> { ["sbom.canonicalDigest"] = null }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var result = _generator.Generate(doc, new RegoGenerationOptions());
|
||||
|
||||
result.RegoSource.Should().Contain("not input.sbom.canonicalDigest");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Policy.Interop\StellaOps.Policy.Interop.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Fixtures\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,234 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Json.Schema;
|
||||
using StellaOps.Policy.Interop.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Interop.Tests.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the PolicyPack v2 JSON Schema against golden fixtures.
|
||||
/// </summary>
|
||||
public class PolicySchemaValidatorTests
|
||||
{
|
||||
private static readonly JsonSchema Schema = LoadSchema();
|
||||
|
||||
private static JsonSchema LoadSchema()
|
||||
{
|
||||
var schemaPath = Path.Combine(
|
||||
AppContext.BaseDirectory, "..", "..", "..", "..", "..",
|
||||
"__Libraries", "StellaOps.Policy.Interop", "Schemas", "policy-pack-v2.schema.json");
|
||||
|
||||
if (!File.Exists(schemaPath))
|
||||
{
|
||||
// Fallback: try embedded resource path
|
||||
schemaPath = Path.Combine(AppContext.BaseDirectory, "Schemas", "policy-pack-v2.schema.json");
|
||||
}
|
||||
|
||||
var schemaJson = File.ReadAllText(schemaPath);
|
||||
return JsonSchema.FromText(schemaJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixture_ShouldValidateAgainstSchema()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var fixtureJson = File.ReadAllText(fixturePath);
|
||||
using var document = JsonDocument.Parse(fixtureJson);
|
||||
|
||||
var result = Schema.Evaluate(document.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeTrue(
|
||||
because: "the golden fixture must validate against the PolicyPack v2 schema. " +
|
||||
$"Errors: {FormatErrors(result)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixture_ShouldDeserializeToDocument()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var fixtureJson = File.ReadAllText(fixturePath);
|
||||
|
||||
var document = JsonSerializer.Deserialize<PolicyPackDocument>(fixtureJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
document.Should().NotBeNull();
|
||||
document!.ApiVersion.Should().Be(PolicyPackDocument.ApiVersionV2);
|
||||
document.Kind.Should().Be(PolicyPackDocument.KindPolicyPack);
|
||||
document.Metadata.Name.Should().Be("production-baseline");
|
||||
document.Metadata.Version.Should().Be("1.0.0");
|
||||
document.Spec.Settings.DefaultAction.Should().Be(PolicyActions.Block);
|
||||
document.Spec.Gates.Should().HaveCount(5);
|
||||
document.Spec.Rules.Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixture_AllGates_ShouldHaveRemediation()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var fixtureJson = File.ReadAllText(fixturePath);
|
||||
|
||||
var document = JsonSerializer.Deserialize<PolicyPackDocument>(fixtureJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
foreach (var gate in document!.Spec.Gates)
|
||||
{
|
||||
gate.Remediation.Should().NotBeNull(
|
||||
because: $"gate '{gate.Id}' must have a remediation hint defined");
|
||||
gate.Remediation!.Code.Should().NotBeNullOrWhiteSpace();
|
||||
gate.Remediation.Severity.Should().BeOneOf(
|
||||
RemediationSeverity.Critical,
|
||||
RemediationSeverity.High,
|
||||
RemediationSeverity.Medium,
|
||||
RemediationSeverity.Low);
|
||||
gate.Remediation.Actions.Should().NotBeEmpty(
|
||||
because: $"gate '{gate.Id}' remediation must have at least one action");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenFixture_AllRules_ShouldHaveRemediation()
|
||||
{
|
||||
var fixturePath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "golden-policy-pack-v2.json");
|
||||
var fixtureJson = File.ReadAllText(fixturePath);
|
||||
|
||||
var document = JsonSerializer.Deserialize<PolicyPackDocument>(fixtureJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
foreach (var rule in document!.Spec.Rules)
|
||||
{
|
||||
rule.Remediation.Should().NotBeNull(
|
||||
because: $"rule '{rule.Name}' must have a remediation hint defined");
|
||||
rule.Remediation!.Code.Should().NotBeNullOrWhiteSpace();
|
||||
rule.Remediation.Actions.Should().NotBeEmpty(
|
||||
because: $"rule '{rule.Name}' remediation must have at least one action");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDocument_MissingApiVersion_ShouldFailValidation()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": { "defaultAction": "block" } }
|
||||
}
|
||||
""");
|
||||
|
||||
var result = Schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDocument_WrongApiVersion_ShouldFailValidation()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v1",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": { "defaultAction": "block" } }
|
||||
}
|
||||
""");
|
||||
|
||||
var result = Schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDocument_BadGateId_ShouldFailValidation()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [{ "id": "INVALID_ID!", "type": "SomeGate" }]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var result = Schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidDocument_BadRemediationCode_ShouldFailValidation()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [{
|
||||
"id": "test-gate",
|
||||
"type": "SomeGate",
|
||||
"remediation": {
|
||||
"code": "invalid-lowercase",
|
||||
"title": "Test",
|
||||
"severity": "high"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var result = Schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidMinimalDocument_ShouldPassValidation()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "minimal", "version": "1.0.0" },
|
||||
"spec": { "settings": { "defaultAction": "allow" } }
|
||||
}
|
||||
""");
|
||||
|
||||
var result = Schema.Evaluate(json.RootElement, new EvaluationOptions
|
||||
{
|
||||
OutputFormat = OutputFormat.List
|
||||
});
|
||||
|
||||
result.IsValid.Should().BeTrue(
|
||||
because: $"a minimal valid document should pass. Errors: {FormatErrors(result)}");
|
||||
}
|
||||
|
||||
private static string FormatErrors(EvaluationResults result)
|
||||
{
|
||||
if (result.IsValid) return "none";
|
||||
var details = result.Details?
|
||||
.Where(d => !d.IsValid && d.Errors != null)
|
||||
.SelectMany(d => d.Errors!.Select(e => $"{d.InstanceLocation}: {e.Value}"))
|
||||
.ToList();
|
||||
return details != null ? string.Join("; ", details) : "unknown";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user