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