refactor: DB schema fixes + container renames + compose include + audit sprint

- FindingsLedger: change schema from public to findings (V3-01)
- Add 9 migration module plugins: RiskEngine, Replay, ExportCenter, Integrations, Signer, IssuerDirectory, Workflow, PacksRegistry, OpsMemory (V4-01 to V4-09)
- Remove 16 redundant inline CREATE SCHEMA patterns (V4-10)
- Rename export→export-web, excititor→excititor-web for consistency
- Compose stella-ops.yml: thin wrapper using include: directive
- Fix dead /api/v1/jobengine/* gateway routes → release-orchestrator/packsregistry
- Scheduler plugin architecture: ISchedulerJobPlugin + ScanJobPlugin + DoctorJobPlugin
- Create unified audit sink sprint plan
- VulnExplorer integration tests + gap analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-08 16:10:36 +03:00
parent 6592cdcc9b
commit 65106afe4c
100 changed files with 5788 additions and 2852 deletions

View File

@@ -11,7 +11,7 @@ public partial class FindingsLedgerDbContext : DbContext
: base(options)
{
_schemaName = string.IsNullOrWhiteSpace(schemaName)
? "public"
? "findings"
: schemaName.Trim();
}

View File

@@ -7,7 +7,7 @@ namespace StellaOps.Findings.Ledger.Infrastructure.Postgres;
internal static class FindingsLedgerDbContextFactory
{
public const string DefaultSchemaName = "public";
public const string DefaultSchemaName = "findings";
public static FindingsLedgerDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
{

View File

@@ -1,9 +1,12 @@
-- 001_initial.sql
-- Findings Ledger bootstrap schema (LEDGER-29-001)
CREATE SCHEMA IF NOT EXISTS findings;
SET search_path TO findings, public;
BEGIN;
CREATE TYPE ledger_event_type AS ENUM (
CREATE TYPE findings.ledger_event_type AS ENUM (
'finding.created',
'finding.status_changed',
'finding.severity_changed',
@@ -16,7 +19,7 @@ CREATE TYPE ledger_event_type AS ENUM (
'finding.closed'
);
CREATE TYPE ledger_action_type AS ENUM (
CREATE TYPE findings.ledger_action_type AS ENUM (
'assign',
'comment',
'attach_evidence',
@@ -28,12 +31,12 @@ CREATE TYPE ledger_action_type AS ENUM (
'close'
);
CREATE TABLE ledger_events (
CREATE TABLE findings.ledger_events (
tenant_id TEXT NOT NULL,
chain_id UUID NOT NULL,
sequence_no BIGINT NOT NULL,
event_id UUID NOT NULL,
event_type ledger_event_type NOT NULL,
event_type findings.ledger_event_type NOT NULL,
policy_version TEXT NOT NULL,
finding_id TEXT NOT NULL,
artifact_id TEXT NOT NULL,
@@ -55,13 +58,13 @@ CREATE TABLE ledger_events (
CONSTRAINT ck_ledger_events_actor_type CHECK (actor_type IN ('system', 'operator', 'integration'))
) PARTITION BY LIST (tenant_id);
CREATE TABLE ledger_events_default PARTITION OF ledger_events DEFAULT;
CREATE TABLE findings.ledger_events_default PARTITION OF findings.ledger_events DEFAULT;
CREATE INDEX ix_ledger_events_finding ON ledger_events (tenant_id, finding_id, policy_version);
CREATE INDEX ix_ledger_events_type ON ledger_events (tenant_id, event_type, recorded_at DESC);
CREATE INDEX ix_ledger_events_recorded_at ON ledger_events (tenant_id, recorded_at DESC);
CREATE INDEX ix_ledger_events_finding ON findings.ledger_events (tenant_id, finding_id, policy_version);
CREATE INDEX ix_ledger_events_type ON findings.ledger_events (tenant_id, event_type, recorded_at DESC);
CREATE INDEX ix_ledger_events_recorded_at ON findings.ledger_events (tenant_id, recorded_at DESC);
CREATE TABLE ledger_merkle_roots (
CREATE TABLE findings.ledger_merkle_roots (
tenant_id TEXT NOT NULL,
anchor_id UUID NOT NULL,
window_start TIMESTAMPTZ NOT NULL,
@@ -77,11 +80,11 @@ CREATE TABLE ledger_merkle_roots (
CONSTRAINT ck_ledger_merkle_root_hash_hex CHECK (root_hash ~ '^[0-9a-f]{64}$')
) PARTITION BY LIST (tenant_id);
CREATE TABLE ledger_merkle_roots_default PARTITION OF ledger_merkle_roots DEFAULT;
CREATE TABLE findings.ledger_merkle_roots_default PARTITION OF findings.ledger_merkle_roots DEFAULT;
CREATE INDEX ix_merkle_sequences ON ledger_merkle_roots (tenant_id, sequence_end DESC);
CREATE INDEX ix_merkle_sequences ON findings.ledger_merkle_roots (tenant_id, sequence_end DESC);
CREATE TABLE findings_projection (
CREATE TABLE findings.findings_projection (
tenant_id TEXT NOT NULL,
finding_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
@@ -96,12 +99,12 @@ CREATE TABLE findings_projection (
CONSTRAINT ck_findings_projection_cycle_hash_hex CHECK (cycle_hash ~ '^[0-9a-f]{64}$')
) PARTITION BY LIST (tenant_id);
CREATE TABLE findings_projection_default PARTITION OF findings_projection DEFAULT;
CREATE TABLE findings.findings_projection_default PARTITION OF findings.findings_projection DEFAULT;
CREATE INDEX ix_projection_status ON findings_projection (tenant_id, status, severity DESC);
CREATE INDEX ix_projection_labels_gin ON findings_projection USING GIN (labels JSONB_PATH_OPS);
CREATE INDEX ix_projection_status ON findings.findings_projection (tenant_id, status, severity DESC);
CREATE INDEX ix_projection_labels_gin ON findings.findings_projection USING GIN (labels JSONB_PATH_OPS);
CREATE TABLE finding_history (
CREATE TABLE findings.finding_history (
tenant_id TEXT NOT NULL,
finding_id TEXT NOT NULL,
policy_version TEXT NOT NULL,
@@ -114,25 +117,25 @@ CREATE TABLE finding_history (
CONSTRAINT pk_finding_history PRIMARY KEY (tenant_id, finding_id, event_id)
) PARTITION BY LIST (tenant_id);
CREATE TABLE finding_history_default PARTITION OF finding_history DEFAULT;
CREATE TABLE findings.finding_history_default PARTITION OF findings.finding_history DEFAULT;
CREATE INDEX ix_finding_history_timeline ON finding_history (tenant_id, finding_id, occurred_at DESC);
CREATE INDEX ix_finding_history_timeline ON findings.finding_history (tenant_id, finding_id, occurred_at DESC);
CREATE TABLE triage_actions (
CREATE TABLE findings.triage_actions (
tenant_id TEXT NOT NULL,
action_id UUID NOT NULL,
event_id UUID NOT NULL,
finding_id TEXT NOT NULL,
action_type ledger_action_type NOT NULL,
action_type findings.ledger_action_type NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by TEXT NOT NULL,
CONSTRAINT pk_triage_actions PRIMARY KEY (tenant_id, action_id)
) PARTITION BY LIST (tenant_id);
CREATE TABLE triage_actions_default PARTITION OF triage_actions DEFAULT;
CREATE TABLE findings.triage_actions_default PARTITION OF findings.triage_actions DEFAULT;
CREATE INDEX ix_triage_actions_event ON triage_actions (tenant_id, event_id);
CREATE INDEX ix_triage_actions_created_at ON triage_actions (tenant_id, created_at DESC);
CREATE INDEX ix_triage_actions_event ON findings.triage_actions (tenant_id, event_id);
CREATE INDEX ix_triage_actions_created_at ON findings.triage_actions (tenant_id, created_at DESC);
COMMIT;

View File

@@ -1,8 +1,10 @@
-- LEDGER-OBS-53-001: persist evidence bundle references alongside ledger entries.
ALTER TABLE ledger_events
SET search_path TO findings, public;
ALTER TABLE findings.ledger_events
ADD COLUMN evidence_bundle_ref text NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_events_finding_evidence_ref
ON ledger_events (tenant_id, finding_id, recorded_at DESC)
ON findings.ledger_events (tenant_id, finding_id, recorded_at DESC)
WHERE evidence_bundle_ref IS NOT NULL;

View File

@@ -1,16 +1,18 @@
-- 002_projection_offsets.sql
-- Projection worker checkpoint storage (LEDGER-29-003)
SET search_path TO findings, public;
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_projection_offsets (
CREATE TABLE IF NOT EXISTS findings.ledger_projection_offsets (
worker_id TEXT NOT NULL PRIMARY KEY,
last_recorded_at TIMESTAMPTZ NOT NULL,
last_event_id UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
INSERT INTO ledger_projection_offsets (worker_id, last_recorded_at, last_event_id, updated_at)
INSERT INTO findings.ledger_projection_offsets (worker_id, last_recorded_at, last_event_id, updated_at)
VALUES (
'default',
'1970-01-01T00:00:00Z',

View File

@@ -1,15 +1,17 @@
-- 003_policy_rationale.sql
-- Add policy rationale column to findings_projection (LEDGER-29-004)
SET search_path TO findings, public;
BEGIN;
ALTER TABLE findings_projection
ALTER TABLE findings.findings_projection
ADD COLUMN IF NOT EXISTS policy_rationale JSONB NOT NULL DEFAULT '[]'::JSONB;
ALTER TABLE findings_projection
ALTER TABLE findings.findings_projection
ALTER COLUMN policy_rationale SET DEFAULT '[]'::JSONB;
UPDATE findings_projection
UPDATE findings.findings_projection
SET policy_rationale = '[]'::JSONB
WHERE policy_rationale IS NULL;

View File

@@ -1,9 +1,11 @@
-- 004_ledger_attestations.sql
-- LEDGER-OBS-54-001: storage for attestation verification exports
SET search_path TO findings, public;
BEGIN;
CREATE TABLE IF NOT EXISTS ledger_attestations (
CREATE TABLE IF NOT EXISTS findings.ledger_attestations (
tenant_id text NOT NULL,
attestation_id uuid NOT NULL,
artifact_id text NOT NULL,
@@ -21,20 +23,20 @@ CREATE TABLE IF NOT EXISTS ledger_attestations (
projection_version text NOT NULL
);
ALTER TABLE ledger_attestations
ALTER TABLE findings.ledger_attestations
ADD CONSTRAINT pk_ledger_attestations PRIMARY KEY (tenant_id, attestation_id);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_recorded
ON ledger_attestations (tenant_id, recorded_at, attestation_id);
ON findings.ledger_attestations (tenant_id, recorded_at, attestation_id);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_artifact
ON ledger_attestations (tenant_id, artifact_id, recorded_at DESC);
ON findings.ledger_attestations (tenant_id, artifact_id, recorded_at DESC);
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_finding
ON ledger_attestations (tenant_id, finding_id, recorded_at DESC)
ON findings.ledger_attestations (tenant_id, finding_id, recorded_at DESC)
WHERE finding_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_ledger_attestations_status
ON ledger_attestations (tenant_id, verification_status, recorded_at DESC);
ON findings.ledger_attestations (tenant_id, verification_status, recorded_at DESC);
COMMIT;

View File

@@ -1,15 +1,17 @@
-- 004_risk_fields.sql
-- Add risk scoring fields to findings_projection (LEDGER-RISK-66-001/002)
SET search_path TO findings, public;
BEGIN;
ALTER TABLE findings_projection
ALTER TABLE findings.findings_projection
ADD COLUMN IF NOT EXISTS risk_score NUMERIC(6,3),
ADD COLUMN IF NOT EXISTS risk_severity TEXT,
ADD COLUMN IF NOT EXISTS risk_profile_version TEXT,
ADD COLUMN IF NOT EXISTS risk_explanation_id UUID,
ADD COLUMN IF NOT EXISTS risk_event_sequence BIGINT;
CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings_projection (tenant_id, risk_severity, risk_score DESC);
CREATE INDEX IF NOT EXISTS ix_projection_risk ON findings.findings_projection (tenant_id, risk_severity, risk_score DESC);
COMMIT;

View File

@@ -1,9 +1,11 @@
-- 005_risk_fields.sql
-- LEDGER-RISK-66-001: add risk scoring fields to findings projection
SET search_path TO findings, public;
BEGIN;
ALTER TABLE findings_projection
ALTER TABLE findings.findings_projection
ADD COLUMN IF NOT EXISTS risk_score numeric(6,2) NULL,
ADD COLUMN IF NOT EXISTS risk_severity text NULL,
ADD COLUMN IF NOT EXISTS risk_profile_version text NULL,
@@ -11,6 +13,6 @@ ALTER TABLE findings_projection
ADD COLUMN IF NOT EXISTS risk_event_sequence bigint NULL;
CREATE INDEX IF NOT EXISTS ix_findings_projection_risk
ON findings_projection (tenant_id, risk_severity, risk_score DESC, recorded_at DESC);
ON findings.findings_projection (tenant_id, risk_severity, risk_score DESC, recorded_at DESC);
COMMIT;

View File

@@ -1,9 +1,11 @@
-- 006_orchestrator_airgap.sql
-- Add orchestrator export provenance and air-gap import provenance tables (LEDGER-34-101, LEDGER-AIRGAP-56-001)
SET search_path TO findings, public;
BEGIN;
CREATE TABLE IF NOT EXISTS orchestrator_exports
CREATE TABLE IF NOT EXISTS findings.orchestrator_exports
(
tenant_id TEXT NOT NULL,
run_id UUID NOT NULL,
@@ -21,12 +23,12 @@ CREATE TABLE IF NOT EXISTS orchestrator_exports
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact_run
ON orchestrator_exports (tenant_id, artifact_hash, run_id);
ON findings.orchestrator_exports (tenant_id, artifact_hash, run_id);
CREATE INDEX IF NOT EXISTS ix_orchestrator_exports_artifact
ON orchestrator_exports (tenant_id, artifact_hash);
ON findings.orchestrator_exports (tenant_id, artifact_hash);
CREATE TABLE IF NOT EXISTS airgap_imports
CREATE TABLE IF NOT EXISTS findings.airgap_imports
(
tenant_id TEXT NOT NULL,
bundle_id TEXT NOT NULL,
@@ -43,9 +45,9 @@ CREATE TABLE IF NOT EXISTS airgap_imports
);
CREATE INDEX IF NOT EXISTS ix_airgap_imports_bundle
ON airgap_imports (tenant_id, bundle_id);
ON findings.airgap_imports (tenant_id, bundle_id);
CREATE INDEX IF NOT EXISTS ix_airgap_imports_event
ON airgap_imports (tenant_id, ledger_event_id);
ON findings.airgap_imports (tenant_id, ledger_event_id);
COMMIT;

View File

@@ -2,6 +2,8 @@
-- Enable Row-Level Security for Findings Ledger tenant isolation (LEDGER-TEN-48-001-DEV)
-- Based on Evidence Locker pattern per CONTRACT-FINDINGS-LEDGER-RLS-011
SET search_path TO findings, public;
BEGIN;
-- ============================================
@@ -34,12 +36,12 @@ COMMENT ON FUNCTION findings_ledger_app.require_current_tenant() IS
-- 2. Enable RLS on ledger_events
-- ============================================
ALTER TABLE ledger_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_events FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_events FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON findings.ledger_events;
CREATE POLICY ledger_events_tenant_isolation
ON ledger_events
ON findings.ledger_events
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -48,12 +50,12 @@ CREATE POLICY ledger_events_tenant_isolation
-- 3. Enable RLS on ledger_merkle_roots
-- ============================================
ALTER TABLE ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_merkle_roots FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_merkle_roots ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_merkle_roots FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON findings.ledger_merkle_roots;
CREATE POLICY ledger_merkle_roots_tenant_isolation
ON ledger_merkle_roots
ON findings.ledger_merkle_roots
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -62,12 +64,12 @@ CREATE POLICY ledger_merkle_roots_tenant_isolation
-- 4. Enable RLS on findings_projection
-- ============================================
ALTER TABLE findings_projection ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings_projection FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.findings_projection ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.findings_projection FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings.findings_projection;
CREATE POLICY findings_projection_tenant_isolation
ON findings_projection
ON findings.findings_projection
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -76,12 +78,12 @@ CREATE POLICY findings_projection_tenant_isolation
-- 5. Enable RLS on finding_history
-- ============================================
ALTER TABLE finding_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE finding_history FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.finding_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.finding_history FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON findings.finding_history;
CREATE POLICY finding_history_tenant_isolation
ON finding_history
ON findings.finding_history
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -90,12 +92,12 @@ CREATE POLICY finding_history_tenant_isolation
-- 6. Enable RLS on triage_actions
-- ============================================
ALTER TABLE triage_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE triage_actions FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.triage_actions ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.triage_actions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON findings.triage_actions;
CREATE POLICY triage_actions_tenant_isolation
ON triage_actions
ON findings.triage_actions
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -104,12 +106,12 @@ CREATE POLICY triage_actions_tenant_isolation
-- 7. Enable RLS on ledger_attestations
-- ============================================
ALTER TABLE ledger_attestations ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestations FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_attestations ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_attestations FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON findings.ledger_attestations;
CREATE POLICY ledger_attestations_tenant_isolation
ON ledger_attestations
ON findings.ledger_attestations
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -118,12 +120,12 @@ CREATE POLICY ledger_attestations_tenant_isolation
-- 8. Enable RLS on orchestrator_exports
-- ============================================
ALTER TABLE orchestrator_exports ENABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.orchestrator_exports ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.orchestrator_exports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON findings.orchestrator_exports;
CREATE POLICY orchestrator_exports_tenant_isolation
ON orchestrator_exports
ON findings.orchestrator_exports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -132,12 +134,12 @@ CREATE POLICY orchestrator_exports_tenant_isolation
-- 9. Enable RLS on airgap_imports
-- ============================================
ALTER TABLE airgap_imports ENABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.airgap_imports ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.airgap_imports FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON findings.airgap_imports;
CREATE POLICY airgap_imports_tenant_isolation
ON airgap_imports
ON findings.airgap_imports
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());

View File

@@ -1,33 +1,35 @@
-- 007_enable_rls_rollback.sql
-- Rollback: Disable Row-Level Security for Findings Ledger (LEDGER-TEN-48-001-DEV)
SET search_path TO findings, public;
BEGIN;
-- ============================================
-- 1. Disable RLS on all tables
-- ============================================
ALTER TABLE ledger_events DISABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_merkle_roots DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings_projection DISABLE ROW LEVEL SECURITY;
ALTER TABLE finding_history DISABLE ROW LEVEL SECURITY;
ALTER TABLE triage_actions DISABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestations DISABLE ROW LEVEL SECURITY;
ALTER TABLE orchestrator_exports DISABLE ROW LEVEL SECURITY;
ALTER TABLE airgap_imports DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_events DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_merkle_roots DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.findings_projection DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.finding_history DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.triage_actions DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_attestations DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.orchestrator_exports DISABLE ROW LEVEL SECURITY;
ALTER TABLE findings.airgap_imports DISABLE ROW LEVEL SECURITY;
-- ============================================
-- 2. Drop all tenant isolation policies
-- ============================================
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON ledger_events;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON ledger_merkle_roots;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings_projection;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON finding_history;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON triage_actions;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON ledger_attestations;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON orchestrator_exports;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON airgap_imports;
DROP POLICY IF EXISTS ledger_events_tenant_isolation ON findings.ledger_events;
DROP POLICY IF EXISTS ledger_merkle_roots_tenant_isolation ON findings.ledger_merkle_roots;
DROP POLICY IF EXISTS findings_projection_tenant_isolation ON findings.findings_projection;
DROP POLICY IF EXISTS finding_history_tenant_isolation ON findings.finding_history;
DROP POLICY IF EXISTS triage_actions_tenant_isolation ON findings.triage_actions;
DROP POLICY IF EXISTS ledger_attestations_tenant_isolation ON findings.ledger_attestations;
DROP POLICY IF EXISTS orchestrator_exports_tenant_isolation ON findings.orchestrator_exports;
DROP POLICY IF EXISTS airgap_imports_tenant_isolation ON findings.airgap_imports;
-- ============================================
-- 3. Drop tenant validation function and schema

View File

@@ -1,13 +1,15 @@
-- 008_attestation_pointers.sql
-- LEDGER-ATTEST-73-001: Persist pointers from findings to verification reports and attestation envelopes
SET search_path TO findings, public;
BEGIN;
-- ============================================
-- 1. Create attestation pointers table
-- ============================================
CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
CREATE TABLE IF NOT EXISTS findings.ledger_attestation_pointers (
tenant_id text NOT NULL,
pointer_id uuid NOT NULL,
finding_id text NOT NULL,
@@ -21,7 +23,7 @@ CREATE TABLE IF NOT EXISTS ledger_attestation_pointers (
ledger_event_id uuid NULL
);
ALTER TABLE ledger_attestation_pointers
ALTER TABLE findings.ledger_attestation_pointers
ADD CONSTRAINT pk_ledger_attestation_pointers PRIMARY KEY (tenant_id, pointer_id);
-- ============================================
@@ -30,41 +32,41 @@ ALTER TABLE ledger_attestation_pointers
-- Index for finding lookups (most common query pattern)
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_finding
ON ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
ON findings.ledger_attestation_pointers (tenant_id, finding_id, created_at DESC);
-- Index for digest-based lookups (idempotency checks)
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_digest
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->>'digest'));
-- Index for attestation type filtering
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_type
ON ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
ON findings.ledger_attestation_pointers (tenant_id, attestation_type, created_at DESC);
-- Index for verification status filtering (verified/unverified/failed)
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_verified
ON ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
ON findings.ledger_attestation_pointers (tenant_id, ((verification_result->>'verified')::boolean))
WHERE verification_result IS NOT NULL;
-- Index for signer identity searches
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_signer
ON ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->'signer_info'->>'subject'))
WHERE attestation_ref->'signer_info' IS NOT NULL;
-- Index for predicate type searches
CREATE INDEX IF NOT EXISTS ix_ledger_attestation_pointers_predicate
ON ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
ON findings.ledger_attestation_pointers (tenant_id, (attestation_ref->>'predicate_type'))
WHERE attestation_ref->>'predicate_type' IS NOT NULL;
-- ============================================
-- 3. Enable Row-Level Security
-- ============================================
ALTER TABLE ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
ALTER TABLE ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_attestation_pointers ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_attestation_pointers FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON ledger_attestation_pointers;
DROP POLICY IF EXISTS ledger_attestation_pointers_tenant_isolation ON findings.ledger_attestation_pointers;
CREATE POLICY ledger_attestation_pointers_tenant_isolation
ON ledger_attestation_pointers
ON findings.ledger_attestation_pointers
FOR ALL
USING (tenant_id = findings_ledger_app.require_current_tenant())
WITH CHECK (tenant_id = findings_ledger_app.require_current_tenant());
@@ -73,28 +75,28 @@ CREATE POLICY ledger_attestation_pointers_tenant_isolation
-- 4. Add comments for documentation
-- ============================================
COMMENT ON TABLE ledger_attestation_pointers IS
COMMENT ON TABLE findings.ledger_attestation_pointers IS
'Links findings to verification reports and attestation envelopes for explainability (LEDGER-ATTEST-73-001)';
COMMENT ON COLUMN ledger_attestation_pointers.pointer_id IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.pointer_id IS
'Unique identifier for this attestation pointer';
COMMENT ON COLUMN ledger_attestation_pointers.finding_id IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.finding_id IS
'Finding that this pointer references';
COMMENT ON COLUMN ledger_attestation_pointers.attestation_type IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.attestation_type IS
'Type of attestation: verification_report, dsse_envelope, slsa_provenance, vex_attestation, sbom_attestation, scan_attestation, policy_attestation, approval_attestation';
COMMENT ON COLUMN ledger_attestation_pointers.relationship IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.relationship IS
'Semantic relationship: verified_by, attested_by, signed_by, approved_by, derived_from';
COMMENT ON COLUMN ledger_attestation_pointers.attestation_ref IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.attestation_ref IS
'JSON object containing digest, storage_uri, payload_type, predicate_type, subject_digests, signer_info, rekor_entry';
COMMENT ON COLUMN ledger_attestation_pointers.verification_result IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.verification_result IS
'JSON object containing verified (bool), verified_at, verifier, verifier_version, policy_ref, checks, warnings, errors';
COMMENT ON COLUMN ledger_attestation_pointers.ledger_event_id IS
COMMENT ON COLUMN findings.ledger_attestation_pointers.ledger_event_id IS
'Reference to the ledger event that recorded this pointer creation';
COMMIT;

View File

@@ -2,8 +2,10 @@
-- Description: Creates ledger_snapshots table for time-travel/snapshot functionality
-- Date: 2025-12-07
SET search_path TO findings, public;
-- Create ledger_snapshots table
CREATE TABLE IF NOT EXISTS ledger_snapshots (
CREATE TABLE IF NOT EXISTS findings.ledger_snapshots (
tenant_id TEXT NOT NULL,
snapshot_id UUID NOT NULL,
label TEXT,
@@ -30,24 +32,24 @@ CREATE TABLE IF NOT EXISTS ledger_snapshots (
-- Index for listing snapshots by status
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_status
ON ledger_snapshots (tenant_id, status, created_at DESC);
ON findings.ledger_snapshots (tenant_id, status, created_at DESC);
-- Index for finding expired snapshots
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_expires
ON ledger_snapshots (expires_at)
ON findings.ledger_snapshots (expires_at)
WHERE expires_at IS NOT NULL AND status = 'Available';
-- Index for sequence lookups
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_sequence
ON ledger_snapshots (tenant_id, sequence_number);
ON findings.ledger_snapshots (tenant_id, sequence_number);
-- Index for label search
CREATE INDEX IF NOT EXISTS idx_ledger_snapshots_label
ON ledger_snapshots (tenant_id, label)
ON findings.ledger_snapshots (tenant_id, label)
WHERE label IS NOT NULL;
-- Enable RLS
ALTER TABLE ledger_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE findings.ledger_snapshots ENABLE ROW LEVEL SECURITY;
-- RLS policy for tenant isolation
DO $$
@@ -57,15 +59,15 @@ BEGIN
WHERE tablename = 'ledger_snapshots'
AND policyname = 'ledger_snapshots_tenant_isolation'
) THEN
CREATE POLICY ledger_snapshots_tenant_isolation ON ledger_snapshots
CREATE POLICY ledger_snapshots_tenant_isolation ON findings.ledger_snapshots
USING (tenant_id = current_setting('app.tenant_id', true))
WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
END IF;
END $$;
-- Add comment
COMMENT ON TABLE ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
COMMENT ON COLUMN ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
COMMENT ON COLUMN ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
COMMENT ON COLUMN ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
COMMENT ON COLUMN ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';
COMMENT ON TABLE findings.ledger_snapshots IS 'Point-in-time snapshots of ledger state for time-travel queries';
COMMENT ON COLUMN findings.ledger_snapshots.sequence_number IS 'Ledger sequence number at snapshot time';
COMMENT ON COLUMN findings.ledger_snapshots.snapshot_timestamp IS 'Timestamp of ledger state captured';
COMMENT ON COLUMN findings.ledger_snapshots.merkle_root IS 'Merkle root hash of all events up to sequence_number';
COMMENT ON COLUMN findings.ledger_snapshots.dsse_digest IS 'DSSE envelope digest if signed';

View File

@@ -0,0 +1,18 @@
-- 001_initial_schema.sql
-- RiskEngine: schema and risk_score_results table.
CREATE SCHEMA IF NOT EXISTS riskengine;
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
job_id UUID PRIMARY KEY,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
success BOOLEAN NOT NULL,
error TEXT NULL,
signals JSONB NOT NULL,
completed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
ON riskengine.risk_score_results (completed_at DESC);

View File

@@ -14,6 +14,11 @@
<ItemGroup>
<PackageReference Include="Npgsql" />
</ItemGroup>
<ItemGroup>
<!-- Embed SQL migrations as resources -->
<EmbeddedResource Include="Migrations\**\*.sql" />
</ItemGroup>

View File

@@ -14,8 +14,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly NpgsqlDataSource _dataSource;
private readonly object _initGate = new();
private bool _tableInitialized;
public PostgresRiskScoreResultStore(string connectionString)
{
@@ -32,7 +30,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
public async Task SaveAsync(RiskScoreResult result, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO riskengine.risk_score_results (
@@ -79,8 +76,6 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
public bool TryGet(Guid jobId, out RiskScoreResult result)
{
EnsureTable();
const string sql = """
SELECT provider, subject, score, success, error, signals, completed_at
FROM riskengine.risk_score_results
@@ -127,75 +122,4 @@ public sealed class PostgresRiskScoreResultStore : IRiskScoreResultStore, IAsync
return _dataSource.DisposeAsync();
}
private async Task EnsureTableAsync(CancellationToken cancellationToken)
{
lock (_initGate)
{
if (_tableInitialized)
{
return;
}
}
const string ddl = """
CREATE SCHEMA IF NOT EXISTS riskengine;
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
job_id UUID PRIMARY KEY,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
success BOOLEAN NOT NULL,
error TEXT NULL,
signals JSONB NOT NULL,
completed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
ON riskengine.risk_score_results (completed_at DESC);
""";
await using var connection = await _dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = new NpgsqlCommand(ddl, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
lock (_initGate)
{
_tableInitialized = true;
}
}
private void EnsureTable()
{
lock (_initGate)
{
if (_tableInitialized)
{
return;
}
}
const string ddl = """
CREATE SCHEMA IF NOT EXISTS riskengine;
CREATE TABLE IF NOT EXISTS riskengine.risk_score_results (
job_id UUID PRIMARY KEY,
provider TEXT NOT NULL,
subject TEXT NOT NULL,
score DOUBLE PRECISION NOT NULL,
success BOOLEAN NOT NULL,
error TEXT NULL,
signals JSONB NOT NULL,
completed_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_risk_score_results_completed_at
ON riskengine.risk_score_results (completed_at DESC);
""";
using var connection = _dataSource.OpenConnection();
using var command = new NpgsqlCommand(ddl, connection);
command.ExecuteNonQuery();
lock (_initGate)
{
_tableInitialized = true;
}
}
}

View File

@@ -0,0 +1,421 @@
// Licensed under BUSL-1.1. Copyright (C) 2026 StellaOps Contributors.
// Integration tests for VulnExplorer endpoints merged into Findings Ledger WebService.
// Sprint: SPRINT_20260408_002 Task: VXLM-005
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Findings.Ledger.Tests.Integration;
/// <summary>
/// Integration tests validating the VulnExplorer endpoints that were merged into
/// the Findings Ledger WebService. Tests cover:
/// - VEX decision CRUD (create, get, list, update)
/// - VEX decision with attestation (signed override + rekor reference)
/// - Fix verification workflow (create + state transition)
/// - Audit bundle creation from persisted decisions
/// - Evidence subgraph retrieval
/// - Vulnerability list/detail queries via Ledger projections
/// - Input validation (bad request handling)
/// </summary>
[Trait("Category", TestCategories.Integration)]
public sealed class VulnExplorerEndpointsIntegrationTests : IClassFixture<FindingsLedgerWebApplicationFactory>
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly FindingsLedgerWebApplicationFactory _factory;
public VulnExplorerEndpointsIntegrationTests(FindingsLedgerWebApplicationFactory factory)
{
_factory = factory;
}
// ====================================================================
// VEX Decision endpoints
// ====================================================================
[Fact(DisplayName = "POST /v1/vex-decisions creates decision and GET returns it")]
public async Task CreateAndGetVexDecision_WorksEndToEnd()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
var createPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-001", "notAffected", withAttestation: false);
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(created);
var decisionId = created?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId), "Created decision should have a non-empty ID");
// Verify GET by ID
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", ct);
Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.Equal("CVE-2025-LEDGER-001", fetched?["vulnerabilityId"]?.GetValue<string>());
Assert.Equal("notAffected", fetched?["status"]?.GetValue<string>());
}
[Fact(DisplayName = "POST /v1/vex-decisions with attestation returns signed override")]
public async Task CreateWithAttestation_ReturnsSignedOverrideAndRekorReference()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-002", "affectedMitigated", withAttestation: true);
var response = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
var signedOverride = body?["signedOverride"]?.AsObject();
Assert.NotNull(signedOverride);
Assert.False(string.IsNullOrWhiteSpace(signedOverride?["envelopeDigest"]?.GetValue<string>()),
"Signed override should contain an envelope digest");
Assert.NotNull(signedOverride?["rekorLogIndex"]);
}
[Fact(DisplayName = "GET /v1/vex-decisions lists created decisions")]
public async Task ListVexDecisions_ReturnsCreatedDecisions()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
// Create a decision first
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-LIST", "notAffected", withAttestation: false);
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
// List decisions
var listResponse = await client.GetAsync("/v1/vex-decisions", ct);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listBody = await listResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(listBody?["items"]);
var items = listBody!["items"]!.AsArray();
Assert.True(items.Count > 0, "Decision list should contain at least one item");
}
[Fact(DisplayName = "PATCH /v1/vex-decisions/{id} updates decision status")]
public async Task UpdateVexDecision_ChangesStatus()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
// Create
var payload = BuildVexDecisionPayload("CVE-2025-LEDGER-PATCH", "notAffected", withAttestation: false);
var createResponse = await client.PostAsJsonAsync("/v1/vex-decisions", payload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var created = await createResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
var decisionId = created?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
// Update
var patchResponse = await client.PatchAsync(
$"/v1/vex-decisions/{decisionId}",
JsonContent.Create(new { status = "affectedMitigated", justificationText = "Mitigation deployed." }),
ct);
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
// Verify
var getResponse = await client.GetAsync($"/v1/vex-decisions/{decisionId}", ct);
var fetched = await getResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.Equal("affectedMitigated", fetched?["status"]?.GetValue<string>());
}
[Fact(DisplayName = "POST /v1/vex-decisions with invalid status returns 400")]
public async Task CreateVexDecision_InvalidStatus_ReturnsBadRequest()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
const string invalidJson = """
{
"vulnerabilityId": "CVE-2025-LEDGER-BAD",
"subject": {
"type": "image",
"name": "registry.example/app:9.9.9",
"digest": { "sha256": "zzz999" }
},
"status": "invalidStatusLiteral",
"justificationType": "other"
}
""";
using var content = new StringContent(invalidJson, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/v1/vex-decisions", content, ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// ====================================================================
// Evidence subgraph endpoint
// ====================================================================
[Fact(DisplayName = "GET /v1/evidence-subgraph/{vulnId} returns subgraph structure")]
public async Task EvidenceSubgraph_ReturnsGraphStructure()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
// Use a non-GUID vulnerability ID to exercise the stub fallback path
var response = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-0001", ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(body?["root"]);
Assert.NotNull(body?["edges"]);
Assert.NotNull(body?["verdict"]);
}
// ====================================================================
// Fix verification endpoints
// ====================================================================
[Fact(DisplayName = "POST + PATCH /v1/fix-verifications tracks state transitions")]
public async Task FixVerificationWorkflow_TracksStateTransitions()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
var createResponse = await client.PostAsJsonAsync(
"/v1/fix-verifications",
new
{
cveId = "CVE-2025-LEDGER-FIX-001",
componentPurl = "pkg:maven/org.example/app@1.2.3",
artifactDigest = "sha256:abc123"
},
ct);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
var patchResponse = await client.PatchAsync(
"/v1/fix-verifications/CVE-2025-LEDGER-FIX-001",
JsonContent.Create(new { verdict = "verified_by_scanner" }),
ct);
Assert.Equal(HttpStatusCode.OK, patchResponse.StatusCode);
var body = await patchResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.Equal("verified_by_scanner", body?["verdict"]?.GetValue<string>());
}
// ====================================================================
// Audit bundle endpoint
// ====================================================================
[Fact(DisplayName = "POST /v1/audit-bundles creates bundle from persisted decisions")]
public async Task CreateAuditBundle_ReturnsBundleForDecisionSet()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
// Create a decision first
var createPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-BUNDLE", "notAffected", withAttestation: false);
var decisionResponse = await client.PostAsJsonAsync("/v1/vex-decisions", createPayload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, decisionResponse.StatusCode);
var decision = await decisionResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
var decisionId = decision?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(decisionId));
// Create audit bundle
var bundleResponse = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
tenant = "tenant-qa",
decisionIds = new[] { decisionId }
},
ct);
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
var bundle = await bundleResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(bundle?["bundleId"]);
Assert.NotNull(bundle?["decisions"]);
}
// ====================================================================
// Vulnerability list/detail endpoints (Ledger projection queries)
// ====================================================================
[Fact(DisplayName = "GET /v1/vulns returns vulnerability list")]
public async Task ListVulns_ReturnsListFromLedgerProjection()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns", ct);
// May return OK with empty list or items depending on DB state
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(body?["items"]);
}
[Fact(DisplayName = "GET /v1/vulns/{id} returns 404 for non-existent finding")]
public async Task GetVulnDetail_NonExistent_ReturnsNotFound()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
var response = await client.GetAsync("/v1/vulns/non-existent-id", ct);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
// ====================================================================
// Full triage workflow (end-to-end sequence)
// ====================================================================
[Fact(DisplayName = "Full triage workflow: VEX decision -> fix verification -> audit bundle")]
public async Task FullTriageWorkflow_EndToEnd()
{
using var client = CreateAuthenticatedClient();
var ct = TestContext.Current.CancellationToken;
// Step 1: Create VEX decision
var vexPayload = BuildVexDecisionPayload("CVE-2025-LEDGER-TRIAGE", "affectedMitigated", withAttestation: true);
var vexResponse = await client.PostAsJsonAsync("/v1/vex-decisions", vexPayload, JsonOptions, ct);
Assert.Equal(HttpStatusCode.Created, vexResponse.StatusCode);
var vexDecision = await vexResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
var vexDecisionId = vexDecision?["id"]?.GetValue<string>();
Assert.False(string.IsNullOrWhiteSpace(vexDecisionId));
// Verify attestation was created
Assert.NotNull(vexDecision?["signedOverride"]?.AsObject());
// Step 2: Create fix verification
var fixResponse = await client.PostAsJsonAsync(
"/v1/fix-verifications",
new
{
cveId = "CVE-2025-LEDGER-TRIAGE",
componentPurl = "pkg:npm/stellaops/core@3.0.0",
artifactDigest = "sha256:triage123"
},
ct);
Assert.Equal(HttpStatusCode.Created, fixResponse.StatusCode);
// Step 3: Update fix verification
var fixPatchResponse = await client.PatchAsync(
"/v1/fix-verifications/CVE-2025-LEDGER-TRIAGE",
JsonContent.Create(new { verdict = "verified_by_scanner" }),
ct);
Assert.Equal(HttpStatusCode.OK, fixPatchResponse.StatusCode);
// Step 4: Create audit bundle
var bundleResponse = await client.PostAsJsonAsync(
"/v1/audit-bundles",
new
{
tenant = "tenant-qa",
decisionIds = new[] { vexDecisionId }
},
ct);
Assert.Equal(HttpStatusCode.Created, bundleResponse.StatusCode);
// Step 5: Retrieve evidence subgraph
var subgraphResponse = await client.GetAsync("/v1/evidence-subgraph/CVE-2025-LEDGER-TRIAGE", ct);
Assert.Equal(HttpStatusCode.OK, subgraphResponse.StatusCode);
var subgraph = await subgraphResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
Assert.NotNull(subgraph?["root"]);
Assert.NotNull(subgraph?["verdict"]);
// Step 6: Verify all decisions are queryable
var listResponse = await client.GetAsync("/v1/vex-decisions?vulnerabilityId=CVE-2025-LEDGER-TRIAGE", ct);
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
var listBody = await listResponse.Content.ReadFromJsonAsync<JsonObject>(ct);
var items = listBody!["items"]!.AsArray();
Assert.True(items.Count >= 1, "Should find at least the decision we created");
}
// ====================================================================
// Authorization checks
// ====================================================================
[Fact(DisplayName = "Unauthenticated requests to VulnExplorer endpoints are rejected")]
public async Task UnauthenticatedRequest_IsRejected()
{
using var client = _factory.CreateClient();
var ct = TestContext.Current.CancellationToken;
// No auth headers at all
var response = await client.GetAsync("/v1/vex-decisions", ct);
// Should be 401 or 403 (depends on auth handler config)
Assert.True(
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden,
$"Expected 401 or 403 but got {(int)response.StatusCode}");
}
// ====================================================================
// Helpers
// ====================================================================
private HttpClient CreateAuthenticatedClient()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token");
client.DefaultRequestHeaders.Add("X-Scopes",
"vuln:view vuln:investigate vuln:operate vuln:audit findings:read findings:write");
client.DefaultRequestHeaders.Add("X-Tenant-Id", "11111111-1111-1111-1111-111111111111");
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-qa");
client.DefaultRequestHeaders.Add("x-stella-user-id", "integration-test-user");
client.DefaultRequestHeaders.Add("x-stella-user-name", "Integration Test User");
return client;
}
private static object BuildVexDecisionPayload(string vulnerabilityId, string status, bool withAttestation)
{
if (withAttestation)
{
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:2.0.0",
digest = new Dictionary<string, string> { ["sha256"] = "def456" }
},
status,
justificationType = "runtimeMitigationPresent",
justificationText = "Runtime guard active.",
attestationOptions = new
{
createAttestation = true,
anchorToRekor = true,
signingKeyId = "test-key"
}
};
}
return new
{
vulnerabilityId,
subject = new
{
type = "image",
name = "registry.example/app:1.2.3",
digest = new Dictionary<string, string> { ["sha256"] = "abc123" }
},
status,
justificationType = "codeNotReachable",
justificationText = "Guarded by deployment policy."
};
}
}