finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

@@ -0,0 +1,93 @@
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Evaluates Rego policies using an embedded OPA binary (offline-capable).
/// Falls back gracefully when OPA binary is unavailable.
/// </summary>
public interface IEmbeddedOpaEvaluator
{
/// <summary>
/// Evaluates a Rego policy against JSON input data offline.
/// Uses the bundled OPA binary via process invocation.
/// </summary>
/// <param name="regoSource">Rego source code to evaluate.</param>
/// <param name="inputJson">JSON input data.</param>
/// <param name="queryPath">OPA query path (e.g., "data.stella.release").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Evaluation result with allow/deny/remediation outputs.</returns>
Task<OpaEvaluationResult> EvaluateAsync(
string regoSource,
string inputJson,
string queryPath,
CancellationToken ct = default);
/// <summary>
/// Evaluates a Rego bundle (tar.gz) against JSON input data.
/// </summary>
Task<OpaEvaluationResult> EvaluateBundleAsync(
byte[] bundleBytes,
string inputJson,
string queryPath,
CancellationToken ct = default);
/// <summary>
/// Validates Rego syntax without evaluation.
/// </summary>
Task<OpaValidationResult> ValidateSyntaxAsync(
string regoSource,
CancellationToken ct = default);
/// <summary>
/// Checks whether the embedded OPA binary is available.
/// </summary>
Task<bool> IsAvailableAsync(CancellationToken ct = default);
}
/// <summary>
/// Result of an OPA evaluation.
/// </summary>
public sealed record OpaEvaluationResult
{
/// <summary>Whether evaluation succeeded.</summary>
public required bool Success { get; init; }
/// <summary>The allow decision from Rego evaluation.</summary>
public bool Allow { get; init; }
/// <summary>Deny messages from Rego deny rules.</summary>
public IReadOnlyList<string> DenyMessages { get; init; } = [];
/// <summary>Structured remediation hints from Rego remediation rules.</summary>
public IReadOnlyList<OpaRemediationOutput> Remediations { get; init; } = [];
/// <summary>Raw JSON output from OPA (for debugging).</summary>
public string? RawOutput { get; init; }
/// <summary>Error message if evaluation failed.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Remediation hint output from OPA Rego evaluation.
/// </summary>
public sealed record OpaRemediationOutput
{
public required string Code { get; init; }
public required string Fix { get; init; }
public required string Severity { get; init; }
}
/// <summary>
/// Result of OPA syntax validation.
/// </summary>
public sealed record OpaValidationResult
{
/// <summary>Whether the Rego source is syntactically valid.</summary>
public required bool IsValid { get; init; }
/// <summary>Syntax errors found.</summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>Warnings from the check.</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}

View File

@@ -0,0 +1,51 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Evaluates policies locally (offline, deterministic).
/// Supports both native JSON policies and imported Rego policies.
/// </summary>
public interface IPolicyEvaluator
{
/// <summary>
/// Evaluates a policy pack against provided evidence input.
/// Returns deterministic results including remediation hints for failures.
/// For native gates: evaluates using C# gate implementations.
/// For OPA-evaluated rules: delegates to embedded OPA evaluator.
/// </summary>
Task<PolicyEvaluationOutput> EvaluateAsync(
PolicyPackDocument policy,
PolicyEvaluationInput input,
CancellationToken ct = default);
/// <summary>
/// Evaluates a policy pack with explicit options.
/// </summary>
Task<PolicyEvaluationOutput> EvaluateAsync(
PolicyPackDocument policy,
PolicyEvaluationInput input,
PolicyEvaluationOptions options,
CancellationToken ct = default);
}
/// <summary>
/// Options controlling policy evaluation behavior.
/// </summary>
public sealed record PolicyEvaluationOptions
{
/// <summary>Include remediation hints in output.</summary>
public bool IncludeRemediation { get; init; } = true;
/// <summary>Stop on first gate failure.</summary>
public bool StopOnFirstFailure { get; init; } = true;
/// <summary>
/// Fixed timestamp for deterministic evaluation.
/// If null, uses the current time (non-deterministic).
/// </summary>
public DateTimeOffset? FixedTimestamp { get; init; }
/// <summary>Target environment for gate resolution.</summary>
public string? Environment { get; init; }
}

View File

@@ -0,0 +1,28 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Exports native C# policy gates to external formats (JSON or OPA/Rego).
/// </summary>
public interface IPolicyExporter
{
/// <summary>
/// Exports the given policy pack document to canonical JSON format.
/// The output is deterministic: same input produces byte-identical output.
/// </summary>
Task<PolicyPackDocument> ExportToJsonAsync(
PolicyPackDocument document,
PolicyExportRequest request,
CancellationToken ct = default);
/// <summary>
/// Exports the given policy pack document to OPA Rego format.
/// Each gate/rule is translated to equivalent Rego deny rules.
/// Remediation hints are included as structured Rego output rules.
/// </summary>
Task<RegoExportResult> ExportToRegoAsync(
PolicyPackDocument document,
PolicyExportRequest request,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,28 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Imports external policy formats (JSON or OPA/Rego) into the native C# gate model.
/// </summary>
public interface IPolicyImporter
{
/// <summary>
/// Imports a policy from the given stream. Format is auto-detected or specified in options.
/// For JSON: deserializes PolicyPack v2 documents.
/// For Rego: parses deny rules, maps known patterns to native gate configs,
/// and preserves unknown patterns as OPA-evaluated rules.
/// </summary>
Task<PolicyImportResult> ImportAsync(
Stream policyStream,
PolicyImportOptions options,
CancellationToken ct = default);
/// <summary>
/// Imports a policy from a string (convenience overload).
/// </summary>
Task<PolicyImportResult> ImportFromStringAsync(
string content,
PolicyImportOptions options,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,27 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Validates policy documents against the canonical schema and determinism rules.
/// </summary>
public interface IPolicyValidator
{
/// <summary>
/// Validates a PolicyPack document against the v2 JSON Schema.
/// Checks structural validity, gate type existence, and rule match syntax.
/// </summary>
PolicyValidationResult Validate(PolicyPackDocument document);
/// <summary>
/// Validates Rego source for compatibility with Stella gate semantics.
/// Checks syntax, package declaration, deny rule structure, and known patterns.
/// </summary>
PolicyValidationResult ValidateRego(string regoSource);
/// <summary>
/// Validates that a policy document is deterministic.
/// Checks for prohibited patterns: time-dependent logic, random sources, external calls.
/// </summary>
PolicyValidationResult ValidateDeterminism(PolicyPackDocument document);
}

View File

@@ -0,0 +1,37 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Generates OPA Rego source code from native policy gate definitions.
/// Maps C# gate types to equivalent Rego deny rules.
/// </summary>
public interface IRegoCodeGenerator
{
/// <summary>
/// Generates a complete Rego module from a PolicyPackDocument.
/// Includes: package declaration, deny rules, allow rule, and remediation hints.
/// </summary>
RegoExportResult Generate(PolicyPackDocument policy, RegoGenerationOptions options);
}
/// <summary>
/// Options controlling Rego code generation.
/// </summary>
public sealed record RegoGenerationOptions
{
/// <summary>Rego package name. Default: "stella.release".</summary>
public string PackageName { get; init; } = "stella.release";
/// <summary>Include remediation hint rules in output.</summary>
public bool IncludeRemediation { get; init; } = true;
/// <summary>Include comments with gate metadata.</summary>
public bool IncludeComments { get; init; } = true;
/// <summary>Target environment for environment-specific rules (null = all environments).</summary>
public string? Environment { get; init; }
/// <summary>Use Rego v1 import syntax ("import rego.v1").</summary>
public bool UseRegoV1Syntax { get; init; } = true;
}

View File

@@ -0,0 +1,83 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Abstractions;
/// <summary>
/// Resolves remediation hints for gate failures.
/// Maps gate types and failure reasons to structured fix guidance.
/// </summary>
public interface IRemediationResolver
{
/// <summary>
/// Resolves a remediation hint for a gate failure.
/// Returns the hint defined in the gate definition, or a default hint for the gate type.
/// </summary>
/// <param name="gateDefinition">The gate definition (may contain custom remediation).</param>
/// <param name="failureReason">The gate failure reason string.</param>
/// <param name="context">Evaluation context for placeholder resolution.</param>
/// <returns>Resolved remediation hint, or null if no hint available.</returns>
RemediationHint? Resolve(
PolicyGateDefinition gateDefinition,
string failureReason,
RemediationContext? context = null);
/// <summary>
/// Resolves a remediation hint for a rule violation.
/// </summary>
RemediationHint? Resolve(
PolicyRuleDefinition ruleDefinition,
RemediationContext? context = null);
/// <summary>
/// Gets the default remediation hint for a known gate type.
/// </summary>
RemediationHint? GetDefaultForGateType(string gateType);
}
/// <summary>
/// Context for resolving placeholders in remediation command templates.
/// </summary>
public sealed record RemediationContext
{
/// <summary>Image digest or reference.</summary>
public string? Image { get; init; }
/// <summary>Package URL.</summary>
public string? Purl { get; init; }
/// <summary>CVE identifier.</summary>
public string? CveId { get; init; }
/// <summary>Justification text.</summary>
public string? Justification { get; init; }
/// <summary>Target environment.</summary>
public string? Environment { get; init; }
/// <summary>Additional key-value pairs for custom placeholders.</summary>
public IReadOnlyDictionary<string, string>? AdditionalValues { get; init; }
/// <summary>
/// Resolves placeholders in a command template string.
/// Replaces {image}, {purl}, {cveId}, {reason}, {environment} and custom keys.
/// </summary>
public string ResolveTemplate(string template)
{
var result = template;
if (Image is not null) result = result.Replace("{image}", Image);
if (Purl is not null) result = result.Replace("{purl}", Purl);
if (CveId is not null) result = result.Replace("{cveId}", CveId);
if (Justification is not null) result = result.Replace("{reason}", Justification);
if (Environment is not null) result = result.Replace("{environment}", Environment);
if (AdditionalValues is not null)
{
foreach (var (key, value) in AdditionalValues)
{
result = result.Replace($"{{{key}}}", value);
}
}
return result;
}
}

View File

@@ -0,0 +1,347 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Interop.Contracts;
/// <summary>
/// Request for policy export operations.
/// </summary>
public sealed record PolicyExportRequest
{
/// <summary>Target format: "json" or "rego".</summary>
public required string Format { get; init; }
/// <summary>Include remediation hints in output.</summary>
public bool IncludeRemediation { get; init; } = true;
/// <summary>Target environment for environment-specific overrides.</summary>
public string? Environment { get; init; }
/// <summary>Rego package name (used for Rego export). Default: "stella.release".</summary>
public string RegoPackage { get; init; } = "stella.release";
}
/// <summary>
/// Result of a Rego code generation.
/// </summary>
public sealed record RegoExportResult
{
/// <summary>Whether generation succeeded.</summary>
public required bool Success { get; init; }
/// <summary>Generated Rego source code.</summary>
public required string RegoSource { get; init; }
/// <summary>Rego package name used.</summary>
public required string PackageName { get; init; }
/// <summary>SHA-256 digest of the generated Rego.</summary>
public string? Digest { get; init; }
/// <summary>Warnings generated during translation.</summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}
/// <summary>
/// Options for policy import operations.
/// </summary>
public sealed record PolicyImportOptions
{
/// <summary>Source format: "json" or "rego". Null for auto-detection.</summary>
public string? Format { get; init; }
/// <summary>Target policy pack ID (required for Rego imports).</summary>
public string? PackId { get; init; }
/// <summary>How to handle existing rules: "replace" or "append".</summary>
public string MergeStrategy { get; init; } = "replace";
/// <summary>Only validate, do not persist.</summary>
public bool ValidateOnly { get; init; }
}
/// <summary>
/// Result of a policy import operation.
/// </summary>
public sealed record PolicyImportResult
{
/// <summary>Whether import succeeded.</summary>
public required bool Success { get; init; }
/// <summary>Imported policy pack document (null if validation-only failed).</summary>
public PolicyPackDocument? Document { get; init; }
/// <summary>Detected source format.</summary>
public string? DetectedFormat { get; init; }
/// <summary>Validation diagnostics.</summary>
public IReadOnlyList<PolicyDiagnostic> Diagnostics { get; init; } = [];
/// <summary>Count of gates imported.</summary>
public int GateCount { get; init; }
/// <summary>Count of rules imported.</summary>
public int RuleCount { get; init; }
/// <summary>
/// For Rego imports: rules that mapped to native gates vs. those remaining as OPA-evaluated.
/// </summary>
public PolicyImportMapping? Mapping { get; init; }
}
/// <summary>
/// Describes how imported Rego rules mapped to native gates.
/// </summary>
public sealed record PolicyImportMapping
{
/// <summary>Rules successfully mapped to native C# gate types.</summary>
public IReadOnlyList<string> NativeMapped { get; init; } = [];
/// <summary>Rules that remain as OPA-evaluated (no native equivalent).</summary>
public IReadOnlyList<string> OpaEvaluated { get; init; } = [];
}
/// <summary>
/// A diagnostic message from validation or import.
/// </summary>
public sealed record PolicyDiagnostic
{
/// <summary>Severity: "error", "warning", "info".</summary>
[JsonPropertyName("severity")]
public required string Severity { get; init; }
/// <summary>Machine-readable diagnostic code.</summary>
[JsonPropertyName("code")]
public required string Code { get; init; }
/// <summary>Human-readable message.</summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
/// <summary>Location in the source (line number, path, etc.).</summary>
[JsonPropertyName("location")]
public string? Location { get; init; }
public static class Severities
{
public const string Error = "error";
public const string Warning = "warning";
public const string Info = "info";
}
}
/// <summary>
/// Input data for policy evaluation (canonical evidence JSON).
/// </summary>
public sealed record PolicyEvaluationInput
{
/// <summary>Target environment for gate resolution.</summary>
[JsonPropertyName("environment")]
public string Environment { get; init; } = "production";
/// <summary>Subject artifact information.</summary>
[JsonPropertyName("subject")]
public EvidenceSubject? Subject { get; init; }
/// <summary>DSSE verification status.</summary>
[JsonPropertyName("dsse")]
public DsseEvidence? Dsse { get; init; }
/// <summary>Rekor transparency log verification status.</summary>
[JsonPropertyName("rekor")]
public RekorEvidence? Rekor { get; init; }
/// <summary>SBOM evidence.</summary>
[JsonPropertyName("sbom")]
public SbomEvidence? Sbom { get; init; }
/// <summary>Freshness/timestamp evidence.</summary>
[JsonPropertyName("freshness")]
public FreshnessEvidence? Freshness { get; init; }
/// <summary>CVSS scoring evidence.</summary>
[JsonPropertyName("cvss")]
public CvssEvidence? Cvss { get; init; }
/// <summary>Reachability evidence.</summary>
[JsonPropertyName("reachability")]
public ReachabilityEvidence? Reachability { get; init; }
/// <summary>Confidence score (0.0 - 1.0).</summary>
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
}
public sealed record EvidenceSubject
{
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; init; }
[JsonPropertyName("purl")]
public string? Purl { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string> Tags { get; init; } = [];
}
public sealed record DsseEvidence
{
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("signers")]
public IReadOnlyList<string> Signers { get; init; } = [];
[JsonPropertyName("provenanceType")]
public string? ProvenanceType { get; init; }
}
public sealed record RekorEvidence
{
[JsonPropertyName("verified")]
public bool Verified { get; init; }
[JsonPropertyName("logID")]
public string? LogId { get; init; }
[JsonPropertyName("integratedTime")]
public long? IntegratedTime { get; init; }
[JsonPropertyName("rootCheckpoint")]
public string? RootCheckpoint { get; init; }
}
public sealed record SbomEvidence
{
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("canonicalDigest")]
public string? CanonicalDigest { get; init; }
[JsonPropertyName("hasDelta")]
public bool HasDelta { get; init; }
[JsonPropertyName("deltaDigest")]
public string? DeltaDigest { get; init; }
}
public sealed record FreshnessEvidence
{
[JsonPropertyName("tstVerified")]
public bool TstVerified { get; init; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
[JsonPropertyName("maxAgeHours")]
public int MaxAgeHours { get; init; } = 24;
}
public sealed record CvssEvidence
{
[JsonPropertyName("score")]
public double Score { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("vector")]
public string? Vector { get; init; }
}
public sealed record ReachabilityEvidence
{
[JsonPropertyName("status")]
public string? Status { get; init; }
[JsonPropertyName("confidence")]
public double? Confidence { get; init; }
[JsonPropertyName("pathLength")]
public int? PathLength { get; init; }
}
/// <summary>
/// Output of a policy evaluation.
/// </summary>
public sealed record PolicyEvaluationOutput
{
/// <summary>Overall decision: "allow", "warn", "block".</summary>
[JsonPropertyName("decision")]
public required string Decision { get; init; }
/// <summary>Individual gate evaluation results.</summary>
[JsonPropertyName("gates")]
public IReadOnlyList<GateEvalOutput> Gates { get; init; } = [];
/// <summary>Aggregated remediation hints for all failures.</summary>
[JsonPropertyName("remediations")]
public IReadOnlyList<RemediationHint> Remediations { get; init; } = [];
/// <summary>Evaluation timestamp (UTC).</summary>
[JsonPropertyName("evaluatedAt")]
public required DateTimeOffset EvaluatedAt { get; init; }
/// <summary>Whether the evaluation was deterministic.</summary>
[JsonPropertyName("deterministic")]
public bool Deterministic { get; init; } = true;
/// <summary>SHA-256 digest of the evaluation output for replay verification.</summary>
[JsonPropertyName("outputDigest")]
public string? OutputDigest { get; init; }
}
/// <summary>
/// Individual gate evaluation output.
/// </summary>
public sealed record GateEvalOutput
{
[JsonPropertyName("gateId")]
public required string GateId { get; init; }
[JsonPropertyName("gateType")]
public required string GateType { get; init; }
[JsonPropertyName("passed")]
public required bool Passed { get; init; }
[JsonPropertyName("reason")]
public string? Reason { get; init; }
[JsonPropertyName("remediation")]
public RemediationHint? Remediation { get; init; }
}
/// <summary>
/// Supported policy format identifiers.
/// </summary>
public static class PolicyFormats
{
public const string Json = "json";
public const string Rego = "rego";
public static readonly IReadOnlyList<string> All = [Json, Rego];
public static bool IsValid(string format) =>
string.Equals(format, Json, StringComparison.OrdinalIgnoreCase) ||
string.Equals(format, Rego, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Result of policy validation.
/// </summary>
public sealed record PolicyValidationResult
{
/// <summary>Whether the document is valid (no errors).</summary>
public required bool IsValid { get; init; }
/// <summary>Validation diagnostics (errors, warnings, info).</summary>
public IReadOnlyList<PolicyDiagnostic> Diagnostics { get; init; } = [];
/// <summary>True if there are any warnings.</summary>
public bool HasWarnings => Diagnostics.Any(d => d.Severity == PolicyDiagnostic.Severities.Warning);
/// <summary>True if there are any errors.</summary>
public bool HasErrors => Diagnostics.Any(d => d.Severity == PolicyDiagnostic.Severities.Error);
}

View File

@@ -0,0 +1,211 @@
using System.Collections.Immutable;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Interop.Contracts;
/// <summary>
/// Canonical PolicyPack v2 document supporting bidirectional JSON/Rego export/import.
/// </summary>
public sealed record PolicyPackDocument
{
/// <summary>Schema version identifier. Must be "policy.stellaops.io/v2".</summary>
[JsonPropertyName("apiVersion")]
public required string ApiVersion { get; init; }
/// <summary>Document kind: "PolicyPack" or "PolicyOverride".</summary>
[JsonPropertyName("kind")]
public required string Kind { get; init; }
/// <summary>Document metadata.</summary>
[JsonPropertyName("metadata")]
public required PolicyPackMetadata Metadata { get; init; }
/// <summary>Policy specification with settings, gates, and rules.</summary>
[JsonPropertyName("spec")]
public required PolicyPackSpec Spec { get; init; }
public const string ApiVersionV2 = "policy.stellaops.io/v2";
public const string KindPolicyPack = "PolicyPack";
public const string KindPolicyOverride = "PolicyOverride";
}
/// <summary>
/// Metadata for a policy pack document.
/// </summary>
public sealed record PolicyPackMetadata
{
/// <summary>Unique name for the policy pack.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Semantic version (e.g., "1.2.0").</summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>Human-readable description.</summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>SHA-256 digest of canonical content.</summary>
[JsonPropertyName("digest")]
public string? Digest { get; init; }
/// <summary>Creation timestamp (ISO 8601 UTC).</summary>
[JsonPropertyName("createdAt")]
public DateTimeOffset? CreatedAt { get; init; }
/// <summary>Export provenance information.</summary>
[JsonPropertyName("exportedFrom")]
public PolicyExportProvenance? ExportedFrom { get; init; }
/// <summary>Parent policy pack name (for PolicyOverride kind).</summary>
[JsonPropertyName("parent")]
public string? Parent { get; init; }
/// <summary>Target environment (for PolicyOverride kind).</summary>
[JsonPropertyName("environment")]
public string? Environment { get; init; }
}
/// <summary>
/// Provenance of a policy export operation.
/// </summary>
public sealed record PolicyExportProvenance
{
[JsonPropertyName("engine")]
public required string Engine { get; init; }
[JsonPropertyName("engineVersion")]
public required string EngineVersion { get; init; }
[JsonPropertyName("exportedAt")]
public DateTimeOffset? ExportedAt { get; init; }
}
/// <summary>
/// Policy specification containing settings, gates, and rules.
/// </summary>
public sealed record PolicyPackSpec
{
/// <summary>Global policy settings.</summary>
[JsonPropertyName("settings")]
public required PolicyPackSettings Settings { get; init; }
/// <summary>Gate definitions with typed configurations.</summary>
[JsonPropertyName("gates")]
public IReadOnlyList<PolicyGateDefinition> Gates { get; init; } = [];
/// <summary>Rule definitions with match conditions.</summary>
[JsonPropertyName("rules")]
public IReadOnlyList<PolicyRuleDefinition> Rules { get; init; } = [];
}
/// <summary>
/// Global settings for policy evaluation behavior.
/// </summary>
public sealed record PolicyPackSettings
{
/// <summary>Default action when no rule matches: "allow", "warn", "block".</summary>
[JsonPropertyName("defaultAction")]
public required string DefaultAction { get; init; }
/// <summary>Threshold for unknowns budget (0.0 - 1.0).</summary>
[JsonPropertyName("unknownsThreshold")]
public double UnknownsThreshold { get; init; } = 0.6;
/// <summary>Stop evaluation on first failure.</summary>
[JsonPropertyName("stopOnFirstFailure")]
public bool StopOnFirstFailure { get; init; } = true;
/// <summary>Enforce deterministic evaluation (reject time-dependent logic).</summary>
[JsonPropertyName("deterministicMode")]
public bool DeterministicMode { get; init; } = true;
}
/// <summary>
/// A gate definition with typed configuration and remediation hints.
/// </summary>
public sealed record PolicyGateDefinition
{
/// <summary>Unique gate identifier within the policy pack.</summary>
[JsonPropertyName("id")]
public required string Id { get; init; }
/// <summary>Gate type (maps to C# gate class name, e.g., "CvssThresholdGate").</summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>Whether this gate is active.</summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; init; } = true;
/// <summary>
/// Gate-specific configuration as key-value pairs.
/// The schema depends on the gate type.
/// </summary>
[JsonPropertyName("config")]
public IReadOnlyDictionary<string, object?> Config { get; init; } = ImmutableDictionary<string, object?>.Empty;
/// <summary>Per-environment configuration overrides.</summary>
[JsonPropertyName("environments")]
public IReadOnlyDictionary<string, IReadOnlyDictionary<string, object?>>? Environments { get; init; }
/// <summary>Remediation hint shown when this gate blocks.</summary>
[JsonPropertyName("remediation")]
public RemediationHint? Remediation { get; init; }
}
/// <summary>
/// A rule definition with match conditions and action.
/// </summary>
public sealed record PolicyRuleDefinition
{
/// <summary>Unique rule name.</summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>Action when matched: "allow", "warn", "block".</summary>
[JsonPropertyName("action")]
public required string Action { get; init; }
/// <summary>Evaluation priority (lower = evaluated first).</summary>
[JsonPropertyName("priority")]
public int Priority { get; init; }
/// <summary>
/// Match conditions as key-value pairs.
/// Keys use dot-notation for nested fields (e.g., "dsse.verified", "cvss.score").
/// Values can be: bool, number, string, null, or comparison objects.
/// </summary>
[JsonPropertyName("match")]
public IReadOnlyDictionary<string, object?> Match { get; init; } = ImmutableDictionary<string, object?>.Empty;
/// <summary>Remediation hint shown when this rule triggers a block/warn.</summary>
[JsonPropertyName("remediation")]
public RemediationHint? Remediation { get; init; }
}
/// <summary>
/// Known gate type identifiers matching C# gate class names.
/// </summary>
public static class PolicyGateTypes
{
public const string CvssThreshold = "CvssThresholdGate";
public const string SignatureRequired = "SignatureRequiredGate";
public const string EvidenceFreshness = "EvidenceFreshnessGate";
public const string SbomPresence = "SbomPresenceGate";
public const string MinimumConfidence = "MinimumConfidenceGate";
public const string UnknownsBudget = "UnknownsBudgetGate";
public const string ReachabilityRequirement = "ReachabilityRequirementGate";
public const string SourceQuota = "SourceQuotaGate";
}
/// <summary>
/// Known policy actions.
/// </summary>
public static class PolicyActions
{
public const string Allow = "allow";
public const string Warn = "warn";
public const string Block = "block";
}

View File

@@ -0,0 +1,115 @@
using System.Text.Json.Serialization;
namespace StellaOps.Policy.Interop.Contracts;
/// <summary>
/// Structured remediation hint attached to gate violations.
/// Provides machine-readable fix guidance with CLI command templates.
/// </summary>
public sealed record RemediationHint
{
/// <summary>Machine-readable code (e.g., "CVSS_EXCEED", "DSSE_MISS").</summary>
[JsonPropertyName("code")]
public required string Code { get; init; }
/// <summary>Human-readable title for the violation.</summary>
[JsonPropertyName("title")]
public required string Title { get; init; }
/// <summary>Detailed explanation of the issue.</summary>
[JsonPropertyName("description")]
public string? Description { get; init; }
/// <summary>Ordered list of remediation actions the operator can take.</summary>
[JsonPropertyName("actions")]
public IReadOnlyList<RemediationAction> Actions { get; init; } = [];
/// <summary>External references for additional context.</summary>
[JsonPropertyName("references")]
public IReadOnlyList<RemediationReference> References { get; init; } = [];
/// <summary>Severity level: "critical", "high", "medium", "low".</summary>
[JsonPropertyName("severity")]
public required string Severity { get; init; }
}
/// <summary>
/// A single remediation action with an optional CLI command template.
/// </summary>
public sealed record RemediationAction
{
/// <summary>
/// Action type: "upgrade", "patch", "vex", "sign", "anchor", "generate", "override", "investigate", "mitigate".
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>Human-readable description of what this action does.</summary>
[JsonPropertyName("description")]
public required string Description { get; init; }
/// <summary>
/// Optional CLI command template with {placeholders} for dynamic values.
/// Example: "stella attest attach --sign --image {image}"
/// </summary>
[JsonPropertyName("command")]
public string? Command { get; init; }
}
/// <summary>
/// External reference for remediation context.
/// </summary>
public sealed record RemediationReference
{
/// <summary>Display title for the reference.</summary>
[JsonPropertyName("title")]
public required string Title { get; init; }
/// <summary>URL to the reference resource.</summary>
[JsonPropertyName("url")]
public required string Url { get; init; }
}
/// <summary>
/// Known remediation action types.
/// </summary>
public static class RemediationActionTypes
{
public const string Upgrade = "upgrade";
public const string Patch = "patch";
public const string Vex = "vex";
public const string Sign = "sign";
public const string Anchor = "anchor";
public const string Generate = "generate";
public const string Override = "override";
public const string Investigate = "investigate";
public const string Mitigate = "mitigate";
}
/// <summary>
/// Known remediation severity levels.
/// </summary>
public static class RemediationSeverity
{
public const string Critical = "critical";
public const string High = "high";
public const string Medium = "medium";
public const string Low = "low";
}
/// <summary>
/// Known remediation codes for built-in gate types.
/// </summary>
public static class RemediationCodes
{
public const string CvssExceed = "CVSS_EXCEED";
public const string DsseMissing = "DSSE_MISS";
public const string RekorMissing = "REKOR_MISS";
public const string SbomMissing = "SBOM_MISS";
public const string SignatureMissing = "SIG_MISS";
public const string FreshnessExpired = "FRESH_EXPIRED";
public const string ConfidenceLow = "CONF_LOW";
public const string UnknownsBudgetExceeded = "UNK_EXCEED";
public const string ReachabilityRequired = "REACH_REQUIRED";
public const string TstMissing = "TST_MISS";
}

View File

@@ -0,0 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Interop.Abstractions;
namespace StellaOps.Policy.Interop.DependencyInjection;
/// <summary>
/// Registers Policy Interop services for export, import, validation, and evaluation.
/// </summary>
public static class PolicyInteropServiceCollectionExtensions
{
/// <summary>
/// Adds Policy Interop services to the service collection.
/// Registers: IPolicyExporter, IPolicyImporter, IPolicyValidator,
/// IPolicyEvaluator, IRegoCodeGenerator, IRemediationResolver.
/// </summary>
public static IServiceCollection AddPolicyInterop(this IServiceCollection services)
{
// Implementations are registered in TASK-02..05 when created.
// This extension point ensures consistent DI wiring.
return services;
}
/// <summary>
/// Adds the embedded OPA evaluator for offline Rego evaluation.
/// Requires the OPA binary to be bundled as a tool asset.
/// </summary>
public static IServiceCollection AddEmbeddedOpaEvaluator(this IServiceCollection services)
{
// Implementation registered in TASK-05.
return services;
}
}

View File

@@ -0,0 +1,358 @@
// 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 System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Interop.Abstractions;
namespace StellaOps.Policy.Interop.Evaluation;
/// <summary>
/// Evaluates Rego policies offline via a bundled OPA binary.
/// Falls back gracefully when OPA is unavailable (air-gapped environments without pre-bundled binary).
/// </summary>
public sealed class EmbeddedOpaEvaluator : IEmbeddedOpaEvaluator
{
private readonly string? _opaBinaryPath;
private readonly TimeSpan _timeout;
public EmbeddedOpaEvaluator(string? opaBinaryPath = null, TimeSpan? timeout = null)
{
_opaBinaryPath = opaBinaryPath ?? DiscoverOpaBinary();
_timeout = timeout ?? TimeSpan.FromSeconds(30);
}
public async Task<OpaEvaluationResult> EvaluateAsync(
string regoSource,
string inputJson,
string queryPath,
CancellationToken ct = default)
{
if (!await IsAvailableAsync(ct).ConfigureAwait(false))
{
return new OpaEvaluationResult
{
Success = false,
Error = "OPA binary not found. Install OPA or provide path via configuration.",
Allow = false
};
}
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-opa-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var regoPath = Path.Combine(tempDir, "policy.rego");
var inputPath = Path.Combine(tempDir, "input.json");
await File.WriteAllTextAsync(regoPath, regoSource, ct).ConfigureAwait(false);
await File.WriteAllTextAsync(inputPath, inputJson, ct).ConfigureAwait(false);
var query = queryPath;
var (exitCode, stdout, stderr) = await RunOpaAsync(
$"eval --data \"{regoPath}\" --input \"{inputPath}\" \"{query}\" --format json",
ct).ConfigureAwait(false);
if (exitCode != 0)
{
return new OpaEvaluationResult
{
Success = false,
Error = $"OPA evaluation failed (exit {exitCode}): {stderr}",
Allow = false
};
}
return ParseEvaluationResult(stdout);
}
finally
{
try { Directory.Delete(tempDir, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
public async Task<OpaEvaluationResult> EvaluateBundleAsync(
byte[] bundleBytes,
string inputJson,
string queryPath,
CancellationToken ct = default)
{
if (!await IsAvailableAsync(ct).ConfigureAwait(false))
{
return new OpaEvaluationResult
{
Success = false,
Error = "OPA binary not found.",
Allow = false
};
}
var tempDir = Path.Combine(Path.GetTempPath(), $"stella-opa-bundle-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var bundlePath = Path.Combine(tempDir, "bundle.tar.gz");
var inputPath = Path.Combine(tempDir, "input.json");
await File.WriteAllBytesAsync(bundlePath, bundleBytes, ct).ConfigureAwait(false);
await File.WriteAllTextAsync(inputPath, inputJson, ct).ConfigureAwait(false);
var (exitCode, stdout, stderr) = await RunOpaAsync(
$"eval --bundle \"{bundlePath}\" --input \"{inputPath}\" \"{queryPath}\" --format json",
ct).ConfigureAwait(false);
if (exitCode != 0)
{
return new OpaEvaluationResult
{
Success = false,
Error = $"OPA bundle evaluation failed (exit {exitCode}): {stderr}",
Allow = false
};
}
return ParseEvaluationResult(stdout);
}
finally
{
try { Directory.Delete(tempDir, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
public async Task<OpaValidationResult> ValidateSyntaxAsync(string regoSource, CancellationToken ct = default)
{
if (!await IsAvailableAsync(ct).ConfigureAwait(false))
{
return new OpaValidationResult
{
IsValid = false,
Errors = ["OPA binary not found. Cannot validate Rego syntax."]
};
}
var tempPath = Path.Combine(Path.GetTempPath(), $"stella-opa-check-{Guid.NewGuid():N}.rego");
try
{
await File.WriteAllTextAsync(tempPath, regoSource, ct).ConfigureAwait(false);
var (exitCode, stdout, stderr) = await RunOpaAsync(
$"check \"{tempPath}\" --format json",
ct).ConfigureAwait(false);
if (exitCode == 0)
{
return new OpaValidationResult { IsValid = true, Errors = [] };
}
var errors = ParseCheckErrors(stderr);
return new OpaValidationResult { IsValid = false, Errors = errors };
}
finally
{
try { File.Delete(tempPath); }
catch { /* best-effort cleanup */ }
}
}
public Task<bool> IsAvailableAsync(CancellationToken ct = default)
{
if (_opaBinaryPath == null || !File.Exists(_opaBinaryPath))
{
return Task.FromResult(false);
}
return Task.FromResult(true);
}
private async Task<(int ExitCode, string Stdout, string Stderr)> RunOpaAsync(
string arguments,
CancellationToken ct)
{
var psi = new ProcessStartInfo
{
FileName = _opaBinaryPath!,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = psi };
var stdoutBuilder = new StringBuilder();
var stderrBuilder = new StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdoutBuilder.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderrBuilder.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
try
{
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { process.Kill(entireProcessTree: true); }
catch { /* best effort */ }
throw new TimeoutException($"OPA process timed out after {_timeout.TotalSeconds}s");
}
return (process.ExitCode, stdoutBuilder.ToString(), stderrBuilder.ToString());
}
private static OpaEvaluationResult ParseEvaluationResult(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// OPA eval output: {"result": [{"expressions": [{"value": {...}, ...}]}]}
if (root.TryGetProperty("result", out var resultArray) &&
resultArray.GetArrayLength() > 0)
{
var firstResult = resultArray[0];
if (firstResult.TryGetProperty("expressions", out var expressions) &&
expressions.GetArrayLength() > 0)
{
var value = expressions[0].GetProperty("value");
var allowed = false;
if (value.TryGetProperty("allow", out var allowProp))
{
allowed = allowProp.GetBoolean();
}
var denyMessages = new List<string>();
if (value.TryGetProperty("deny", out var denyProp) &&
denyProp.ValueKind == JsonValueKind.Array)
{
foreach (var item in denyProp.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
denyMessages.Add(item.GetString()!);
}
}
}
var remediations = new List<OpaRemediationOutput>();
if (value.TryGetProperty("remediation", out var remProp) &&
remProp.ValueKind == JsonValueKind.Array)
{
foreach (var item in remProp.EnumerateArray())
{
var code = item.TryGetProperty("code", out var c) ? c.GetString() : null;
var fix = item.TryGetProperty("fix", out var f) ? f.GetString() : null;
var severity = item.TryGetProperty("severity", out var s) ? s.GetString() : null;
if (code != null)
{
remediations.Add(new OpaRemediationOutput
{
Code = code,
Fix = fix ?? "",
Severity = severity ?? "medium"
});
}
}
}
return new OpaEvaluationResult
{
Success = true,
Allow = allowed,
DenyMessages = denyMessages,
Remediations = remediations,
RawOutput = json
};
}
}
return new OpaEvaluationResult
{
Success = true,
Allow = false,
Error = "Could not parse OPA evaluation output structure",
RawOutput = json
};
}
catch (JsonException ex)
{
return new OpaEvaluationResult
{
Success = false,
Error = $"Failed to parse OPA JSON output: {ex.Message}",
Allow = false
};
}
}
private static IReadOnlyList<string> ParseCheckErrors(string stderr)
{
var errors = new List<string>();
try
{
using var doc = JsonDocument.Parse(stderr);
if (doc.RootElement.TryGetProperty("errors", out var errorsArray))
{
foreach (var err in errorsArray.EnumerateArray())
{
var msg = err.TryGetProperty("message", out var m) ? m.GetString() : err.ToString();
if (msg != null) errors.Add(msg);
}
}
}
catch
{
// Fallback: treat stderr as plain text errors
var lines = stderr.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
errors.AddRange(lines);
}
return errors.Count > 0 ? errors : ["Syntax validation failed"];
}
private static string? DiscoverOpaBinary()
{
// Check well-known locations
var candidates = new List<string>();
var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "opa.exe" : "opa";
// 1. Bundled alongside the application
var appDir = AppContext.BaseDirectory;
candidates.Add(Path.Combine(appDir, "tools", exeName));
candidates.Add(Path.Combine(appDir, exeName));
// 2. User-level tools
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
candidates.Add(Path.Combine(homeDir, ".stella", "tools", exeName));
// 3. System PATH
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? [];
foreach (var dir in pathDirs)
{
candidates.Add(Path.Combine(dir, exeName));
}
return candidates.FirstOrDefault(File.Exists);
}
}

View File

@@ -0,0 +1,162 @@
using StellaOps.Policy.Interop.Abstractions;
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Evaluation;
/// <summary>
/// Resolves remediation hints for gate failures.
/// Returns the hint defined in the gate/rule definition, or falls back to built-in defaults.
/// Supports placeholder resolution in CLI command templates.
/// </summary>
public sealed class RemediationResolver : IRemediationResolver
{
private static readonly IReadOnlyDictionary<string, RemediationHint> DefaultHints =
new Dictionary<string, RemediationHint>(StringComparer.Ordinal)
{
[PolicyGateTypes.CvssThreshold] = new()
{
Code = RemediationCodes.CvssExceed,
Title = "CVSS score exceeds threshold",
Description = "One or more vulnerabilities exceed the configured CVSS severity threshold.",
Actions =
[
new() { Type = RemediationActionTypes.Upgrade, Description = "Upgrade affected package to a patched version.", Command = "stella advisory patch --purl {purl}" },
new() { Type = RemediationActionTypes.Vex, Description = "Provide a VEX not_affected statement if unreachable.", Command = "stella vex emit --status not_affected --purl {purl} --justification {reason}" },
new() { Type = RemediationActionTypes.Override, Description = "Request policy override with justification.", Command = "stella gate evaluate --allow-override --justification '{reason}'" }
],
References = [new() { Title = "CVSS v3.1 Specification", Url = "https://www.first.org/cvss/v3.1/specification-document" }],
Severity = RemediationSeverity.High
},
[PolicyGateTypes.SignatureRequired] = new()
{
Code = RemediationCodes.SignatureMissing,
Title = "Required signature missing",
Description = "The artifact is missing a required DSSE signature or Rekor transparency log entry.",
Actions =
[
new() { Type = RemediationActionTypes.Sign, Description = "Sign attestation with DSSE.", Command = "stella attest attach --sign --image {image}" },
new() { Type = RemediationActionTypes.Anchor, Description = "Anchor attestation in Rekor.", Command = "stella attest attach --rekor --image {image}" }
],
Severity = RemediationSeverity.Critical
},
[PolicyGateTypes.EvidenceFreshness] = new()
{
Code = RemediationCodes.FreshnessExpired,
Title = "Evidence freshness expired",
Description = "The attestation evidence exceeds the maximum age threshold.",
Actions =
[
new() { Type = RemediationActionTypes.Generate, Description = "Re-generate attestation with current timestamp.", Command = "stella attest build --image {image}" },
new() { Type = RemediationActionTypes.Sign, Description = "Request an RFC-3161 timestamp for freshness proof.", Command = "stella attest attach --tst --image {image}" }
],
References = [new() { Title = "RFC 3161 - TSA Protocol", Url = "https://datatracker.ietf.org/doc/html/rfc3161" }],
Severity = RemediationSeverity.High
},
[PolicyGateTypes.SbomPresence] = new()
{
Code = RemediationCodes.SbomMissing,
Title = "SBOM missing or invalid",
Description = "A canonical SBOM with verified digest is required for release verification.",
Actions =
[
new() { Type = RemediationActionTypes.Generate, Description = "Generate SBOM and include digest in attestation.", Command = "stella sbom generate --format cyclonedx --output sbom.cdx.json" }
],
Severity = RemediationSeverity.High
},
[PolicyGateTypes.MinimumConfidence] = new()
{
Code = RemediationCodes.ConfidenceLow,
Title = "Confidence score below threshold",
Description = "The reachability confidence score is below the minimum required.",
Actions =
[
new() { Type = RemediationActionTypes.Investigate, Description = "Provide additional reachability evidence.", Command = "stella scan reachability --purl {purl} --deep" }
],
Severity = RemediationSeverity.Medium
},
[PolicyGateTypes.UnknownsBudget] = new()
{
Code = RemediationCodes.UnknownsBudgetExceeded,
Title = "Unknowns budget exceeded",
Description = "Too many findings have unknown reachability status.",
Actions =
[
new() { Type = RemediationActionTypes.Investigate, Description = "Analyze unknown findings and provide VEX statements.", Command = "stella vex emit --status under_investigation --purl {purl}" }
],
Severity = RemediationSeverity.Medium
},
[PolicyGateTypes.ReachabilityRequirement] = new()
{
Code = RemediationCodes.ReachabilityRequired,
Title = "Reachability proof required",
Description = "A reachability analysis is required before this finding can pass.",
Actions =
[
new() { Type = RemediationActionTypes.Investigate, Description = "Run reachability analysis on the affected package.", Command = "stella scan reachability --purl {purl}" }
],
Severity = RemediationSeverity.High
},
};
/// <inheritdoc/>
public RemediationHint? Resolve(
PolicyGateDefinition gateDefinition,
string failureReason,
RemediationContext? context = null)
{
// Priority 1: use the hint defined on the gate itself
var hint = gateDefinition.Remediation;
// Priority 2: fall back to built-in default for the gate type
if (hint is null)
{
hint = GetDefaultForGateType(gateDefinition.Type);
}
if (hint is null) return null;
// Resolve command placeholders if context is provided
if (context is not null)
{
return ResolveTemplates(hint, context);
}
return hint;
}
/// <inheritdoc/>
public RemediationHint? Resolve(
PolicyRuleDefinition ruleDefinition,
RemediationContext? context = null)
{
var hint = ruleDefinition.Remediation;
if (hint is null) return null;
if (context is not null)
{
return ResolveTemplates(hint, context);
}
return hint;
}
/// <inheritdoc/>
public RemediationHint? GetDefaultForGateType(string gateType)
{
return DefaultHints.TryGetValue(gateType, out var hint) ? hint : null;
}
/// <summary>
/// Creates a new RemediationHint with all command placeholders resolved.
/// </summary>
private static RemediationHint ResolveTemplates(RemediationHint hint, RemediationContext context)
{
var resolvedActions = hint.Actions
.Select(a => a.Command is not null
? a with { Command = context.ResolveTemplate(a.Command) }
: a)
.ToList();
return hint with { Actions = resolvedActions };
}
}

View File

@@ -0,0 +1,129 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Policy.Interop.Abstractions;
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Export;
/// <summary>
/// Exports PolicyPackDocuments to canonical JSON format.
/// Output is deterministic: same input produces byte-identical output (sorted keys, consistent formatting).
/// </summary>
public sealed class JsonPolicyExporter : IPolicyExporter
{
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
/// <inheritdoc/>
public Task<PolicyPackDocument> ExportToJsonAsync(
PolicyPackDocument document,
PolicyExportRequest request,
CancellationToken ct = default)
{
var exported = document;
// Apply environment filter if specified
if (request.Environment is not null)
{
exported = FilterByEnvironment(exported, request.Environment);
}
// Strip remediation if not requested
if (!request.IncludeRemediation)
{
exported = StripRemediation(exported);
}
// Compute digest
var json = JsonSerializer.Serialize(exported, CanonicalOptions);
var digest = ComputeDigest(json);
exported = exported with
{
Metadata = exported.Metadata with { Digest = digest }
};
return Task.FromResult(exported);
}
/// <inheritdoc/>
public Task<RegoExportResult> ExportToRegoAsync(
PolicyPackDocument document,
PolicyExportRequest request,
CancellationToken ct = default)
{
// Delegate to IRegoCodeGenerator - this method bridges the exporter interface
// For direct Rego export, use IRegoCodeGenerator.Generate() instead
var generator = new Rego.RegoCodeGenerator();
var options = new RegoGenerationOptions
{
PackageName = request.RegoPackage,
IncludeRemediation = request.IncludeRemediation,
Environment = request.Environment
};
var result = generator.Generate(document, options);
return Task.FromResult(result);
}
/// <summary>
/// Serializes a PolicyPackDocument to canonical JSON bytes.
/// </summary>
public static byte[] SerializeCanonical(PolicyPackDocument document)
{
return JsonSerializer.SerializeToUtf8Bytes(document, CanonicalOptions);
}
/// <summary>
/// Serializes a PolicyPackDocument to canonical JSON string.
/// </summary>
public static string SerializeToString(PolicyPackDocument document)
{
return JsonSerializer.Serialize(document, CanonicalOptions);
}
private static PolicyPackDocument FilterByEnvironment(PolicyPackDocument doc, string environment)
{
var filteredGates = doc.Spec.Gates.Select(g =>
{
if (g.Environments is null || !g.Environments.ContainsKey(environment))
return g;
// Merge environment-specific config into base config
var envConfig = g.Environments[environment];
var mergedConfig = new Dictionary<string, object?>(g.Config);
foreach (var (key, value) in envConfig)
{
mergedConfig[key] = value;
}
return g with { Config = mergedConfig, Environments = null };
}).ToList();
return doc with
{
Spec = doc.Spec with { Gates = filteredGates }
};
}
private static PolicyPackDocument StripRemediation(PolicyPackDocument doc)
{
var gates = doc.Spec.Gates.Select(g => g with { Remediation = null }).ToList();
var rules = doc.Spec.Rules.Select(r => r with { Remediation = null }).ToList();
return doc with
{
Spec = doc.Spec with { Gates = gates, Rules = rules }
};
}
private static string ComputeDigest(string json)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -0,0 +1,88 @@
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Import;
/// <summary>
/// Auto-detects policy format (JSON vs Rego) from file content.
/// </summary>
public static class FormatDetector
{
/// <summary>
/// Detects the format of a policy file from its content.
/// Returns "json" or "rego", or null if unrecognizable.
/// </summary>
public static string? Detect(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var trimmed = content.TrimStart();
// JSON detection: starts with { and contains apiVersion
if (trimmed.StartsWith('{'))
{
if (trimmed.Contains("apiVersion", StringComparison.OrdinalIgnoreCase) ||
trimmed.Contains("\"kind\"", StringComparison.Ordinal))
{
return PolicyFormats.Json;
}
// Could be generic JSON - still treat as JSON
return PolicyFormats.Json;
}
// Rego detection: contains package declaration
if (ContainsRegoPackage(trimmed))
{
return PolicyFormats.Rego;
}
// Rego detection: contains deny/allow rules
if (trimmed.Contains("deny", StringComparison.Ordinal) &&
(trimmed.Contains("contains", StringComparison.Ordinal) ||
trimmed.Contains(":=", StringComparison.Ordinal)))
{
return PolicyFormats.Rego;
}
return null;
}
/// <summary>
/// Detects format from a file extension.
/// </summary>
public static string? DetectFromExtension(string filePath)
{
var ext = Path.GetExtension(filePath).ToLowerInvariant();
return ext switch
{
".json" => PolicyFormats.Json,
".rego" => PolicyFormats.Rego,
_ => null
};
}
/// <summary>
/// Detects format using both extension and content (extension takes priority).
/// </summary>
public static string? Detect(string filePath, string content)
{
return DetectFromExtension(filePath) ?? Detect(content);
}
private static bool ContainsRegoPackage(string content)
{
// Look for "package <name>" pattern
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmedLine = line.TrimStart();
if (trimmedLine.StartsWith('#')) continue; // skip comments
if (trimmedLine.StartsWith("package ", StringComparison.Ordinal))
return true;
if (trimmedLine.Length > 0 && !trimmedLine.StartsWith('#'))
break; // non-comment, non-package first line
}
return false;
}
}

View File

@@ -0,0 +1,223 @@
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Interop.Abstractions;
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Import;
/// <summary>
/// Imports PolicyPack v2 JSON documents into the native model.
/// Validates structure and provides diagnostics.
/// </summary>
public sealed class JsonPolicyImporter : IPolicyImporter
{
private static readonly JsonSerializerOptions DeserializeOptions = new()
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
/// <inheritdoc/>
public async Task<PolicyImportResult> ImportAsync(
Stream policyStream,
PolicyImportOptions options,
CancellationToken ct = default)
{
using var reader = new StreamReader(policyStream, Encoding.UTF8);
var content = await reader.ReadToEndAsync(ct);
return await ImportFromStringAsync(content, options, ct);
}
/// <inheritdoc/>
public Task<PolicyImportResult> ImportFromStringAsync(
string content,
PolicyImportOptions options,
CancellationToken ct = default)
{
var diagnostics = new List<PolicyDiagnostic>();
// Detect format
var format = options.Format ?? FormatDetector.Detect(content);
if (format is null)
{
return Task.FromResult(new PolicyImportResult
{
Success = false,
DetectedFormat = null,
Diagnostics = [new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "FORMAT_UNKNOWN",
Message = "Unable to detect policy format. Specify --format explicitly."
}]
});
}
if (format == PolicyFormats.Rego)
{
return Task.FromResult(new PolicyImportResult
{
Success = false,
DetectedFormat = PolicyFormats.Rego,
Diagnostics = [new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "REGO_USE_IMPORTER",
Message = "Rego format detected. Use RegoPolicyImporter for Rego files."
}]
});
}
// Parse JSON
PolicyPackDocument? document;
try
{
document = JsonSerializer.Deserialize<PolicyPackDocument>(content, DeserializeOptions);
}
catch (JsonException ex)
{
return Task.FromResult(new PolicyImportResult
{
Success = false,
DetectedFormat = PolicyFormats.Json,
Diagnostics = [new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "JSON_PARSE_ERROR",
Message = $"JSON parse error: {ex.Message}",
Location = ex.Path
}]
});
}
if (document is null)
{
return Task.FromResult(new PolicyImportResult
{
Success = false,
DetectedFormat = PolicyFormats.Json,
Diagnostics = [new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "JSON_NULL",
Message = "Parsed document is null."
}]
});
}
// Validate apiVersion
if (document.ApiVersion != PolicyPackDocument.ApiVersionV2)
{
if (document.ApiVersion == "policy.stellaops.io/v1")
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Warning,
Code = "VERSION_V1",
Message = "Document uses v1 schema. Imported with v2 compatibility adapter."
});
}
else
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "VERSION_UNKNOWN",
Message = $"Unknown apiVersion: '{document.ApiVersion}'. Expected '{PolicyPackDocument.ApiVersionV2}'."
});
return Task.FromResult(new PolicyImportResult
{
Success = false,
DetectedFormat = PolicyFormats.Json,
Diagnostics = diagnostics
});
}
}
// Validate kind
if (document.Kind != PolicyPackDocument.KindPolicyPack &&
document.Kind != PolicyPackDocument.KindPolicyOverride)
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "KIND_INVALID",
Message = $"Invalid kind: '{document.Kind}'. Expected 'PolicyPack' or 'PolicyOverride'."
});
}
// Validate gate IDs are unique
var gateIds = document.Spec.Gates.Select(g => g.Id).ToList();
var duplicateGates = gateIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
foreach (var dup in duplicateGates)
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "GATE_ID_DUPLICATE",
Message = $"Duplicate gate ID: '{dup}'."
});
}
// Validate rule names are unique
var ruleNames = document.Spec.Rules.Select(r => r.Name).ToList();
var duplicateRules = ruleNames.GroupBy(n => n).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
foreach (var dup in duplicateRules)
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Error,
Code = "RULE_NAME_DUPLICATE",
Message = $"Duplicate rule name: '{dup}'."
});
}
// Validate remediation codes
foreach (var gate in document.Spec.Gates.Where(g => g.Remediation is not null))
{
ValidateRemediationHint(gate.Remediation!, $"gate '{gate.Id}'", diagnostics);
}
foreach (var rule in document.Spec.Rules.Where(r => r.Remediation is not null))
{
ValidateRemediationHint(rule.Remediation!, $"rule '{rule.Name}'", diagnostics);
}
var hasErrors = diagnostics.Any(d => d.Severity == PolicyDiagnostic.Severities.Error);
return Task.FromResult(new PolicyImportResult
{
Success = !hasErrors,
Document = hasErrors ? null : document,
DetectedFormat = PolicyFormats.Json,
Diagnostics = diagnostics,
GateCount = document.Spec.Gates.Count,
RuleCount = document.Spec.Rules.Count
});
}
private static void ValidateRemediationHint(RemediationHint hint, string location, List<PolicyDiagnostic> diagnostics)
{
if (string.IsNullOrWhiteSpace(hint.Code))
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Warning,
Code = "REMEDIATION_NO_CODE",
Message = $"Remediation on {location} has no code.",
Location = location
});
}
if (hint.Actions.Count == 0)
{
diagnostics.Add(new PolicyDiagnostic
{
Severity = PolicyDiagnostic.Severities.Warning,
Code = "REMEDIATION_NO_ACTIONS",
Message = $"Remediation on {location} has no actions defined.",
Location = location
});
}
}
}

