doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,325 @@
// -----------------------------------------------------------------------------
// HttpOpaClient.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: HTTP client implementation for Open Policy Agent (OPA)
// -----------------------------------------------------------------------------
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// HTTP client for interacting with an external OPA server.
/// </summary>
public sealed class HttpOpaClient : IOpaClient, IDisposable
{
private readonly HttpClient _httpClient;
private readonly ILogger<HttpOpaClient> _logger;
private readonly OpaClientOptions _options;
private readonly bool _ownsHttpClient;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Creates a new HTTP OPA client with the specified options.
/// </summary>
public HttpOpaClient(
IOptions<OpaClientOptions> options,
ILogger<HttpOpaClient> logger,
HttpClient? httpClient = null)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
if (httpClient is null)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(_options.BaseUrl),
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
/// <inheritdoc />
public async Task<OpaEvaluationResult> EvaluateAsync(
string policyPath,
object input,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyPath);
ArgumentNullException.ThrowIfNull(input);
try
{
var requestPath = BuildQueryPath(policyPath);
var request = new OpaQueryRequest { Input = input };
_logger.LogDebug("Evaluating OPA policy at {Path}", requestPath);
var response = await _httpClient.PostAsJsonAsync(requestPath, request, JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"OPA evaluation failed: {StatusCode} - {Error}",
response.StatusCode, errorContent);
return new OpaEvaluationResult
{
Success = false,
Error = $"OPA returned {response.StatusCode}: {errorContent}"
};
}
var result = await response.Content.ReadFromJsonAsync<OpaQueryResponse>(JsonOptions, cancellationToken)
.ConfigureAwait(false);
return new OpaEvaluationResult
{
Success = true,
DecisionId = result?.DecisionId,
Result = result?.Result,
Metrics = result?.Metrics is not null ? MapMetrics(result.Metrics) : null
};
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP error connecting to OPA at {BaseUrl}", _options.BaseUrl);
return new OpaEvaluationResult
{
Success = false,
Error = $"HTTP error: {ex.Message}"
};
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.LogError(ex, "OPA request timed out after {Timeout}s", _options.TimeoutSeconds);
return new OpaEvaluationResult
{
Success = false,
Error = $"Request timed out after {_options.TimeoutSeconds} seconds"
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse OPA response");
return new OpaEvaluationResult
{
Success = false,
Error = $"JSON parse error: {ex.Message}"
};
}
}
/// <inheritdoc />
public async Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
string policyPath,
object input,
CancellationToken cancellationToken = default)
{
var result = await EvaluateAsync(policyPath, input, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
return new OpaTypedResult<TResult>
{
Success = false,
DecisionId = result.DecisionId,
Error = result.Error,
Metrics = result.Metrics
};
}
try
{
var typedResult = default(TResult);
if (result.Result is JsonElement jsonElement)
{
typedResult = jsonElement.Deserialize<TResult>(JsonOptions);
}
else if (result.Result is TResult directResult)
{
typedResult = directResult;
}
else if (result.Result is not null)
{
// Try re-serializing and deserializing
var json = JsonSerializer.Serialize(result.Result, JsonOptions);
typedResult = JsonSerializer.Deserialize<TResult>(json, JsonOptions);
}
return new OpaTypedResult<TResult>
{
Success = true,
DecisionId = result.DecisionId,
Result = typedResult,
Metrics = result.Metrics
};
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize OPA result to {Type}", typeof(TResult).Name);
return new OpaTypedResult<TResult>
{
Success = false,
DecisionId = result.DecisionId,
Error = $"Failed to deserialize result: {ex.Message}",
Metrics = result.Metrics
};
}
}
/// <inheritdoc />
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync("health", cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OPA health check failed");
return false;
}
}
/// <inheritdoc />
public async Task UploadPolicyAsync(
string policyId,
string regoContent,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
ArgumentException.ThrowIfNullOrWhiteSpace(regoContent);
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
using var content = new StringContent(regoContent, System.Text.Encoding.UTF8, "text/plain");
var response = await _httpClient.PutAsync(requestPath, content, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to upload policy: {response.StatusCode} - {errorContent}");
}
_logger.LogInformation("Uploaded policy {PolicyId} to OPA", policyId);
}
/// <inheritdoc />
public async Task DeletePolicyAsync(
string policyId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
var response = await _httpClient.DeleteAsync(requestPath, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to delete policy: {response.StatusCode} - {errorContent}");
}
_logger.LogInformation("Deleted policy {PolicyId} from OPA", policyId);
}
/// <summary>
/// Disposes the HTTP client if owned.
/// </summary>
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
private string BuildQueryPath(string policyPath)
{
// Normalize path: remove leading "data/" if present
var normalizedPath = policyPath.TrimStart('/');
if (normalizedPath.StartsWith("data/", StringComparison.OrdinalIgnoreCase))
{
normalizedPath = normalizedPath[5..];
}
// Use v1/data endpoint for queries
return $"v1/data/{normalizedPath}?metrics={_options.IncludeMetrics.ToString().ToLowerInvariant()}";
}
private static OpaMetrics MapMetrics(Dictionary<string, long> metrics) => new()
{
TimerRegoQueryCompileNs = metrics.GetValueOrDefault("timer_rego_query_compile_ns"),
TimerRegoQueryEvalNs = metrics.GetValueOrDefault("timer_rego_query_eval_ns"),
TimerServerHandlerNs = metrics.GetValueOrDefault("timer_server_handler_ns")
};
private sealed record OpaQueryRequest
{
public required object Input { get; init; }
}
private sealed record OpaQueryResponse
{
[JsonPropertyName("decision_id")]
public string? DecisionId { get; init; }
public object? Result { get; init; }
public Dictionary<string, long>? Metrics { get; init; }
}
}
/// <summary>
/// Configuration options for the OPA client.
/// </summary>
public sealed class OpaClientOptions
{
/// <summary>
/// Section name in configuration.
/// </summary>
public const string SectionName = "Opa";
/// <summary>
/// Base URL of the OPA server (e.g., "http://localhost:8181").
/// </summary>
public string BaseUrl { get; set; } = "http://localhost:8181";
/// <summary>
/// Request timeout in seconds.
/// </summary>
public int TimeoutSeconds { get; set; } = 10;
/// <summary>
/// Whether to include metrics in responses.
/// </summary>
public bool IncludeMetrics { get; set; } = true;
/// <summary>
/// Optional API key for authenticated OPA servers.
/// </summary>
public string? ApiKey { get; set; }
}

