doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,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';
|
||||
@@ -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)';
|
||||
@@ -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)';
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// HttpOpaClient.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: HTTP client implementation for Open Policy Agent (OPA)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for interacting with an external OPA server.
|
||||
/// </summary>
|
||||
public sealed class HttpOpaClient : IOpaClient, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<HttpOpaClient> _logger;
|
||||
private readonly OpaClientOptions _options;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new HTTP OPA client with the specified options.
|
||||
/// </summary>
|
||||
public HttpOpaClient(
|
||||
IOptions<OpaClientOptions> options,
|
||||
ILogger<HttpOpaClient> logger,
|
||||
HttpClient? httpClient = null)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (httpClient is null)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(_options.BaseUrl),
|
||||
Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds)
|
||||
};
|
||||
_ownsHttpClient = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpaEvaluationResult> EvaluateAsync(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyPath);
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
try
|
||||
{
|
||||
var requestPath = BuildQueryPath(policyPath);
|
||||
var request = new OpaQueryRequest { Input = input };
|
||||
|
||||
_logger.LogDebug("Evaluating OPA policy at {Path}", requestPath);
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(requestPath, request, JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning(
|
||||
"OPA evaluation failed: {StatusCode} - {Error}",
|
||||
response.StatusCode, errorContent);
|
||||
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"OPA returned {response.StatusCode}: {errorContent}"
|
||||
};
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<OpaQueryResponse>(JsonOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = result?.DecisionId,
|
||||
Result = result?.Result,
|
||||
Metrics = result?.Metrics is not null ? MapMetrics(result.Metrics) : null
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "HTTP error connecting to OPA at {BaseUrl}", _options.BaseUrl);
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"HTTP error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
|
||||
{
|
||||
_logger.LogError(ex, "OPA request timed out after {Timeout}s", _options.TimeoutSeconds);
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"Request timed out after {_options.TimeoutSeconds} seconds"
|
||||
};
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse OPA response");
|
||||
return new OpaEvaluationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = $"JSON parse error: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await EvaluateAsync(policyPath, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = false,
|
||||
DecisionId = result.DecisionId,
|
||||
Error = result.Error,
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var typedResult = default(TResult);
|
||||
|
||||
if (result.Result is JsonElement jsonElement)
|
||||
{
|
||||
typedResult = jsonElement.Deserialize<TResult>(JsonOptions);
|
||||
}
|
||||
else if (result.Result is TResult directResult)
|
||||
{
|
||||
typedResult = directResult;
|
||||
}
|
||||
else if (result.Result is not null)
|
||||
{
|
||||
// Try re-serializing and deserializing
|
||||
var json = JsonSerializer.Serialize(result.Result, JsonOptions);
|
||||
typedResult = JsonSerializer.Deserialize<TResult>(json, JsonOptions);
|
||||
}
|
||||
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = true,
|
||||
DecisionId = result.DecisionId,
|
||||
Result = typedResult,
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize OPA result to {Type}", typeof(TResult).Name);
|
||||
return new OpaTypedResult<TResult>
|
||||
{
|
||||
Success = false,
|
||||
DecisionId = result.DecisionId,
|
||||
Error = $"Failed to deserialize result: {ex.Message}",
|
||||
Metrics = result.Metrics
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("health", cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "OPA health check failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UploadPolicyAsync(
|
||||
string policyId,
|
||||
string regoContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(regoContent);
|
||||
|
||||
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
|
||||
|
||||
using var content = new StringContent(regoContent, System.Text.Encoding.UTF8, "text/plain");
|
||||
var response = await _httpClient.PutAsync(requestPath, content, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to upload policy: {response.StatusCode} - {errorContent}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Uploaded policy {PolicyId} to OPA", policyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeletePolicyAsync(
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(policyId);
|
||||
|
||||
var requestPath = $"v1/policies/{Uri.EscapeDataString(policyId)}";
|
||||
var response = await _httpClient.DeleteAsync(requestPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode && response.StatusCode != System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to delete policy: {response.StatusCode} - {errorContent}");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Deleted policy {PolicyId} from OPA", policyId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the HTTP client if owned.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildQueryPath(string policyPath)
|
||||
{
|
||||
// Normalize path: remove leading "data/" if present
|
||||
var normalizedPath = policyPath.TrimStart('/');
|
||||
if (normalizedPath.StartsWith("data/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalizedPath = normalizedPath[5..];
|
||||
}
|
||||
|
||||
// Use v1/data endpoint for queries
|
||||
return $"v1/data/{normalizedPath}?metrics={_options.IncludeMetrics.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static OpaMetrics MapMetrics(Dictionary<string, long> metrics) => new()
|
||||
{
|
||||
TimerRegoQueryCompileNs = metrics.GetValueOrDefault("timer_rego_query_compile_ns"),
|
||||
TimerRegoQueryEvalNs = metrics.GetValueOrDefault("timer_rego_query_eval_ns"),
|
||||
TimerServerHandlerNs = metrics.GetValueOrDefault("timer_server_handler_ns")
|
||||
};
|
||||
|
||||
private sealed record OpaQueryRequest
|
||||
{
|
||||
public required object Input { get; init; }
|
||||
}
|
||||
|
||||
private sealed record OpaQueryResponse
|
||||
{
|
||||
[JsonPropertyName("decision_id")]
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
public object? Result { get; init; }
|
||||
|
||||
public Dictionary<string, long>? Metrics { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the OPA client.
|
||||
/// </summary>
|
||||
public sealed class OpaClientOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Section name in configuration.
|
||||
/// </summary>
|
||||
public const string SectionName = "Opa";
|
||||
|
||||
/// <summary>
|
||||
/// Base URL of the OPA server (e.g., "http://localhost:8181").
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = "http://localhost:8181";
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout in seconds.
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include metrics in responses.
|
||||
/// </summary>
|
||||
public bool IncludeMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional API key for authenticated OPA servers.
|
||||
/// </summary>
|
||||
public string? ApiKey { get; set; }
|
||||
}
|
||||
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
150
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/IOpaClient.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// IOpaClient.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Interface for Open Policy Agent (OPA) client
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for interacting with Open Policy Agent (OPA).
|
||||
/// </summary>
|
||||
public interface IOpaClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates a policy decision against OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyPath">The policy path (e.g., "data/stella/attestation/allow").</param>
|
||||
/// <param name="input">The input data for policy evaluation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The policy evaluation result.</returns>
|
||||
Task<OpaEvaluationResult> EvaluateAsync(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a policy and returns a typed result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The expected result type.</typeparam>
|
||||
/// <param name="policyPath">The policy path.</param>
|
||||
/// <param name="input">The input data for policy evaluation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The typed policy evaluation result.</returns>
|
||||
Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(
|
||||
string policyPath,
|
||||
object input,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks OPA server health.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if OPA is healthy.</returns>
|
||||
Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a policy to OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyId">Unique policy identifier.</param>
|
||||
/// <param name="regoContent">The Rego policy content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task UploadPolicyAsync(
|
||||
string policyId,
|
||||
string regoContent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a policy from OPA.
|
||||
/// </summary>
|
||||
/// <param name="policyId">The policy identifier to delete.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task DeletePolicyAsync(
|
||||
string policyId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an OPA policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record OpaEvaluationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the evaluation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision ID for tracing.
|
||||
/// </summary>
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The raw result object.
|
||||
/// </summary>
|
||||
public object? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA (timing, etc.).
|
||||
/// </summary>
|
||||
public OpaMetrics? Metrics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Typed result of an OPA policy evaluation.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The result type.</typeparam>
|
||||
public sealed record OpaTypedResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the evaluation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The decision ID for tracing.
|
||||
/// </summary>
|
||||
public string? DecisionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The typed result.
|
||||
/// </summary>
|
||||
public T? Result { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if evaluation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA.
|
||||
/// </summary>
|
||||
public OpaMetrics? Metrics { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics from OPA policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record OpaMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Time taken to compile the query (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerRegoQueryCompileNs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time taken to evaluate the query (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerRegoQueryEvalNs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total server handler time (nanoseconds).
|
||||
/// </summary>
|
||||
public long? TimerServerHandlerNs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpaGateAdapter.cs
|
||||
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
// Task: TASK-017-007 - OPA Client Integration
|
||||
// Description: Adapter that wraps OPA policy evaluation as an IPolicyGate
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Gates.Opa;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that wraps an OPA policy evaluation as an <see cref="IPolicyGate"/>.
|
||||
/// This enables Rego policies to be used alongside C# gates in the gate registry.
|
||||
/// </summary>
|
||||
public sealed class OpaGateAdapter : IPolicyGate
|
||||
{
|
||||
private readonly IOpaClient _opaClient;
|
||||
private readonly ILogger<OpaGateAdapter> _logger;
|
||||
private readonly OpaGateOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
public OpaGateAdapter(
|
||||
IOpaClient opaClient,
|
||||
IOptions<OpaGateOptions> options,
|
||||
ILogger<OpaGateAdapter> logger)
|
||||
{
|
||||
_opaClient = opaClient ?? throw new ArgumentNullException(nameof(opaClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GateResult> EvaluateAsync(
|
||||
MergeResult mergeResult,
|
||||
PolicyGateContext context,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var input = BuildOpaInput(mergeResult, context);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evaluating OPA gate {GateName} at policy path {PolicyPath}",
|
||||
_options.GateName, _options.PolicyPath);
|
||||
|
||||
var result = await _opaClient.EvaluateAsync<OpaGateResult>(
|
||||
_options.PolicyPath,
|
||||
input,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"OPA gate {GateName} evaluation failed: {Error}",
|
||||
_options.GateName, result.Error);
|
||||
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
$"OPA evaluation error: {result.Error}");
|
||||
}
|
||||
|
||||
var opaResult = result.Result;
|
||||
if (opaResult is null)
|
||||
{
|
||||
_logger.LogWarning("OPA gate {GateName} returned null result", _options.GateName);
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
"OPA returned null result");
|
||||
}
|
||||
|
||||
var passed = opaResult.Allow ?? false;
|
||||
var reason = opaResult.Reason ?? (passed ? "Policy allowed" : "Policy denied");
|
||||
|
||||
_logger.LogDebug(
|
||||
"OPA gate {GateName} result: Passed={Passed}, Reason={Reason}",
|
||||
_options.GateName, passed, reason);
|
||||
|
||||
return new GateResult
|
||||
{
|
||||
GateName = _options.GateName,
|
||||
Passed = passed,
|
||||
Reason = reason,
|
||||
Details = BuildDetails(result.DecisionId, opaResult, result.Metrics)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OPA gate {GateName} threw exception", _options.GateName);
|
||||
|
||||
return BuildFailureResult(
|
||||
_options.FailOnError ? false : true,
|
||||
$"OPA gate exception: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private object BuildOpaInput(MergeResult mergeResult, PolicyGateContext context)
|
||||
{
|
||||
// Build a comprehensive input object for OPA evaluation
|
||||
return new
|
||||
{
|
||||
MergeResult = new
|
||||
{
|
||||
mergeResult.Findings,
|
||||
mergeResult.TotalFindings,
|
||||
mergeResult.CriticalCount,
|
||||
mergeResult.HighCount,
|
||||
mergeResult.MediumCount,
|
||||
mergeResult.LowCount,
|
||||
mergeResult.UnknownCount,
|
||||
mergeResult.NewFindings,
|
||||
mergeResult.RemovedFindings,
|
||||
mergeResult.UnchangedFindings
|
||||
},
|
||||
Context = new
|
||||
{
|
||||
context.Environment,
|
||||
context.UnknownCount,
|
||||
context.HasReachabilityProof,
|
||||
context.Severity,
|
||||
context.CveId,
|
||||
context.SubjectKey,
|
||||
ReasonCodes = context.ReasonCodes.ToArray()
|
||||
},
|
||||
Policy = new
|
||||
{
|
||||
_options.TrustedKeyIds,
|
||||
_options.IntegratedTimeCutoff,
|
||||
_options.AllowedPayloadTypes,
|
||||
_options.CustomData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private GateResult BuildFailureResult(bool passed, string reason)
|
||||
{
|
||||
return new GateResult
|
||||
{
|
||||
GateName = _options.GateName,
|
||||
Passed = passed,
|
||||
Reason = reason,
|
||||
Details = ImmutableDictionary<string, object>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private ImmutableDictionary<string, object> BuildDetails(
|
||||
string? decisionId,
|
||||
OpaGateResult opaResult,
|
||||
OpaMetrics? metrics)
|
||||
{
|
||||
var builder = ImmutableDictionary.CreateBuilder<string, object>();
|
||||
|
||||
if (decisionId is not null)
|
||||
{
|
||||
builder.Add("opaDecisionId", decisionId);
|
||||
}
|
||||
|
||||
if (opaResult.Violations is not null && opaResult.Violations.Count > 0)
|
||||
{
|
||||
builder.Add("violations", opaResult.Violations);
|
||||
}
|
||||
|
||||
if (opaResult.Warnings is not null && opaResult.Warnings.Count > 0)
|
||||
{
|
||||
builder.Add("warnings", opaResult.Warnings);
|
||||
}
|
||||
|
||||
if (metrics is not null)
|
||||
{
|
||||
builder.Add("opaEvalTimeNs", metrics.TimerRegoQueryEvalNs ?? 0);
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expected structure of the OPA gate evaluation result.
|
||||
/// </summary>
|
||||
private sealed record OpaGateResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the policy allows the action.
|
||||
/// </summary>
|
||||
public bool? Allow { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of policy violations (if denied).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Violations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of policy warnings (even if allowed).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? Warnings { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for an OPA gate adapter.
|
||||
/// </summary>
|
||||
public sealed class OpaGateOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the gate (used in results and logging).
|
||||
/// </summary>
|
||||
public string GateName { get; set; } = "OpaGate";
|
||||
|
||||
/// <summary>
|
||||
/// The OPA policy path to evaluate (e.g., "stella/attestation/allow").
|
||||
/// </summary>
|
||||
public string PolicyPath { get; set; } = "stella/policy/allow";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to fail the gate if OPA evaluation fails.
|
||||
/// If false, gate passes on OPA errors.
|
||||
/// </summary>
|
||||
public bool FailOnError { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// List of trusted key IDs to pass to the policy.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TrustedKeyIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Integrated time cutoff for Rekor freshness checks.
|
||||
/// </summary>
|
||||
public DateTimeOffset? IntegratedTimeCutoff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of allowed payload types.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> AllowedPayloadTypes { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Custom data to pass to the policy.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, object>? CustomData { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# attestation.rego
|
||||
# Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
|
||||
# Task: TASK-017-007 - OPA Client Integration
|
||||
# Description: Sample Rego policy for attestation verification
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
package stella.attestation
|
||||
|
||||
import future.keywords.if
|
||||
import future.keywords.in
|
||||
import future.keywords.contains
|
||||
|
||||
# Default deny
|
||||
default allow := false
|
||||
|
||||
# Allow if all attestation checks pass
|
||||
allow if {
|
||||
valid_payload_type
|
||||
trusted_key
|
||||
rekor_fresh_enough
|
||||
vex_status_acceptable
|
||||
}
|
||||
|
||||
# Build comprehensive response
|
||||
result := {
|
||||
"allow": allow,
|
||||
"reason": reason,
|
||||
"violations": violations,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# Determine reason for decision
|
||||
reason := "All attestation checks passed" if {
|
||||
allow
|
||||
}
|
||||
|
||||
reason := concat("; ", violations) if {
|
||||
not allow
|
||||
count(violations) > 0
|
||||
}
|
||||
|
||||
reason := "Unknown policy failure" if {
|
||||
not allow
|
||||
count(violations) == 0
|
||||
}
|
||||
|
||||
# Collect all violations
|
||||
violations contains msg if {
|
||||
not valid_payload_type
|
||||
msg := sprintf("Invalid payload type: got %v, expected one of %v",
|
||||
[input.attestation.payloadType, input.policy.allowedPayloadTypes])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
not trusted_key
|
||||
msg := sprintf("Untrusted signing key: %v not in trusted set",
|
||||
[input.attestation.keyId])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
not rekor_fresh_enough
|
||||
msg := sprintf("Rekor proof too old or too new: integratedTime %v outside valid range",
|
||||
[input.rekor.integratedTime])
|
||||
}
|
||||
|
||||
violations contains msg if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "affected"
|
||||
vuln.reachable == true
|
||||
msg := sprintf("Reachable vulnerability with affected status: %v", [vuln.id])
|
||||
}
|
||||
|
||||
# Collect warnings
|
||||
warnings contains msg if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "under_investigation"
|
||||
msg := sprintf("Vulnerability under investigation: %v", [vuln.id])
|
||||
}
|
||||
|
||||
warnings contains msg if {
|
||||
input.rekor.integratedTime
|
||||
time_since_integrated := time.now_ns() / 1000000000 - input.rekor.integratedTime
|
||||
time_since_integrated > 86400 * 7 # More than 7 days old
|
||||
msg := sprintf("Rekor proof is %v days old", [time_since_integrated / 86400])
|
||||
}
|
||||
|
||||
# Check payload type is in allowed list
|
||||
valid_payload_type if {
|
||||
input.attestation.payloadType in input.policy.allowedPayloadTypes
|
||||
}
|
||||
|
||||
valid_payload_type if {
|
||||
count(input.policy.allowedPayloadTypes) == 0 # No restrictions
|
||||
}
|
||||
|
||||
# Check if signing key is trusted
|
||||
trusted_key if {
|
||||
input.attestation.keyId in input.policy.trustedKeyIds
|
||||
}
|
||||
|
||||
trusted_key if {
|
||||
count(input.policy.trustedKeyIds) == 0 # No restrictions
|
||||
}
|
||||
|
||||
# Check if the key fingerprint matches
|
||||
trusted_key if {
|
||||
some key in input.policy.trustedKeys
|
||||
key.fingerprint == input.attestation.fingerprint
|
||||
key.active == true
|
||||
not key.revoked
|
||||
}
|
||||
|
||||
# Check Rekor freshness
|
||||
rekor_fresh_enough if {
|
||||
not input.policy.integratedTimeCutoff # No cutoff set
|
||||
}
|
||||
|
||||
rekor_fresh_enough if {
|
||||
input.rekor.integratedTime
|
||||
input.rekor.integratedTime <= input.policy.integratedTimeCutoff
|
||||
}
|
||||
|
||||
rekor_fresh_enough if {
|
||||
not input.rekor.integratedTime
|
||||
not input.policy.requireRekorProof
|
||||
}
|
||||
|
||||
# Check VEX status
|
||||
vex_status_acceptable if {
|
||||
not input.vex # No VEX data
|
||||
}
|
||||
|
||||
vex_status_acceptable if {
|
||||
not affected_and_reachable
|
||||
}
|
||||
|
||||
# Helper: check if any vulnerability is both affected and reachable
|
||||
affected_and_reachable if {
|
||||
some vuln in input.vex.vulnerabilities
|
||||
vuln.status == "affected"
|
||||
vuln.reachable == true
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Additional policy rules for composite checks
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# Minimum confidence score check
|
||||
minimum_confidence_met if {
|
||||
input.context.confidenceScore >= input.policy.minimumConfidence
|
||||
}
|
||||
|
||||
minimum_confidence_met if {
|
||||
not input.policy.minimumConfidence
|
||||
}
|
||||
|
||||
# SBOM presence check
|
||||
sbom_present if {
|
||||
input.artifacts.sbom
|
||||
input.artifacts.sbom.present == true
|
||||
}
|
||||
|
||||
sbom_present if {
|
||||
not input.policy.requireSbom
|
||||
}
|
||||
|
||||
# Signature algorithm allowlist
|
||||
allowed_algorithm if {
|
||||
input.attestation.algorithm in input.policy.allowedAlgorithms
|
||||
}
|
||||
|
||||
allowed_algorithm if {
|
||||
count(input.policy.allowedAlgorithms) == 0
|
||||
}
|
||||
|
||||
# Environment-specific rules
|
||||
production_ready if {
|
||||
input.context.environment != "production"
|
||||
}
|
||||
|
||||
production_ready if {
|
||||
input.context.environment == "production"
|
||||
minimum_confidence_met
|
||||
sbom_present
|
||||
allowed_algorithm
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user