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:
@@ -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; }
|
||||
}
|
||||
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user