View File

@@ -0,0 +1,150 @@
// -----------------------------------------------------------------------------
// IOpaClient.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Interface for Open Policy Agent (OPA) client
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// Client interface for interacting with Open Policy Agent (OPA).
/// </summary>
public interface IOpaClient
{
/// <summary>
/// Evaluates a policy decision against OPA.
/// </summary>
/// <param name="policyPath">The policy path (e.g., "data/stella/attestation/allow").</param>
/// <param name="input">The input data for policy evaluation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The policy evaluation result.</returns>
Task<OpaEvaluationResult> EvaluateAsync(
string policyPath,
object input,
CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates a policy and returns a typed result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="policyPath">The policy path.</param>
/// <param name="input">The input data for policy evaluation.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The typed policy evaluation result.</returns>
Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
string policyPath,
object input,
CancellationToken cancellationToken = default);
/// <summary>
/// Checks OPA server health.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if OPA is healthy.</returns>
Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Uploads a policy to OPA.
/// </summary>
/// <param name="policyId">Unique policy identifier.</param>
/// <param name="regoContent">The Rego policy content.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UploadPolicyAsync(
string policyId,
string regoContent,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a policy from OPA.
/// </summary>
/// <param name="policyId">The policy identifier to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeletePolicyAsync(
string policyId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of an OPA policy evaluation.
/// </summary>
public sealed record OpaEvaluationResult
{
/// <summary>
/// Whether the evaluation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The decision ID for tracing.
/// </summary>
public string? DecisionId { get; init; }
/// <summary>
/// The raw result object.
/// </summary>
public object? Result { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Metrics from OPA (timing, etc.).
/// </summary>
public OpaMetrics? Metrics { get; init; }
}
/// <summary>
/// Typed result of an OPA policy evaluation.
/// </summary>
/// <typeparam name="T">The result type.</typeparam>
public sealed record OpaTypedResult<T>
{
/// <summary>
/// Whether the evaluation was successful.
/// </summary>
public required bool Success { get; init; }
/// <summary>
/// The decision ID for tracing.
/// </summary>
public string? DecisionId { get; init; }
/// <summary>
/// The typed result.
/// </summary>
public T? Result { get; init; }
/// <summary>
/// Error message if evaluation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Metrics from OPA.
/// </summary>
public OpaMetrics? Metrics { get; init; }
}
/// <summary>
/// Metrics from OPA policy evaluation.
/// </summary>
public sealed record OpaMetrics
{
/// <summary>
/// Time taken to compile the query (nanoseconds).
/// </summary>
public long? TimerRegoQueryCompileNs { get; init; }
/// <summary>
/// Time taken to evaluate the query (nanoseconds).
/// </summary>
public long? TimerRegoQueryEvalNs { get; init; }
/// <summary>
/// Total server handler time (nanoseconds).
/// </summary>
public long? TimerServerHandlerNs { get; init; }
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// OpaGateAdapter.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Adapter that wraps OPA policy evaluation as an IPolicyGate
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Gates.Opa;
/// <summary>
/// Adapter that wraps an OPA policy evaluation as an <see cref="IPolicyGate"/>.
/// This enables Rego policies to be used alongside C# gates in the gate registry.
/// </summary>
public sealed class OpaGateAdapter : IPolicyGate
{
private readonly IOpaClient _opaClient;
private readonly ILogger<OpaGateAdapter> _logger;
private readonly OpaGateOptions _options;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public OpaGateAdapter(
IOpaClient opaClient,
IOptions<OpaGateOptions> options,
ILogger<OpaGateAdapter> logger)
{
_opaClient = opaClient ?? throw new ArgumentNullException(nameof(opaClient));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(
MergeResult mergeResult,
PolicyGateContext context,
CancellationToken ct = default)
{
try
{
var input = BuildOpaInput(mergeResult, context);
_logger.LogDebug(
"Evaluating OPA gate {GateName} at policy path {PolicyPath}",
_options.GateName, _options.PolicyPath);
var result = await _opaClient.EvaluateAsync<OpaGateResult>(
_options.PolicyPath,
input,
ct).ConfigureAwait(false);
if (!result.Success)
{
_logger.LogWarning(
"OPA gate {GateName} evaluation failed: {Error}",
_options.GateName, result.Error);
return BuildFailureResult(
_options.FailOnError ? false : true,
$"OPA evaluation error: {result.Error}");
}
var opaResult = result.Result;
if (opaResult is null)
{
_logger.LogWarning("OPA gate {GateName} returned null result", _options.GateName);
return BuildFailureResult(
_options.FailOnError ? false : true,
"OPA returned null result");
}
var passed = opaResult.Allow ?? false;
var reason = opaResult.Reason ?? (passed ? "Policy allowed" : "Policy denied");
_logger.LogDebug(
"OPA gate {GateName} result: Passed={Passed}, Reason={Reason}",
_options.GateName, passed, reason);
return new GateResult
{
GateName = _options.GateName,
Passed = passed,
Reason = reason,
Details = BuildDetails(result.DecisionId, opaResult, result.Metrics)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "OPA gate {GateName} threw exception", _options.GateName);
return BuildFailureResult(
_options.FailOnError ? false : true,
$"OPA gate exception: {ex.Message}");
}
}
private object BuildOpaInput(MergeResult mergeResult, PolicyGateContext context)
{
// Build a comprehensive input object for OPA evaluation
return new
{
MergeResult = new
{
mergeResult.Findings,
mergeResult.TotalFindings,
mergeResult.CriticalCount,
mergeResult.HighCount,
mergeResult.MediumCount,
mergeResult.LowCount,
mergeResult.UnknownCount,
mergeResult.NewFindings,
mergeResult.RemovedFindings,
mergeResult.UnchangedFindings
},
Context = new
{
context.Environment,
context.UnknownCount,
context.HasReachabilityProof,
context.Severity,
context.CveId,
context.SubjectKey,
ReasonCodes = context.ReasonCodes.ToArray()
},
Policy = new
{
_options.TrustedKeyIds,
_options.IntegratedTimeCutoff,
_options.AllowedPayloadTypes,
_options.CustomData
}
};
}
private GateResult BuildFailureResult(bool passed, string reason)
{
return new GateResult
{
GateName = _options.GateName,
Passed = passed,
Reason = reason,
Details = ImmutableDictionary<string, object>.Empty
};
}
private ImmutableDictionary<string, object> BuildDetails(
string? decisionId,
OpaGateResult opaResult,
OpaMetrics? metrics)
{
var builder = ImmutableDictionary.CreateBuilder<string, object>();
if (decisionId is not null)
{
builder.Add("opaDecisionId", decisionId);
}
if (opaResult.Violations is not null && opaResult.Violations.Count > 0)
{
builder.Add("violations", opaResult.Violations);
}
if (opaResult.Warnings is not null && opaResult.Warnings.Count > 0)
{
builder.Add("warnings", opaResult.Warnings);
}
if (metrics is not null)
{
builder.Add("opaEvalTimeNs", metrics.TimerRegoQueryEvalNs ?? 0);
}
return builder.ToImmutable();
}
/// <summary>
/// Expected structure of the OPA gate evaluation result.
/// </summary>
private sealed record OpaGateResult
{
/// <summary>
/// Whether the policy allows the action.
/// </summary>
public bool? Allow { get; init; }
/// <summary>
/// Human-readable reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// List of policy violations (if denied).
/// </summary>
public IReadOnlyList<string>? Violations { get; init; }
/// <summary>
/// List of policy warnings (even if allowed).
/// </summary>
public IReadOnlyList<string>? Warnings { get; init; }
}
}
/// <summary>
/// Configuration options for an OPA gate adapter.
/// </summary>
public sealed class OpaGateOptions
{
/// <summary>
/// Name of the gate (used in results and logging).
/// </summary>
public string GateName { get; set; } = "OpaGate";
/// <summary>
/// The OPA policy path to evaluate (e.g., "stella/attestation/allow").
/// </summary>
public string PolicyPath { get; set; } = "stella/policy/allow";
/// <summary>
/// Whether to fail the gate if OPA evaluation fails.
/// If false, gate passes on OPA errors.
/// </summary>
public bool FailOnError { get; set; } = true;
/// <summary>
/// List of trusted key IDs to pass to the policy.
/// </summary>
public IReadOnlyList<string> TrustedKeyIds { get; set; } = [];
/// <summary>
/// Integrated time cutoff for Rekor freshness checks.
/// </summary>
public DateTimeOffset? IntegratedTimeCutoff { get; set; }
/// <summary>
/// List of allowed payload types.
/// </summary>
public IReadOnlyList<string> AllowedPayloadTypes { get; set; } = [];
/// <summary>
/// Custom data to pass to the policy.
/// </summary>
public IReadOnlyDictionary<string, object>? CustomData { get; set; }
}

View File

@@ -0,0 +1,187 @@
# -----------------------------------------------------------------------------
# attestation.rego
# Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
# Task: TASK-017-007 - OPA Client Integration
# Description: Sample Rego policy for attestation verification
# -----------------------------------------------------------------------------
package stella.attestation
import future.keywords.if
import future.keywords.in
import future.keywords.contains
# Default deny
default allow := false
# Allow if all attestation checks pass
allow if {
valid_payload_type
trusted_key
rekor_fresh_enough
vex_status_acceptable
}
# Build comprehensive response
result := {
"allow": allow,
"reason": reason,
"violations": violations,
"warnings": warnings,
}
# Determine reason for decision
reason := "All attestation checks passed" if {
allow
}
reason := concat("; ", violations) if {
not allow
count(violations) > 0
}
reason := "Unknown policy failure" if {
not allow
count(violations) == 0
}
# Collect all violations
violations contains msg if {
not valid_payload_type
msg := sprintf("Invalid payload type: got %v, expected one of %v",
[input.attestation.payloadType, input.policy.allowedPayloadTypes])
}
violations contains msg if {
not trusted_key
msg := sprintf("Untrusted signing key: %v not in trusted set",
[input.attestation.keyId])
}
violations contains msg if {
not rekor_fresh_enough
msg := sprintf("Rekor proof too old or too new: integratedTime %v outside valid range",
[input.rekor.integratedTime])
}
violations contains msg if {
some vuln in input.vex.vulnerabilities
vuln.status == "affected"
vuln.reachable == true
msg := sprintf("Reachable vulnerability with affected status: %v", [vuln.id])
}
# Collect warnings
warnings contains msg if {
some vuln in input.vex.vulnerabilities
vuln.status == "under_investigation"
msg := sprintf("Vulnerability under investigation: %v", [vuln.id])
}
warnings contains msg if {
input.rekor.integratedTime
time_since_integrated := time.now_ns() / 1000000000 - input.rekor.integratedTime
time_since_integrated > 86400 * 7 # More than 7 days old
msg := sprintf("Rekor proof is %v days old", [time_since_integrated / 86400])
}
# Check payload type is in allowed list
valid_payload_type if {
input.attestation.payloadType in input.policy.allowedPayloadTypes
}
valid_payload_type if {
count(input.policy.allowedPayloadTypes) == 0 # No restrictions
}
# Check if signing key is trusted
trusted_key if {
input.attestation.keyId in input.policy.trustedKeyIds
}
trusted_key if {
count(input.policy.trustedKeyIds) == 0 # No restrictions
}
# Check if the key fingerprint matches
trusted_key if {
some key in input.policy.trustedKeys
key.fingerprint == input.attestation.fingerprint
key.active == true
not key.revoked
}
# Check Rekor freshness
rekor_fresh_enough if {
not input.policy.integratedTimeCutoff # No cutoff set
}
rekor_fresh_enough if {
input.rekor.integratedTime
input.rekor.integratedTime <= input.policy.integratedTimeCutoff
}
rekor_fresh_enough if {
not input.rekor.integratedTime
not input.policy.requireRekorProof
}
# Check VEX status
vex_status_acceptable if {
not input.vex # No VEX data
}
vex_status_acceptable if {
not affected_and_reachable
}
# Helper: check if any vulnerability is both affected and reachable
affected_and_reachable if {
some vuln in input.vex.vulnerabilities
vuln.status == "affected"
vuln.reachable == true
}
# -----------------------------------------------------------------------------
# Additional policy rules for composite checks
# -----------------------------------------------------------------------------
# Minimum confidence score check
minimum_confidence_met if {
input.context.confidenceScore >= input.policy.minimumConfidence
}
minimum_confidence_met if {
not input.policy.minimumConfidence
}
# SBOM presence check
sbom_present if {
input.artifacts.sbom
input.artifacts.sbom.present == true
}
sbom_present if {
not input.policy.requireSbom
}
# Signature algorithm allowlist
allowed_algorithm if {
input.attestation.algorithm in input.policy.allowedAlgorithms
}
allowed_algorithm if {
count(input.policy.allowedAlgorithms) == 0
}
# Environment-specific rules
production_ready if {
input.context.environment != "production"
}
production_ready if {
input.context.environment == "production"
minimum_confidence_met
sbom_present
allowed_algorithm
}