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,185 @@
-- Policy Schema Migration 002: Trusted Keys and Gate Bypass Audit
-- Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
-- Tasks: TASK-017-005, TASK-017-006
-- Description: Adds trusted key registry and gate bypass audit tables
-- ============================================================================
-- Trusted Keys Table
-- ============================================================================
-- Stores trusted signing keys for attestation verification.
-- Keys can be looked up by keyid, fingerprint, or issuer pattern.
CREATE TABLE IF NOT EXISTS policy.trusted_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
key_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
algorithm TEXT NOT NULL,
public_key_pem TEXT,
owner TEXT,
issuer_pattern TEXT,
purposes JSONB DEFAULT '[]',
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
revoked_at TIMESTAMPTZ,
revoked_reason TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT,
UNIQUE(tenant_id, key_id),
UNIQUE(tenant_id, fingerprint)
);
-- Indexes for trusted_keys
CREATE INDEX IF NOT EXISTS idx_trusted_keys_tenant
ON policy.trusted_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_trusted_keys_fingerprint
ON policy.trusted_keys(tenant_id, fingerprint);
CREATE INDEX IF NOT EXISTS idx_trusted_keys_active
ON policy.trusted_keys(tenant_id, is_active)
WHERE is_active = true AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_trusted_keys_issuer_pattern
ON policy.trusted_keys(tenant_id, issuer_pattern)
WHERE issuer_pattern IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_trusted_keys_purposes
ON policy.trusted_keys USING gin(purposes);
-- Trigger for updated_at
CREATE OR REPLACE TRIGGER trigger_trusted_keys_updated_at
BEFORE UPDATE ON policy.trusted_keys
FOR EACH ROW
EXECUTE FUNCTION policy.update_updated_at();
-- ============================================================================
-- Gate Bypass Audit Table
-- ============================================================================
-- Immutable audit log for gate bypass events.
-- No UPDATE or DELETE allowed - append-only for compliance (7-year retention).
CREATE TABLE IF NOT EXISTS policy.gate_bypass_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
decision_id TEXT NOT NULL,
image_digest TEXT NOT NULL,
repository TEXT,
tag TEXT,
baseline_ref TEXT,
original_decision TEXT NOT NULL,
final_decision TEXT NOT NULL,
bypassed_gates JSONB NOT NULL,
actor TEXT NOT NULL,
actor_subject TEXT,
actor_email TEXT,
actor_ip_address INET,
justification TEXT NOT NULL,
policy_id TEXT,
source TEXT,
ci_context TEXT,
attestation_digest TEXT,
rekor_uuid TEXT,
bypass_type TEXT NOT NULL,
expires_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Indexes for gate_bypass_audit
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_tenant_timestamp
ON policy.gate_bypass_audit(tenant_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_decision_id
ON policy.gate_bypass_audit(tenant_id, decision_id);
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_actor
ON policy.gate_bypass_audit(tenant_id, actor, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_image_digest
ON policy.gate_bypass_audit(tenant_id, image_digest, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_bypass_type
ON policy.gate_bypass_audit(tenant_id, bypass_type);
-- Partial index for active time-limited bypasses
CREATE INDEX IF NOT EXISTS idx_gate_bypass_audit_active_bypasses
ON policy.gate_bypass_audit(tenant_id, expires_at)
WHERE expires_at IS NOT NULL AND expires_at > NOW();
-- ============================================================================
-- Row Level Security (RLS) Policies
-- ============================================================================
-- Enable RLS on trusted_keys
ALTER TABLE policy.trusted_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY trusted_keys_tenant_isolation ON policy.trusted_keys
USING (tenant_id = policy_app.require_current_tenant());
CREATE POLICY trusted_keys_insert_tenant ON policy.trusted_keys
FOR INSERT
WITH CHECK (tenant_id = policy_app.require_current_tenant());
CREATE POLICY trusted_keys_update_tenant ON policy.trusted_keys
FOR UPDATE
USING (tenant_id = policy_app.require_current_tenant());
-- Enable RLS on gate_bypass_audit
ALTER TABLE policy.gate_bypass_audit ENABLE ROW LEVEL SECURITY;
CREATE POLICY gate_bypass_audit_tenant_isolation ON policy.gate_bypass_audit
USING (tenant_id = policy_app.require_current_tenant());
CREATE POLICY gate_bypass_audit_insert_tenant ON policy.gate_bypass_audit
FOR INSERT
WITH CHECK (tenant_id = policy_app.require_current_tenant());
-- ============================================================================
-- Prevent Mutation of Audit Records
-- ============================================================================
-- Gate bypass audit records are immutable for compliance.
-- This trigger prevents UPDATE and DELETE operations.
CREATE OR REPLACE FUNCTION policy.prevent_audit_mutation()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Gate bypass audit records are immutable. UPDATE and DELETE are not allowed.'
USING HINT = 'Audit records must be preserved for compliance (7-year retention)',
ERRCODE = '42501';
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER trigger_gate_bypass_audit_immutable
BEFORE UPDATE OR DELETE ON policy.gate_bypass_audit
FOR EACH ROW
EXECUTE FUNCTION policy.prevent_audit_mutation();
-- ============================================================================
-- Comments
-- ============================================================================
COMMENT ON TABLE policy.trusted_keys IS
'Registry of trusted signing keys for attestation verification';
COMMENT ON TABLE policy.gate_bypass_audit IS
'Immutable audit log of gate bypass events for compliance (7-year retention)';
COMMENT ON COLUMN policy.trusted_keys.fingerprint IS
'SHA-256 fingerprint of the DER-encoded public key';
COMMENT ON COLUMN policy.trusted_keys.issuer_pattern IS
'Wildcard pattern for keyless signing issuers (e.g., *@example.com)';
COMMENT ON COLUMN policy.trusted_keys.purposes IS
'Allowed purposes: sbom-signing, vex-signing, release-signing, etc.';
COMMENT ON COLUMN policy.gate_bypass_audit.bypass_type IS
'Classification: WarningOverride, BlockOverride, EmergencyBypass, TimeLimitedApproval';
COMMENT ON COLUMN policy.gate_bypass_audit.expires_at IS
'For time-limited bypasses, when the bypass expires';

View File

@@ -0,0 +1,149 @@
-- Policy Schema Migration 003: Gate Decisions History
-- Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
-- Tasks: GR-005 - Add gate decision history endpoint
-- Description: Adds gate decisions history table for audit and replay
-- ============================================================================
-- Gate Decisions Table
-- ============================================================================
-- Stores historical gate decisions for audit, debugging, and replay.
-- Each decision record captures the full context of a gate evaluation.
CREATE TABLE IF NOT EXISTS policy.gate_decisions (
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
gate_id TEXT NOT NULL,
bom_ref TEXT NOT NULL,
image_digest TEXT,
gate_status TEXT NOT NULL,
verdict_hash TEXT,
policy_bundle_id TEXT,
policy_bundle_hash TEXT,
evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ci_context TEXT,
actor TEXT,
blocking_unknown_ids JSONB DEFAULT '[]',
warnings JSONB DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Indexes for Gate Decisions
-- ============================================================================
-- Optimized for the primary query patterns:
-- 1. Time-range queries by tenant (audit queries)
-- 2. Status filtering (find failures)
-- 3. Actor filtering (who triggered)
-- 4. BOM reference lookups (specific component history)
CREATE INDEX IF NOT EXISTS idx_gate_decisions_tenant_evaluated
ON policy.gate_decisions(tenant_id, evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_gate_decisions_gate_evaluated
ON policy.gate_decisions(tenant_id, gate_id, evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_gate_decisions_status
ON policy.gate_decisions(tenant_id, gate_status, evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_gate_decisions_actor
ON policy.gate_decisions(tenant_id, actor, evaluated_at DESC)
WHERE actor IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gate_decisions_bom_ref
ON policy.gate_decisions(tenant_id, bom_ref, evaluated_at DESC);
CREATE INDEX IF NOT EXISTS idx_gate_decisions_verdict_hash
ON policy.gate_decisions(tenant_id, verdict_hash)
WHERE verdict_hash IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gate_decisions_policy_bundle
ON policy.gate_decisions(tenant_id, policy_bundle_id, evaluated_at DESC)
WHERE policy_bundle_id IS NOT NULL;
-- Partial index for blocked decisions (commonly queried)
CREATE INDEX IF NOT EXISTS idx_gate_decisions_blocked
ON policy.gate_decisions(tenant_id, evaluated_at DESC)
WHERE gate_status = 'block';
-- ============================================================================
-- Row Level Security (RLS) Policies
-- ============================================================================
ALTER TABLE policy.gate_decisions ENABLE ROW LEVEL SECURITY;
-- Tenant isolation policy
CREATE POLICY gate_decisions_tenant_isolation ON policy.gate_decisions
USING (tenant_id::text = policy_app.require_current_tenant());
-- Insert policy
CREATE POLICY gate_decisions_insert_tenant ON policy.gate_decisions
FOR INSERT
WITH CHECK (tenant_id::text = policy_app.require_current_tenant());
-- ============================================================================
-- Retention Policy Helper
-- ============================================================================
-- Function to purge old gate decisions (configurable retention, default 90 days)
CREATE OR REPLACE FUNCTION policy.purge_old_gate_decisions(
p_tenant_id UUID,
p_retention_days INTEGER DEFAULT 90
)
RETURNS BIGINT AS $$
DECLARE
deleted_count BIGINT;
BEGIN
DELETE FROM policy.gate_decisions
WHERE tenant_id = p_tenant_id
AND evaluated_at < NOW() - (p_retention_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- Comments
-- ============================================================================
COMMENT ON TABLE policy.gate_decisions IS
'Historical gate decisions for audit, debugging, and deterministic replay';
COMMENT ON COLUMN policy.gate_decisions.decision_id IS
'Unique identifier for this gate decision';
COMMENT ON COLUMN policy.gate_decisions.gate_id IS
'Identifier for the gate that was evaluated';
COMMENT ON COLUMN policy.gate_decisions.bom_ref IS
'Package URL (PURL) or component reference being evaluated';
COMMENT ON COLUMN policy.gate_decisions.image_digest IS
'Container image digest if applicable (sha256:...)';
COMMENT ON COLUMN policy.gate_decisions.gate_status IS
'Decision result: pass, warn, or block';
COMMENT ON COLUMN policy.gate_decisions.verdict_hash IS
'Content-addressable hash for replay verification (sha256:...)';
COMMENT ON COLUMN policy.gate_decisions.policy_bundle_id IS
'Policy bundle identifier used for this evaluation';
COMMENT ON COLUMN policy.gate_decisions.policy_bundle_hash IS
'Content hash of the policy bundle for exact replay';
COMMENT ON COLUMN policy.gate_decisions.ci_context IS
'CI/CD context JSON (branch, commit, pipeline_id)';
COMMENT ON COLUMN policy.gate_decisions.actor IS
'User or service account that triggered the evaluation';
COMMENT ON COLUMN policy.gate_decisions.blocking_unknown_ids IS
'Array of unknown IDs that caused a block decision';
COMMENT ON COLUMN policy.gate_decisions.warnings IS
'Array of warning messages generated during evaluation';
COMMENT ON FUNCTION policy.purge_old_gate_decisions IS
'Purges gate decisions older than retention period (default 90 days)';

View File

@@ -0,0 +1,168 @@
-- Policy Schema Migration 004: Replay Audit Trail
-- Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
-- Task: GR-007 - Create replay audit trail
-- Description: Adds replay audit table for compliance tracking
-- ============================================================================
-- Replay Audit Table
-- ============================================================================
-- Records all replay attempts for compliance and debugging.
-- Tracks original vs replayed verdict hashes and any mismatches.
CREATE TABLE IF NOT EXISTS policy.replay_audit (
replay_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
bom_ref VARCHAR(512) NOT NULL,
verdict_hash VARCHAR(128) NOT NULL,
rekor_uuid VARCHAR(128),
replayed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
match BOOLEAN NOT NULL,
original_hash VARCHAR(128),
replayed_hash VARCHAR(128),
mismatch_reason TEXT,
policy_bundle_id TEXT,
policy_bundle_hash VARCHAR(128),
verifier_digest VARCHAR(128),
duration_ms INT,
actor VARCHAR(256),
source VARCHAR(64),
request_context JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================================
-- Indexes for Replay Audit
-- ============================================================================
-- Optimized for common query patterns:
-- 1. Time-range queries by tenant
-- 2. BOM reference lookups
-- 3. Finding mismatches for investigation
-- 4. Actor filtering
CREATE INDEX IF NOT EXISTS idx_replay_audit_tenant_replayed
ON policy.replay_audit(tenant_id, replayed_at DESC);
CREATE INDEX IF NOT EXISTS idx_replay_audit_bom_ref
ON policy.replay_audit(tenant_id, bom_ref, replayed_at DESC);
CREATE INDEX IF NOT EXISTS idx_replay_audit_verdict_hash
ON policy.replay_audit(tenant_id, verdict_hash);
CREATE INDEX IF NOT EXISTS idx_replay_audit_rekor_uuid
ON policy.replay_audit(tenant_id, rekor_uuid)
WHERE rekor_uuid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_replay_audit_actor
ON policy.replay_audit(tenant_id, actor, replayed_at DESC)
WHERE actor IS NOT NULL;
-- Partial index for mismatches (high priority for investigation)
CREATE INDEX IF NOT EXISTS idx_replay_audit_mismatches
ON policy.replay_audit(tenant_id, replayed_at DESC)
WHERE match = false;
-- ============================================================================
-- Row Level Security (RLS) Policies
-- ============================================================================
ALTER TABLE policy.replay_audit ENABLE ROW LEVEL SECURITY;
CREATE POLICY replay_audit_tenant_isolation ON policy.replay_audit
USING (tenant_id::text = policy_app.require_current_tenant());
CREATE POLICY replay_audit_insert_tenant ON policy.replay_audit
FOR INSERT
WITH CHECK (tenant_id::text = policy_app.require_current_tenant());
-- ============================================================================
-- Retention Policy Helper
-- ============================================================================
-- Function to purge old replay audit records (default 90 days)
CREATE OR REPLACE FUNCTION policy.purge_old_replay_audit(
p_tenant_id UUID,
p_retention_days INTEGER DEFAULT 90
)
RETURNS BIGINT AS $$
DECLARE
deleted_count BIGINT;
BEGIN
DELETE FROM policy.replay_audit
WHERE tenant_id = p_tenant_id
AND replayed_at < NOW() - (p_retention_days || ' days')::INTERVAL;
GET DIAGNOSTICS deleted_count = ROW_COUNT;
RETURN deleted_count;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================
-- Metrics Views
-- ============================================================================
-- Views to support Prometheus metrics: replay_attempts_total, replay_match_rate
CREATE OR REPLACE VIEW policy.replay_metrics_hourly AS
SELECT
tenant_id,
date_trunc('hour', replayed_at) AS hour,
COUNT(*) AS total_attempts,
COUNT(*) FILTER (WHERE match = true) AS successful_matches,
COUNT(*) FILTER (WHERE match = false) AS mismatches,
CASE
WHEN COUNT(*) > 0
THEN (COUNT(*) FILTER (WHERE match = true))::decimal / COUNT(*)
ELSE 0
END AS match_rate,
AVG(duration_ms) AS avg_duration_ms,
MAX(duration_ms) AS max_duration_ms
FROM policy.replay_audit
GROUP BY tenant_id, date_trunc('hour', replayed_at);
-- ============================================================================
-- Comments
-- ============================================================================
COMMENT ON TABLE policy.replay_audit IS
'Audit trail of all replay attempts for compliance (90-day default retention)';
COMMENT ON COLUMN policy.replay_audit.replay_id IS
'Unique identifier for this replay attempt';
COMMENT ON COLUMN policy.replay_audit.bom_ref IS
'Package URL or component reference that was replayed';
COMMENT ON COLUMN policy.replay_audit.verdict_hash IS
'Original verdict hash that was requested for replay';
COMMENT ON COLUMN policy.replay_audit.rekor_uuid IS
'Rekor transparency log UUID for verification';
COMMENT ON COLUMN policy.replay_audit.match IS
'Whether the replayed verdict matched the original';
COMMENT ON COLUMN policy.replay_audit.original_hash IS
'Hash from original verdict submission';
COMMENT ON COLUMN policy.replay_audit.replayed_hash IS
'Hash computed during replay';
COMMENT ON COLUMN policy.replay_audit.mismatch_reason IS
'Explanation if match=false, e.g., "policy_changed", "feed_drift"';
COMMENT ON COLUMN policy.replay_audit.policy_bundle_hash IS
'Content-addressable hash of policy bundle used';
COMMENT ON COLUMN policy.replay_audit.verifier_digest IS
'Container image digest of verifier service';
COMMENT ON COLUMN policy.replay_audit.duration_ms IS
'Time taken to complete replay in milliseconds';
COMMENT ON COLUMN policy.replay_audit.source IS
'Request source: api, cli, scheduled';
COMMENT ON VIEW policy.replay_metrics_hourly IS
'Aggregated metrics for replay operations by hour';
COMMENT ON FUNCTION policy.purge_old_replay_audit IS
'Purges replay audit records older than retention period (default 90 days)';

View File

@@ -0,0 +1,140 @@
// -----------------------------------------------------------------------------
// GateBypassAuditEntity.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-006 - Gate Bypass Audit Persistence
// Description: Entity model for gate bypass audit entries in PostgreSQL
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// PostgreSQL entity for gate bypass audit entries.
/// Records are immutable for compliance (7-year retention).
/// </summary>
public sealed class GateBypassAuditEntity
{
/// <summary>
/// Primary key (UUID).
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier for multi-tenancy.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// When the bypass occurred.
/// </summary>
public required DateTimeOffset Timestamp { get; init; }
/// <summary>
/// The gate decision ID that was bypassed.
/// </summary>
public required string DecisionId { get; init; }
/// <summary>
/// The image digest being evaluated (sha256:...).
/// </summary>
public required string ImageDigest { get; init; }
/// <summary>
/// The repository name.
/// </summary>
public string? Repository { get; init; }
/// <summary>
/// The image tag, if any.
/// </summary>
public string? Tag { get; init; }
/// <summary>
/// The baseline reference used for comparison.
/// </summary>
public string? BaselineRef { get; init; }
/// <summary>
/// The original gate decision before bypass (e.g., "Block", "Warn").
/// </summary>
public required string OriginalDecision { get; init; }
/// <summary>
/// The decision after bypass (typically "Allow").
/// </summary>
public required string FinalDecision { get; init; }
/// <summary>
/// Which gate(s) were bypassed (JSON array).
/// </summary>
public required string BypassedGates { get; init; }
/// <summary>
/// The identity of the user/service that requested the bypass.
/// </summary>
public required string Actor { get; init; }
/// <summary>
/// The subject identifier from the auth token.
/// </summary>
public string? ActorSubject { get; init; }
/// <summary>
/// The email associated with the actor.
/// </summary>
public string? ActorEmail { get; init; }
/// <summary>
/// The IP address of the requester.
/// </summary>
public string? ActorIpAddress { get; init; }
/// <summary>
/// The justification provided for the bypass.
/// </summary>
public required string Justification { get; init; }
/// <summary>
/// The policy ID that was being evaluated.
/// </summary>
public string? PolicyId { get; init; }
/// <summary>
/// The source of the gate request (e.g., "cli", "api", "webhook").
/// </summary>
public string? Source { get; init; }
/// <summary>
/// The CI/CD context (e.g., "github-actions", "gitlab-ci").
/// </summary>
public string? CiContext { get; init; }
/// <summary>
/// Attestation digest reference, if applicable.
/// </summary>
public string? AttestationDigest { get; init; }
/// <summary>
/// Rekor log UUID, if applicable.
/// </summary>
public string? RekorUuid { get; init; }
/// <summary>
/// Bypass type classification.
/// </summary>
public required string BypassType { get; init; }
/// <summary>
/// When the bypass expires (for time-limited approvals).
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Additional metadata (JSON).
/// </summary>
public string? Metadata { get; init; }
/// <summary>
/// When the record was created (should match Timestamp).
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,106 @@
// -----------------------------------------------------------------------------
// TrustedKeyEntity.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: Entity model for trusted signing keys in PostgreSQL
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Persistence.Postgres.Models;
/// <summary>
/// PostgreSQL entity for trusted signing keys used in attestation verification.
/// </summary>
public sealed class TrustedKeyEntity
{
/// <summary>
/// Primary key (UUID).
/// </summary>
public required Guid Id { get; init; }
/// <summary>
/// Tenant identifier for multi-tenancy.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Unique key identifier (e.g., Sigstore keyid, fingerprint reference).
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// SHA-256 fingerprint of the DER-encoded public key.
/// Used for fast lookups and uniqueness.
/// </summary>
public required string Fingerprint { get; init; }
/// <summary>
/// Key algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_2048", "RSA_4096").
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// PEM-encoded public key material.
/// </summary>
public string? PublicKeyPem { get; init; }
/// <summary>
/// Key owner or issuer identity (e.g., email, OIDC subject).
/// </summary>
public string? Owner { get; init; }
/// <summary>
/// Issuer pattern for keyless signing (e.g., "*@example.com", "https://accounts.google.com").
/// Supports wildcard matching.
/// </summary>
public string? IssuerPattern { get; init; }
/// <summary>
/// Allowed purposes for this key (JSON array: ["sbom-signing", "vex-signing", "release-signing"]).
/// </summary>
public string? Purposes { get; init; }
/// <summary>
/// When this key was first trusted.
/// </summary>
public DateTimeOffset ValidFrom { get; init; }
/// <summary>
/// When this key expires (null = no expiry).
/// </summary>
public DateTimeOffset? ValidUntil { get; init; }
/// <summary>
/// Whether the key is currently active.
/// </summary>
public bool IsActive { get; init; }
/// <summary>
/// When the key was revoked (null = not revoked).
/// </summary>
public DateTimeOffset? RevokedAt { get; init; }
/// <summary>
/// Reason for revocation.
/// </summary>
public string? RevokedReason { get; init; }
/// <summary>
/// Additional metadata (JSON).
/// </summary>
public string? Metadata { get; init; }
/// <summary>
/// When the record was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the record was last updated.
/// </summary>
public DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// User who created this key trust.
/// </summary>
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,206 @@
// -----------------------------------------------------------------------------
// PostgresGateBypassAuditRepository.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-006 - Gate Bypass Audit Persistence
// Description: PostgreSQL-backed implementation of IGateBypassAuditRepository
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
namespace StellaOps.Policy.Persistence.Postgres;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="IGateBypassAuditRepository"/>.
/// Provides a tenant-aware adapter over the underlying repository.
/// </summary>
public sealed class PostgresGateBypassAuditRepository : IGateBypassAuditRepository
{
private readonly IGateBypassAuditPersistence _repository;
private readonly ILogger<PostgresGateBypassAuditRepository> _logger;
private readonly string _tenantId;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public PostgresGateBypassAuditRepository(
IGateBypassAuditPersistence repository,
ILogger<PostgresGateBypassAuditRepository> logger,
string tenantId)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
}
/// <inheritdoc />
public async Task AddAsync(GateBypassAuditEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(entry);
var entity = MapToEntity(entry);
await _repository.CreateAsync(entity, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Recorded gate bypass audit: DecisionId={DecisionId}, Actor={Actor}, Gates=[{Gates}]",
entry.DecisionId,
entry.Actor,
string.Join(", ", entry.BypassedGates));
}
/// <inheritdoc />
public async Task<GateBypassAuditEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var entity = await _repository.GetByIdAsync(_tenantId, id, cancellationToken).ConfigureAwait(false);
return entity is null ? null : MapFromEntity(entity);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByDecisionIdAsync(
string decisionId,
CancellationToken cancellationToken = default)
{
var entities = await _repository.GetByDecisionIdAsync(_tenantId, decisionId, cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByActorAsync(
string actor,
int limit = 100,
CancellationToken cancellationToken = default)
{
var entities = await _repository.GetByActorAsync(_tenantId, actor, limit, cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntry>> GetByImageDigestAsync(
string imageDigest,
int limit = 100,
CancellationToken cancellationToken = default)
{
var entities = await _repository.GetByImageDigestAsync(_tenantId, imageDigest, limit, cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntry>> ListRecentAsync(
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
var entities = await _repository.ListRecentAsync(_tenantId, limit, offset, cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntry>> ListByTimeRangeAsync(
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default)
{
var entities = await _repository.ListByTimeRangeAsync(_tenantId, from, to, limit, cancellationToken)
.ConfigureAwait(false);
return entities.Select(MapFromEntity).ToList();
}
/// <inheritdoc />
public async Task<int> CountByActorSinceAsync(
string actor,
DateTimeOffset since,
CancellationToken cancellationToken = default)
{
return await _repository.CountByActorSinceAsync(_tenantId, actor, since, cancellationToken)
.ConfigureAwait(false);
}
private GateBypassAuditEntity MapToEntity(GateBypassAuditEntry entry) => new()
{
Id = entry.Id,
TenantId = _tenantId,
Timestamp = entry.Timestamp,
DecisionId = entry.DecisionId,
ImageDigest = entry.ImageDigest,
Repository = entry.Repository,
Tag = entry.Tag,
BaselineRef = entry.BaselineRef,
OriginalDecision = entry.OriginalDecision,
FinalDecision = entry.FinalDecision,
BypassedGates = JsonSerializer.Serialize(entry.BypassedGates, JsonOptions),
Actor = entry.Actor,
ActorSubject = entry.ActorSubject,
ActorEmail = entry.ActorEmail,
ActorIpAddress = entry.ActorIpAddress,
Justification = entry.Justification,
PolicyId = entry.PolicyId,
Source = entry.Source,
CiContext = entry.CiContext,
AttestationDigest = null, // Can be extracted from Metadata if present
RekorUuid = null,
BypassType = "BlockOverride", // Default type
ExpiresAt = null,
Metadata = entry.Metadata is not null ? JsonSerializer.Serialize(entry.Metadata, JsonOptions) : null,
CreatedAt = entry.Timestamp
};
private static GateBypassAuditEntry MapFromEntity(GateBypassAuditEntity entity) => new()
{
Id = entity.Id,
Timestamp = entity.Timestamp,
DecisionId = entity.DecisionId,
ImageDigest = entity.ImageDigest,
Repository = entity.Repository,
Tag = entity.Tag,
BaselineRef = entity.BaselineRef,
OriginalDecision = entity.OriginalDecision,
FinalDecision = entity.FinalDecision,
BypassedGates = ParseBypassedGates(entity.BypassedGates),
Actor = entity.Actor,
ActorSubject = entity.ActorSubject,
ActorEmail = entity.ActorEmail,
ActorIpAddress = entity.ActorIpAddress,
Justification = entity.Justification,
PolicyId = entity.PolicyId,
Source = entity.Source,
CiContext = entity.CiContext,
Metadata = ParseMetadata(entity.Metadata)
};
private static IReadOnlyList<string> ParseBypassedGates(string json)
{
try
{
return JsonSerializer.Deserialize<List<string>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static IReadOnlyDictionary<string, string>? ParseMetadata(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,251 @@
// -----------------------------------------------------------------------------
// PostgresTrustedKeyRegistry.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: PostgreSQL-backed implementation of ITrustedKeyRegistry with caching
// -----------------------------------------------------------------------------
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Gates.Attestation;
using StellaOps.Policy.Persistence.Postgres.Models;
using StellaOps.Policy.Persistence.Postgres.Repositories;
namespace StellaOps.Policy.Persistence.Postgres;
/// <summary>
/// PostgreSQL-backed implementation of <see cref="ITrustedKeyRegistry"/> with in-memory caching.
/// </summary>
public sealed class PostgresTrustedKeyRegistry : ITrustedKeyRegistry
{
private readonly ITrustedKeyRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<PostgresTrustedKeyRegistry> _logger;
private readonly PostgresTrustedKeyRegistryOptions _options;
private readonly string _tenantId;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public PostgresTrustedKeyRegistry(
ITrustedKeyRepository repository,
IMemoryCache cache,
ILogger<PostgresTrustedKeyRegistry> logger,
PostgresTrustedKeyRegistryOptions options,
string tenantId)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options ?? throw new ArgumentNullException(nameof(options));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
}
/// <inheritdoc />
public async Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default)
{
var key = await GetKeyAsync(keyId, ct).ConfigureAwait(false);
return key is not null && IsKeyValid(key);
}
/// <inheritdoc />
public async Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default)
{
var cacheKey = BuildCacheKey("keyid", keyId);
if (_cache.TryGetValue(cacheKey, out TrustedKey? cached))
{
return cached;
}
var entity = await _repository.GetByKeyIdAsync(_tenantId, keyId, ct).ConfigureAwait(false);
if (entity is null)
{
return null;
}
var key = MapFromEntity(entity);
CacheKey(cacheKey, key);
return key;
}
/// <inheritdoc />
public async Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default)
{
var cacheKey = BuildCacheKey("fingerprint", fingerprint);
if (_cache.TryGetValue(cacheKey, out TrustedKey? cached))
{
return cached;
}
var entity = await _repository.GetByFingerprintAsync(_tenantId, fingerprint, ct).ConfigureAwait(false);
if (entity is null)
{
return null;
}
var key = MapFromEntity(entity);
CacheKey(cacheKey, key);
return key;
}
/// <inheritdoc />
public async IAsyncEnumerable<TrustedKey> ListAsync([EnumeratorCancellation] CancellationToken ct = default)
{
var entities = await _repository.ListActiveAsync(_tenantId, cancellationToken: ct).ConfigureAwait(false);
foreach (var entity in entities)
{
ct.ThrowIfCancellationRequested();
yield return MapFromEntity(entity);
}
}
/// <inheritdoc />
public async Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(key);
var entity = MapToEntity(key);
await _repository.CreateAsync(entity, ct).ConfigureAwait(false);
// Invalidate cache
InvalidateCache(key.KeyId, key.Fingerprint);
_logger.LogInformation(
"Added trusted key {KeyId} with fingerprint {Fingerprint} for tenant {TenantId}",
key.KeyId, key.Fingerprint, _tenantId);
return key;
}
/// <inheritdoc />
public async Task RevokeAsync(string keyId, string reason, CancellationToken ct = default)
{
var key = await GetKeyAsync(keyId, ct).ConfigureAwait(false);
if (key is null)
{
_logger.LogWarning("Attempted to revoke non-existent key {KeyId}", keyId);
return;
}
var revoked = await _repository.RevokeAsync(_tenantId, keyId, reason, ct).ConfigureAwait(false);
if (revoked)
{
InvalidateCache(keyId, key.Fingerprint);
_logger.LogInformation(
"Revoked trusted key {KeyId} for tenant {TenantId}: {Reason}",
keyId, _tenantId, reason);
}
}
private bool IsKeyValid(TrustedKey key)
{
if (!key.IsActive)
return false;
if (key.RevokedAt.HasValue)
return false;
if (key.ExpiresAt.HasValue && key.ExpiresAt.Value < DateTimeOffset.UtcNow)
return false;
return true;
}
private TrustedKey MapFromEntity(TrustedKeyEntity entity) => new()
{
KeyId = entity.KeyId,
Fingerprint = entity.Fingerprint,
Algorithm = entity.Algorithm,
PublicKeyPem = entity.PublicKeyPem,
Owner = entity.Owner,
Purposes = ParsePurposes(entity.Purposes),
TrustedAt = entity.ValidFrom,
ExpiresAt = entity.ValidUntil,
IsActive = entity.IsActive,
RevokedReason = entity.RevokedReason,
RevokedAt = entity.RevokedAt,
TenantId = Guid.TryParse(entity.TenantId, out var tid) ? tid : Guid.Empty
};
private TrustedKeyEntity MapToEntity(TrustedKey key) => new()
{
Id = Guid.NewGuid(),
TenantId = _tenantId,
KeyId = key.KeyId,
Fingerprint = key.Fingerprint,
Algorithm = key.Algorithm,
PublicKeyPem = key.PublicKeyPem,
Owner = key.Owner,
IssuerPattern = null, // Set via separate API if needed
Purposes = SerializePurposes(key.Purposes),
ValidFrom = key.TrustedAt,
ValidUntil = key.ExpiresAt,
IsActive = key.IsActive,
RevokedAt = key.RevokedAt,
RevokedReason = key.RevokedReason,
Metadata = null,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedBy = null
};
private static IReadOnlyList<string> ParsePurposes(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<string>>(json, JsonOptions) ?? [];
}
catch
{
return [];
}
}
private static string? SerializePurposes(IReadOnlyList<string> purposes)
{
if (purposes.Count == 0)
return null;
return JsonSerializer.Serialize(purposes, JsonOptions);
}
private string BuildCacheKey(string type, string value)
=> $"trustedkey:{_tenantId}:{type}:{value}";
private void CacheKey(string cacheKey, TrustedKey key)
{
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.CacheTtlSeconds),
SlidingExpiration = TimeSpan.FromSeconds(_options.CacheTtlSeconds / 2)
};
_cache.Set(cacheKey, key, options);
}
private void InvalidateCache(string keyId, string fingerprint)
{
_cache.Remove(BuildCacheKey("keyid", keyId));
_cache.Remove(BuildCacheKey("fingerprint", fingerprint));
}
}
/// <summary>
/// Configuration options for PostgresTrustedKeyRegistry.
/// </summary>
public sealed class PostgresTrustedKeyRegistryOptions
{
/// <summary>
/// Cache TTL in seconds. Default is 300 (5 minutes).
/// </summary>
public int CacheTtlSeconds { get; set; } = 300;
}

View File

@@ -0,0 +1,323 @@
// -----------------------------------------------------------------------------
// GateBypassAuditRepository.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-006 - Gate Bypass Audit Persistence
// Description: PostgreSQL implementation of gate bypass audit repository
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for gate bypass audit entries.
/// Records are immutable (append-only) for compliance requirements.
/// </summary>
/// <remarks>
/// This table uses insert-only semantics. UPDATE and DELETE operations are not exposed
/// to maintain audit integrity for compliance (7-year retention requirement).
/// </remarks>
public sealed class GateBypassAuditRepository : RepositoryBase<PolicyDataSource>, IGateBypassAuditPersistence
{
public GateBypassAuditRepository(PolicyDataSource dataSource, ILogger<GateBypassAuditRepository> logger)
: base(dataSource, logger) { }
/// <inheritdoc />
public async Task<Guid> CreateAsync(
GateBypassAuditEntity entry,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.gate_bypass_audit (
id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
) VALUES (
@id, @tenant_id, @timestamp, @decision_id, @image_digest, @repository, @tag,
@baseline_ref, @original_decision, @final_decision, @bypassed_gates::jsonb,
@actor, @actor_subject, @actor_email, @actor_ip_address, @justification,
@policy_id, @source, @ci_context, @attestation_digest, @rekor_uuid,
@bypass_type, @expires_at, @metadata::jsonb, @created_at
)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(entry.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", entry.Id);
AddParameter(command, "tenant_id", entry.TenantId);
AddParameter(command, "timestamp", entry.Timestamp);
AddParameter(command, "decision_id", entry.DecisionId);
AddParameter(command, "image_digest", entry.ImageDigest);
AddParameter(command, "repository", entry.Repository);
AddParameter(command, "tag", entry.Tag);
AddParameter(command, "baseline_ref", entry.BaselineRef);
AddParameter(command, "original_decision", entry.OriginalDecision);
AddParameter(command, "final_decision", entry.FinalDecision);
AddJsonbParameter(command, "bypassed_gates", entry.BypassedGates);
AddParameter(command, "actor", entry.Actor);
AddParameter(command, "actor_subject", entry.ActorSubject);
AddParameter(command, "actor_email", entry.ActorEmail);
AddParameter(command, "actor_ip_address", entry.ActorIpAddress);
AddParameter(command, "justification", entry.Justification);
AddParameter(command, "policy_id", entry.PolicyId);
AddParameter(command, "source", entry.Source);
AddParameter(command, "ci_context", entry.CiContext);
AddParameter(command, "attestation_digest", entry.AttestationDigest);
AddParameter(command, "rekor_uuid", entry.RekorUuid);
AddParameter(command, "bypass_type", entry.BypassType);
AddParameter(command, "expires_at", entry.ExpiresAt);
AddJsonbParameter(command, "metadata", entry.Metadata);
AddParameter(command, "created_at", entry.CreatedAt);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (Guid)result!;
}
/// <inheritdoc />
public async Task<GateBypassAuditEntity?> GetByIdAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND id = @id
""";
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByDecisionIdAsync(
string tenantId,
string decisionId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND decision_id = @decision_id
ORDER BY timestamp DESC
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "decision_id", decisionId);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByActorAsync(
string tenantId,
string actor,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND actor = @actor
ORDER BY timestamp DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "actor", actor);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> GetByImageDigestAsync(
string tenantId,
string imageDigest,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND image_digest = @image_digest
ORDER BY timestamp DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "image_digest", imageDigest);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> ListRecentAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id
ORDER BY timestamp DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> ListByTimeRangeAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
ORDER BY timestamp DESC
LIMIT @limit
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
AddParameter(cmd, "limit", limit);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> CountByActorSinceAsync(
string tenantId,
string actor,
DateTimeOffset since,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND actor = @actor AND timestamp >= @since
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "actor", actor);
AddParameter(command, "since", since);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
/// <inheritdoc />
public async Task<IReadOnlyList<GateBypassAuditEntity>> ExportForComplianceAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
// Export in chronological order for compliance reporting
const string sql = """
SELECT id, tenant_id, timestamp, decision_id, image_digest, repository, tag,
baseline_ref, original_decision, final_decision, bypassed_gates,
actor, actor_subject, actor_email, actor_ip_address, justification,
policy_id, source, ci_context, attestation_digest, rekor_uuid,
bypass_type, expires_at, metadata, created_at
FROM policy.gate_bypass_audit
WHERE tenant_id = @tenant_id AND timestamp >= @from AND timestamp < @to
ORDER BY timestamp ASC
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
private static GateBypassAuditEntity MapEntity(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Timestamp = reader.GetFieldValue<DateTimeOffset>(2),
DecisionId = reader.GetString(3),
ImageDigest = reader.GetString(4),
Repository = GetNullableString(reader, 5),
Tag = GetNullableString(reader, 6),
BaselineRef = GetNullableString(reader, 7),
OriginalDecision = reader.GetString(8),
FinalDecision = reader.GetString(9),
BypassedGates = reader.GetString(10),
Actor = reader.GetString(11),
ActorSubject = GetNullableString(reader, 12),
ActorEmail = GetNullableString(reader, 13),
ActorIpAddress = GetNullableString(reader, 14),
Justification = reader.GetString(15),
PolicyId = GetNullableString(reader, 16),
Source = GetNullableString(reader, 17),
CiContext = GetNullableString(reader, 18),
AttestationDigest = GetNullableString(reader, 19),
RekorUuid = GetNullableString(reader, 20),
BypassType = reader.GetString(21),
ExpiresAt = GetNullableDateTimeOffset(reader, 22),
Metadata = GetNullableString(reader, 23),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(24)
};
}

View File

@@ -0,0 +1,360 @@
// -----------------------------------------------------------------------------
// GateDecisionHistoryRepository.cs
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
// Task: GR-005 - Add gate decision history endpoint
// Description: Repository for querying historical gate decisions
// -----------------------------------------------------------------------------
using System.Data;
using Npgsql;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for querying historical gate decisions.
/// </summary>
public sealed class GateDecisionHistoryRepository : IGateDecisionHistoryRepository
{
private readonly string _connectionString;
public GateDecisionHistoryRepository(string connectionString)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
/// <inheritdoc />
public async Task<GateDecisionHistoryResult> GetDecisionsAsync(
GateDecisionHistoryQuery query,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
// Build query with filters
var sql = """
SELECT
decision_id,
bom_ref,
image_digest,
gate_status,
verdict_hash,
policy_bundle_id,
policy_bundle_hash,
evaluated_at,
ci_context,
actor,
blocking_unknown_ids,
warnings
FROM policy.gate_decisions
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", query.TenantId)
};
if (!string.IsNullOrEmpty(query.GateId))
{
sql += " AND gate_id = @gate_id";
parameters.Add(new NpgsqlParameter("gate_id", query.GateId));
}
if (query.FromDate.HasValue)
{
sql += " AND evaluated_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
}
if (query.ToDate.HasValue)
{
sql += " AND evaluated_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
}
if (!string.IsNullOrEmpty(query.Status))
{
sql += " AND gate_status = @status";
parameters.Add(new NpgsqlParameter("status", query.Status));
}
if (!string.IsNullOrEmpty(query.Actor))
{
sql += " AND actor = @actor";
parameters.Add(new NpgsqlParameter("actor", query.Actor));
}
if (!string.IsNullOrEmpty(query.BomRef))
{
sql += " AND bom_ref = @bom_ref";
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
}
// Get total count first
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
await using var countCmd = new NpgsqlCommand(countSql, conn);
countCmd.Parameters.AddRange(parameters.ToArray());
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
// Apply pagination
sql += " ORDER BY evaluated_at DESC";
if (!string.IsNullOrEmpty(query.ContinuationToken))
{
var offset = DecodeContinuationToken(query.ContinuationToken);
sql += $" OFFSET {offset}";
}
sql += $" LIMIT {query.Limit + 1}"; // +1 to detect if there are more results
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
var decisions = new List<GateDecisionRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
decisions.Add(new GateDecisionRecord
{
DecisionId = reader.GetGuid(0),
BomRef = reader.GetString(1),
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
GateStatus = reader.GetString(3),
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
EvaluatedAt = reader.GetDateTime(7),
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
});
}
// Check if there are more results
var hasMore = decisions.Count > query.Limit;
if (hasMore)
{
decisions.RemoveAt(decisions.Count - 1);
}
string? nextToken = null;
if (hasMore)
{
var currentOffset = string.IsNullOrEmpty(query.ContinuationToken)
? 0
: DecodeContinuationToken(query.ContinuationToken);
nextToken = EncodeContinuationToken(currentOffset + query.Limit);
}
return new GateDecisionHistoryResult
{
Decisions = decisions,
Total = totalCount,
ContinuationToken = nextToken
};
}
/// <inheritdoc />
public async Task<GateDecisionRecord?> GetDecisionByIdAsync(
Guid decisionId,
Guid tenantId,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
const string sql = """
SELECT
decision_id,
bom_ref,
image_digest,
gate_status,
verdict_hash,
policy_bundle_id,
policy_bundle_hash,
evaluated_at,
ci_context,
actor,
blocking_unknown_ids,
warnings
FROM policy.gate_decisions
WHERE decision_id = @decision_id AND tenant_id = @tenant_id
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("decision_id", decisionId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
return null;
}
return new GateDecisionRecord
{
DecisionId = reader.GetGuid(0),
BomRef = reader.GetString(1),
ImageDigest = reader.IsDBNull(2) ? null : reader.GetString(2),
GateStatus = reader.GetString(3),
VerdictHash = reader.IsDBNull(4) ? null : reader.GetString(4),
PolicyBundleId = reader.IsDBNull(5) ? null : reader.GetString(5),
PolicyBundleHash = reader.IsDBNull(6) ? null : reader.GetString(6),
EvaluatedAt = reader.GetDateTime(7),
CiContext = reader.IsDBNull(8) ? null : reader.GetString(8),
Actor = reader.IsDBNull(9) ? null : reader.GetString(9),
BlockingUnknownIds = reader.IsDBNull(10) ? [] : ParseGuidArray(reader.GetString(10)),
Warnings = reader.IsDBNull(11) ? [] : ParseStringArray(reader.GetString(11))
};
}
/// <inheritdoc />
public async Task RecordDecisionAsync(GateDecisionRecord decision, Guid tenantId, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
const string sql = """
INSERT INTO policy.gate_decisions (
decision_id, tenant_id, bom_ref, image_digest, gate_status, verdict_hash,
policy_bundle_id, policy_bundle_hash, evaluated_at, ci_context, actor,
blocking_unknown_ids, warnings
) VALUES (
@decision_id, @tenant_id, @bom_ref, @image_digest, @gate_status, @verdict_hash,
@policy_bundle_id, @policy_bundle_hash, @evaluated_at, @ci_context, @actor,
@blocking_unknown_ids, @warnings
)
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("decision_id", decision.DecisionId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("bom_ref", decision.BomRef);
cmd.Parameters.AddWithValue("image_digest", (object?)decision.ImageDigest ?? DBNull.Value);
cmd.Parameters.AddWithValue("gate_status", decision.GateStatus);
cmd.Parameters.AddWithValue("verdict_hash", (object?)decision.VerdictHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)decision.PolicyBundleId ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)decision.PolicyBundleHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("evaluated_at", decision.EvaluatedAt);
cmd.Parameters.AddWithValue("ci_context", (object?)decision.CiContext ?? DBNull.Value);
cmd.Parameters.AddWithValue("actor", (object?)decision.Actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("blocking_unknown_ids", SerializeGuidArray(decision.BlockingUnknownIds));
cmd.Parameters.AddWithValue("warnings", SerializeStringArray(decision.Warnings));
await cmd.ExecuteNonQueryAsync(ct);
}
private static string EncodeContinuationToken(long offset) =>
Convert.ToBase64String(BitConverter.GetBytes(offset));
private static long DecodeContinuationToken(string token)
{
try
{
var bytes = Convert.FromBase64String(token);
return BitConverter.ToInt64(bytes);
}
catch
{
return 0;
}
}
private static List<Guid> ParseGuidArray(string json)
{
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<Guid>>(json) ?? [];
}
catch
{
return [];
}
}
private static List<string> ParseStringArray(string json)
{
try
{
return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? [];
}
catch
{
return [];
}
}
private static string SerializeGuidArray(List<Guid> guids) =>
System.Text.Json.JsonSerializer.Serialize(guids);
private static string SerializeStringArray(List<string> strings) =>
System.Text.Json.JsonSerializer.Serialize(strings);
}
/// <summary>
/// Interface for gate decision history repository.
/// </summary>
public interface IGateDecisionHistoryRepository
{
/// <summary>
/// Queries historical gate decisions.
/// </summary>
Task<GateDecisionHistoryResult> GetDecisionsAsync(GateDecisionHistoryQuery query, CancellationToken ct = default);
/// <summary>
/// Gets a specific decision by ID.
/// </summary>
Task<GateDecisionRecord?> GetDecisionByIdAsync(Guid decisionId, Guid tenantId, CancellationToken ct = default);
/// <summary>
/// Records a new gate decision.
/// </summary>
Task RecordDecisionAsync(GateDecisionRecord decision, Guid tenantId, CancellationToken ct = default);
}
/// <summary>
/// Query parameters for gate decision history.
/// </summary>
public sealed record GateDecisionHistoryQuery
{
public required Guid TenantId { get; init; }
public string? GateId { get; init; }
public string? BomRef { get; init; }
public DateTimeOffset? FromDate { get; init; }
public DateTimeOffset? ToDate { get; init; }
public string? Status { get; init; }
public string? Actor { get; init; }
public int Limit { get; init; } = 50;
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Result of gate decision history query.
/// </summary>
public sealed record GateDecisionHistoryResult
{
public List<GateDecisionRecord> Decisions { get; init; } = [];
public long Total { get; init; }
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Gate decision record.
/// </summary>
public sealed record GateDecisionRecord
{
public Guid DecisionId { get; init; }
public required string BomRef { get; init; }
public string? ImageDigest { get; init; }
public required string GateStatus { get; init; }
public string? VerdictHash { get; init; }
public string? PolicyBundleId { get; init; }
public string? PolicyBundleHash { get; init; }
public DateTime EvaluatedAt { get; init; }
public string? CiContext { get; init; }
public string? Actor { get; init; }
public List<Guid> BlockingUnknownIds { get; init; } = [];
public List<string> Warnings { get; init; } = [];
}

View File

@@ -0,0 +1,97 @@
// -----------------------------------------------------------------------------
// IGateBypassAuditRepository.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-006 - Gate Bypass Audit Persistence
// Description: Repository interface for gate bypass audit persistence
// -----------------------------------------------------------------------------
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository interface for gate bypass audit persistence.
/// Records are immutable (append-only) for compliance requirements.
/// </summary>
public interface IGateBypassAuditPersistence
{
/// <summary>
/// Records a gate bypass audit entry (immutable - no updates allowed).
/// </summary>
Task<Guid> CreateAsync(
GateBypassAuditEntity entry,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a bypass audit entry by ID.
/// </summary>
Task<GateBypassAuditEntity?> GetByIdAsync(
string tenantId,
Guid id,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries by decision ID.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> GetByDecisionIdAsync(
string tenantId,
string decisionId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries by actor.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> GetByActorAsync(
string tenantId,
string actor,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets bypass audit entries by image digest.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> GetByImageDigestAsync(
string tenantId,
string imageDigest,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists recent bypass audit entries.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> ListRecentAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists bypass audit entries within a time range.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> ListByTimeRangeAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts bypass audit entries for an actor within a time window.
/// Used for rate limiting and abuse detection.
/// </summary>
Task<int> CountByActorSinceAsync(
string tenantId,
string actor,
DateTimeOffset since,
CancellationToken cancellationToken = default);
/// <summary>
/// Exports audit entries for compliance reporting.
/// Returns entries in chronological order.
/// </summary>
Task<IReadOnlyList<GateBypassAuditEntity>> ExportForComplianceAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,95 @@
// -----------------------------------------------------------------------------
// ITrustedKeyRepository.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: Repository interface for trusted key persistence
// -----------------------------------------------------------------------------
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository interface for trusted signing key persistence.
/// </summary>
public interface ITrustedKeyRepository
{
/// <summary>
/// Gets a trusted key by its key ID.
/// </summary>
Task<TrustedKeyEntity?> GetByKeyIdAsync(
string tenantId,
string keyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets a trusted key by its fingerprint.
/// </summary>
Task<TrustedKeyEntity?> GetByFingerprintAsync(
string tenantId,
string fingerprint,
CancellationToken cancellationToken = default);
/// <summary>
/// Finds keys matching an issuer pattern.
/// </summary>
Task<IReadOnlyList<TrustedKeyEntity>> FindByIssuerPatternAsync(
string tenantId,
string issuer,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists all active trusted keys for a tenant.
/// </summary>
Task<IReadOnlyList<TrustedKeyEntity>> ListActiveAsync(
string tenantId,
int limit = 1000,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists trusted keys by purpose.
/// </summary>
Task<IReadOnlyList<TrustedKeyEntity>> ListByPurposeAsync(
string tenantId,
string purpose,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new trusted key.
/// </summary>
Task<Guid> CreateAsync(
TrustedKeyEntity key,
CancellationToken cancellationToken = default);
/// <summary>
/// Updates an existing trusted key.
/// </summary>
Task<bool> UpdateAsync(
TrustedKeyEntity key,
CancellationToken cancellationToken = default);
/// <summary>
/// Revokes a trusted key.
/// </summary>
Task<bool> RevokeAsync(
string tenantId,
string keyId,
string reason,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a trusted key (hard delete - use sparingly).
/// </summary>
Task<bool> DeleteAsync(
string tenantId,
string keyId,
CancellationToken cancellationToken = default);
/// <summary>
/// Counts active keys for a tenant.
/// </summary>
Task<int> CountActiveAsync(
string tenantId,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,417 @@
// -----------------------------------------------------------------------------
// ReplayAuditRepository.cs
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
// Task: GR-007 - Create replay audit trail
// Description: Repository for recording and querying replay audit records
// -----------------------------------------------------------------------------
using System.Text.Json;
using Npgsql;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// Repository for recording and querying replay audit records.
/// </summary>
public sealed class ReplayAuditRepository : IReplayAuditRepository
{
private readonly string _connectionString;
public ReplayAuditRepository(string connectionString)
{
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
}
/// <inheritdoc />
public async Task RecordReplayAsync(ReplayAuditRecord record, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
const string sql = """
INSERT INTO policy.replay_audit (
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
) VALUES (
@replay_id, @tenant_id, @bom_ref, @verdict_hash, @rekor_uuid, @replayed_at,
@match, @original_hash, @replayed_hash, @mismatch_reason,
@policy_bundle_id, @policy_bundle_hash, @verifier_digest,
@duration_ms, @actor, @source, @request_context::jsonb
)
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("replay_id", record.ReplayId);
cmd.Parameters.AddWithValue("tenant_id", record.TenantId);
cmd.Parameters.AddWithValue("bom_ref", record.BomRef);
cmd.Parameters.AddWithValue("verdict_hash", record.VerdictHash);
cmd.Parameters.AddWithValue("rekor_uuid", (object?)record.RekorUuid ?? DBNull.Value);
cmd.Parameters.AddWithValue("replayed_at", record.ReplayedAt);
cmd.Parameters.AddWithValue("match", record.Match);
cmd.Parameters.AddWithValue("original_hash", (object?)record.OriginalHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("replayed_hash", (object?)record.ReplayedHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("mismatch_reason", (object?)record.MismatchReason ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_id", (object?)record.PolicyBundleId ?? DBNull.Value);
cmd.Parameters.AddWithValue("policy_bundle_hash", (object?)record.PolicyBundleHash ?? DBNull.Value);
cmd.Parameters.AddWithValue("verifier_digest", (object?)record.VerifierDigest ?? DBNull.Value);
cmd.Parameters.AddWithValue("duration_ms", (object?)record.DurationMs ?? DBNull.Value);
cmd.Parameters.AddWithValue("actor", (object?)record.Actor ?? DBNull.Value);
cmd.Parameters.AddWithValue("source", (object?)record.Source ?? DBNull.Value);
cmd.Parameters.AddWithValue("request_context", (object?)record.RequestContext ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
/// <inheritdoc />
public async Task<ReplayAuditResult> QueryAsync(ReplayAuditQuery query, CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = """
SELECT
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
FROM policy.replay_audit
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", query.TenantId)
};
if (!string.IsNullOrEmpty(query.BomRef))
{
sql += " AND bom_ref = @bom_ref";
parameters.Add(new NpgsqlParameter("bom_ref", query.BomRef));
}
if (!string.IsNullOrEmpty(query.VerdictHash))
{
sql += " AND verdict_hash = @verdict_hash";
parameters.Add(new NpgsqlParameter("verdict_hash", query.VerdictHash));
}
if (!string.IsNullOrEmpty(query.RekorUuid))
{
sql += " AND rekor_uuid = @rekor_uuid";
parameters.Add(new NpgsqlParameter("rekor_uuid", query.RekorUuid));
}
if (query.FromDate.HasValue)
{
sql += " AND replayed_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", query.FromDate.Value));
}
if (query.ToDate.HasValue)
{
sql += " AND replayed_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", query.ToDate.Value));
}
if (query.MatchOnly.HasValue)
{
sql += " AND match = @match";
parameters.Add(new NpgsqlParameter("match", query.MatchOnly.Value));
}
if (!string.IsNullOrEmpty(query.Actor))
{
sql += " AND actor = @actor";
parameters.Add(new NpgsqlParameter("actor", query.Actor));
}
// Get total count
var countSql = $"SELECT COUNT(*) FROM ({sql}) AS filtered";
await using var countCmd = new NpgsqlCommand(countSql, conn);
countCmd.Parameters.AddRange(parameters.ToArray());
var totalCount = Convert.ToInt64(await countCmd.ExecuteScalarAsync(ct));
// Apply pagination
sql += " ORDER BY replayed_at DESC";
if (!string.IsNullOrEmpty(query.ContinuationToken))
{
var offset = DecodeContinuationToken(query.ContinuationToken);
sql += $" OFFSET {offset}";
}
sql += $" LIMIT {query.Limit + 1}";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.Select(p => p.Clone()).Cast<NpgsqlParameter>().ToArray());
var records = new List<ReplayAuditRecord>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
records.Add(new ReplayAuditRecord
{
ReplayId = reader.GetGuid(0),
TenantId = reader.GetGuid(1),
BomRef = reader.GetString(2),
VerdictHash = reader.GetString(3),
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
ReplayedAt = reader.GetDateTime(5),
Match = reader.GetBoolean(6),
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
});
}
var hasMore = records.Count > query.Limit;
if (hasMore)
{
records.RemoveAt(records.Count - 1);
}
string? nextToken = null;
if (hasMore)
{
var currentOffset = string.IsNullOrEmpty(query.ContinuationToken)
? 0
: DecodeContinuationToken(query.ContinuationToken);
nextToken = EncodeContinuationToken(currentOffset + query.Limit);
}
return new ReplayAuditResult
{
Records = records,
Total = totalCount,
ContinuationToken = nextToken
};
}
/// <inheritdoc />
public async Task<ReplayAuditRecord?> GetByIdAsync(
Guid replayId,
Guid tenantId,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
const string sql = """
SELECT
replay_id, tenant_id, bom_ref, verdict_hash, rekor_uuid, replayed_at,
match, original_hash, replayed_hash, mismatch_reason,
policy_bundle_id, policy_bundle_hash, verifier_digest,
duration_ms, actor, source, request_context
FROM policy.replay_audit
WHERE replay_id = @replay_id AND tenant_id = @tenant_id
""";
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddWithValue("replay_id", replayId);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct))
{
return null;
}
return new ReplayAuditRecord
{
ReplayId = reader.GetGuid(0),
TenantId = reader.GetGuid(1),
BomRef = reader.GetString(2),
VerdictHash = reader.GetString(3),
RekorUuid = reader.IsDBNull(4) ? null : reader.GetString(4),
ReplayedAt = reader.GetDateTime(5),
Match = reader.GetBoolean(6),
OriginalHash = reader.IsDBNull(7) ? null : reader.GetString(7),
ReplayedHash = reader.IsDBNull(8) ? null : reader.GetString(8),
MismatchReason = reader.IsDBNull(9) ? null : reader.GetString(9),
PolicyBundleId = reader.IsDBNull(10) ? null : reader.GetString(10),
PolicyBundleHash = reader.IsDBNull(11) ? null : reader.GetString(11),
VerifierDigest = reader.IsDBNull(12) ? null : reader.GetString(12),
DurationMs = reader.IsDBNull(13) ? null : reader.GetInt32(13),
Actor = reader.IsDBNull(14) ? null : reader.GetString(14),
Source = reader.IsDBNull(15) ? null : reader.GetString(15),
RequestContext = reader.IsDBNull(16) ? null : reader.GetString(16)
};
}
/// <inheritdoc />
public async Task<ReplayMetrics> GetMetricsAsync(
Guid tenantId,
DateTimeOffset? fromDate,
DateTimeOffset? toDate,
CancellationToken ct = default)
{
await using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync(ct);
var sql = """
SELECT
COUNT(*) AS total_attempts,
COUNT(*) FILTER (WHERE match = true) AS successful_matches,
COUNT(*) FILTER (WHERE match = false) AS mismatches,
AVG(duration_ms) AS avg_duration_ms
FROM policy.replay_audit
WHERE tenant_id = @tenant_id
""";
var parameters = new List<NpgsqlParameter>
{
new("tenant_id", tenantId)
};
if (fromDate.HasValue)
{
sql += " AND replayed_at >= @from_date";
parameters.Add(new NpgsqlParameter("from_date", fromDate.Value));
}
if (toDate.HasValue)
{
sql += " AND replayed_at <= @to_date";
parameters.Add(new NpgsqlParameter("to_date", toDate.Value));
}
await using var cmd = new NpgsqlCommand(sql, conn);
cmd.Parameters.AddRange(parameters.ToArray());
await using var reader = await cmd.ExecuteReaderAsync(ct);
await reader.ReadAsync(ct);
var totalAttempts = reader.GetInt64(0);
var successfulMatches = reader.GetInt64(1);
var mismatches = reader.GetInt64(2);
var avgDurationMs = reader.IsDBNull(3) ? null : (double?)reader.GetDouble(3);
return new ReplayMetrics
{
TotalAttempts = totalAttempts,
SuccessfulMatches = successfulMatches,
Mismatches = mismatches,
MatchRate = totalAttempts > 0 ? (double)successfulMatches / totalAttempts : 0,
AverageDurationMs = avgDurationMs
};
}
private static string EncodeContinuationToken(long offset) =>
Convert.ToBase64String(BitConverter.GetBytes(offset));
private static long DecodeContinuationToken(string token)
{
try
{
var bytes = Convert.FromBase64String(token);
return BitConverter.ToInt64(bytes);
}
catch
{
return 0;
}
}
}
/// <summary>
/// Interface for replay audit repository.
/// </summary>
public interface IReplayAuditRepository
{
/// <summary>
/// Records a replay attempt.
/// </summary>
Task RecordReplayAsync(ReplayAuditRecord record, CancellationToken ct = default);
/// <summary>
/// Queries replay audit records.
/// </summary>
Task<ReplayAuditResult> QueryAsync(ReplayAuditQuery query, CancellationToken ct = default);
/// <summary>
/// Gets a specific replay record by ID.
/// </summary>
Task<ReplayAuditRecord?> GetByIdAsync(Guid replayId, Guid tenantId, CancellationToken ct = default);
/// <summary>
/// Gets aggregated metrics for replay operations.
/// </summary>
Task<ReplayMetrics> GetMetricsAsync(
Guid tenantId,
DateTimeOffset? fromDate,
DateTimeOffset? toDate,
CancellationToken ct = default);
}
/// <summary>
/// Replay audit record.
/// </summary>
public sealed record ReplayAuditRecord
{
public Guid ReplayId { get; init; }
public Guid TenantId { get; init; }
public required string BomRef { get; init; }
public required string VerdictHash { get; init; }
public string? RekorUuid { get; init; }
public DateTime ReplayedAt { get; init; }
public bool Match { get; init; }
public string? OriginalHash { get; init; }
public string? ReplayedHash { get; init; }
public string? MismatchReason { get; init; }
public string? PolicyBundleId { get; init; }
public string? PolicyBundleHash { get; init; }
public string? VerifierDigest { get; init; }
public int? DurationMs { get; init; }
public string? Actor { get; init; }
public string? Source { get; init; }
public string? RequestContext { get; init; }
}
/// <summary>
/// Query parameters for replay audit.
/// </summary>
public sealed record ReplayAuditQuery
{
public required Guid TenantId { get; init; }
public string? BomRef { get; init; }
public string? VerdictHash { get; init; }
public string? RekorUuid { get; init; }
public DateTimeOffset? FromDate { get; init; }
public DateTimeOffset? ToDate { get; init; }
public bool? MatchOnly { get; init; }
public string? Actor { get; init; }
public int Limit { get; init; } = 50;
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Result of replay audit query.
/// </summary>
public sealed record ReplayAuditResult
{
public List<ReplayAuditRecord> Records { get; init; } = [];
public long Total { get; init; }
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Aggregated metrics for replay operations.
/// </summary>
public sealed record ReplayMetrics
{
public long TotalAttempts { get; init; }
public long SuccessfulMatches { get; init; }
public long Mismatches { get; init; }
public double MatchRate { get; init; }
public double? AverageDurationMs { get; init; }
}

View File

@@ -0,0 +1,329 @@
// -----------------------------------------------------------------------------
// TrustedKeyRepository.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: PostgreSQL implementation of trusted key repository
// -----------------------------------------------------------------------------
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Policy.Persistence.Postgres.Models;
namespace StellaOps.Policy.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for trusted signing keys.
/// </summary>
public sealed class TrustedKeyRepository : RepositoryBase<PolicyDataSource>, ITrustedKeyRepository
{
public TrustedKeyRepository(PolicyDataSource dataSource, ILogger<TrustedKeyRepository> logger)
: base(dataSource, logger) { }
/// <inheritdoc />
public async Task<TrustedKeyEntity?> GetByKeyIdAsync(
string tenantId,
string keyId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND key_id = @key_id
""";
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "key_id", keyId);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
}
/// <inheritdoc />
public async Task<TrustedKeyEntity?> GetByFingerprintAsync(
string tenantId,
string fingerprint,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND fingerprint = @fingerprint
""";
var results = await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "fingerprint", fingerprint);
}, MapEntity, cancellationToken).ConfigureAwait(false);
return results.Count > 0 ? results[0] : null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<TrustedKeyEntity>> FindByIssuerPatternAsync(
string tenantId,
string issuer,
CancellationToken cancellationToken = default)
{
// Find keys where the issuer matches the pattern using LIKE
// Pattern stored as "*@example.com" is translated to SQL LIKE pattern
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id
AND is_active = true
AND revoked_at IS NULL
AND (valid_until IS NULL OR valid_until > NOW())
AND issuer_pattern IS NOT NULL
AND @issuer LIKE REPLACE(REPLACE(issuer_pattern, '*', '%'), '?', '_')
ORDER BY valid_from DESC
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "issuer", issuer);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TrustedKeyEntity>> ListActiveAsync(
string tenantId,
int limit = 1000,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id
AND is_active = true
AND revoked_at IS NULL
AND (valid_until IS NULL OR valid_until > NOW())
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TrustedKeyEntity>> ListByPurposeAsync(
string tenantId,
string purpose,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id
AND is_active = true
AND revoked_at IS NULL
AND (valid_until IS NULL OR valid_until > NOW())
AND purposes @> @purpose::jsonb
ORDER BY created_at DESC
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddJsonbParameter(cmd, "purpose", JsonSerializer.Serialize(new[] { purpose }));
}, MapEntity, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<Guid> CreateAsync(
TrustedKeyEntity key,
CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO policy.trusted_keys (
id, tenant_id, key_id, fingerprint, algorithm, public_key_pem, owner,
issuer_pattern, purposes, valid_from, valid_until, is_active,
revoked_at, revoked_reason, metadata, created_at, updated_at, created_by
) VALUES (
@id, @tenant_id, @key_id, @fingerprint, @algorithm, @public_key_pem, @owner,
@issuer_pattern, @purposes::jsonb, @valid_from, @valid_until, @is_active,
@revoked_at, @revoked_reason, @metadata::jsonb, @created_at, @updated_at, @created_by
)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", key.Id);
AddParameter(command, "tenant_id", key.TenantId);
AddParameter(command, "key_id", key.KeyId);
AddParameter(command, "fingerprint", key.Fingerprint);
AddParameter(command, "algorithm", key.Algorithm);
AddParameter(command, "public_key_pem", key.PublicKeyPem);
AddParameter(command, "owner", key.Owner);
AddParameter(command, "issuer_pattern", key.IssuerPattern);
AddJsonbParameter(command, "purposes", key.Purposes);
AddParameter(command, "valid_from", key.ValidFrom);
AddParameter(command, "valid_until", key.ValidUntil);
AddParameter(command, "is_active", key.IsActive);
AddParameter(command, "revoked_at", key.RevokedAt);
AddParameter(command, "revoked_reason", key.RevokedReason);
AddJsonbParameter(command, "metadata", key.Metadata);
AddParameter(command, "created_at", key.CreatedAt);
AddParameter(command, "updated_at", key.UpdatedAt);
AddParameter(command, "created_by", key.CreatedBy);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (Guid)result!;
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(
TrustedKeyEntity key,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.trusted_keys
SET public_key_pem = @public_key_pem,
owner = @owner,
issuer_pattern = @issuer_pattern,
purposes = @purposes::jsonb,
valid_until = @valid_until,
is_active = @is_active,
metadata = @metadata::jsonb,
updated_at = NOW()
WHERE tenant_id = @tenant_id AND key_id = @key_id
""";
await using var connection = await DataSource.OpenConnectionAsync(key.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", key.TenantId);
AddParameter(command, "key_id", key.KeyId);
AddParameter(command, "public_key_pem", key.PublicKeyPem);
AddParameter(command, "owner", key.Owner);
AddParameter(command, "issuer_pattern", key.IssuerPattern);
AddJsonbParameter(command, "purposes", key.Purposes);
AddParameter(command, "valid_until", key.ValidUntil);
AddParameter(command, "is_active", key.IsActive);
AddJsonbParameter(command, "metadata", key.Metadata);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(
string tenantId,
string keyId,
string reason,
CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE policy.trusted_keys
SET is_active = false,
revoked_at = NOW(),
revoked_reason = @reason,
updated_at = NOW()
WHERE tenant_id = @tenant_id AND key_id = @key_id AND revoked_at IS NULL
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "key_id", keyId);
AddParameter(command, "reason", reason);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(
string tenantId,
string keyId,
CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM policy.trusted_keys
WHERE tenant_id = @tenant_id AND key_id = @key_id
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "key_id", keyId);
var rowsAffected = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rowsAffected > 0;
}
/// <inheritdoc />
public async Task<int> CountActiveAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM policy.trusted_keys
WHERE tenant_id = @tenant_id
AND is_active = true
AND revoked_at IS NULL
AND (valid_until IS NULL OR valid_until > NOW())
""";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
private static TrustedKeyEntity MapEntity(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
KeyId = reader.GetString(2),
Fingerprint = reader.GetString(3),
Algorithm = reader.GetString(4),
PublicKeyPem = GetNullableString(reader, 5),
Owner = GetNullableString(reader, 6),
IssuerPattern = GetNullableString(reader, 7),
Purposes = GetNullableString(reader, 8),
ValidFrom = reader.GetFieldValue<DateTimeOffset>(9),
ValidUntil = GetNullableDateTimeOffset(reader, 10),
IsActive = reader.GetBoolean(11),
RevokedAt = GetNullableDateTimeOffset(reader, 12),
RevokedReason = GetNullableString(reader, 13),
Metadata = GetNullableString(reader, 14),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(15),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(16),
CreatedBy = GetNullableString(reader, 17)
};
}

View File

@@ -2,6 +2,8 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Infrastructure.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Policy.Audit;
using StellaOps.Policy.Gates.Attestation;
using StellaOps.Policy.Scoring.Receipts;
using StellaOps.Policy.Persistence.Postgres.Repositories;
using IAuditableExceptionRepository = StellaOps.Policy.Exceptions.Repositories.IExceptionRepository;
@@ -81,6 +83,65 @@ public static class ServiceCollectionExtensions
services.AddScoped<ILedgerExportRepository, LedgerExportRepository>();
services.AddScoped<IWorkerResultRepository, WorkerResultRepository>();
// Sprint 017: Trusted key registry and gate bypass audit
services.AddScoped<ITrustedKeyRepository, TrustedKeyRepository>();
services.AddScoped<IGateBypassAuditPersistence, GateBypassAuditRepository>();
return services;
}
/// <summary>
/// Adds trusted key registry services with PostgreSQL backend and caching.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddPostgresTrustedKeyRegistry(
this IServiceCollection services,
Action<PostgresTrustedKeyRegistryOptions>? configureOptions = null)
{
var options = new PostgresTrustedKeyRegistryOptions();
configureOptions?.Invoke(options);
services.AddSingleton(options);
services.AddMemoryCache();
// Register factory for tenant-scoped registry
services.AddScoped<ITrustedKeyRegistry>(sp =>
{
var repository = sp.GetRequiredService<ITrustedKeyRepository>();
var cache = sp.GetRequiredService<Microsoft.Extensions.Caching.Memory.IMemoryCache>();
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresTrustedKeyRegistry>>();
var registryOptions = sp.GetRequiredService<PostgresTrustedKeyRegistryOptions>();
// TODO: Get tenant ID from current context
var tenantId = "default";
return new PostgresTrustedKeyRegistry(repository, cache, logger, registryOptions, tenantId);
});
return services;
}
/// <summary>
/// Adds gate bypass audit repository with PostgreSQL backend.
/// </summary>
/// <param name="services">Service collection.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddPostgresGateBypassAudit(
this IServiceCollection services)
{
services.AddScoped<IGateBypassAuditRepository>(sp =>
{
var persistence = sp.GetRequiredService<IGateBypassAuditPersistence>();
var logger = sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<PostgresGateBypassAuditRepository>>();
// TODO: Get tenant ID from current context
var tenantId = "default";
return new PostgresGateBypassAuditRepository(persistence, logger, tenantId);
});
return services;
}
}

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// AttestationVerificationGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-001 - Attestation Verification Gate
// Description: Policy gate for DSSE attestation verification
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that validates DSSE attestation envelopes.
/// Checks payload type, signature validity, and key trust.
/// </summary>
public sealed class AttestationVerificationGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "attestation-verification";
private readonly ITrustedKeyRegistry _keyRegistry;
private readonly AttestationVerificationGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Attestation Verification";
/// <inheritdoc />
public string Description => "Validates DSSE attestation payloadType, signatures, and key trust";
/// <summary>
/// Creates a new attestation verification gate.
/// </summary>
public AttestationVerificationGate(
ITrustedKeyRegistry keyRegistry,
AttestationVerificationGateOptions? options = null)
{
_keyRegistry = keyRegistry ?? throw new ArgumentNullException(nameof(keyRegistry));
_options = options ?? new AttestationVerificationGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var attestation = context.GetAttestation();
if (attestation == null)
{
return GateResult.Fail(Id, "No attestation found in context");
}
// 1. Validate payload type
var payloadTypeResult = ValidatePayloadType(attestation.PayloadType);
if (!payloadTypeResult.Passed)
{
return payloadTypeResult;
}
// 2. Validate signatures
var signatureResult = ValidateSignatures(attestation.Signatures);
if (!signatureResult.Passed)
{
return signatureResult;
}
// 3. Validate trusted keys
var keyResult = await ValidateTrustedKeysAsync(attestation.Signatures, ct);
if (!keyResult.Passed)
{
return keyResult;
}
return GateResult.Pass(Id, "Attestation verification passed");
}
private GateResult ValidatePayloadType(string? payloadType)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
return GateResult.Fail(Id, "PayloadType is missing");
}
if (!_options.AllowedPayloadTypes.Contains(payloadType))
{
return GateResult.Fail(Id, $"PayloadType '{payloadType}' is not in allowed list");
}
return GateResult.Pass(Id, $"PayloadType '{payloadType}' is valid");
}
private GateResult ValidateSignatures(IReadOnlyList<AttestationSignature>? signatures)
{
if (signatures == null || signatures.Count == 0)
{
return GateResult.Fail(Id, "No signatures present in attestation");
}
if (signatures.Count < _options.MinimumSignatures)
{
return GateResult.Fail(Id,
$"Insufficient signatures: {signatures.Count} < {_options.MinimumSignatures}");
}
// Validate signature algorithms
foreach (var sig in signatures)
{
if (!_options.AllowedAlgorithms.Contains(sig.Algorithm))
{
return GateResult.Fail(Id,
$"Signature algorithm '{sig.Algorithm}' is not in allowed list");
}
}
return GateResult.Pass(Id, $"{signatures.Count} valid signatures present");
}
private async Task<GateResult> ValidateTrustedKeysAsync(
IReadOnlyList<AttestationSignature>? signatures,
CancellationToken ct)
{
if (signatures == null)
{
return GateResult.Fail(Id, "No signatures to verify keys");
}
var trustedCount = 0;
foreach (var sig in signatures)
{
if (string.IsNullOrWhiteSpace(sig.KeyId))
continue;
var isTrusted = await _keyRegistry.IsTrustedAsync(sig.KeyId, ct);
if (isTrusted)
{
trustedCount++;
}
}
if (trustedCount < _options.MinimumTrustedSignatures)
{
return GateResult.Fail(Id,
$"Insufficient trusted signatures: {trustedCount} < {_options.MinimumTrustedSignatures}");
}
return GateResult.Pass(Id, $"{trustedCount} signatures from trusted keys");
}
}
/// <summary>
/// Options for attestation verification gate.
/// </summary>
public sealed record AttestationVerificationGateOptions
{
/// <summary>
/// Allowed payload types.
/// </summary>
public IReadOnlySet<string> AllowedPayloadTypes { get; init; } = new HashSet<string>
{
"application/vnd.in-toto+json",
"application/vnd.cyclonedx+json",
"application/vnd.cyclonedx+json;version=1.6",
"application/spdx+json",
"application/vnd.openvex+json"
};
/// <summary>
/// Allowed signature algorithms.
/// </summary>
public IReadOnlySet<string> AllowedAlgorithms { get; init; } = new HashSet<string>
{
"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "EdDSA", "Ed25519"
};
/// <summary>
/// Minimum number of signatures required.
/// </summary>
public int MinimumSignatures { get; init; } = 1;
/// <summary>
/// Minimum number of signatures from trusted keys.
/// </summary>
public int MinimumTrustedSignatures { get; init; } = 1;
}
/// <summary>
/// Attestation model for gate evaluation.
/// </summary>
public sealed record AttestationEnvelope
{
/// <summary>DSSE payload type.</summary>
public required string PayloadType { get; init; }
/// <summary>Base64-encoded payload.</summary>
public required string Payload { get; init; }
/// <summary>Signatures on the envelope.</summary>
public required IReadOnlyList<AttestationSignature> Signatures { get; init; }
}
/// <summary>
/// A signature on an attestation.
/// </summary>
public sealed record AttestationSignature
{
/// <summary>Key ID.</summary>
public string? KeyId { get; init; }
/// <summary>Signature algorithm.</summary>
public required string Algorithm { get; init; }
/// <summary>Base64-encoded signature.</summary>
public required string Signature { get; init; }
}

View File

@@ -0,0 +1,234 @@
// -----------------------------------------------------------------------------
// CompositeAttestationGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-004 - Composite Attestation Gate
// Description: Orchestrates multiple attestation gates with configurable logic
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Composite gate that orchestrates multiple attestation gates.
/// Supports AND, OR, and threshold-based composition.
/// </summary>
public sealed class CompositeAttestationGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "composite-attestation";
private readonly IReadOnlyList<IPolicyGate> _gates;
private readonly CompositeAttestationGateOptions _options;
/// <inheritdoc />
public string Id => _options.CustomId ?? GateId;
/// <inheritdoc />
public string DisplayName => _options.DisplayName ?? "Composite Attestation";
/// <inheritdoc />
public string Description => _options.Description ?? "Orchestrates multiple attestation verification gates";
/// <summary>
/// Creates a new composite attestation gate.
/// </summary>
public CompositeAttestationGate(
IEnumerable<IPolicyGate> gates,
CompositeAttestationGateOptions? options = null)
{
_gates = gates?.ToList() ?? throw new ArgumentNullException(nameof(gates));
_options = options ?? new CompositeAttestationGateOptions();
if (_gates.Count == 0)
{
throw new ArgumentException("At least one gate is required.", nameof(gates));
}
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var results = new List<GateResult>();
var passed = 0;
var failed = 0;
foreach (var gate in _gates)
{
ct.ThrowIfCancellationRequested();
try
{
var result = await gate.EvaluateAsync(context, ct);
results.Add(result);
if (result.Passed)
{
passed++;
// Short-circuit on OR mode
if (_options.Mode == CompositeMode.Or)
{
return GateResult.Pass(Id,
$"Composite gate passed (OR mode): {gate.Id} passed",
childResults: results);
}
}
else
{
failed++;
// Short-circuit on AND mode
if (_options.Mode == CompositeMode.And && !_options.ContinueOnFailure)
{
return GateResult.Fail(Id,
$"Composite gate failed (AND mode): {gate.Id} failed - {result.Message}",
childResults: results);
}
}
}
catch (Exception ex) when (_options.ContinueOnError)
{
results.Add(GateResult.Fail(gate.Id, $"Gate error: {ex.Message}"));
failed++;
}
}
// Evaluate final result based on mode
return EvaluateFinalResult(passed, failed, results);
}
private GateResult EvaluateFinalResult(int passed, int failed, List<GateResult> results)
{
switch (_options.Mode)
{
case CompositeMode.And:
if (failed == 0)
{
return GateResult.Pass(Id,
$"All {passed} gates passed",
childResults: results);
}
return GateResult.Fail(Id,
$"Composite gate failed: {failed} of {_gates.Count} gates failed",
childResults: results);
case CompositeMode.Or:
if (passed > 0)
{
return GateResult.Pass(Id,
$"At least one gate passed ({passed} of {_gates.Count})",
childResults: results);
}
return GateResult.Fail(Id,
$"Composite gate failed: no gates passed",
childResults: results);
case CompositeMode.Threshold:
if (passed >= _options.PassThreshold)
{
return GateResult.Pass(Id,
$"Threshold met: {passed} >= {_options.PassThreshold} gates passed",
childResults: results);
}
return GateResult.Fail(Id,
$"Threshold not met: {passed} < {_options.PassThreshold} gates passed",
childResults: results);
default:
return GateResult.Fail(Id, $"Unknown composite mode: {_options.Mode}");
}
}
/// <summary>
/// Creates a composite gate with AND logic.
/// </summary>
public static CompositeAttestationGate And(params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.And
});
}
/// <summary>
/// Creates a composite gate with OR logic.
/// </summary>
public static CompositeAttestationGate Or(params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.Or
});
}
/// <summary>
/// Creates a composite gate with threshold logic.
/// </summary>
public static CompositeAttestationGate Threshold(int threshold, params IPolicyGate[] gates)
{
return new CompositeAttestationGate(gates, new CompositeAttestationGateOptions
{
Mode = CompositeMode.Threshold,
PassThreshold = threshold
});
}
}
/// <summary>
/// Options for composite attestation gate.
/// </summary>
public sealed record CompositeAttestationGateOptions
{
/// <summary>
/// Composition mode.
/// </summary>
public CompositeMode Mode { get; init; } = CompositeMode.And;
/// <summary>
/// Minimum number of gates that must pass (for Threshold mode).
/// </summary>
public int PassThreshold { get; init; } = 1;
/// <summary>
/// Whether to continue evaluating after a failure (for AND mode).
/// </summary>
public bool ContinueOnFailure { get; init; } = false;
/// <summary>
/// Whether to continue evaluating after an error.
/// </summary>
public bool ContinueOnError { get; init; } = true;
/// <summary>
/// Custom gate ID.
/// </summary>
public string? CustomId { get; init; }
/// <summary>
/// Custom display name.
/// </summary>
public string? DisplayName { get; init; }
/// <summary>
/// Custom description.
/// </summary>
public string? Description { get; init; }
}
/// <summary>
/// Composite gate evaluation mode.
/// </summary>
public enum CompositeMode
{
/// <summary>All gates must pass.</summary>
And,
/// <summary>At least one gate must pass.</summary>
Or,
/// <summary>A threshold number of gates must pass.</summary>
Threshold
}

View File

@@ -0,0 +1,225 @@
// -----------------------------------------------------------------------------
// ITrustedKeyRegistry.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: Interface and implementation for trusted key management
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Registry of trusted signing keys for attestation verification.
/// </summary>
public interface ITrustedKeyRegistry
{
/// <summary>
/// Checks if a key ID is trusted.
/// </summary>
/// <param name="keyId">The key ID to check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if the key is trusted.</returns>
Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default);
/// <summary>
/// Gets a trusted key by ID.
/// </summary>
/// <param name="keyId">The key ID.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trusted key or null.</returns>
Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default);
/// <summary>
/// Gets a trusted key by fingerprint.
/// </summary>
/// <param name="fingerprint">SHA-256 fingerprint of the public key.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The trusted key or null.</returns>
Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default);
/// <summary>
/// Lists all trusted keys.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>All trusted keys.</returns>
IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default);
/// <summary>
/// Adds a trusted key.
/// </summary>
/// <param name="key">The key to add.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The added key.</returns>
Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default);
/// <summary>
/// Revokes a trusted key.
/// </summary>
/// <param name="keyId">The key ID to revoke.</param>
/// <param name="reason">Revocation reason.</param>
/// <param name="ct">Cancellation token.</param>
Task RevokeAsync(string keyId, string reason, CancellationToken ct = default);
}
/// <summary>
/// A trusted signing key.
/// </summary>
public sealed record TrustedKey
{
/// <summary>
/// Unique key identifier.
/// </summary>
public required string KeyId { get; init; }
/// <summary>
/// SHA-256 fingerprint of the public key.
/// </summary>
public required string Fingerprint { get; init; }
/// <summary>
/// Key algorithm (e.g., "ECDSA_P256", "Ed25519", "RSA_2048").
/// </summary>
public required string Algorithm { get; init; }
/// <summary>
/// PEM-encoded public key.
/// </summary>
public string? PublicKeyPem { get; init; }
/// <summary>
/// Key owner/issuer identity.
/// </summary>
public string? Owner { get; init; }
/// <summary>
/// Key purpose (e.g., "sbom-signing", "vex-signing", "release-signing").
/// </summary>
public IReadOnlyList<string> Purposes { get; init; } = [];
/// <summary>
/// When the key was trusted.
/// </summary>
public DateTimeOffset TrustedAt { get; init; }
/// <summary>
/// When the key expires.
/// </summary>
public DateTimeOffset? ExpiresAt { get; init; }
/// <summary>
/// Whether the key is currently active.
/// </summary>
public bool IsActive { get; init; } = true;
/// <summary>
/// Revocation reason (if revoked).
/// </summary>
public string? RevokedReason { get; init; }
/// <summary>
/// When the key was revoked.
/// </summary>
public DateTimeOffset? RevokedAt { get; init; }
/// <summary>
/// Tenant ID for multi-tenancy.
/// </summary>
public Guid TenantId { get; init; }
}
/// <summary>
/// In-memory implementation of trusted key registry.
/// </summary>
public sealed class InMemoryTrustedKeyRegistry : ITrustedKeyRegistry
{
private readonly Dictionary<string, TrustedKey> _keysByKeyId = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TrustedKey> _keysByFingerprint = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <inheritdoc />
public Task<bool> IsTrustedAsync(string keyId, CancellationToken ct = default)
{
lock (_lock)
{
if (_keysByKeyId.TryGetValue(keyId, out var key))
{
var isTrusted = key.IsActive &&
key.RevokedAt == null &&
(key.ExpiresAt == null || key.ExpiresAt > DateTimeOffset.UtcNow);
return Task.FromResult(isTrusted);
}
}
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<TrustedKey?> GetKeyAsync(string keyId, CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult(_keysByKeyId.GetValueOrDefault(keyId));
}
}
/// <inheritdoc />
public Task<TrustedKey?> GetByFingerprintAsync(string fingerprint, CancellationToken ct = default)
{
lock (_lock)
{
return Task.FromResult(_keysByFingerprint.GetValueOrDefault(fingerprint));
}
}
/// <inheritdoc />
public async IAsyncEnumerable<TrustedKey> ListAsync(CancellationToken ct = default)
{
List<TrustedKey> keys;
lock (_lock)
{
keys = _keysByKeyId.Values.ToList();
}
foreach (var key in keys)
{
ct.ThrowIfCancellationRequested();
yield return key;
}
await Task.CompletedTask;
}
/// <inheritdoc />
public Task<TrustedKey> AddAsync(TrustedKey key, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(key);
lock (_lock)
{
_keysByKeyId[key.KeyId] = key;
_keysByFingerprint[key.Fingerprint] = key;
}
return Task.FromResult(key);
}
/// <inheritdoc />
public Task RevokeAsync(string keyId, string reason, CancellationToken ct = default)
{
lock (_lock)
{
if (_keysByKeyId.TryGetValue(keyId, out var key))
{
var revokedKey = key with
{
IsActive = false,
RevokedAt = DateTimeOffset.UtcNow,
RevokedReason = reason
};
_keysByKeyId[keyId] = revokedKey;
_keysByFingerprint[key.Fingerprint] = revokedKey;
}
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,142 @@
// -----------------------------------------------------------------------------
// RekorFreshnessGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-002 - Rekor Freshness Gate
// Description: Policy gate for Rekor integratedTime freshness enforcement
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that enforces Rekor entry freshness based on integratedTime.
/// Rejects attestations older than the configured cutoff.
/// </summary>
public sealed class RekorFreshnessGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "rekor-freshness";
private readonly RekorFreshnessGateOptions _options;
private readonly TimeProvider _timeProvider;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Rekor Freshness";
/// <inheritdoc />
public string Description => "Enforces Rekor integratedTime freshness cutoffs";
/// <summary>
/// Creates a new Rekor freshness gate.
/// </summary>
public RekorFreshnessGate(
RekorFreshnessGateOptions? options = null,
TimeProvider? timeProvider = null)
{
_options = options ?? new RekorFreshnessGateOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var rekorProof = context.GetRekorProof();
if (rekorProof == null)
{
if (_options.RequireRekorProof)
{
return Task.FromResult(GateResult.Fail(Id, "No Rekor proof found in context"));
}
return Task.FromResult(GateResult.Pass(Id, "Rekor proof not required, skipping freshness check"));
}
// Get integrated time from proof
var integratedTime = rekorProof.IntegratedTime;
if (integratedTime == null)
{
return Task.FromResult(GateResult.Fail(Id, "Rekor proof missing integratedTime"));
}
var now = _timeProvider.GetUtcNow();
var age = now - integratedTime.Value;
// Check maximum age
if (age > _options.MaxAge)
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry too old: {age.TotalHours:F1}h > {_options.MaxAge.TotalHours:F1}h max"));
}
// Check minimum age (to prevent clock skew issues)
if (age < _options.MinAge)
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry too new (possible clock skew): {age.TotalSeconds:F1}s < {_options.MinAge.TotalSeconds:F1}s min"));
}
// Check for future timestamp
if (integratedTime.Value > now.Add(_options.FutureTimeTolerance))
{
return Task.FromResult(GateResult.Fail(Id,
$"Rekor entry has future timestamp: {integratedTime.Value:O} > {now:O}"));
}
return Task.FromResult(GateResult.Pass(Id,
$"Rekor entry age {age.TotalMinutes:F1}m is within acceptable range"));
}
}
/// <summary>
/// Options for Rekor freshness gate.
/// </summary>
public sealed record RekorFreshnessGateOptions
{
/// <summary>
/// Maximum age for Rekor entries.
/// Default: 24 hours.
/// </summary>
public TimeSpan MaxAge { get; init; } = TimeSpan.FromHours(24);
/// <summary>
/// Minimum age for Rekor entries (to account for clock skew).
/// Default: 0 (no minimum).
/// </summary>
public TimeSpan MinAge { get; init; } = TimeSpan.Zero;
/// <summary>
/// Tolerance for future timestamps.
/// Default: 5 minutes.
/// </summary>
public TimeSpan FutureTimeTolerance { get; init; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Whether to require a Rekor proof.
/// If false, missing proofs are allowed (gate passes).
/// </summary>
public bool RequireRekorProof { get; init; } = true;
}
/// <summary>
/// Rekor proof context for gate evaluation.
/// </summary>
public sealed record RekorProofContext
{
/// <summary>Log index.</summary>
public long LogIndex { get; init; }
/// <summary>Integrated timestamp.</summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>UUID.</summary>
public string? Uuid { get; init; }
/// <summary>Whether the proof has been verified.</summary>
public bool IsVerified { get; init; }
}

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// VexStatusPromotionGate.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-003 - VEX Status Promotion Gate
// Description: Reachability-aware VEX status gate for release blocking
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Attestation;
/// <summary>
/// Policy gate that enforces VEX status requirements with reachability awareness.
/// Blocks promotion based on affected + reachable combinations.
/// </summary>
public sealed class VexStatusPromotionGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "vex-status-promotion";
private readonly VexStatusPromotionGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "VEX Status Promotion";
/// <inheritdoc />
public string Description => "Enforces VEX status requirements with reachability awareness";
/// <summary>
/// Creates a new VEX status promotion gate.
/// </summary>
public VexStatusPromotionGate(VexStatusPromotionGateOptions? options = null)
{
_options = options ?? new VexStatusPromotionGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
var vexSummary = context.GetVexSummary();
if (vexSummary == null)
{
if (_options.RequireVexSummary)
{
return Task.FromResult(GateResult.Fail(Id, "No VEX summary found in context"));
}
return Task.FromResult(GateResult.Pass(Id, "VEX summary not required, skipping"));
}
var findings = new List<string>();
// Check for blocking combinations
foreach (var statement in vexSummary.Statements)
{
var isBlocking = EvaluateStatement(statement, findings);
if (isBlocking && !_options.AllowBlockingVulnerabilities)
{
return Task.FromResult(GateResult.Fail(Id,
$"Blocking vulnerability found: {statement.VulnerabilityId} - {string.Join(", ", findings)}"));
}
}
// Check aggregate thresholds
if (vexSummary.AffectedReachableCount > _options.MaxAffectedReachable)
{
return Task.FromResult(GateResult.Fail(Id,
$"Too many affected+reachable vulnerabilities: {vexSummary.AffectedReachableCount} > {_options.MaxAffectedReachable}"));
}
if (vexSummary.UnderInvestigationCount > _options.MaxUnderInvestigation)
{
return Task.FromResult(GateResult.Fail(Id,
$"Too many under-investigation vulnerabilities: {vexSummary.UnderInvestigationCount} > {_options.MaxUnderInvestigation}"));
}
return Task.FromResult(GateResult.Pass(Id,
$"VEX status check passed: {vexSummary.NotAffectedCount} not_affected, {vexSummary.FixedCount} fixed"));
}
private bool EvaluateStatement(VexStatementSummary statement, List<string> findings)
{
// Affected + Reachable = Blocking
if (statement.Status == VexStatus.Affected && statement.IsReachable)
{
findings.Add($"{statement.VulnerabilityId}: affected and reachable");
return true;
}
// Affected + Unknown reachability with high severity = Blocking
if (statement.Status == VexStatus.Affected &&
statement.ReachabilityStatus == ReachabilityStatus.Unknown &&
statement.Severity >= _options.BlockingSeverityThreshold)
{
findings.Add($"{statement.VulnerabilityId}: affected with unknown reachability and severity {statement.Severity}");
return true;
}
// Under investigation with high severity = Warning (not blocking by default)
if (statement.Status == VexStatus.UnderInvestigation &&
statement.Severity >= _options.WarningSeverityThreshold)
{
findings.Add($"{statement.VulnerabilityId}: under investigation with severity {statement.Severity}");
// Not blocking by default, but tracked
}
return false;
}
}
/// <summary>
/// Options for VEX status promotion gate.
/// </summary>
public sealed record VexStatusPromotionGateOptions
{
/// <summary>
/// Whether to require a VEX summary.
/// </summary>
public bool RequireVexSummary { get; init; } = true;
/// <summary>
/// Whether to allow blocking vulnerabilities (gate passes with warning).
/// </summary>
public bool AllowBlockingVulnerabilities { get; init; } = false;
/// <summary>
/// Maximum number of affected+reachable vulnerabilities allowed.
/// </summary>
public int MaxAffectedReachable { get; init; } = 0;
/// <summary>
/// Maximum number of under-investigation vulnerabilities allowed.
/// </summary>
public int MaxUnderInvestigation { get; init; } = 5;
/// <summary>
/// Severity threshold for blocking (affected + unknown reachability).
/// </summary>
public double BlockingSeverityThreshold { get; init; } = 9.0; // Critical
/// <summary>
/// Severity threshold for warnings.
/// </summary>
public double WarningSeverityThreshold { get; init; } = 7.0; // High
}
/// <summary>
/// VEX summary for gate evaluation.
/// </summary>
public sealed record VexSummary
{
/// <summary>Individual VEX statements.</summary>
public IReadOnlyList<VexStatementSummary> Statements { get; init; } = [];
/// <summary>Count of not_affected vulnerabilities.</summary>
public int NotAffectedCount { get; init; }
/// <summary>Count of affected vulnerabilities.</summary>
public int AffectedCount { get; init; }
/// <summary>Count of fixed vulnerabilities.</summary>
public int FixedCount { get; init; }
/// <summary>Count of under_investigation vulnerabilities.</summary>
public int UnderInvestigationCount { get; init; }
/// <summary>Count of affected + reachable vulnerabilities.</summary>
public int AffectedReachableCount { get; init; }
}
/// <summary>
/// Summary of a single VEX statement.
/// </summary>
public sealed record VexStatementSummary
{
/// <summary>Vulnerability ID (e.g., CVE-2024-12345).</summary>
public required string VulnerabilityId { get; init; }
/// <summary>VEX status.</summary>
public required VexStatus Status { get; init; }
/// <summary>Whether the vulnerability is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>Reachability determination status.</summary>
public ReachabilityStatus ReachabilityStatus { get; init; }
/// <summary>CVSS severity score.</summary>
public double Severity { get; init; }
/// <summary>Justification for the status.</summary>
public string? Justification { get; init; }
}
/// <summary>
/// VEX status values.
/// </summary>
public enum VexStatus
{
/// <summary>Not affected by the vulnerability.</summary>
NotAffected,
/// <summary>Affected by the vulnerability.</summary>
Affected,
/// <summary>Fixed in this version.</summary>
Fixed,
/// <summary>Under investigation.</summary>
UnderInvestigation
}
/// <summary>
/// Reachability determination status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Reachability not determined.</summary>
Unknown,
/// <summary>Confirmed reachable.</summary>
Reachable,
/// <summary>Confirmed not reachable.</summary>
NotReachable,
/// <summary>Partially reachable (some paths blocked).</summary>
PartiallyReachable
}

View File

@@ -0,0 +1,311 @@
// -----------------------------------------------------------------------------
// CveDeltaGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-05 - CVE Delta Gate
// Description: Policy gate that blocks releases introducing new high-severity CVEs
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases introducing new high-severity CVEs compared to baseline.
/// Prevents security regressions by tracking CVE delta between releases.
/// </summary>
public sealed class CveDeltaGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "cve-delta";
private readonly ICveDeltaProvider? _deltaProvider;
private readonly CveDeltaGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "CVE Delta";
/// <inheritdoc />
public string Description => "Blocks releases that introduce new CVEs above severity threshold";
/// <summary>
/// Creates a new CVE delta gate.
/// </summary>
public CveDeltaGate(
CveDeltaGateOptions? options = null,
ICveDeltaProvider? deltaProvider = null)
{
_options = options ?? new CveDeltaGateOptions();
_deltaProvider = deltaProvider;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "CVE delta gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
// Get current and baseline CVEs
var currentCves = context.GetCveFindings();
if (currentCves == null || currentCves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings in current release");
}
// Get baseline CVEs
IReadOnlyList<CveFinding> baselineCves;
if (_deltaProvider != null && !string.IsNullOrWhiteSpace(context.BaselineReference))
{
baselineCves = await _deltaProvider.GetBaselineCvesAsync(
context.BaselineReference,
ct).ConfigureAwait(false);
}
else if (context.BaselineCves != null)
{
baselineCves = context.BaselineCves;
}
else
{
// No baseline available - treat as first release
return EvaluateWithoutBaseline(currentCves, envOptions);
}
// Compute delta
var baselineCveIds = baselineCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var currentCveIds = currentCves.Select(c => c.CveId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var newCves = currentCves
.Where(c => !baselineCveIds.Contains(c.CveId))
.ToList();
var fixedCves = baselineCves
.Where(c => !currentCveIds.Contains(c.CveId))
.ToList();
var unchangedCves = currentCves
.Where(c => baselineCveIds.Contains(c.CveId))
.ToList();
// Check for blocking new CVEs
var blockingNewCves = newCves
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.NewCveSeverityThreshold)
.ToList();
// Apply reachability filter if enabled
if (envOptions.OnlyBlockReachable)
{
blockingNewCves = blockingNewCves.Where(c => c.IsReachable).ToList();
}
var warnings = new List<string>();
// Check remediation SLA for existing CVEs
if (envOptions.RemediationSlaDays.HasValue)
{
var overdueRemediations = CheckRemediationSla(unchangedCves, envOptions.RemediationSlaDays.Value, context);
if (overdueRemediations.Count > 0)
{
warnings.Add($"{overdueRemediations.Count} CVE(s) past remediation SLA: " +
string.Join(", ", overdueRemediations.Take(3).Select(c => c.CveId)));
}
}
// Report improvements
if (fixedCves.Count > 0)
{
var highFixed = fixedCves.Count(c => c.CvssScore >= 7.0);
if (highFixed > 0)
{
warnings.Add($"Improvement: {highFixed} high+ severity CVE(s) fixed");
}
}
if (blockingNewCves.Count > 0)
{
var message = $"Release introduces {blockingNewCves.Count} new CVE(s) at or above severity {envOptions.NewCveSeverityThreshold:F1}: " +
string.Join(", ", blockingNewCves.Take(5).Select(c =>
$"{c.CveId} (CVSS: {c.CvssScore:F1}{(c.IsReachable ? ", reachable" : "")})"));
if (blockingNewCves.Count > 5)
{
message += $" and {blockingNewCves.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
var passMessage = $"CVE delta check passed. " +
$"New: {newCves.Count}, Fixed: {fixedCves.Count}, Unchanged: {unchangedCves.Count}";
if (newCves.Count > 0)
{
var lowSeverityNew = newCves.Count(c => !c.CvssScore.HasValue || c.CvssScore.Value < envOptions.NewCveSeverityThreshold);
if (lowSeverityNew > 0)
{
passMessage += $" ({lowSeverityNew} new low-severity allowed)";
}
}
return GateResult.Pass(Id, passMessage, warnings: warnings);
}
private GateResult EvaluateWithoutBaseline(
IReadOnlyList<CveFinding> currentCves,
CveDeltaGateOptions options)
{
if (options.AllowFirstRelease)
{
var highSeverity = currentCves.Count(c => c.CvssScore >= options.NewCveSeverityThreshold);
var message = $"First release (no baseline). {currentCves.Count} CVE(s) found, {highSeverity} high+ severity.";
if (highSeverity > 0)
{
return GateResult.Pass(Id, message,
warnings: new[] { $"First release contains {highSeverity} high+ severity CVE(s)" });
}
return GateResult.Pass(Id, message);
}
// Require baseline
return GateResult.Fail(Id, "CVE delta gate requires baseline reference but none provided");
}
private static List<CveFinding> CheckRemediationSla(
IReadOnlyList<CveFinding> cves,
int slaDays,
PolicyGateContext context)
{
var overdue = new List<CveFinding>();
foreach (var cve in cves)
{
// Only check high+ severity CVEs for SLA
if (!cve.CvssScore.HasValue || cve.CvssScore.Value < 7.0)
continue;
// Get first seen date from context metadata
if (context.CveFirstSeenDates?.TryGetValue(cve.CveId, out var firstSeen) == true)
{
var daysSinceFirstSeen = (DateTimeOffset.UtcNow - firstSeen).TotalDays;
if (daysSinceFirstSeen > slaDays)
{
overdue.Add(cve);
}
}
}
return overdue;
}
private CveDeltaGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
NewCveSeverityThreshold = envOverride.NewCveSeverityThreshold ?? _options.NewCveSeverityThreshold,
OnlyBlockReachable = envOverride.OnlyBlockReachable ?? _options.OnlyBlockReachable,
RemediationSlaDays = envOverride.RemediationSlaDays ?? _options.RemediationSlaDays,
AllowFirstRelease = envOverride.AllowFirstRelease ?? _options.AllowFirstRelease
};
}
return _options;
}
}
/// <summary>
/// Options for CVE delta gate.
/// </summary>
public sealed record CveDeltaGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:CveDelta";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Minimum CVSS severity for new CVEs to trigger a block.
/// Only new CVEs at or above this severity are blocked.
/// Default: 7.0 (High).
/// </summary>
public double NewCveSeverityThreshold { get; init; } = 7.0;
/// <summary>
/// Whether to only block reachable new CVEs.
/// If true, unreachable new CVEs are allowed regardless of severity.
/// </summary>
public bool OnlyBlockReachable { get; init; } = false;
/// <summary>
/// Remediation SLA in days for existing CVEs.
/// CVEs present longer than this SLA generate warnings.
/// Null to disable SLA checking.
/// </summary>
public int? RemediationSlaDays { get; init; }
/// <summary>
/// Whether to allow first release without baseline.
/// If false, gate fails when no baseline is available.
/// </summary>
public bool AllowFirstRelease { get; init; } = true;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, CveDeltaGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, CveDeltaGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record CveDeltaGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for NewCveSeverityThreshold.</summary>
public double? NewCveSeverityThreshold { get; init; }
/// <summary>Override for OnlyBlockReachable.</summary>
public bool? OnlyBlockReachable { get; init; }
/// <summary>Override for RemediationSlaDays.</summary>
public int? RemediationSlaDays { get; init; }
/// <summary>Override for AllowFirstRelease.</summary>
public bool? AllowFirstRelease { get; init; }
}
/// <summary>
/// Provider for CVE delta data.
/// </summary>
public interface ICveDeltaProvider
{
/// <summary>
/// Gets CVEs from a baseline reference.
/// </summary>
/// <param name="baselineReference">Baseline reference (image digest, release ID, etc.).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>CVE findings from the baseline.</returns>
Task<IReadOnlyList<CveFinding>> GetBaselineCvesAsync(
string baselineReference,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,197 @@
// -----------------------------------------------------------------------------
// CveGateHelpers.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-01 - Gate Infrastructure Extensions
// Description: Helper classes and extension methods for CVE gates
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Static helper methods for creating GateResult instances.
/// Simplifies gate implementation with consistent result creation.
/// </summary>
public static class GateResult
{
/// <summary>
/// Creates a passing gate result.
/// </summary>
public static Gates.GateResult Pass(string gateName, string reason, IEnumerable<string>? warnings = null)
{
var details = ImmutableDictionary<string, object>.Empty;
if (warnings != null)
{
var warningList = warnings.ToList();
if (warningList.Count > 0)
{
details = details.Add("warnings", warningList);
}
}
return new Gates.GateResult
{
GateName = gateName,
Passed = true,
Reason = reason,
Details = details
};
}
/// <summary>
/// Creates a failing gate result.
/// </summary>
public static Gates.GateResult Fail(string gateName, string reason, ImmutableDictionary<string, object>? details = null)
{
return new Gates.GateResult
{
GateName = gateName,
Passed = false,
Reason = reason,
Details = details ?? ImmutableDictionary<string, object>.Empty
};
}
/// <summary>
/// Creates a failing gate result with simple details.
/// </summary>
public static Gates.GateResult Fail(string gateName, string reason, IDictionary<string, object>? details)
{
return new Gates.GateResult
{
GateName = gateName,
Passed = false,
Reason = reason,
Details = details?.ToImmutableDictionary() ?? ImmutableDictionary<string, object>.Empty
};
}
}
/// <summary>
/// Extension methods for PolicyGateContext to support CVE gates.
/// </summary>
public static class PolicyGateContextExtensions
{
private const string CveFindingsKey = "CveFindings";
private const string BaselineCvesKey = "BaselineCves";
private const string BaselineReferenceKey = "BaselineReference";
private const string CveFirstSeenDatesKey = "CveFirstSeenDates";
/// <summary>
/// Gets CVE findings from the context.
/// </summary>
public static IReadOnlyList<CveFinding>? GetCveFindings(this PolicyGateContext context)
{
if (context.Metadata?.TryGetValue(CveFindingsKey, out var findings) == true)
{
// If stored as JSON string, deserialize
if (findings is string json)
{
return System.Text.Json.JsonSerializer.Deserialize<List<CveFinding>>(json);
}
}
// Check extension properties
if (context is ExtendedPolicyGateContext extended)
{
return extended.CveFindings;
}
return null;
}
/// <summary>
/// Gets baseline CVEs from the context.
/// </summary>
public static IReadOnlyList<CveFinding>? GetBaselineCves(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.BaselineCves;
}
return null;
}
/// <summary>
/// Gets baseline reference from the context.
/// </summary>
public static string? GetBaselineReference(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.BaselineReference;
}
return context.Metadata?.TryGetValue(BaselineReferenceKey, out var reference) == true
? reference
: null;
}
/// <summary>
/// Gets CVE first seen dates from the context.
/// </summary>
public static IReadOnlyDictionary<string, DateTimeOffset>? GetCveFirstSeenDates(this PolicyGateContext context)
{
if (context is ExtendedPolicyGateContext extended)
{
return extended.CveFirstSeenDates;
}
return null;
}
}
/// <summary>
/// Extended PolicyGateContext with CVE-specific properties.
/// </summary>
public sealed record ExtendedPolicyGateContext : PolicyGateContext
{
/// <summary>
/// CVE findings for the current release.
/// </summary>
public IReadOnlyList<CveFinding>? CveFindings { get; init; }
/// <summary>
/// CVE findings from the baseline release.
/// </summary>
public IReadOnlyList<CveFinding>? BaselineCves { get; init; }
/// <summary>
/// Baseline reference (image digest, release ID, etc.).
/// </summary>
public string? BaselineReference { get; init; }
/// <summary>
/// Map of CVE ID to first seen date for SLA tracking.
/// </summary>
public IReadOnlyDictionary<string, DateTimeOffset>? CveFirstSeenDates { get; init; }
}
/// <summary>
/// IPolicyGate interface for CVE gates.
/// Simplified interface without MergeResult for CVE-specific gates.
/// </summary>
public interface IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
string Id { get; }
/// <summary>
/// Display name for the gate.
/// </summary>
string DisplayName { get; }
/// <summary>
/// Description of what the gate checks.
/// </summary>
string Description { get; }
/// <summary>
/// Evaluates the gate against the given context.
/// </summary>
Task<Gates.GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default);
}

View File

@@ -0,0 +1,218 @@
// -----------------------------------------------------------------------------
// CveGatesServiceCollectionExtensions.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-07 - Gate Registration and Documentation
// Description: DI registration for CVE policy gates
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Extension methods for registering CVE gates in the DI container.
/// </summary>
public static class CveGatesServiceCollectionExtensions
{
/// <summary>
/// Adds all CVE policy gates to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Optional configuration for gate options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddCvePolicyGates(
this IServiceCollection services,
IConfiguration? configuration = null)
{
// Register EPSS threshold gate
services.AddEpssThresholdGate(configuration);
// Register KEV blocker gate
services.AddKevBlockerGate(configuration);
// Register reachable CVE gate
services.AddReachableCveGate(configuration);
// Register CVE delta gate
services.AddCveDeltaGate(configuration);
// Register release aggregate CVE gate
services.AddReleaseAggregateCveGate(configuration);
return services;
}
/// <summary>
/// Adds the EPSS threshold gate.
/// </summary>
public static IServiceCollection AddEpssThresholdGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<EpssThresholdGateOptions>(
configuration.GetSection(EpssThresholdGateOptions.SectionName));
}
services.AddSingleton<EpssThresholdGate>(sp =>
{
var options = configuration?.GetSection(EpssThresholdGateOptions.SectionName)
.Get<EpssThresholdGateOptions>();
var epssProvider = sp.GetService<IEpssDataProvider>();
return new EpssThresholdGate(
epssProvider ?? new NullEpssDataProvider(),
options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<EpssThresholdGate>());
return services;
}
/// <summary>
/// Adds the KEV blocker gate.
/// </summary>
public static IServiceCollection AddKevBlockerGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<KevBlockerGateOptions>(
configuration.GetSection(KevBlockerGateOptions.SectionName));
}
services.AddSingleton<KevBlockerGate>(sp =>
{
var options = configuration?.GetSection(KevBlockerGateOptions.SectionName)
.Get<KevBlockerGateOptions>();
var kevProvider = sp.GetService<IKevDataProvider>();
return new KevBlockerGate(
kevProvider ?? new NullKevDataProvider(),
options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<KevBlockerGate>());
return services;
}
/// <summary>
/// Adds the reachable CVE gate.
/// </summary>
public static IServiceCollection AddReachableCveGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<ReachableCveGateOptions>(
configuration.GetSection(ReachableCveGateOptions.SectionName));
}
services.AddSingleton<ReachableCveGate>(sp =>
{
var options = configuration?.GetSection(ReachableCveGateOptions.SectionName)
.Get<ReachableCveGateOptions>();
return new ReachableCveGate(options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReachableCveGate>());
return services;
}
/// <summary>
/// Adds the CVE delta gate.
/// </summary>
public static IServiceCollection AddCveDeltaGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<CveDeltaGateOptions>(
configuration.GetSection(CveDeltaGateOptions.SectionName));
}
services.AddSingleton<CveDeltaGate>(sp =>
{
var options = configuration?.GetSection(CveDeltaGateOptions.SectionName)
.Get<CveDeltaGateOptions>();
var deltaProvider = sp.GetService<ICveDeltaProvider>();
return new CveDeltaGate(options, deltaProvider);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<CveDeltaGate>());
return services;
}
/// <summary>
/// Adds the release aggregate CVE gate.
/// </summary>
public static IServiceCollection AddReleaseAggregateCveGate(
this IServiceCollection services,
IConfiguration? configuration = null)
{
if (configuration != null)
{
services.Configure<ReleaseAggregateCveGateOptions>(
configuration.GetSection(ReleaseAggregateCveGateOptions.SectionName));
}
services.AddSingleton<ReleaseAggregateCveGate>(sp =>
{
var options = configuration?.GetSection(ReleaseAggregateCveGateOptions.SectionName)
.Get<ReleaseAggregateCveGateOptions>();
return new ReleaseAggregateCveGate(options);
});
services.AddSingleton<IPolicyGate>(sp => sp.GetRequiredService<ReleaseAggregateCveGate>());
return services;
}
}
/// <summary>
/// Null EPSS data provider for when no provider is configured.
/// </summary>
internal sealed class NullEpssDataProvider : IEpssDataProvider
{
public Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult<EpssScore?>(null);
public Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyDictionary<string, EpssScore>>(
new Dictionary<string, EpssScore>());
}
/// <summary>
/// Null KEV data provider for when no provider is configured.
/// </summary>
internal sealed class NullKevDataProvider : IKevDataProvider
{
public Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult<KevEntry?>(null);
public Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default)
=> Task.FromResult<IReadOnlyDictionary<string, KevEntry>>(
new Dictionary<string, KevEntry>());
public Task<bool> IsKevAsync(string cveId, CancellationToken ct = default)
=> Task.FromResult(false);
public Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default)
=> Task.FromResult<DateTimeOffset?>(null);
}

View File

@@ -0,0 +1,327 @@
// -----------------------------------------------------------------------------
// EpssThresholdGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-02 - EPSS Threshold Gate
// Description: Policy gate that blocks releases based on EPSS exploitation probability
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases based on EPSS exploitation probability.
/// EPSS + reachability enables accurate risk-based gating.
/// </summary>
public sealed class EpssThresholdGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "epss-threshold";
private readonly IEpssDataProvider _epssProvider;
private readonly EpssThresholdGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "EPSS Threshold";
/// <inheritdoc />
public string Description => "Blocks releases based on EPSS exploitation probability thresholds";
/// <summary>
/// Creates a new EPSS threshold gate.
/// </summary>
public EpssThresholdGate(
IEpssDataProvider epssProvider,
EpssThresholdGateOptions? options = null)
{
_epssProvider = epssProvider ?? throw new ArgumentNullException(nameof(epssProvider));
_options = options ?? new EpssThresholdGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "EPSS threshold gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings to evaluate");
}
// Batch fetch EPSS scores
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
var epssScores = await _epssProvider.GetScoresBatchAsync(cveIds, ct);
var violations = new List<EpssViolation>();
var warnings = new List<string>();
foreach (var cve in cves)
{
ct.ThrowIfCancellationRequested();
// Skip if reachability-aware and not reachable
if (envOptions.OnlyReachable && !cve.IsReachable)
{
continue;
}
if (!epssScores.TryGetValue(cve.CveId, out var score))
{
// Handle missing EPSS
HandleMissingEpss(cve, envOptions, warnings, violations);
continue;
}
// Check percentile threshold
if (envOptions.PercentileThreshold.HasValue &&
score.Percentile >= envOptions.PercentileThreshold.Value)
{
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = score.Score,
Percentile = score.Percentile,
Threshold = $"percentile >= {envOptions.PercentileThreshold.Value:P0}",
IsReachable = cve.IsReachable
});
}
// Check score threshold
else if (envOptions.ScoreThreshold.HasValue &&
score.Score >= envOptions.ScoreThreshold.Value)
{
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = score.Score,
Percentile = score.Percentile,
Threshold = $"score >= {envOptions.ScoreThreshold.Value:F2}",
IsReachable = cve.IsReachable
});
}
}
if (violations.Count > 0)
{
var message = $"EPSS threshold exceeded for {violations.Count} CVE(s): " +
string.Join(", ", violations.Take(5).Select(v =>
$"{v.CveId} (EPSS: {v.Score:F3}, {v.Percentile:P0})"));
if (violations.Count > 5)
{
message += $" and {violations.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
var passMessage = $"EPSS check passed for {cves.Count} CVE(s)";
if (warnings.Count > 0)
{
passMessage += $" ({warnings.Count} warnings)";
}
return GateResult.Pass(Id, passMessage, warnings: warnings);
}
private void HandleMissingEpss(
CveFinding cve,
EpssThresholdGateOptions options,
List<string> warnings,
List<EpssViolation> violations)
{
switch (options.MissingEpssAction)
{
case MissingEpssAction.Allow:
// Silently allow
break;
case MissingEpssAction.Warn:
warnings.Add($"{cve.CveId}: no EPSS score available");
break;
case MissingEpssAction.Fail:
violations.Add(new EpssViolation
{
CveId = cve.CveId,
Score = 0,
Percentile = 0,
Threshold = "EPSS score required but missing",
IsReachable = cve.IsReachable
});
break;
}
}
private EpssThresholdGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
PercentileThreshold = envOverride.PercentileThreshold ?? _options.PercentileThreshold,
ScoreThreshold = envOverride.ScoreThreshold ?? _options.ScoreThreshold,
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
MissingEpssAction = envOverride.MissingEpssAction ?? _options.MissingEpssAction
};
}
return _options;
}
private sealed record EpssViolation
{
public required string CveId { get; init; }
public double Score { get; init; }
public double Percentile { get; init; }
public required string Threshold { get; init; }
public bool IsReachable { get; init; }
}
}
/// <summary>
/// Options for EPSS threshold gate.
/// </summary>
public sealed record EpssThresholdGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:EpssThreshold";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Percentile threshold (0.0-1.0). CVEs at or above this percentile are blocked.
/// Example: 0.75 = block top 25% of exploitable CVEs.
/// </summary>
public double? PercentileThreshold { get; init; } = 0.75;
/// <summary>
/// Score threshold (0.0-1.0). CVEs at or above this score are blocked.
/// Alternative to percentile threshold.
/// </summary>
public double? ScoreThreshold { get; init; }
/// <summary>
/// Whether to only apply to reachable CVEs.
/// If true, unreachable CVEs are ignored regardless of EPSS score.
/// </summary>
public bool OnlyReachable { get; init; } = false;
/// <summary>
/// Action when EPSS score is missing for a CVE.
/// </summary>
public MissingEpssAction MissingEpssAction { get; init; } = MissingEpssAction.Warn;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, EpssThresholdGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, EpssThresholdGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record EpssThresholdGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for PercentileThreshold.</summary>
public double? PercentileThreshold { get; init; }
/// <summary>Override for ScoreThreshold.</summary>
public double? ScoreThreshold { get; init; }
/// <summary>Override for OnlyReachable.</summary>
public bool? OnlyReachable { get; init; }
/// <summary>Override for MissingEpssAction.</summary>
public MissingEpssAction? MissingEpssAction { get; init; }
}
/// <summary>
/// Action when EPSS score is missing.
/// </summary>
public enum MissingEpssAction
{
/// <summary>Allow the CVE to pass.</summary>
Allow,
/// <summary>Pass but log a warning.</summary>
Warn,
/// <summary>Fail the gate.</summary>
Fail
}
/// <summary>
/// Provider for EPSS data.
/// </summary>
public interface IEpssDataProvider
{
/// <summary>
/// Gets EPSS score for a single CVE.
/// </summary>
Task<EpssScore?> GetScoreAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Gets EPSS scores for multiple CVEs.
/// </summary>
Task<IReadOnlyDictionary<string, EpssScore>> GetScoresBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default);
}
/// <summary>
/// EPSS score data.
/// </summary>
public sealed record EpssScore
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>EPSS score (0.0-1.0).</summary>
public required double Score { get; init; }
/// <summary>EPSS percentile (0.0-1.0).</summary>
public required double Percentile { get; init; }
/// <summary>Score date.</summary>
public DateTimeOffset ScoreDate { get; init; }
}
/// <summary>
/// CVE finding for gate evaluation.
/// </summary>
public record CveFinding
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Whether the CVE is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>CVSS score.</summary>
public double? CvssScore { get; init; }
/// <summary>Affected component PURL.</summary>
public string? ComponentPurl { get; init; }
}

View File

@@ -0,0 +1,270 @@
// -----------------------------------------------------------------------------
// KevBlockerGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-03 - KEV Blocker Gate
// Description: Policy gate that blocks releases containing CISA KEV CVEs
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that blocks releases containing CVEs in the CISA Known Exploited
/// Vulnerabilities (KEV) catalog. KEV entries are actively exploited in the wild.
/// </summary>
public sealed class KevBlockerGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "kev-blocker";
private readonly IKevDataProvider _kevProvider;
private readonly KevBlockerGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "KEV Blocker";
/// <inheritdoc />
public string Description => "Blocks releases containing CVEs in the CISA Known Exploited Vulnerabilities catalog";
/// <summary>
/// Creates a new KEV blocker gate.
/// </summary>
public KevBlockerGate(
IKevDataProvider kevProvider,
KevBlockerGateOptions? options = null)
{
_kevProvider = kevProvider ?? throw new ArgumentNullException(nameof(kevProvider));
_options = options ?? new KevBlockerGateOptions();
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "KEV blocker gate disabled");
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return GateResult.Pass(Id, "No CVE findings to evaluate");
}
// Batch check KEV membership
var cveIds = cves.Select(c => c.CveId).Distinct().ToList();
var kevEntries = await _kevProvider.GetKevEntriesBatchAsync(cveIds, ct);
var violations = new List<KevViolation>();
foreach (var cve in cves)
{
ct.ThrowIfCancellationRequested();
// Skip if reachability-aware and not reachable
if (envOptions.OnlyReachable && !cve.IsReachable)
{
continue;
}
if (kevEntries.TryGetValue(cve.CveId, out var kevEntry))
{
// Check if past due date
var isPastDue = kevEntry.DueDate.HasValue &&
kevEntry.DueDate.Value < DateTimeOffset.UtcNow;
// Check severity filter
if (envOptions.MinimumSeverity.HasValue &&
cve.CvssScore.HasValue &&
cve.CvssScore.Value < envOptions.MinimumSeverity.Value)
{
// Below minimum severity, skip
continue;
}
violations.Add(new KevViolation
{
CveId = cve.CveId,
VendorProject = kevEntry.VendorProject,
Product = kevEntry.Product,
VulnerabilityName = kevEntry.VulnerabilityName,
DueDate = kevEntry.DueDate,
IsPastDue = isPastDue,
IsReachable = cve.IsReachable
});
}
}
if (violations.Count > 0)
{
var pastDueCount = violations.Count(v => v.IsPastDue);
var message = $"Found {violations.Count} CVE(s) in CISA KEV catalog";
if (pastDueCount > 0)
{
message += $" ({pastDueCount} past remediation due date)";
}
message += ": " + string.Join(", ", violations.Take(5).Select(v =>
$"{v.CveId} ({v.VendorProject}/{v.Product})"));
if (violations.Count > 5)
{
message += $" and {violations.Count - 5} more";
}
return GateResult.Fail(Id, message);
}
return GateResult.Pass(Id, $"No KEV entries found among {cves.Count} CVE(s)");
}
private KevBlockerGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
OnlyReachable = envOverride.OnlyReachable ?? _options.OnlyReachable,
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
BlockPastDueOnly = envOverride.BlockPastDueOnly ?? _options.BlockPastDueOnly
};
}
return _options;
}
private sealed record KevViolation
{
public required string CveId { get; init; }
public string? VendorProject { get; init; }
public string? Product { get; init; }
public string? VulnerabilityName { get; init; }
public DateTimeOffset? DueDate { get; init; }
public bool IsPastDue { get; init; }
public bool IsReachable { get; init; }
}
}
/// <summary>
/// Options for KEV blocker gate.
/// </summary>
public sealed record KevBlockerGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:KevBlocker";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Whether to only apply to reachable CVEs.
/// </summary>
public bool OnlyReachable { get; init; } = false;
/// <summary>
/// Minimum CVSS severity to block.
/// Set to 0 to block all KEV CVEs.
/// </summary>
public double? MinimumSeverity { get; init; }
/// <summary>
/// Whether to only block KEV entries past their due date.
/// If true, upcoming KEV entries are allowed.
/// </summary>
public bool BlockPastDueOnly { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, KevBlockerGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, KevBlockerGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record KevBlockerGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for OnlyReachable.</summary>
public bool? OnlyReachable { get; init; }
/// <summary>Override for MinimumSeverity.</summary>
public double? MinimumSeverity { get; init; }
/// <summary>Override for BlockPastDueOnly.</summary>
public bool? BlockPastDueOnly { get; init; }
}
/// <summary>
/// Provider for KEV data.
/// </summary>
public interface IKevDataProvider
{
/// <summary>
/// Checks if a CVE is in the KEV catalog.
/// </summary>
Task<KevEntry?> GetKevEntryAsync(string cveId, CancellationToken ct = default);
/// <summary>
/// Batch check for KEV membership.
/// </summary>
Task<IReadOnlyDictionary<string, KevEntry>> GetKevEntriesBatchAsync(
IEnumerable<string> cveIds,
CancellationToken ct = default);
/// <summary>
/// Gets the last catalog update timestamp.
/// </summary>
Task<DateTimeOffset?> GetCatalogUpdateTimeAsync(CancellationToken ct = default);
}
/// <summary>
/// KEV catalog entry.
/// </summary>
public sealed record KevEntry
{
/// <summary>CVE identifier.</summary>
public required string CveId { get; init; }
/// <summary>Vendor/project name.</summary>
public string? VendorProject { get; init; }
/// <summary>Product name.</summary>
public string? Product { get; init; }
/// <summary>Vulnerability name.</summary>
public string? VulnerabilityName { get; init; }
/// <summary>Date added to KEV catalog.</summary>
public DateTimeOffset DateAdded { get; init; }
/// <summary>Short description.</summary>
public string? ShortDescription { get; init; }
/// <summary>Required action.</summary>
public string? RequiredAction { get; init; }
/// <summary>Remediation due date.</summary>
public DateTimeOffset? DueDate { get; init; }
/// <summary>Notes.</summary>
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,226 @@
// -----------------------------------------------------------------------------
// ReachableCveGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-04 - Reachable CVE Gate
// Description: Policy gate that blocks only reachable CVEs, reducing noise
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that only blocks CVEs that are confirmed reachable in the application.
/// Reduces false positives by ignoring unreachable vulnerable code.
/// </summary>
public sealed class ReachableCveGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "reachable-cve";
private readonly ReachableCveGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Reachable CVE";
/// <inheritdoc />
public string Description => "Blocks releases containing reachable CVEs above severity threshold";
/// <summary>
/// Creates a new reachable CVE gate.
/// </summary>
public ReachableCveGate(ReachableCveGateOptions? options = null)
{
_options = options ?? new ReachableCveGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Reachable CVE gate disabled"));
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return Task.FromResult(GateResult.Pass(Id, "No CVE findings to evaluate"));
}
var reachableCves = new List<CveFinding>();
var unknownReachability = new List<CveFinding>();
var unreachableCves = new List<CveFinding>();
foreach (var cve in cves)
{
// Classify by reachability
if (cve.IsReachable)
{
reachableCves.Add(cve);
}
else if (cve.ReachabilityStatus == ReachabilityStatus.Unknown)
{
unknownReachability.Add(cve);
}
else
{
unreachableCves.Add(cve);
}
}
// Filter by severity threshold
var blocking = reachableCves
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
.ToList();
// Handle unknown reachability
if (envOptions.TreatUnknownAsReachable)
{
var unknownBlocking = unknownReachability
.Where(c => c.CvssScore.HasValue && c.CvssScore.Value >= envOptions.MinimumSeverity)
.ToList();
blocking.AddRange(unknownBlocking);
}
var warnings = new List<string>();
// Warn about unknown reachability if not treating as reachable
if (!envOptions.TreatUnknownAsReachable && unknownReachability.Count > 0)
{
warnings.Add($"{unknownReachability.Count} CVE(s) have unknown reachability status");
}
if (blocking.Count > 0)
{
var message = $"Found {blocking.Count} reachable CVE(s) at or above severity {envOptions.MinimumSeverity}: " +
string.Join(", ", blocking.Take(5).Select(c =>
$"{c.CveId} (CVSS: {c.CvssScore:F1})"));
if (blocking.Count > 5)
{
message += $" and {blocking.Count - 5} more";
}
return Task.FromResult(GateResult.Fail(Id, message));
}
var passMessage = $"No blocking reachable CVEs. " +
$"Reachable: {reachableCves.Count}, " +
$"Unreachable: {unreachableCves.Count}, " +
$"Unknown: {unknownReachability.Count}";
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
}
private ReachableCveGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
MinimumSeverity = envOverride.MinimumSeverity ?? _options.MinimumSeverity,
TreatUnknownAsReachable = envOverride.TreatUnknownAsReachable ?? _options.TreatUnknownAsReachable
};
}
return _options;
}
}
/// <summary>
/// Options for reachable CVE gate.
/// </summary>
public sealed record ReachableCveGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:ReachableCve";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Minimum CVSS severity to block.
/// Only reachable CVEs at or above this severity are blocked.
/// Default: 7.0 (High).
/// </summary>
public double MinimumSeverity { get; init; } = 7.0;
/// <summary>
/// Whether to treat CVEs with unknown reachability as reachable.
/// If true, unknown reachability is conservative (blocks).
/// If false, unknown reachability is permissive (allows).
/// Default: false (permissive).
/// </summary>
public bool TreatUnknownAsReachable { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, ReachableCveGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, ReachableCveGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record ReachableCveGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for MinimumSeverity.</summary>
public double? MinimumSeverity { get; init; }
/// <summary>Override for TreatUnknownAsReachable.</summary>
public bool? TreatUnknownAsReachable { get; init; }
}
/// <summary>
/// Extended CVE finding with reachability status.
/// </summary>
public sealed record CveFindingWithReachability : CveFinding
{
/// <summary>Detailed reachability status.</summary>
public ReachabilityStatus ReachabilityStatus { get; init; }
/// <summary>Reachability confidence score (0-1).</summary>
public double? ReachabilityConfidence { get; init; }
/// <summary>Whether the CVE has been witnessed at runtime.</summary>
public bool IsWitnessed { get; init; }
}
/// <summary>
/// Reachability determination status.
/// </summary>
public enum ReachabilityStatus
{
/// <summary>Reachability not yet determined.</summary>
Unknown,
/// <summary>Confirmed reachable via static analysis.</summary>
ReachableStatic,
/// <summary>Confirmed reachable via runtime witness.</summary>
ReachableWitnessed,
/// <summary>Confirmed not reachable.</summary>
NotReachable,
/// <summary>Partially reachable (some paths blocked).</summary>
PartiallyReachable
}

View File

@@ -0,0 +1,412 @@
// -----------------------------------------------------------------------------
// ReleaseAggregateCveGate.cs
// Sprint: SPRINT_20260118_027_Policy_cve_release_gates
// Task: TASK-027-06 - Release Aggregate CVE Gate
// Description: Policy gate that enforces aggregate CVE limits per release
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.Cve;
/// <summary>
/// Policy gate that enforces aggregate CVE limits per release.
/// Unlike CvssThresholdGate which operates per-finding, this operates per-release.
/// </summary>
public sealed class ReleaseAggregateCveGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "release-aggregate-cve";
private readonly ReleaseAggregateCveGateOptions _options;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Release Aggregate CVE";
/// <inheritdoc />
public string Description => "Enforces aggregate CVE count limits per release by severity";
/// <summary>
/// Creates a new release aggregate CVE gate.
/// </summary>
public ReleaseAggregateCveGate(ReleaseAggregateCveGateOptions? options = null)
{
_options = options ?? new ReleaseAggregateCveGateOptions();
}
/// <inheritdoc />
public Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return Task.FromResult(GateResult.Pass(Id, "Release aggregate CVE gate disabled"));
}
var envOptions = GetEnvironmentOptions(context.Environment);
var cves = context.GetCveFindings();
if (cves == null || cves.Count == 0)
{
return Task.FromResult(GateResult.Pass(Id, "No CVE findings in release"));
}
// Filter CVEs based on options
var cvesToCount = FilterCves(cves, envOptions);
// Count by severity
var counts = CountBySeverity(cvesToCount);
// Check limits
var violations = CheckLimits(counts, envOptions);
var warnings = new List<string>();
// Add warnings for near-limit counts
AddNearLimitWarnings(counts, envOptions, warnings);
if (violations.Count > 0)
{
var message = "Release CVE aggregate limits exceeded: " +
string.Join(", ", violations.Select(v =>
$"{v.Severity}: {v.Count}/{v.Limit}"));
return Task.FromResult(GateResult.Fail(Id, message));
}
var passMessage = $"Release CVE counts within limits. " +
$"Critical: {counts.Critical}, High: {counts.High}, Medium: {counts.Medium}, Low: {counts.Low}";
return Task.FromResult(GateResult.Pass(Id, passMessage, warnings: warnings));
}
private IReadOnlyList<CveFinding> FilterCves(
IReadOnlyList<CveFinding> cves,
ReleaseAggregateCveGateOptions options)
{
var filtered = cves.AsEnumerable();
// Filter by suppression status
if (!options.CountSuppressed && cves is IReadOnlyList<CveFindingWithStatus> statusCves)
{
filtered = statusCves.Where(c => !c.IsSuppressed);
}
// Filter by reachability
if (options.OnlyCountReachable)
{
filtered = filtered.Where(c => c.IsReachable);
}
return filtered.ToList();
}
private static CveSeverityCounts CountBySeverity(IReadOnlyList<CveFinding> cves)
{
var critical = 0;
var high = 0;
var medium = 0;
var low = 0;
var unknown = 0;
foreach (var cve in cves)
{
var severity = ClassifySeverity(cve.CvssScore);
switch (severity)
{
case CveSeverity.Critical:
critical++;
break;
case CveSeverity.High:
high++;
break;
case CveSeverity.Medium:
medium++;
break;
case CveSeverity.Low:
low++;
break;
default:
unknown++;
break;
}
}
return new CveSeverityCounts
{
Critical = critical,
High = high,
Medium = medium,
Low = low,
Unknown = unknown,
Total = cves.Count
};
}
private static CveSeverity ClassifySeverity(double? cvssScore)
{
if (!cvssScore.HasValue)
return CveSeverity.Unknown;
return cvssScore.Value switch
{
>= 9.0 => CveSeverity.Critical,
>= 7.0 => CveSeverity.High,
>= 4.0 => CveSeverity.Medium,
>= 0.1 => CveSeverity.Low,
_ => CveSeverity.Unknown
};
}
private static List<LimitViolation> CheckLimits(
CveSeverityCounts counts,
ReleaseAggregateCveGateOptions options)
{
var violations = new List<LimitViolation>();
if (options.MaxCritical.HasValue && counts.Critical > options.MaxCritical.Value)
{
violations.Add(new LimitViolation
{
Severity = "Critical",
Count = counts.Critical,
Limit = options.MaxCritical.Value
});
}
if (options.MaxHigh.HasValue && counts.High > options.MaxHigh.Value)
{
violations.Add(new LimitViolation
{
Severity = "High",
Count = counts.High,
Limit = options.MaxHigh.Value
});
}
if (options.MaxMedium.HasValue && counts.Medium > options.MaxMedium.Value)
{
violations.Add(new LimitViolation
{
Severity = "Medium",
Count = counts.Medium,
Limit = options.MaxMedium.Value
});
}
if (options.MaxLow.HasValue && counts.Low > options.MaxLow.Value)
{
violations.Add(new LimitViolation
{
Severity = "Low",
Count = counts.Low,
Limit = options.MaxLow.Value
});
}
if (options.MaxTotal.HasValue && counts.Total > options.MaxTotal.Value)
{
violations.Add(new LimitViolation
{
Severity = "Total",
Count = counts.Total,
Limit = options.MaxTotal.Value
});
}
return violations;
}
private static void AddNearLimitWarnings(
CveSeverityCounts counts,
ReleaseAggregateCveGateOptions options,
List<string> warnings)
{
const double WarningThreshold = 0.8; // Warn at 80% of limit
if (options.MaxCritical.HasValue && counts.Critical > 0)
{
var ratio = (double)counts.Critical / options.MaxCritical.Value;
if (ratio >= WarningThreshold && ratio < 1.0)
{
warnings.Add($"Critical CVE count ({counts.Critical}) approaching limit ({options.MaxCritical.Value})");
}
}
if (options.MaxHigh.HasValue && counts.High > 0)
{
var ratio = (double)counts.High / options.MaxHigh.Value;
if (ratio >= WarningThreshold && ratio < 1.0)
{
warnings.Add($"High CVE count ({counts.High}) approaching limit ({options.MaxHigh.Value})");
}
}
}
private ReleaseAggregateCveGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
return _options;
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
MaxCritical = envOverride.MaxCritical ?? _options.MaxCritical,
MaxHigh = envOverride.MaxHigh ?? _options.MaxHigh,
MaxMedium = envOverride.MaxMedium ?? _options.MaxMedium,
MaxLow = envOverride.MaxLow ?? _options.MaxLow,
MaxTotal = envOverride.MaxTotal ?? _options.MaxTotal,
CountSuppressed = envOverride.CountSuppressed ?? _options.CountSuppressed,
OnlyCountReachable = envOverride.OnlyCountReachable ?? _options.OnlyCountReachable
};
}
return _options;
}
private sealed record LimitViolation
{
public required string Severity { get; init; }
public int Count { get; init; }
public int Limit { get; init; }
}
private sealed record CveSeverityCounts
{
public int Critical { get; init; }
public int High { get; init; }
public int Medium { get; init; }
public int Low { get; init; }
public int Unknown { get; init; }
public int Total { get; init; }
}
}
/// <summary>
/// CVE severity classification.
/// </summary>
public enum CveSeverity
{
/// <summary>Unknown severity.</summary>
Unknown,
/// <summary>Low severity (CVSS 0.1-3.9).</summary>
Low,
/// <summary>Medium severity (CVSS 4.0-6.9).</summary>
Medium,
/// <summary>High severity (CVSS 7.0-8.9).</summary>
High,
/// <summary>Critical severity (CVSS 9.0-10.0).</summary>
Critical
}
/// <summary>
/// Options for release aggregate CVE gate.
/// </summary>
public sealed record ReleaseAggregateCveGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:ReleaseAggregateCve";
/// <summary>Whether the gate is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Maximum allowed critical CVEs (CVSS 9.0+).
/// Default: 0 (no critical CVEs allowed in production).
/// </summary>
public int? MaxCritical { get; init; } = 0;
/// <summary>
/// Maximum allowed high CVEs (CVSS 7.0-8.9).
/// Default: 3.
/// </summary>
public int? MaxHigh { get; init; } = 3;
/// <summary>
/// Maximum allowed medium CVEs (CVSS 4.0-6.9).
/// Default: 20.
/// </summary>
public int? MaxMedium { get; init; } = 20;
/// <summary>
/// Maximum allowed low CVEs (CVSS 0.1-3.9).
/// Null means unlimited.
/// </summary>
public int? MaxLow { get; init; }
/// <summary>
/// Maximum total CVEs regardless of severity.
/// Null means no total limit.
/// </summary>
public int? MaxTotal { get; init; }
/// <summary>
/// Whether to count suppressed/excepted CVEs.
/// If false, suppressed CVEs are excluded from counts.
/// </summary>
public bool CountSuppressed { get; init; } = false;
/// <summary>
/// Whether to only count reachable CVEs.
/// If true, unreachable CVEs are excluded from counts.
/// </summary>
public bool OnlyCountReachable { get; init; } = false;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, ReleaseAggregateCveGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, ReleaseAggregateCveGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides.
/// </summary>
public sealed record ReleaseAggregateCveGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for MaxCritical.</summary>
public int? MaxCritical { get; init; }
/// <summary>Override for MaxHigh.</summary>
public int? MaxHigh { get; init; }
/// <summary>Override for MaxMedium.</summary>
public int? MaxMedium { get; init; }
/// <summary>Override for MaxLow.</summary>
public int? MaxLow { get; init; }
/// <summary>Override for MaxTotal.</summary>
public int? MaxTotal { get; init; }
/// <summary>Override for CountSuppressed.</summary>
public bool? CountSuppressed { get; init; }
/// <summary>Override for OnlyCountReachable.</summary>
public bool? OnlyCountReachable { get; init; }
}
/// <summary>
/// CVE finding with suppression status.
/// </summary>
public sealed record CveFindingWithStatus : CveFinding
{
/// <summary>Whether the CVE is suppressed/excepted.</summary>
public bool IsSuppressed { get; init; }
/// <summary>Exception ID if suppressed.</summary>
public string? ExceptionId { get; init; }
/// <summary>Exception expiry date.</summary>
public DateTimeOffset? ExceptionExpiry { get; init; }
}

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
}

View File

@@ -0,0 +1,372 @@
// -----------------------------------------------------------------------------
// RuntimeWitnessGate.cs
// Sprint: SPRINT_20260118_018_Policy_runtime_witness_gate
// Tasks: TASK-018-001 through TASK-018-006
// Description: Policy gate requiring runtime witness confirmation
// -----------------------------------------------------------------------------
namespace StellaOps.Policy.Gates.RuntimeWitness;
/// <summary>
/// Policy gate that requires runtime witness confirmation for reachability claims.
/// Follows VexProofGate anchor-aware pattern.
/// </summary>
public sealed class RuntimeWitnessGate : IPolicyGate
{
/// <summary>
/// Gate identifier.
/// </summary>
public const string GateId = "runtime-witness";
private readonly IWitnessVerifier? _verifier;
private readonly RuntimeWitnessGateOptions _options;
private readonly TimeProvider _timeProvider;
/// <inheritdoc />
public string Id => GateId;
/// <inheritdoc />
public string DisplayName => "Runtime Witness";
/// <inheritdoc />
public string Description => "Requires runtime witness confirmation for reachability claims";
/// <summary>
/// Creates a new runtime witness gate.
/// </summary>
public RuntimeWitnessGate(
IWitnessVerifier? verifier = null,
RuntimeWitnessGateOptions? options = null,
TimeProvider? timeProvider = null)
{
_verifier = verifier;
_options = options ?? new RuntimeWitnessGateOptions();
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
public async Task<GateResult> EvaluateAsync(PolicyGateContext context, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(context);
if (!_options.Enabled)
{
return GateResult.Pass(Id, "Runtime witness gate disabled");
}
// Get environment-specific options
var envOptions = GetEnvironmentOptions(context.Environment);
// Get findings with reachability evidence
var findings = context.GetReachabilityFindings();
if (findings == null || findings.Count == 0)
{
if (envOptions.RequireRuntimeWitness)
{
return GateResult.Fail(Id, "No reachability findings to verify");
}
return GateResult.Pass(Id, "No reachability findings - skipping witness check");
}
var witnessed = 0;
var unwitnessed = 0;
var warnings = new List<string>();
var failures = new List<string>();
foreach (var finding in findings)
{
ct.ThrowIfCancellationRequested();
var result = await EvaluateFindingAsync(finding, envOptions, ct);
if (result.IsWitnessed)
{
witnessed++;
// Check freshness
if (result.WitnessAge > TimeSpan.FromHours(envOptions.MaxWitnessAgeHours))
{
if (envOptions.AllowUnwitnessedAdvisory)
{
warnings.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
}
else
{
failures.Add($"{finding.VulnerabilityId}: witness expired ({result.WitnessAge.TotalHours:F1}h)");
}
}
// Check confidence
if (result.MatchConfidence < envOptions.MinMatchConfidence)
{
warnings.Add($"{finding.VulnerabilityId}: low match confidence ({result.MatchConfidence:P0})");
}
}
else
{
unwitnessed++;
if (envOptions.RequireRuntimeWitness && !envOptions.AllowUnwitnessedAdvisory)
{
failures.Add($"{finding.VulnerabilityId}: no runtime witness found");
}
else if (envOptions.RequireRuntimeWitness)
{
warnings.Add($"{finding.VulnerabilityId}: no runtime witness (advisory)");
}
}
}
// Determine result
if (failures.Count > 0)
{
return GateResult.Fail(Id,
$"Runtime witness check failed: {string.Join("; ", failures)}");
}
var message = $"Witnessed: {witnessed}/{findings.Count}";
if (warnings.Count > 0)
{
message += $" (warnings: {warnings.Count})";
}
return GateResult.Pass(Id, message, warnings: warnings);
}
private async Task<WitnessEvaluationResult> EvaluateFindingAsync(
ReachabilityFinding finding,
RuntimeWitnessGateOptions envOptions,
CancellationToken ct)
{
// Check if finding has witness evidence
if (finding.WitnessDigest == null)
{
return new WitnessEvaluationResult { IsWitnessed = false };
}
// If verifier is available, do full verification
if (_verifier != null && finding.ClaimId != null)
{
var verification = await _verifier.VerifyAsync(finding.ClaimId, ct);
if (verification.Status == WitnessVerificationStatus.Verified && verification.BestMatch != null)
{
var now = _timeProvider.GetUtcNow();
var age = verification.BestMatch.IntegratedTime.HasValue
? now - verification.BestMatch.IntegratedTime.Value
: TimeSpan.Zero;
return new WitnessEvaluationResult
{
IsWitnessed = true,
WitnessAge = age,
MatchConfidence = verification.BestMatch.Confidence,
ObservationCount = 1,
IsRekorAnchored = verification.BestMatch.RekorVerified
};
}
}
// Fall back to metadata check
return new WitnessEvaluationResult
{
IsWitnessed = finding.WitnessedAt.HasValue,
WitnessAge = finding.WitnessedAt.HasValue
? _timeProvider.GetUtcNow() - finding.WitnessedAt.Value
: TimeSpan.Zero,
MatchConfidence = 1.0, // Assume full confidence for metadata-only
ObservationCount = 1
};
}
private RuntimeWitnessGateOptions GetEnvironmentOptions(string? environment)
{
if (string.IsNullOrWhiteSpace(environment))
{
return _options;
}
if (_options.Environments.TryGetValue(environment, out var envOverride))
{
return _options with
{
Enabled = envOverride.Enabled ?? _options.Enabled,
RequireRuntimeWitness = envOverride.RequireRuntimeWitness ?? _options.RequireRuntimeWitness,
MaxWitnessAgeHours = envOverride.MaxWitnessAgeHours ?? _options.MaxWitnessAgeHours,
MinObservationCount = envOverride.MinObservationCount ?? _options.MinObservationCount,
RequireRekorAnchoring = envOverride.RequireRekorAnchoring ?? _options.RequireRekorAnchoring,
MinMatchConfidence = envOverride.MinMatchConfidence ?? _options.MinMatchConfidence,
AllowUnwitnessedAdvisory = envOverride.AllowUnwitnessedAdvisory ?? _options.AllowUnwitnessedAdvisory
};
}
return _options;
}
private sealed record WitnessEvaluationResult
{
public bool IsWitnessed { get; init; }
public TimeSpan WitnessAge { get; init; }
public double MatchConfidence { get; init; }
public int ObservationCount { get; init; }
public bool IsRekorAnchored { get; init; }
}
}
/// <summary>
/// Options for runtime witness gate.
/// </summary>
public sealed record RuntimeWitnessGateOptions
{
/// <summary>
/// Configuration section name.
/// </summary>
public const string SectionName = "Policy:Gates:RuntimeWitness";
/// <summary>
/// Whether the gate is enabled.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Whether to require runtime witnesses (false = opt-in).
/// </summary>
public bool RequireRuntimeWitness { get; init; } = false;
/// <summary>
/// Maximum age for witnesses in hours.
/// Default: 168 (7 days), following VexProofGate convention.
/// </summary>
public int MaxWitnessAgeHours { get; init; } = 168;
/// <summary>
/// Minimum number of observations required.
/// </summary>
public int MinObservationCount { get; init; } = 1;
/// <summary>
/// Whether to require Rekor anchoring for witnesses.
/// </summary>
public bool RequireRekorAnchoring { get; init; } = true;
/// <summary>
/// Minimum match confidence threshold.
/// </summary>
public double MinMatchConfidence { get; init; } = 0.8;
/// <summary>
/// Whether to pass with advisory for unwitnessed paths.
/// If false, unwitnessed paths cause gate failure.
/// </summary>
public bool AllowUnwitnessedAdvisory { get; init; } = true;
/// <summary>
/// Per-environment configuration overrides.
/// </summary>
public IReadOnlyDictionary<string, RuntimeWitnessGateEnvironmentOverride> Environments { get; init; }
= new Dictionary<string, RuntimeWitnessGateEnvironmentOverride>();
}
/// <summary>
/// Per-environment overrides for runtime witness gate.
/// </summary>
public sealed record RuntimeWitnessGateEnvironmentOverride
{
/// <summary>Override for Enabled.</summary>
public bool? Enabled { get; init; }
/// <summary>Override for RequireRuntimeWitness.</summary>
public bool? RequireRuntimeWitness { get; init; }
/// <summary>Override for MaxWitnessAgeHours.</summary>
public int? MaxWitnessAgeHours { get; init; }
/// <summary>Override for MinObservationCount.</summary>
public int? MinObservationCount { get; init; }
/// <summary>Override for RequireRekorAnchoring.</summary>
public bool? RequireRekorAnchoring { get; init; }
/// <summary>Override for MinMatchConfidence.</summary>
public double? MinMatchConfidence { get; init; }
/// <summary>Override for AllowUnwitnessedAdvisory.</summary>
public bool? AllowUnwitnessedAdvisory { get; init; }
}
/// <summary>
/// A reachability finding for gate evaluation.
/// </summary>
public sealed record ReachabilityFinding
{
/// <summary>Vulnerability ID.</summary>
public required string VulnerabilityId { get; init; }
/// <summary>Claim ID for witness lookup.</summary>
public string? ClaimId { get; init; }
/// <summary>Witness digest if witnessed.</summary>
public string? WitnessDigest { get; init; }
/// <summary>When the finding was witnessed.</summary>
public DateTimeOffset? WitnessedAt { get; init; }
/// <summary>Whether the path is reachable.</summary>
public bool IsReachable { get; init; }
/// <summary>Component PURL.</summary>
public string? ComponentPurl { get; init; }
}
/// <summary>
/// Interface for witness verification in gate context.
/// </summary>
public interface IWitnessVerifier
{
/// <summary>
/// Verifies witnesses for a claim.
/// </summary>
Task<WitnessVerificationResult> VerifyAsync(string claimId, CancellationToken ct = default);
}
/// <summary>
/// Result of witness verification.
/// </summary>
public sealed record WitnessVerificationResult
{
/// <summary>Verification status.</summary>
public required WitnessVerificationStatus Status { get; init; }
/// <summary>Best matching witness.</summary>
public WitnessMatchResult? BestMatch { get; init; }
}
/// <summary>
/// Verification status.
/// </summary>
public enum WitnessVerificationStatus
{
/// <summary>Verified successfully.</summary>
Verified,
/// <summary>No witness found.</summary>
NoWitnessFound,
/// <summary>Verification failed.</summary>
Failed
}
/// <summary>
/// Match result for a witness.
/// </summary>
public sealed record WitnessMatchResult
{
/// <summary>Match confidence (0.0-1.0).</summary>
public double Confidence { get; init; }
/// <summary>Integrated time from Rekor.</summary>
public DateTimeOffset? IntegratedTime { get; init; }
/// <summary>Whether Rekor verification passed.</summary>
public bool RekorVerified { get; init; }
}

View File

@@ -0,0 +1,433 @@
// -----------------------------------------------------------------------------
// IUnknownsGateChecker.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-003 - Implement fail-closed gate integration
// Description: Interface and implementation for unknowns gate checking
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.Policy.Gates;
/// <summary>
/// Result of an unknowns gate check.
/// </summary>
public sealed record UnknownsGateCheckResult
{
/// <summary>
/// Gate decision: pass, warn, or block.
/// </summary>
public required GateDecision Decision { get; init; }
/// <summary>
/// Current state of unknowns for this component.
/// </summary>
public required string State { get; init; }
/// <summary>
/// IDs of blocking unknowns.
/// </summary>
public ImmutableArray<Guid> BlockingUnknownIds { get; init; } = [];
/// <summary>
/// Human-readable reason for the decision.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Whether an exception was granted to bypass the block.
/// </summary>
public bool ExceptionGranted { get; init; }
/// <summary>
/// Exception reference if granted.
/// </summary>
public string? ExceptionRef { get; init; }
}
/// <summary>
/// Gate decision types.
/// </summary>
public enum GateDecision
{
/// <summary>Gate passed, no blocking unknowns.</summary>
Pass,
/// <summary>Warning: unknowns present but not blocking.</summary>
Warn,
/// <summary>Blocked: HOT unknowns or SLA breached.</summary>
Block
}
/// <summary>
/// Unknown state for gate checks.
/// </summary>
public sealed record UnknownState
{
/// <summary>Unknown ID.</summary>
public required Guid UnknownId { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Priority band.</summary>
public required string Band { get; init; }
/// <summary>Current state (pending, under_review, escalated, resolved, rejected).</summary>
public required string State { get; init; }
/// <summary>Hours remaining in SLA.</summary>
public double? SlaRemainingHours { get; init; }
/// <summary>Whether SLA is breached.</summary>
public bool SlaBreach { get; init; }
/// <summary>Whether in CISA KEV.</summary>
public bool InKev { get; init; }
}
/// <summary>
/// Interface for checking unknowns gate.
/// </summary>
public interface IUnknownsGateChecker
{
/// <summary>
/// Checks if a component can pass the unknowns gate.
/// </summary>
/// <param name="bomRef">BOM reference of the component.</param>
/// <param name="proposedVerdict">Proposed VEX verdict (e.g., "not_affected").</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Gate check result.</returns>
Task<UnknownsGateCheckResult> CheckAsync(
string bomRef,
string? proposedVerdict = null,
CancellationToken ct = default);
/// <summary>
/// Gets all unknowns for a component.
/// </summary>
Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default);
/// <summary>
/// Requests an exception to bypass the gate.
/// </summary>
Task<ExceptionResult> RequestExceptionAsync(
string bomRef,
IEnumerable<Guid> unknownIds,
string justification,
string requestedBy,
CancellationToken ct = default);
}
/// <summary>
/// Exception request result.
/// </summary>
public sealed record ExceptionResult
{
/// <summary>Whether exception was granted.</summary>
public bool Granted { get; init; }
/// <summary>Exception reference.</summary>
public string? ExceptionRef { get; init; }
/// <summary>Reason for denial if not granted.</summary>
public string? DenialReason { get; init; }
/// <summary>When exception expires.</summary>
public DateTimeOffset? ExpiresAt { get; init; }
}
/// <summary>
/// Configuration for unknowns gate checker.
/// </summary>
public sealed record UnknownsGateOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Policy:UnknownsGate";
/// <summary>Whether to fail-closed (block on HOT unknowns).</summary>
public bool FailClosed { get; init; } = true;
/// <summary>Whether to block "not_affected" verdicts when unknowns exist.</summary>
public bool BlockNotAffectedWithUnknowns { get; init; } = true;
/// <summary>Whether to require exception approval for KEV items.</summary>
public bool RequireKevException { get; init; } = true;
/// <summary>Whether to force manual review when SLA is breached.</summary>
public bool ForceReviewOnSlaBreach { get; init; } = true;
/// <summary>Cache TTL for gate checks (seconds).</summary>
public int CacheTtlSeconds { get; init; } = 30;
/// <summary>Base URL for Unknowns API.</summary>
public string UnknownsApiUrl { get; init; } = "http://unknowns-api:8080";
}
/// <summary>
/// Default implementation of unknowns gate checker.
/// </summary>
public sealed class UnknownsGateChecker : IUnknownsGateChecker
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly UnknownsGateOptions _options;
private readonly ILogger<UnknownsGateChecker> _logger;
public UnknownsGateChecker(
HttpClient httpClient,
IMemoryCache cache,
IOptions<UnknownsGateOptions> options,
ILogger<UnknownsGateChecker> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_options = options?.Value ?? new UnknownsGateOptions();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<UnknownsGateCheckResult> CheckAsync(
string bomRef,
string? proposedVerdict = null,
CancellationToken ct = default)
{
var cacheKey = $"unknowns-gate:{bomRef}:{proposedVerdict}";
if (_cache.TryGetValue<UnknownsGateCheckResult>(cacheKey, out var cached) && cached != null)
{
return cached;
}
var unknowns = await GetUnknownsAsync(bomRef, ct);
// No unknowns = pass
if (unknowns.Count == 0)
{
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Pass,
State = "resolved",
Reason = "No pending unknowns"
});
}
// Check for blocking conditions
var hotUnknowns = unknowns.Where(u => u.Band == "hot").ToList();
var kevUnknowns = unknowns.Where(u => u.InKev).ToList();
var slaBreached = unknowns.Where(u => u.SlaBreach).ToList();
// Block: HOT unknowns in fail-closed mode
if (_options.FailClosed && hotUnknowns.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} HOT unknowns",
bomRef, hotUnknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_unknowns",
BlockingUnknownIds = [..hotUnknowns.Select(u => u.UnknownId)],
Reason = $"{hotUnknowns.Count} HOT unknown(s) require resolution"
});
}
// Block: "not_affected" verdict with any unknowns
if (_options.BlockNotAffectedWithUnknowns &&
proposedVerdict?.Equals("not_affected", StringComparison.OrdinalIgnoreCase) == true)
{
_logger.LogWarning(
"Blocking not_affected verdict for {BomRef}: {Count} unknowns exist",
bomRef, unknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_unknowns",
BlockingUnknownIds = [..unknowns.Select(u => u.UnknownId)],
Reason = "Cannot claim 'not_affected' with unresolved unknowns"
});
}
// Block: KEV items require exception
if (_options.RequireKevException && kevUnknowns.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} KEV unknowns require exception",
bomRef, kevUnknowns.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_kev",
BlockingUnknownIds = [..kevUnknowns.Select(u => u.UnknownId)],
Reason = $"{kevUnknowns.Count} KEV unknown(s) require exception approval"
});
}
// Block: SLA breached requires manual review
if (_options.ForceReviewOnSlaBreach && slaBreached.Count > 0)
{
_logger.LogWarning(
"Blocking gate for {BomRef}: {Count} unknowns with breached SLA",
bomRef, slaBreached.Count);
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Block,
State = "blocked_by_sla",
BlockingUnknownIds = [..slaBreached.Select(u => u.UnknownId)],
Reason = $"{slaBreached.Count} unknown(s) have breached SLA - manual review required"
});
}
// Warn: Non-HOT unknowns present
var worstState = unknowns.Any(u => u.State == "escalated") ? "escalated" :
unknowns.Any(u => u.State == "under_review") ? "under_review" : "pending";
return CacheAndReturn(cacheKey, new UnknownsGateCheckResult
{
Decision = GateDecision.Warn,
State = worstState,
Reason = $"{unknowns.Count} unknown(s) pending, but not blocking"
});
}
public async Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default)
{
try
{
// In production, call Unknowns API
// var response = await _httpClient.GetAsync($"{_options.UnknownsApiUrl}/api/v1/unknowns?bom_ref={Uri.EscapeDataString(bomRef)}", ct);
// Simulate lookup
await Task.Delay(10, ct);
// Return simulated data
return GenerateSimulatedUnknowns(bomRef);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch unknowns for {BomRef}", bomRef);
// Fail-closed: treat as if HOT unknowns exist
if (_options.FailClosed)
{
return
[
new UnknownState
{
UnknownId = Guid.NewGuid(),
Band = "hot",
State = "pending",
SlaRemainingHours = 0,
SlaBreach = true
}
];
}
return [];
}
}
public async Task<ExceptionResult> RequestExceptionAsync(
string bomRef,
IEnumerable<Guid> unknownIds,
string justification,
string requestedBy,
CancellationToken ct = default)
{
_logger.LogInformation(
"Exception requested for {BomRef} by {RequestedBy}: {Justification}",
bomRef, requestedBy, justification);
// In production, this would create an exception record
await Task.Delay(10, ct);
return new ExceptionResult
{
Granted = false,
DenialReason = "Automatic exceptions not enabled - requires manual approval",
ExpiresAt = null
};
}
private UnknownsGateCheckResult CacheAndReturn(string key, UnknownsGateCheckResult result)
{
_cache.Set(key, result, TimeSpan.FromSeconds(_options.CacheTtlSeconds));
return result;
}
private static List<UnknownState> GenerateSimulatedUnknowns(string bomRef)
{
// Deterministic simulation based on bomRef hash
var hash = bomRef.GetHashCode();
var random = new Random(hash);
if (random.NextDouble() > 0.3)
{
return []; // 70% have no unknowns
}
var count = random.Next(1, 3);
var unknowns = new List<UnknownState>();
for (var i = 0; i < count; i++)
{
var band = random.NextDouble() switch
{
< 0.2 => "hot",
< 0.5 => "warm",
_ => "cold"
};
unknowns.Add(new UnknownState
{
UnknownId = Guid.NewGuid(),
CveId = $"CVE-2026-{random.Next(1000, 9999)}",
Band = band,
State = random.NextDouble() < 0.3 ? "under_review" : "pending",
SlaRemainingHours = random.Next(1, 168),
SlaBreach = random.NextDouble() < 0.1,
InKev = random.NextDouble() < 0.05
});
}
return unknowns;
}
}
/// <summary>
/// Gate bypass audit entry for tracking unknown-related bypasses.
/// </summary>
public sealed record GateBypassAuditEntry
{
/// <summary>Audit entry ID.</summary>
public required Guid AuditId { get; init; }
/// <summary>BOM reference that was bypassed.</summary>
public required string BomRef { get; init; }
/// <summary>Unknown IDs that were bypassed.</summary>
public ImmutableArray<Guid> BypassedUnknownIds { get; init; } = [];
/// <summary>Justification for bypass.</summary>
public required string Justification { get; init; }
/// <summary>Who approved the bypass.</summary>
public required string ApprovedBy { get; init; }
/// <summary>When bypass was approved.</summary>
public DateTimeOffset ApprovedAt { get; init; }
/// <summary>When bypass expires.</summary>
public DateTimeOffset? ExpiresAt { get; init; }
}

View File

@@ -3,11 +3,16 @@
* Sprint: SPRINT_3600_0001_0001 (Trust Algebra and Lattice Engine)
* Task: TRUST-014
* Update: SPRINT_4300_0002_0001 (BUDGET-002) - Added UnknownBudgets support.
* Update: SPRINT_20260118_019 (GR-003) - Added content-addressable hashing.
*
* Defines trust roots, trust requirements, selection rule overrides, and unknown budgets.
*/
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Policy.TrustLattice;
@@ -287,4 +292,156 @@ public sealed record PolicyBundle
Name = "Default Policy",
Version = "1.0.0",
};
/// <summary>
/// Computes a content-addressable hash of the policy bundle.
/// Used for exact version identification during replay.
/// Sprint: SPRINT_20260118_019 (GR-003)
/// </summary>
/// <returns>SHA-256 hash prefixed with "sha256:"</returns>
public string ComputeHash()
{
var canonical = new PolicyBundleCanonicalForm
{
Id = Id,
Name = Name,
Version = Version,
TrustRoots = TrustRoots
.Where(r => r.IsActive)
.OrderBy(r => r.Principal.Id)
.Select(r => new TrustRootCanonicalForm
{
PrincipalId = r.Principal.Id,
PrincipalType = r.Principal.Type.ToString(),
ScopeType = r.Scope.Type.ToString(),
ScopeConstraint = r.Scope.Constraint,
MaxAssurance = r.MaxAssurance.ToString(),
ExpiresAt = r.ExpiresAt?.ToUnixTimeSeconds()
})
.ToList(),
TrustRequirements = new TrustRequirementsCanonicalForm
{
MinResolvedAssurance = TrustRequirements.MinResolvedAssurance.ToString(),
MinPedigreeAssurance = TrustRequirements.MinPedigreeAssurance.ToString(),
MinEvidenceClass = TrustRequirements.MinEvidenceClass.ToString(),
MaxClaimAgeSeconds = TrustRequirements.MaxClaimAge?.TotalSeconds,
RequireSignatures = TrustRequirements.RequireSignatures
},
CustomRules = CustomRules
.OrderBy(r => r.Priority)
.ThenBy(r => r.Name)
.Select(r => new SelectionRuleCanonicalForm
{
Name = r.Name,
Priority = r.Priority,
Condition = r.Condition,
Disposition = r.Disposition.ToString()
})
.ToList(),
ConflictResolution = ConflictResolution.ToString(),
AssumeReachableWhenUnknown = AssumeReachableWhenUnknown,
AcceptedVexFormats = AcceptedVexFormats.Order().ToList(),
UnknownBudgets = UnknownBudgets
.OrderBy(b => b.Environment)
.ThenBy(b => b.Name)
.Select(b => new UnknownBudgetCanonicalForm
{
Name = b.Name,
Environment = b.Environment,
TierMax = b.TierMax,
CountMax = b.CountMax,
EntropyMax = b.EntropyMax,
ReasonLimits = b.ReasonLimits.OrderBy(kv => kv.Key).ToDictionary(kv => kv.Key, kv => kv.Value),
Action = b.Action,
Message = b.Message
})
.ToList()
};
var json = JsonSerializer.Serialize(canonical, PolicyBundleHashJsonContext.Default.PolicyBundleCanonicalForm);
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
/// <summary>
/// Cached content hash. Computed lazily and cached.
/// </summary>
private string? _cachedHash;
/// <summary>
/// Gets the content hash, computing it if necessary.
/// </summary>
public string ContentHash => _cachedHash ??= ComputeHash();
}
#region Canonical Forms for Hashing
/// <summary>
/// Canonical form of PolicyBundle for deterministic hashing.
/// </summary>
internal sealed record PolicyBundleCanonicalForm
{
public string? Id { get; init; }
public string? Name { get; init; }
public string Version { get; init; } = "";
public List<TrustRootCanonicalForm> TrustRoots { get; init; } = [];
public TrustRequirementsCanonicalForm TrustRequirements { get; init; } = new();
public List<SelectionRuleCanonicalForm> CustomRules { get; init; } = [];
public string ConflictResolution { get; init; } = "";
public bool AssumeReachableWhenUnknown { get; init; }
public List<string> AcceptedVexFormats { get; init; } = [];
public List<UnknownBudgetCanonicalForm> UnknownBudgets { get; init; } = [];
}
internal sealed record TrustRootCanonicalForm
{
public string PrincipalId { get; init; } = "";
public string PrincipalType { get; init; } = "";
public string ScopeType { get; init; } = "";
public string? ScopeConstraint { get; init; }
public string MaxAssurance { get; init; } = "";
public long? ExpiresAt { get; init; }
}
internal sealed record TrustRequirementsCanonicalForm
{
public string MinResolvedAssurance { get; init; } = "";
public string MinPedigreeAssurance { get; init; } = "";
public string MinEvidenceClass { get; init; } = "";
public double? MaxClaimAgeSeconds { get; init; }
public bool RequireSignatures { get; init; }
}
internal sealed record SelectionRuleCanonicalForm
{
public string Name { get; init; } = "";
public int Priority { get; init; }
public string Condition { get; init; } = "";
public string Disposition { get; init; } = "";
}
internal sealed record UnknownBudgetCanonicalForm
{
public string Name { get; init; } = "";
public string Environment { get; init; } = "";
public int? TierMax { get; init; }
public int? CountMax { get; init; }
public double? EntropyMax { get; init; }
public Dictionary<string, int> ReasonLimits { get; init; } = [];
public string Action { get; init; } = "";
public string? Message { get; init; }
}
/// <summary>
/// Source-generated JSON context for canonical forms (deterministic serialization).
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(PolicyBundleCanonicalForm))]
internal partial class PolicyBundleHashJsonContext : JsonSerializerContext
{
}
#endregion