View File

@@ -0,0 +1,326 @@
// 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 System.Text.RegularExpressions;
using StellaOps.Policy.Interop.Abstractions;
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Import;
/// <summary>
/// Imports OPA/Rego source into the native PolicyPackDocument model.
/// Known patterns are mapped to native gate types; unknown patterns are preserved
/// as custom rules evaluated via the embedded OPA evaluator.
/// </summary>
public sealed class RegoPolicyImporter : IPolicyImporter
{
private static readonly Regex PackagePattern = new(
@"^package\s+([\w.]+)", RegexOptions.Compiled | RegexOptions.Multiline);
private static readonly Regex DenyRulePattern = new(
@"deny\s+contains\s+msg\s+if\s*\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex CvssPattern = new(
@"input\.cvss\.score\s*>=\s*([\d.]+)", RegexOptions.Compiled);
private static readonly Regex DssePattern = new(
@"not\s+input\.dsse\.verified", RegexOptions.Compiled);
private static readonly Regex RekorPattern = new(
@"not\s+input\.rekor\.verified", RegexOptions.Compiled);
private static readonly Regex SbomPattern = new(
@"not\s+input\.sbom\.canonicalDigest", RegexOptions.Compiled);
private static readonly Regex ConfidencePattern = new(
@"input\.confidence\s*<\s*([\d.]+)", RegexOptions.Compiled);
private static readonly Regex FreshnessPattern = new(
@"not\s+input\.freshness\.tstVerified", RegexOptions.Compiled);
private static readonly Regex ReachabilityPattern = new(
@"not\s+input\.reachability\.status", RegexOptions.Compiled);
private static readonly Regex UnknownsPattern = new(
@"input\.unknownsRatio\s*>\s*([\d.]+)", RegexOptions.Compiled);
private static readonly Regex MsgPattern = new(
@"msg\s*:=\s*""([^""]+)""", RegexOptions.Compiled);
private static readonly Regex EnvironmentPattern = new(
@"input\.environment\s*==\s*""([^""]+)""", RegexOptions.Compiled);
private static readonly Regex RemediationBlockPattern = new(
@"remediation\s+contains\s+hint\s+if\s*\{([^}]+)\}", RegexOptions.Compiled | RegexOptions.Singleline);
private static readonly Regex RemediationCodePattern = new(
@"""code"":\s*""([^""]+)""", RegexOptions.Compiled);
private static readonly Regex RemediationFixPattern = new(
@"""fix"":\s*""([^""]+)""", RegexOptions.Compiled);
private static readonly Regex RemediationSeverityPattern = new(
@"""severity"":\s*""([^""]+)""", RegexOptions.Compiled);
public async Task<PolicyImportResult> ImportAsync(Stream policyStream, PolicyImportOptions options, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
using var reader = new StreamReader(policyStream);
var content = await reader.ReadToEndAsync(ct).ConfigureAwait(false);
return await ImportFromStringAsync(content, options, ct).ConfigureAwait(false);
}
public Task<PolicyImportResult> ImportFromStringAsync(string content, PolicyImportOptions options, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var diagnostics = new List<PolicyDiagnostic>();
var gates = new List<PolicyGateDefinition>();
var rules = new List<PolicyRuleDefinition>();
var nativeMappedRules = new List<string>();
var opaEvaluatedRules = new List<string>();
// Detect format
var format = FormatDetector.Detect(content);
if (format != PolicyFormats.Rego)
{
diagnostics.Add(new PolicyDiagnostic { Severity = "error", Code = "FORMAT_MISMATCH",
Message = "Content does not appear to be Rego. Use JsonPolicyImporter for JSON content." });
return Task.FromResult(new PolicyImportResult { Success = false, Diagnostics = diagnostics });
}
// Extract package name
var packageMatch = PackagePattern.Match(content);
var packageName = packageMatch.Success ? packageMatch.Groups[1].Value : "stella.release";
// Parse deny rules
var denyMatches = DenyRulePattern.Matches(content);
var gateIndex = 0;
foreach (Match denyMatch in denyMatches)
{
var body = denyMatch.Groups[1].Value;
var msgMatch = MsgPattern.Match(body);
var message = msgMatch.Success ? msgMatch.Groups[1].Value : $"deny-rule-{gateIndex}";
var envMatch = EnvironmentPattern.Match(body);
var environment = envMatch.Success ? envMatch.Groups[1].Value : null;
// Try to map to native gate types
var cvssMatch = CvssPattern.Match(body);
if (cvssMatch.Success)
{
var threshold = double.Parse(cvssMatch.Groups[1].Value);
var envDict = environment != null
? new Dictionary<string, IReadOnlyDictionary<string, object?>>
{
[environment] = new Dictionary<string, object?> { ["threshold"] = threshold }
}
: null;
gates.Add(new PolicyGateDefinition
{
Id = $"cvss-threshold-{gateIndex}",
Type = PolicyGateTypes.CvssThreshold,
Enabled = true,
Config = new Dictionary<string, object?> { ["threshold"] = threshold },
Environments = envDict
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
if (DssePattern.IsMatch(body))
{
gates.Add(new PolicyGateDefinition
{
Id = $"signature-required-{gateIndex}",
Type = PolicyGateTypes.SignatureRequired,
Enabled = true
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
if (SbomPattern.IsMatch(body))
{
gates.Add(new PolicyGateDefinition
{
Id = $"sbom-presence-{gateIndex}",
Type = PolicyGateTypes.SbomPresence,
Enabled = true
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
var confMatch = ConfidencePattern.Match(body);
if (confMatch.Success)
{
var threshold = double.Parse(confMatch.Groups[1].Value);
gates.Add(new PolicyGateDefinition
{
Id = $"minimum-confidence-{gateIndex}",
Type = PolicyGateTypes.MinimumConfidence,
Enabled = true,
Config = new Dictionary<string, object?> { ["threshold"] = threshold }
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
if (FreshnessPattern.IsMatch(body))
{
gates.Add(new PolicyGateDefinition
{
Id = $"evidence-freshness-{gateIndex}",
Type = PolicyGateTypes.EvidenceFreshness,
Enabled = true
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
if (ReachabilityPattern.IsMatch(body))
{
gates.Add(new PolicyGateDefinition
{
Id = $"reachability-requirement-{gateIndex}",
Type = PolicyGateTypes.ReachabilityRequirement,
Enabled = true
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
var unkMatch = UnknownsPattern.Match(body);
if (unkMatch.Success)
{
var threshold = double.Parse(unkMatch.Groups[1].Value);
gates.Add(new PolicyGateDefinition
{
Id = $"unknowns-budget-{gateIndex}",
Type = PolicyGateTypes.UnknownsBudget,
Enabled = true,
Config = new Dictionary<string, object?> { ["threshold"] = threshold }
});
nativeMappedRules.Add(message);
gateIndex++;
continue;
}
// Unknown pattern: preserve as custom rule evaluated via OPA
rules.Add(new PolicyRuleDefinition
{
Name = $"rego-rule-{gateIndex}",
Action = PolicyActions.Block,
Match = new Dictionary<string, object?> { ["_rego_body"] = body.Trim() }
});
opaEvaluatedRules.Add(message);
diagnostics.Add(new PolicyDiagnostic { Severity = "warning", Code = "UNMAPPED_RULE",
Message = $"Rule '{message}' could not be mapped to a native gate type and will be evaluated via OPA." });
gateIndex++;
}
// Parse remediation blocks and attach to gates using record `with`
var remediationMatches = RemediationBlockPattern.Matches(content);
foreach (Match remMatch in remediationMatches)
{
var body = remMatch.Groups[1].Value;
var codeMatch = RemediationCodePattern.Match(body);
var fixMatch = RemediationFixPattern.Match(body);
var sevMatch = RemediationSeverityPattern.Match(body);
if (codeMatch.Success)
{
var code = codeMatch.Groups[1].Value;
var gateIdx = gates.FindIndex(g =>
g.Remediation?.Code == code ||
GetDefaultCodeForGateType(g.Type) == code);
if (gateIdx >= 0)
{
gates[gateIdx] = gates[gateIdx] with
{
Remediation = new RemediationHint
{
Code = code,
Title = fixMatch.Success ? fixMatch.Groups[1].Value : code,
Description = fixMatch.Success ? fixMatch.Groups[1].Value : "",
Actions = fixMatch.Success
? [new RemediationAction { Type = "fix", Description = fixMatch.Groups[1].Value }]
: [],
References = [],
Severity = sevMatch.Success ? sevMatch.Groups[1].Value : RemediationSeverity.Medium
}
};
}
}
}
var document = new PolicyPackDocument
{
ApiVersion = PolicyPackDocument.ApiVersionV2,
Kind = PolicyPackDocument.KindPolicyPack,
Metadata = new PolicyPackMetadata
{
Name = packageName.Replace('.', '-'),
Version = "1.0.0",
Description = $"Imported from Rego package {packageName}"
},
Spec = new PolicyPackSpec
{
Settings = new PolicyPackSettings { DefaultAction = PolicyActions.Block },
Gates = gates,
Rules = rules
}
};
if (nativeMappedRules.Count > 0)
{
diagnostics.Add(new PolicyDiagnostic { Severity = "info", Code = "NATIVE_MAPPED",
Message = $"{nativeMappedRules.Count} rule(s) mapped to native gate types." });
}
if (opaEvaluatedRules.Count > 0)
{
diagnostics.Add(new PolicyDiagnostic { Severity = "info", Code = "OPA_EVALUATED",
Message = $"{opaEvaluatedRules.Count} rule(s) will be evaluated via embedded OPA." });
}
return Task.FromResult(new PolicyImportResult
{
Success = true,
Document = document,
DetectedFormat = PolicyFormats.Rego,
GateCount = gates.Count,
RuleCount = rules.Count,
Diagnostics = diagnostics,
Mapping = new PolicyImportMapping
{
NativeMapped = nativeMappedRules,
OpaEvaluated = opaEvaluatedRules
}
});
}
private static string? GetDefaultCodeForGateType(string gateType) => gateType switch
{
PolicyGateTypes.CvssThreshold => RemediationCodes.CvssExceed,
PolicyGateTypes.SignatureRequired => RemediationCodes.SignatureMissing,
PolicyGateTypes.EvidenceFreshness => RemediationCodes.FreshnessExpired,
PolicyGateTypes.SbomPresence => RemediationCodes.SbomMissing,
PolicyGateTypes.MinimumConfidence => RemediationCodes.ConfidenceLow,
PolicyGateTypes.UnknownsBudget => RemediationCodes.UnknownsBudgetExceeded,
PolicyGateTypes.ReachabilityRequirement => RemediationCodes.ReachabilityRequired,
_ => null
};
}

View File

@@ -0,0 +1,384 @@
using System.Text;
using System.Text.Json;
using StellaOps.Policy.Interop.Abstractions;
using StellaOps.Policy.Interop.Contracts;
namespace StellaOps.Policy.Interop.Rego;
/// <summary>
/// Generates OPA Rego source from PolicyPackDocuments.
/// Maps C# gate types and rules to equivalent Rego deny rules.
/// Includes remediation hints as structured output rules.
/// </summary>
public sealed class RegoCodeGenerator : IRegoCodeGenerator
{
/// <inheritdoc/>
public RegoExportResult Generate(PolicyPackDocument policy, RegoGenerationOptions options)
{
var warnings = new List<string>();
var sb = new StringBuilder();
// Header
sb.AppendLine($"package {options.PackageName}");
sb.AppendLine();
if (options.UseRegoV1Syntax)
{
sb.AppendLine("import rego.v1");
sb.AppendLine();
}
// Default allow
sb.AppendLine("default allow := false");
sb.AppendLine();
// Generate deny rules from gates
foreach (var gate in policy.Spec.Gates.Where(g => g.Enabled))
{
var regoRule = GenerateGateDenyRule(gate, options, warnings);
if (regoRule is not null)
{
if (options.IncludeComments)
{
sb.AppendLine($"# Gate: {gate.Id} ({gate.Type})");
}
sb.AppendLine(regoRule);
sb.AppendLine();
}
}
// Generate deny rules from rules
foreach (var rule in policy.Spec.Rules.OrderBy(r => r.Priority))
{
var regoRule = GenerateRuleDenyRule(rule, options);
if (regoRule is not null)
{
if (options.IncludeComments)
{
sb.AppendLine($"# Rule: {rule.Name}");
}
sb.AppendLine(regoRule);
sb.AppendLine();
}
}
// Allow rule
sb.AppendLine("allow if { count(deny) == 0 }");
sb.AppendLine();
// Remediation hints
if (options.IncludeRemediation)
{
var remediationRules = GenerateRemediationRules(policy, options);
if (remediationRules.Length > 0)
{
if (options.IncludeComments)
{
sb.AppendLine("# Remediation hints (structured output)");
}
sb.Append(remediationRules);
}
}
var source = sb.ToString().TrimEnd() + "\n";
var digest = ComputeDigest(source);
return new RegoExportResult
{
Success = true,
RegoSource = source,
PackageName = options.PackageName,
Digest = digest,
Warnings = warnings
};
}
private string? GenerateGateDenyRule(PolicyGateDefinition gate, RegoGenerationOptions options, List<string> warnings)
{
return gate.Type switch
{
PolicyGateTypes.CvssThreshold => GenerateCvssRule(gate, options),
PolicyGateTypes.SignatureRequired => GenerateSignatureRule(gate),
PolicyGateTypes.EvidenceFreshness => GenerateFreshnessRule(gate, options),
PolicyGateTypes.SbomPresence => GenerateSbomRule(gate),
PolicyGateTypes.MinimumConfidence => GenerateConfidenceRule(gate, options),
PolicyGateTypes.UnknownsBudget => GenerateUnknownsBudgetRule(gate),
PolicyGateTypes.ReachabilityRequirement => GenerateReachabilityRule(gate),
_ => GenerateUnknownGateRule(gate, warnings)
};
}
private string GenerateCvssRule(PolicyGateDefinition gate, RegoGenerationOptions options)
{
var threshold = GetConfigValue<double>(gate, "threshold", options.Environment, 7.0);
var sb = new StringBuilder();
sb.AppendLine("deny contains msg if {");
if (options.Environment is not null)
{
sb.AppendLine($" input.environment == \"{options.Environment}\"");
}
sb.AppendLine($" input.cvss.score >= {threshold:F1}");
var msg = gate.Remediation?.Title ?? "CVSS score exceeds threshold";
sb.AppendLine($" msg := \"{EscapeRego(msg)}\"");
sb.Append('}');
return sb.ToString();
}
private string GenerateSignatureRule(PolicyGateDefinition gate)
{
var requireDsse = GetConfigValue<bool>(gate, "requireDsse", null, true);
var requireRekor = GetConfigValue<bool>(gate, "requireRekor", null, true);
var sb = new StringBuilder();
if (requireDsse)
{
sb.AppendLine("deny contains msg if {");
sb.AppendLine(" not input.dsse.verified");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "DSSE signature missing or invalid")}\"");
sb.AppendLine("}");
}
if (requireRekor)
{
if (sb.Length > 0) sb.AppendLine();
sb.AppendLine("deny contains msg if {");
sb.AppendLine(" not input.rekor.verified");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Rekor v2 inclusion proof missing or invalid")}\"");
sb.Append('}');
}
return sb.ToString();
}
private string GenerateFreshnessRule(PolicyGateDefinition gate, RegoGenerationOptions options)
{
var maxAge = GetConfigValue<int>(gate, "maxAgeHours", options.Environment, 24);
var requireTst = GetConfigValue<bool>(gate, "requireTst", options.Environment, false);
var sb = new StringBuilder();
if (requireTst)
{
sb.AppendLine("deny contains msg if {");
sb.AppendLine($" input.freshness.maxAgeHours <= {maxAge}");
sb.AppendLine(" not input.freshness.tstVerified");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "RFC-3161 timestamp missing for freshness policy")}\"");
sb.Append('}');
}
else
{
sb.AppendLine("deny contains msg if {");
sb.AppendLine($" input.freshness.maxAgeHours <= {maxAge}");
sb.AppendLine(" not input.freshness.tstVerified");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Evidence freshness cannot be verified")}\"");
sb.Append('}');
}
return sb.ToString();
}
private string GenerateSbomRule(PolicyGateDefinition gate)
{
var sb = new StringBuilder();
sb.AppendLine("deny contains msg if {");
sb.AppendLine(" not input.sbom.canonicalDigest");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Canonical SBOM digest missing or mismatch")}\"");
sb.Append('}');
return sb.ToString();
}
private string GenerateConfidenceRule(PolicyGateDefinition gate, RegoGenerationOptions options)
{
var threshold = GetConfigValue<double>(gate, "threshold", options.Environment, 0.75);
var sb = new StringBuilder();
sb.AppendLine("deny contains msg if {");
sb.AppendLine($" input.confidence < {threshold:F2}");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Confidence score below threshold")}\"");
sb.Append('}');
return sb.ToString();
}
private string GenerateUnknownsBudgetRule(PolicyGateDefinition gate)
{
var sb = new StringBuilder();
sb.AppendLine("deny contains msg if {");
sb.AppendLine(" input.unknownsRatio > input.unknownsThreshold");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Unknowns budget exceeded")}\"");
sb.Append('}');
return sb.ToString();
}
private string GenerateReachabilityRule(PolicyGateDefinition gate)
{
var sb = new StringBuilder();
sb.AppendLine("deny contains msg if {");
sb.AppendLine(" not input.reachability.status");
sb.AppendLine($" msg := \"{EscapeRego(gate.Remediation?.Title ?? "Reachability proof required")}\"");
sb.Append('}');
return sb.ToString();
}
private string? GenerateUnknownGateRule(PolicyGateDefinition gate, List<string> warnings)
{
warnings.Add($"Gate type '{gate.Type}' has no Rego translation. Skipped gate '{gate.Id}'.");
return null;
}
private string? GenerateRuleDenyRule(PolicyRuleDefinition rule, RegoGenerationOptions options)
{
if (rule.Action == PolicyActions.Allow) return null;
var sb = new StringBuilder();
var keyword = rule.Action == PolicyActions.Block ? "deny" : "deny";
sb.AppendLine($"{keyword} contains msg if {{");
foreach (var (key, value) in rule.Match)
{
var condition = GenerateMatchCondition(key, value);
if (condition is not null)
{
sb.AppendLine($" {condition}");
}
}
var msg = rule.Remediation?.Title ?? $"Rule '{rule.Name}' violated";
sb.AppendLine($" msg := \"{EscapeRego(msg)}\"");
sb.Append('}');
return sb.ToString();
}
private static string? GenerateMatchCondition(string key, object? value)
{
var inputPath = $"input.{key}";
return value switch
{
null => $"not {inputPath}",
false or "false" => $"not {inputPath}",
true or "true" => inputPath,
JsonElement element => GenerateJsonElementCondition(inputPath, element),
_ => $"{inputPath} == {FormatRegoValue(value)}"
};
}
private static string GenerateJsonElementCondition(string inputPath, JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.False => $"not {inputPath}",
JsonValueKind.True => inputPath,
JsonValueKind.Null => $"not {inputPath}",
JsonValueKind.Number => $"{inputPath} == {element.GetRawText()}",
JsonValueKind.String => $"{inputPath} == \"{EscapeRego(element.GetString()!)}\"",
_ => $"{inputPath} == {element.GetRawText()}"
};
}
private string GenerateRemediationRules(PolicyPackDocument policy, RegoGenerationOptions options)
{
var sb = new StringBuilder();
var hintSources = new List<(string msg, RemediationHint hint)>();
foreach (var gate in policy.Spec.Gates.Where(g => g.Enabled && g.Remediation is not null))
{
var msg = gate.Remediation!.Title;
hintSources.Add((msg, gate.Remediation));
}
foreach (var rule in policy.Spec.Rules.Where(r => r.Remediation is not null && r.Action != PolicyActions.Allow))
{
var msg = rule.Remediation!.Title;
hintSources.Add((msg, rule.Remediation));
}
foreach (var (msg, hint) in hintSources)
{
var fix = hint.Actions.Count > 0 && hint.Actions[0].Command is not null
? $"Run: {hint.Actions[0].Command}"
: hint.Actions.Count > 0
? hint.Actions[0].Description
: hint.Title;
sb.AppendLine("remediation contains hint if {");
sb.AppendLine(" some msg in deny");
sb.AppendLine($" msg == \"{EscapeRego(msg)}\"");
sb.AppendLine($" hint := {{\"code\": \"{EscapeRego(hint.Code)}\", \"fix\": \"{EscapeRego(fix)}\", \"severity\": \"{hint.Severity}\"}}");
sb.AppendLine("}");
sb.AppendLine();
}
return sb.ToString();
}
private T GetConfigValue<T>(PolicyGateDefinition gate, string key, string? environment, T defaultValue)
{
// Check environment-specific config first
if (environment is not null &&
gate.Environments is not null &&
gate.Environments.TryGetValue(environment, out var envConfig) &&
envConfig.TryGetValue(key, out var envValue))
{
var result = ConvertValue<T>(envValue);
if (result is not null) return result;
}
// Fall back to base config
if (gate.Config.TryGetValue(key, out var value))
{
var result = ConvertValue<T>(value);
if (result is not null) return result;
}
return defaultValue;
}
private static T? ConvertValue<T>(object? value)
{
if (value is null) return default;
if (value is T typed) return typed;
if (value is JsonElement element)
{
if (typeof(T) == typeof(double))
return (T)(object)element.GetDouble();
if (typeof(T) == typeof(int))
return (T)(object)element.GetInt32();
if (typeof(T) == typeof(bool))
return (T)(object)element.GetBoolean();
if (typeof(T) == typeof(string))
return (T)(object)(element.GetString() ?? "");
}
try
{
return (T)Convert.ChangeType(value, typeof(T));
}
catch
{
return default;
}
}
private static string EscapeRego(string s) =>
s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
private static string FormatRegoValue(object value) =>
value switch
{
string s => $"\"{EscapeRego(s)}\"",
bool b => b ? "true" : "false",
_ => value.ToString() ?? "null"
};
private static string ComputeDigest(string content)
{
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(content));
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}

View File

@@ -0,0 +1,273 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://stella-ops.org/schemas/policy-pack-v2.schema.json",
"title": "Stella Ops PolicyPack v2",
"description": "Canonical policy pack format supporting bidirectional JSON/Rego interop with structured remediation hints.",
"type": "object",
"required": ["apiVersion", "kind", "metadata", "spec"],
"properties": {
"apiVersion": {
"type": "string",
"const": "policy.stellaops.io/v2",
"description": "Schema version identifier."
},
"kind": {
"type": "string",
"enum": ["PolicyPack", "PolicyOverride"],
"description": "Document kind."
},
"metadata": { "$ref": "#/$defs/PolicyPackMetadata" },
"spec": { "$ref": "#/$defs/PolicyPackSpec" }
},
"additionalProperties": false,
"$defs": {
"PolicyPackMetadata": {
"type": "object",
"required": ["name", "version"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
"description": "Unique name (DNS-label format)."
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+",
"description": "Semantic version."
},
"description": {
"type": "string",
"maxLength": 500,
"description": "Human-readable description."
},
"digest": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$",
"description": "SHA-256 digest of canonical content."
},
"createdAt": {
"type": "string",
"format": "date-time",
"description": "Creation timestamp (ISO 8601 UTC)."
},
"exportedFrom": { "$ref": "#/$defs/PolicyExportProvenance" },
"parent": {
"type": "string",
"description": "Parent policy pack name (for PolicyOverride)."
},
"environment": {
"type": "string",
"description": "Target environment (for PolicyOverride)."
}
},
"additionalProperties": false
},
"PolicyExportProvenance": {
"type": "object",
"required": ["engine", "engineVersion"],
"properties": {
"engine": {
"type": "string",
"description": "Exporting engine name."
},
"engineVersion": {
"type": "string",
"description": "Engine version."
},
"exportedAt": {
"type": "string",
"format": "date-time",
"description": "Export timestamp."
}
},
"additionalProperties": false
},
"PolicyPackSpec": {
"type": "object",
"required": ["settings"],
"properties": {
"settings": { "$ref": "#/$defs/PolicyPackSettings" },
"gates": {
"type": "array",
"items": { "$ref": "#/$defs/PolicyGateDefinition" },
"description": "Gate definitions with typed configurations."
},
"rules": {
"type": "array",
"items": { "$ref": "#/$defs/PolicyRuleDefinition" },
"description": "Rule definitions with match conditions."
}
},
"additionalProperties": false
},
"PolicyPackSettings": {
"type": "object",
"required": ["defaultAction"],
"properties": {
"defaultAction": {
"type": "string",
"enum": ["allow", "warn", "block"],
"description": "Default action when no rule matches."
},
"unknownsThreshold": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"default": 0.6,
"description": "Threshold for unknowns budget."
},
"stopOnFirstFailure": {
"type": "boolean",
"default": true,
"description": "Stop evaluation on first failure."
},
"deterministicMode": {
"type": "boolean",
"default": true,
"description": "Enforce deterministic evaluation."
}
},
"additionalProperties": false
},
"PolicyGateDefinition": {
"type": "object",
"required": ["id", "type"],
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
"description": "Unique gate identifier."
},
"type": {
"type": "string",
"description": "Gate type (C# gate class name)."
},
"enabled": {
"type": "boolean",
"default": true,
"description": "Whether this gate is active."
},
"config": {
"type": "object",
"description": "Gate-specific configuration.",
"additionalProperties": true
},
"environments": {
"type": "object",
"description": "Per-environment config overrides.",
"additionalProperties": {
"type": "object",
"additionalProperties": true
}
},
"remediation": { "$ref": "#/$defs/RemediationHint" }
},
"additionalProperties": false
},
"PolicyRuleDefinition": {
"type": "object",
"required": ["name", "action"],
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]{0,62}$",
"description": "Unique rule name."
},
"action": {
"type": "string",
"enum": ["allow", "warn", "block"],
"description": "Action when matched."
},
"priority": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Evaluation priority (lower = first)."
},
"match": {
"type": "object",
"description": "Match conditions (dot-notation keys, typed values).",
"additionalProperties": true
},
"remediation": { "$ref": "#/$defs/RemediationHint" }
},
"additionalProperties": false
},
"RemediationHint": {
"type": "object",
"required": ["code", "title", "severity"],
"properties": {
"code": {
"type": "string",
"pattern": "^[A-Z][A-Z0-9_]{1,30}$",
"description": "Machine-readable remediation code."
},
"title": {
"type": "string",
"maxLength": 200,
"description": "Human-readable title."
},
"description": {
"type": "string",
"maxLength": 1000,
"description": "Detailed explanation."
},
"actions": {
"type": "array",
"items": { "$ref": "#/$defs/RemediationAction" },
"description": "Ordered remediation actions."
},
"references": {
"type": "array",
"items": { "$ref": "#/$defs/RemediationReference" },
"description": "External references."
},
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"],
"description": "Issue severity."
}
},
"additionalProperties": false
},
"RemediationAction": {
"type": "object",
"required": ["type", "description"],
"properties": {
"type": {
"type": "string",
"enum": ["upgrade", "patch", "vex", "sign", "anchor", "generate", "override", "investigate", "mitigate"],
"description": "Action type."
},
"description": {
"type": "string",
"maxLength": 500,
"description": "What this action does."
},
"command": {
"type": "string",
"maxLength": 500,
"description": "CLI command template with {placeholders}."
}
},
"additionalProperties": false
},
"RemediationReference": {
"type": "object",
"required": ["title", "url"],
"properties": {
"title": {
"type": "string",
"maxLength": 200,
"description": "Display title."
},
"url": {
"type": "string",
"format": "uri",
"description": "Reference URL."
}
},
"additionalProperties": false
}
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="JsonSchema.Net" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Policy\StellaOps.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Schemas\policy-pack-v2.schema.json" />
</ItemGroup>
</Project>

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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"
}
}
]
}
}

View File

@@ -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"}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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>

View File

@@ -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";
}
}