diff --git a/apps/api/prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql b/apps/api/prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql new file mode 100644 index 0000000..204197c --- /dev/null +++ b/apps/api/prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql @@ -0,0 +1,699 @@ +-- T044 proves that consuming T043 advisory output performs one proof-ledger +-- write and no authoritative finding, lifecycle, policy, waiver, or suppression +-- mutation. The ledger stores only scope references, counts, digests, and fixed +-- zero-authority bits; it never stores advisory, source, evidence, or secret text. +CREATE TABLE "SastAiAdvisoryAuthorityProof" ( + "id" TEXT NOT NULL, + "advisoryId" TEXT NOT NULL, + "handoffId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "normalizedFindingId" TEXT NOT NULL, + "findingFingerprint" TEXT NOT NULL, + "requestDigest" TEXT NOT NULL, + "handoffDigest" TEXT NOT NULL, + "normalizedFindingCount" INTEGER NOT NULL, + "normalizedFindingSetDigest" TEXT NOT NULL, + "targetFindingDigest" TEXT NOT NULL, + "lifecycleStateCount" INTEGER NOT NULL, + "lifecycleStateSetDigest" TEXT NOT NULL, + "policyDecisionCount" INTEGER NOT NULL, + "policyDecisionSetDigest" TEXT NOT NULL, + "waiverCount" INTEGER NOT NULL, + "waiverSetDigest" TEXT NOT NULL, + "suppressionCount" INTEGER NOT NULL, + "suppressionSetDigest" TEXT NOT NULL, + "beforeStateDigest" TEXT NOT NULL, + "afterStateDigest" TEXT NOT NULL, + "findingCreateAuthority" BOOLEAN NOT NULL DEFAULT false, + "findingStatusMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "findingSeverityMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "lifecycleMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "waiverMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "suppressionMutationAuthority" BOOLEAN NOT NULL DEFAULT false, + "policyOverrideAuthority" BOOLEAN NOT NULL DEFAULT false, + "blockDecisionAuthority" BOOLEAN NOT NULL DEFAULT false, + "publicationAuthority" BOOLEAN NOT NULL DEFAULT false, + "scmWriteAuthority" BOOLEAN NOT NULL DEFAULT false, + "advisoryOnly" BOOLEAN NOT NULL DEFAULT true, + "proofLedgerWritten" BOOLEAN NOT NULL DEFAULT true, + "authoritativeFindingWritten" BOOLEAN NOT NULL DEFAULT false, + "lifecycleStateWritten" BOOLEAN NOT NULL DEFAULT false, + "policyDecisionWritten" BOOLEAN NOT NULL DEFAULT false, + "waiverWritten" BOOLEAN NOT NULL DEFAULT false, + "suppressionWritten" BOOLEAN NOT NULL DEFAULT false, + "callerAuthorityFieldsAccepted" BOOLEAN NOT NULL DEFAULT false, + "advisoryContentStored" BOOLEAN NOT NULL DEFAULT false, + "sourceContentStored" BOOLEAN NOT NULL DEFAULT false, + "secretValueStored" BOOLEAN NOT NULL DEFAULT false, + "verifiedAt" TIMESTAMP(3) NOT NULL, + "proofDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastAiAdvisoryAuthorityProof_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastAiAdvisoryAuthorityProof_contract_check" CHECK ( + "id" ~ '^sast-ai-authority-proof://[a-f0-9]{64}$' + AND "advisoryId" ~ '^sast-ai-advisory://[a-f0-9]{64}$' + AND "handoffId" ~ '^sast-ai-handoff://[a-f0-9]{64}$' + AND "occurrenceId" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "findingFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "requestDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "handoffDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "normalizedFindingSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "targetFindingDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "lifecycleStateSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "policyDecisionSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "waiverSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "suppressionSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "beforeStateDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "afterStateDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "proofDigest" ~ '^sha256:[a-f0-9]{64}$' + AND octet_length("tenantId") BETWEEN 1 AND 512 + AND octet_length("repositoryBindingId") BETWEEN 1 AND 512 + AND octet_length("scanRequestId") BETWEEN 1 AND 512 + AND octet_length("attemptId") BETWEEN 1 AND 512 + AND octet_length("normalizedFindingId") BETWEEN 1 AND 512 + AND "normalizedFindingCount" BETWEEN 1 AND 25000 + AND "lifecycleStateCount" = 1 + AND "policyDecisionCount" BETWEEN 0 AND 1024 + AND "waiverCount" BETWEEN 0 AND 1024 + AND "suppressionCount" BETWEEN 0 AND 1024 + AND "beforeStateDigest" = "afterStateDigest" + AND "findingCreateAuthority" IS FALSE + AND "findingStatusMutationAuthority" IS FALSE + AND "findingSeverityMutationAuthority" IS FALSE + AND "lifecycleMutationAuthority" IS FALSE + AND "waiverMutationAuthority" IS FALSE + AND "suppressionMutationAuthority" IS FALSE + AND "policyOverrideAuthority" IS FALSE + AND "blockDecisionAuthority" IS FALSE + AND "publicationAuthority" IS FALSE + AND "scmWriteAuthority" IS FALSE + AND "advisoryOnly" IS TRUE + AND "proofLedgerWritten" IS TRUE + AND "authoritativeFindingWritten" IS FALSE + AND "lifecycleStateWritten" IS FALSE + AND "policyDecisionWritten" IS FALSE + AND "waiverWritten" IS FALSE + AND "suppressionWritten" IS FALSE + AND "callerAuthorityFieldsAccepted" IS FALSE + AND "advisoryContentStored" IS FALSE + AND "sourceContentStored" IS FALSE + AND "secretValueStored" IS FALSE + ) +); + +COMMENT ON TABLE "SastAiAdvisoryAuthorityProof" IS + 'Immutable T044 security proof retained with advisory audit metadata; controlled tenant/legal hard purge requires prior audit export and explicit privileged maintenance.'; + +-- This coordination ledger is not authoritative product state. Every writer +-- that can change a T044 snapshot advances and locks the same scoped row that +-- proof creation locks before reading the snapshot. That makes a concurrent +-- write a real PostgreSQL serialization conflict instead of a same-snapshot +-- before/after comparison. +CREATE TABLE "SastAiAdvisoryAuthorityFence" ( + "scopeKey" TEXT NOT NULL, + "version" BIGINT NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastAiAdvisoryAuthorityFence_pkey" PRIMARY KEY ("scopeKey"), + CONSTRAINT "SastAiAdvisoryAuthorityFence_contract_check" CHECK ( + octet_length("scopeKey") BETWEEN 1 AND 4096 + AND "version" >= 1 + ) +); + +COMMENT ON TABLE "SastAiAdvisoryAuthorityFence" IS + 'Internal T044 concurrency fence; contains only canonical scope keys and monotonic versions.'; + +CREATE FUNCTION "sast_ai_authority_scan_fence_key"( + tenant_id TEXT, + scan_request_id TEXT +) +RETURNS TEXT +LANGUAGE sql +STABLE +STRICT +SET search_path = pg_catalog +AS $$ + SELECT jsonb_build_array( + 'sast-ai-authority-scan-v1', + tenant_id, + scan_request_id + )::TEXT +$$; + +CREATE FUNCTION "sast_ai_authority_lifecycle_fence_key"( + tenant_id TEXT, + repository_binding_id TEXT, + lifecycle_context_key TEXT, + lineage_id TEXT +) +RETURNS TEXT +LANGUAGE sql +STABLE +STRICT +SET search_path = pg_catalog +AS $$ + SELECT jsonb_build_array( + 'sast-ai-authority-lifecycle-v1', + tenant_id, + repository_binding_id, + lifecycle_context_key, + lineage_id + )::TEXT +$$; + +CREATE FUNCTION "sast_ai_authority_finding_fence_key"( + tenant_id TEXT, + normalized_finding_id TEXT +) +RETURNS TEXT +LANGUAGE sql +STABLE +STRICT +SET search_path = pg_catalog +AS $$ + SELECT jsonb_build_array( + 'sast-ai-authority-finding-v1', + tenant_id, + normalized_finding_id + )::TEXT +$$; + +CREATE FUNCTION "sast_ai_authority_advisory_fence_key"( + tenant_id TEXT, + advisory_id TEXT +) +RETURNS TEXT +LANGUAGE sql +STABLE +STRICT +SET search_path = pg_catalog +AS $$ + SELECT jsonb_build_array( + 'sast-ai-authority-advisory-v1', + tenant_id, + advisory_id + )::TEXT +$$; + +CREATE FUNCTION "touch_sast_ai_advisory_authority_fences"( + scope_keys TEXT[] +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + current_scope_key TEXT; +BEGIN + FOR current_scope_key IN + SELECT DISTINCT scope_key + FROM unnest(scope_keys) AS requested(scope_key) + WHERE scope_key IS NOT NULL AND octet_length(scope_key) > 0 + ORDER BY scope_key + LOOP + INSERT INTO public."SastAiAdvisoryAuthorityFence" ( + "scopeKey", + "version" + ) VALUES ( + current_scope_key, + 1 + ) + ON CONFLICT ("scopeKey") DO UPDATE + SET "version" = + public."SastAiAdvisoryAuthorityFence"."version" + 1; + END LOOP; +END; +$$; + +-- Transition-table triggers collapse a bulk createMany/updateMany/deleteMany +-- into one ordered touch per distinct scan, finding, or lifecycle scope. +CREATE FUNCTION "fence_sast_ai_authority_normalized_finding_statement"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + scope_keys TEXT[]; +BEGIN + IF TG_OP = 'INSERT' THEN + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_scan_fence_key"( + "tenantId", + "scanRequestId" + ) AS scope_key + FROM new_rows + UNION + SELECT public."sast_ai_authority_finding_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM new_rows + ) AS changed; + ELSIF TG_OP = 'UPDATE' THEN + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_scan_fence_key"( + "tenantId", + "scanRequestId" + ) AS scope_key + FROM old_rows + UNION + SELECT public."sast_ai_authority_finding_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM old_rows + UNION + SELECT public."sast_ai_authority_scan_fence_key"( + "tenantId", + "scanRequestId" + ) AS scope_key + FROM new_rows + UNION + SELECT public."sast_ai_authority_finding_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM new_rows + ) AS changed; + ELSE + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_scan_fence_key"( + "tenantId", + "scanRequestId" + ) AS scope_key + FROM old_rows + UNION + SELECT public."sast_ai_authority_finding_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM old_rows + ) AS changed; + END IF; + PERFORM public."touch_sast_ai_advisory_authority_fences"( + COALESCE(scope_keys, ARRAY[]::TEXT[]) + ); + RETURN NULL; +END; +$$; + +CREATE FUNCTION "fence_sast_ai_authority_lifecycle_state_statement"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + scope_keys TEXT[]; +BEGIN + IF TG_OP = 'INSERT' THEN + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_lifecycle_fence_key"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ) AS scope_key + FROM new_rows + ) AS changed; + ELSIF TG_OP = 'UPDATE' THEN + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_lifecycle_fence_key"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ) AS scope_key + FROM old_rows + UNION + SELECT public."sast_ai_authority_lifecycle_fence_key"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ) AS scope_key + FROM new_rows + ) AS changed; + ELSE + SELECT array_agg(changed.scope_key ORDER BY changed.scope_key) + INTO scope_keys + FROM ( + SELECT public."sast_ai_authority_lifecycle_fence_key"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ) AS scope_key + FROM old_rows + ) AS changed; + END IF; + PERFORM public."touch_sast_ai_advisory_authority_fences"( + COALESCE(scope_keys, ARRAY[]::TEXT[]) + ); + RETURN NULL; +END; +$$; + +CREATE FUNCTION "fence_sast_ai_authority_finding_reference"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + scope_keys TEXT[] := ARRAY[]::TEXT[]; +BEGIN + IF TG_OP IN ('UPDATE', 'DELETE') AND OLD."findingId" IS NOT NULL THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_finding_fence_key"( + OLD."tenantId", + OLD."findingId" + ) + ); + END IF; + IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."findingId" IS NOT NULL THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_finding_fence_key"( + NEW."tenantId", + NEW."findingId" + ) + ); + END IF; + PERFORM public."touch_sast_ai_advisory_authority_fences"(scope_keys); + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; + +CREATE FUNCTION "fence_sast_ai_authority_waiver"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + scope_keys TEXT[] := ARRAY[]::TEXT[]; +BEGIN + IF TG_OP IN ('UPDATE', 'DELETE') AND OLD."scope" LIKE 'finding:_%' THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_finding_fence_key"( + OLD."tenantId", + substring(OLD."scope" FROM 9) + ) + ); + END IF; + IF TG_OP IN ('INSERT', 'UPDATE') AND NEW."scope" LIKE 'finding:_%' THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_finding_fence_key"( + NEW."tenantId", + substring(NEW."scope" FROM 9) + ) + ); + END IF; + PERFORM public."touch_sast_ai_advisory_authority_fences"(scope_keys); + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; + +CREATE FUNCTION "fence_sast_ai_authority_advisory_metadata"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + scope_keys TEXT[] := ARRAY[]::TEXT[]; +BEGIN + IF TG_OP IN ('UPDATE', 'DELETE') THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_advisory_fence_key"( + OLD."tenantId", + OLD."id" + ) + ); + END IF; + IF TG_OP IN ('INSERT', 'UPDATE') THEN + scope_keys := array_append( + scope_keys, + public."sast_ai_authority_advisory_fence_key"( + NEW."tenantId", + NEW."id" + ) + ); + END IF; + PERFORM public."touch_sast_ai_advisory_authority_fences"(scope_keys); + IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; + RETURN NEW; +END; +$$; + +INSERT INTO "SastAiAdvisoryAuthorityFence" ("scopeKey", "version") +SELECT DISTINCT scope_key, 1 +FROM ( + SELECT "sast_ai_authority_scan_fence_key"( + "tenantId", + "scanRequestId" + ) AS scope_key + FROM "NormalizedFinding" + UNION ALL + SELECT "sast_ai_authority_finding_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM "NormalizedFinding" + UNION ALL + SELECT "sast_ai_authority_lifecycle_fence_key"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ) AS scope_key + FROM "SastFindingLifecycleState" + UNION ALL + SELECT "sast_ai_authority_advisory_fence_key"( + "tenantId", + "id" + ) AS scope_key + FROM "AiAdvisoryMetadata" +) AS existing_scopes +ON CONFLICT ("scopeKey") DO NOTHING; + +CREATE TRIGGER "NormalizedFinding_ai_authority_fence_insert" + AFTER INSERT ON "NormalizedFinding" + REFERENCING NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_normalized_finding_statement"(); + +CREATE TRIGGER "NormalizedFinding_ai_authority_fence_update" + AFTER UPDATE ON "NormalizedFinding" + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_normalized_finding_statement"(); + +CREATE TRIGGER "NormalizedFinding_ai_authority_fence_delete" + AFTER DELETE ON "NormalizedFinding" + REFERENCING OLD TABLE AS old_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_normalized_finding_statement"(); + +CREATE TRIGGER "SastFindingLifecycleState_ai_authority_fence_insert" + AFTER INSERT ON "SastFindingLifecycleState" + REFERENCING NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_lifecycle_state_statement"(); + +CREATE TRIGGER "SastFindingLifecycleState_ai_authority_fence_update" + AFTER UPDATE ON "SastFindingLifecycleState" + REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_lifecycle_state_statement"(); + +CREATE TRIGGER "SastFindingLifecycleState_ai_authority_fence_delete" + AFTER DELETE ON "SastFindingLifecycleState" + REFERENCING OLD TABLE AS old_rows + FOR EACH STATEMENT + EXECUTE FUNCTION "fence_sast_ai_authority_lifecycle_state_statement"(); + +CREATE TRIGGER "PolicyDecision_ai_authority_fence" + BEFORE INSERT OR UPDATE OR DELETE ON "PolicyDecision" + FOR EACH ROW + EXECUTE FUNCTION "fence_sast_ai_authority_finding_reference"(); + +CREATE TRIGGER "Suppression_ai_authority_fence" + BEFORE INSERT OR UPDATE OR DELETE ON "Suppression" + FOR EACH ROW + EXECUTE FUNCTION "fence_sast_ai_authority_finding_reference"(); + +CREATE TRIGGER "Waiver_ai_authority_fence" + BEFORE INSERT OR UPDATE OR DELETE ON "Waiver" + FOR EACH ROW + EXECUTE FUNCTION "fence_sast_ai_authority_waiver"(); + +CREATE TRIGGER "AiAdvisoryMetadata_ai_authority_fence" + BEFORE INSERT OR UPDATE OR DELETE ON "AiAdvisoryMetadata" + FOR EACH ROW + EXECUTE FUNCTION "fence_sast_ai_authority_advisory_metadata"(); + +CREATE FUNCTION "acquire_sast_ai_advisory_context_fence"( + tenant_id TEXT, + advisory_id TEXT +) +RETURNS INTEGER +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + locked_context_count INTEGER := 0; +BEGIN + PERFORM 1 + FROM public."SastAiAdvisoryAuthorityFence" + WHERE "scopeKey" = public."sast_ai_authority_advisory_fence_key"( + tenant_id, + advisory_id + ) + FOR UPDATE; + GET DIAGNOSTICS locked_context_count = ROW_COUNT; + RETURN locked_context_count; +END; +$$; + +CREATE FUNCTION "acquire_sast_ai_advisory_authority_fence"( + tenant_id TEXT, + scan_request_id TEXT, + repository_binding_id TEXT, + lifecycle_context_key TEXT, + lineage_id TEXT, + normalized_finding_id TEXT +) +RETURNS INTEGER +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +DECLARE + current_scope_key TEXT; + locked_scope_count INTEGER := 0; + required_scope_keys TEXT[] := ARRAY[ + public."sast_ai_authority_scan_fence_key"( + tenant_id, + scan_request_id + ), + public."sast_ai_authority_lifecycle_fence_key"( + tenant_id, + repository_binding_id, + lifecycle_context_key, + lineage_id + ), + public."sast_ai_authority_finding_fence_key"( + tenant_id, + normalized_finding_id + ) + ]; +BEGIN + FOR current_scope_key IN + SELECT "scopeKey" + FROM public."SastAiAdvisoryAuthorityFence" + WHERE "scopeKey" = ANY(required_scope_keys) + ORDER BY "scopeKey" + FOR UPDATE + LOOP + locked_scope_count := locked_scope_count + 1; + END LOOP; + RETURN locked_scope_count; +END; +$$; + +CREATE UNIQUE INDEX "SastAiAdvisoryAuthorityProof_advisoryId_key" + ON "SastAiAdvisoryAuthorityProof"("advisoryId"); +CREATE UNIQUE INDEX "SastAiAdvisoryAuthorityProof_handoffId_key" + ON "SastAiAdvisoryAuthorityProof"("handoffId"); +CREATE UNIQUE INDEX "SastAiAdvisoryAuthorityProof_proofDigest_key" + ON "SastAiAdvisoryAuthorityProof"("proofDigest"); +CREATE UNIQUE INDEX "SastAiAdvisoryAuthorityProof_tenant_scope_key" + ON "SastAiAdvisoryAuthorityProof"("id", "tenantId"); +CREATE UNIQUE INDEX "SastAiAdvisoryAuthorityProof_handoff_tenant_key" + ON "SastAiAdvisoryAuthorityProof"("handoffId", "tenantId"); +CREATE INDEX "SastAiAdvisoryAuthorityProof_scan_idx" + ON "SastAiAdvisoryAuthorityProof"( + "tenantId", + "repositoryBindingId", + "scanRequestId", + "verifiedAt" + ); +CREATE INDEX "SastAiAdvisoryAuthorityProof_occurrence_scope_idx" + ON "SastAiAdvisoryAuthorityProof"( + "occurrenceId", + "tenantId", + "repositoryBindingId", + "scanRequestId", + "attemptId" + ); +CREATE INDEX "SastAiAdvisoryAuthorityProof_finding_scope_idx" + ON "SastAiAdvisoryAuthorityProof"( + "normalizedFindingId", + "tenantId", + "scanRequestId" + ); + +ALTER TABLE "SastAiAdvisoryAuthorityProof" + ADD CONSTRAINT "SastAiAdvisoryAuthorityProof_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "SastAiAdvisoryAuthorityProof" + ADD CONSTRAINT "SastAiAdvisoryAuthorityProof_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "SastAiAdvisoryAuthorityProof" + ADD CONSTRAINT "SastAiAdvisoryAuthorityProof_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "SastAiAdvisoryAuthorityProof" + ADD CONSTRAINT "SastAiAdvisoryAuthorityProof_handoff_scope_fkey" + FOREIGN KEY ("handoffId", "tenantId") + REFERENCES "SastAiAdvisoryHandoff"("id", "tenantId") + ON DELETE RESTRICT ON UPDATE RESTRICT; +ALTER TABLE "SastAiAdvisoryAuthorityProof" + ADD CONSTRAINT "SastAiAdvisoryAuthorityProof_advisoryId_fkey" + FOREIGN KEY ("advisoryId") REFERENCES "AiAdvisoryMetadata"("id") + ON DELETE RESTRICT ON UPDATE RESTRICT; + +CREATE FUNCTION "reject_sast_ai_advisory_authority_proof_update"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'SAST AI advisory authority proof ledgers are immutable' + USING ERRCODE = '55000'; + RETURN OLD; +END; +$$; + +CREATE TRIGGER "SastAiAdvisoryAuthorityProof_immutable_update" + BEFORE UPDATE ON "SastAiAdvisoryAuthorityProof" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_ai_advisory_authority_proof_update"(); + +CREATE TRIGGER "SastAiAdvisoryAuthorityProof_immutable_delete" + BEFORE DELETE ON "SastAiAdvisoryAuthorityProof" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_ai_advisory_authority_proof_update"(); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5e47380..aa10f5d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -383,6 +383,7 @@ model Tenant { sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] + sastAiAdvisoryAuthorityProofs SastAiAdvisoryAuthorityProof[] users User[] } @@ -442,6 +443,7 @@ model RepositoryBinding { sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] + sastAiAdvisoryAuthorityProofs SastAiAdvisoryAuthorityProof[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -491,6 +493,7 @@ model ScanRequest { sastEvidenceAccessDecisions SastEvidenceAccessDecision[] sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] + sastAiAdvisoryAuthorityProofs SastAiAdvisoryAuthorityProof[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -844,17 +847,19 @@ model NormalizedFinding { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) policyDecisions PolicyDecision[] suppressions Suppression[] sastOccurrence SastFindingOccurrence? sastAiAdvisoryHandoffs SastAiAdvisoryHandoff[] + sastAiAuthorityProofs SastAiAdvisoryAuthorityProof[] // Installed concurrently by the mandatory online-schema step so legacy // rows remain deployable while T037 metadata is introduced. @@unique([id, tenantId, scanRequestId, scannerRunId], map: "NormalizedFinding_sast_occurrence_scope_key") + @@unique([id, tenantId, scanRequestId], map: "NormalizedFinding_ai_authority_scope_key") @@index([tenantId]) @@index([scanRequestId]) @@index([scannerRunId]) @@ -983,6 +988,7 @@ model SastFindingOccurrence { correlationProvenances SastFindingCorrelationProvenance[] evidenceBuildDecisions SastEvidenceBuildDecision[] aiAdvisoryHandoffs SastAiAdvisoryHandoff[] + aiAdvisoryProofs SastAiAdvisoryAuthorityProof[] @@unique([observationBatchId, ordinal], map: "SastFindingOccurrence_batch_ordinal_key") @@unique([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastFindingOccurrence_normalized_scope_key") @@ -1709,21 +1715,95 @@ model SastAiAdvisoryHandoff { scmWriteAuthority Boolean @default(false) createdAt DateTime - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_repository_scope_fkey") - scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_scan_scope_fkey") - occurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_occurrence_scope_fkey") - normalizedFinding NormalizedFinding @relation(fields: [normalizedFindingId, tenantId, scanRequestId, scannerRunId], references: [id, tenantId, scanRequestId, scannerRunId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_finding_scope_fkey") - accessDecision SastEvidenceAccessDecision @relation(fields: [accessDecisionId, accessDecisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, createdAt], references: [id, decisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, decidedAt], onDelete: Restrict, map: "SastAiAdvisoryHandoff_access_scope_fkey") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_scan_scope_fkey") + occurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_occurrence_scope_fkey") + normalizedFinding NormalizedFinding @relation(fields: [normalizedFindingId, tenantId, scanRequestId, scannerRunId], references: [id, tenantId, scanRequestId, scannerRunId], onDelete: Restrict, map: "SastAiAdvisoryHandoff_finding_scope_fkey") + accessDecision SastEvidenceAccessDecision @relation(fields: [accessDecisionId, accessDecisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, createdAt], references: [id, decisionDigest, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId, findingFingerprint, decidedAt], onDelete: Restrict, map: "SastAiAdvisoryHandoff_access_scope_fkey") advisoryMetadata AiAdvisoryMetadata? + authorityProof SastAiAdvisoryAuthorityProof? @@unique([id, tenantId], map: "SastAiAdvisoryHandoff_tenant_scope_key") + @@unique([id, advisoryId, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, normalizedFindingId, findingFingerprint, requestDigest, handoffDigest], map: "SastAiAdvisoryHandoff_authority_scope_key") @@index([tenantId, repositoryBindingId, scanRequestId, createdAt], map: "SastAiAdvisoryHandoff_scan_idx") @@index([accessDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId, occurrenceId, evidencePackId], map: "SastAiAdvisoryHandoff_access_scope_idx") @@index([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastAiAdvisoryHandoff_finding_scope_idx") @@index([payloadExpiresAt], map: "SastAiAdvisoryHandoff_payloadExpiresAt_idx") } +model SastAiAdvisoryAuthorityProof { + id String @id + advisoryId String @unique + handoffId String @unique + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + normalizedFindingId String + findingFingerprint String + requestDigest String + handoffDigest String + normalizedFindingCount Int + normalizedFindingSetDigest String + targetFindingDigest String + lifecycleStateCount Int + lifecycleStateSetDigest String + policyDecisionCount Int + policyDecisionSetDigest String + waiverCount Int + waiverSetDigest String + suppressionCount Int + suppressionSetDigest String + beforeStateDigest String + afterStateDigest String + findingCreateAuthority Boolean @default(false) + findingStatusMutationAuthority Boolean @default(false) + findingSeverityMutationAuthority Boolean @default(false) + lifecycleMutationAuthority Boolean @default(false) + waiverMutationAuthority Boolean @default(false) + suppressionMutationAuthority Boolean @default(false) + policyOverrideAuthority Boolean @default(false) + blockDecisionAuthority Boolean @default(false) + publicationAuthority Boolean @default(false) + scmWriteAuthority Boolean @default(false) + advisoryOnly Boolean @default(true) + proofLedgerWritten Boolean @default(true) + authoritativeFindingWritten Boolean @default(false) + lifecycleStateWritten Boolean @default(false) + policyDecisionWritten Boolean @default(false) + waiverWritten Boolean @default(false) + suppressionWritten Boolean @default(false) + callerAuthorityFieldsAccepted Boolean @default(false) + advisoryContentStored Boolean @default(false) + sourceContentStored Boolean @default(false) + secretValueStored Boolean @default(false) + verifiedAt DateTime + proofDigest String @unique + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict, onUpdate: Restrict) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_scan_scope_fkey") + handoff SastAiAdvisoryHandoff @relation(fields: [handoffId, tenantId], references: [id, tenantId], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_handoff_scope_fkey") + advisory AiAdvisoryMetadata @relation(fields: [advisoryId], references: [id], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_advisoryId_fkey") + occurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_occurrence_scope_fkey") + normalizedFinding NormalizedFinding @relation(fields: [normalizedFindingId, tenantId, scanRequestId], references: [id, tenantId, scanRequestId], onDelete: Restrict, onUpdate: Restrict, map: "SastAiAdvisoryAuthorityProof_finding_scope_fkey") + + @@unique([id, tenantId], map: "SastAiAdvisoryAuthorityProof_tenant_scope_key") + @@unique([handoffId, tenantId], map: "SastAiAdvisoryAuthorityProof_handoff_tenant_key") + @@index([tenantId, repositoryBindingId, scanRequestId, verifiedAt], map: "SastAiAdvisoryAuthorityProof_scan_idx") + @@index([occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastAiAdvisoryAuthorityProof_occurrence_scope_idx") + @@index([normalizedFindingId, tenantId, scanRequestId], map: "SastAiAdvisoryAuthorityProof_finding_scope_idx") +} + +model SastAiAdvisoryAuthorityFence { + scopeKey String @id + version BigInt @default(1) + createdAt DateTime @default(now()) +} + model SastFindingCorrelationEdge { id String @id correlationBatchId String @@ -1861,9 +1941,10 @@ model AiAdvisoryMetadata { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - sastHandoff SastAiAdvisoryHandoff? @relation(fields: [sastHandoffId], references: [id], onDelete: Restrict, map: "AiAdvisoryMetadata_sastHandoffId_fkey") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + sastHandoff SastAiAdvisoryHandoff? @relation(fields: [sastHandoffId], references: [id], onDelete: Restrict, map: "AiAdvisoryMetadata_sastHandoffId_fkey") + authorityProof SastAiAdvisoryAuthorityProof? @@index([tenantId]) @@index([scanRequestId]) diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index 28daf3c..17d8af2 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -81,6 +81,12 @@ const indexes = [ create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_sast_occurrence_scope_key" ON "NormalizedFinding"("id", "tenantId", "scanRequestId", "scannerRunId")' }, + { + name: 'NormalizedFinding_ai_authority_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_ai_authority_scope_key" ON "NormalizedFinding"("id", "tenantId", "scanRequestId")' + }, { name: 'NormalizedFinding_sastLineageId_idx', unique: false, @@ -111,6 +117,12 @@ const indexes = [ create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AiAdvisoryMetadata_sastHandoffId_key" ON "AiAdvisoryMetadata"("sastHandoffId")' }, + { + name: 'SastAiAdvisoryHandoff_authority_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastAiAdvisoryHandoff_authority_scope_key" ON "SastAiAdvisoryHandoff"("id", "advisoryId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "normalizedFindingId", "findingFingerprint", "requestDigest", "handoffDigest")' + }, { name: 'SastArtifactDispositionDecision_coverage_scope_key', unique: true, @@ -649,6 +661,27 @@ const constraints = [ definition: 'FOREIGN KEY ("sastHandoffId") REFERENCES "SastAiAdvisoryHandoff"("id") ON DELETE RESTRICT ON UPDATE CASCADE' }, + { + table: 'SastAiAdvisoryAuthorityProof', + name: 'SastAiAdvisoryAuthorityProof_occurrence_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("occurrenceId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") REFERENCES "SastFindingOccurrence"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") ON DELETE RESTRICT ON UPDATE RESTRICT' + }, + { + table: 'SastAiAdvisoryAuthorityProof', + name: 'SastAiAdvisoryAuthorityProof_handoff_authority_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("handoffId", "advisoryId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "normalizedFindingId", "findingFingerprint", "requestDigest", "handoffDigest") REFERENCES "SastAiAdvisoryHandoff"("id", "advisoryId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId", "occurrenceId", "normalizedFindingId", "findingFingerprint", "requestDigest", "handoffDigest") ON DELETE RESTRICT ON UPDATE RESTRICT' + }, + { + table: 'SastAiAdvisoryAuthorityProof', + name: 'SastAiAdvisoryAuthorityProof_finding_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("normalizedFindingId", "tenantId", "scanRequestId") REFERENCES "NormalizedFinding"("id", "tenantId", "scanRequestId") ON DELETE RESTRICT ON UPDATE RESTRICT' + }, { table: 'SastFindingCorrelationEdge', name: 'SastFindingCorrelationEdge_source_occurrence_scope_fkey', diff --git a/apps/api/src/ai-plane/ai-advisory-authority.service.ts b/apps/api/src/ai-plane/ai-advisory-authority.service.ts new file mode 100644 index 0000000..4e9c585 --- /dev/null +++ b/apps/api/src/ai-plane/ai-advisory-authority.service.ts @@ -0,0 +1,146 @@ +import { + buildSastAiAdvisoryPolicyReference, + isSastAiAdvisoryAuthorityProofIntentShapeValid, + isSastAiAdvisoryPolicyReferenceShapeValid, + type SastAiAdvisoryAuthorityProofIntent, + type SastAiAdvisoryPolicyReference +} from '@aegisai/shared'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException +} from '@nestjs/common'; + +import { digestAuthorityCanonical } from './sast-ai-advisory-authority-canonical'; +import { SastAiAdvisoryAuthorityPersistenceError } from './sast-ai-advisory-authority.store'; +import { SastAiAdvisoryAuthorityStore } from './sast-ai-advisory-authority.store'; + +type AuthorityClock = () => string; + +@Injectable() +export class AiAdvisoryAuthorityService { + private readonly logger = new Logger( + AiAdvisoryAuthorityService.name + ); + + constructor(private readonly store: SastAiAdvisoryAuthorityStore) {} + + async createProof( + input: SastAiAdvisoryAuthorityProofIntent, + clock: AuthorityClock = () => new Date().toISOString() + ) { + if (!isSastAiAdvisoryAuthorityProofIntentShapeValid(input)) { + throw new BadRequestException( + 'AI authority proof intent must contain only tenant and advisory identifiers.' + ); + } + const verifiedAt = readClock(clock); + if (!verifiedAt) throw unavailable(); + + try { + const persisted = await this.store.createProof({ + tenantId: input.tenantId, + advisoryId: input.advisoryId, + verifiedAt + }); + const policyReference = buildSastAiAdvisoryPolicyReference( + persisted.proof, + digestAuthorityCanonical + ); + if (!policyReference) throw new Error('invalid proof reference'); + return { ...persisted, policyReference }; + } catch (error) { + this.logger.error( + `AI advisory authority proof failed (${safeErrorCategory(error)}).` + ); + throw mappedFailure(error); + } + } + + async verifyPolicyReference(input: { + tenantId: string; + normalizedFindingId: string; + reference: Readonly; + }): Promise { + if ( + !isBoundedReference(input.tenantId) || + !isBoundedReference(input.normalizedFindingId) || + !isSastAiAdvisoryPolicyReferenceShapeValid(input.reference) + ) { + return false; + } + try { + return await this.store.verifyPolicyReference(input); + } catch (error) { + this.logger.warn( + `AI advisory authority proof verification failed (${safeErrorCategory(error)}).` + ); + return false; + } + } +} + +function readClock(clock: AuthorityClock): string | null { + try { + const value = clock(); + return typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value + ? value + : null; + } catch { + return null; + } +} + +function isBoundedReference(value: unknown): value is string { + return typeof value === 'string' && + value.length > 0 && + value.trim() === value && + new TextEncoder().encode(value).length <= 512; +} + +function safeErrorCategory(error: unknown): string { + if (error instanceof SastAiAdvisoryAuthorityPersistenceError) { + return `${error.name}:${error.reason}`; + } + if (!(error instanceof Error)) return 'UnknownError'; + return [ + 'Error', + 'TypeError', + 'SastAiAdvisoryAuthorityPersistenceError', + 'PrismaClientKnownRequestError', + 'PrismaClientUnknownRequestError', + 'PrismaClientInitializationError' + ].includes(error.name) + ? error.name + : 'UnknownError'; +} + +function mappedFailure( + error: unknown +): NotFoundException | ConflictException | ServiceUnavailableException { + if (error instanceof SastAiAdvisoryAuthorityPersistenceError) { + if (error.reason === 'CONTEXT_DRIFT') return unavailable(); + if ( + error.reason === 'REPLAY_CONFLICT' || + error.reason === 'STATE_DRIFT' + ) { + return new ConflictException( + 'AI advisory authority proof conflicts with current authoritative state.' + ); + } + } + return new ServiceUnavailableException( + 'AI advisory authority proof service is temporarily unavailable.' + ); +} + +function unavailable(): NotFoundException { + return new NotFoundException( + 'AI advisory authority proof source is unavailable.' + ); +} diff --git a/apps/api/src/ai-plane/ai-advisory.controller.ts b/apps/api/src/ai-plane/ai-advisory.controller.ts index 147eeba..b07a213 100644 --- a/apps/api/src/ai-plane/ai-advisory.controller.ts +++ b/apps/api/src/ai-plane/ai-advisory.controller.ts @@ -1,14 +1,31 @@ -import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Post, + UseGuards +} from '@nestjs/common'; -import type { SastAiAdvisoryIntent } from '@aegisai/shared'; +import type { + SastAiAdvisoryAuthorityProofIntent, + SastAiAdvisoryIntent +} from '@aegisai/shared'; import { CurrentTenant } from '../auth/decorators/current-tenant.decorator'; import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; +import { CurrentInternalTenant } from '../common/security/current-internal-tenant.decorator'; import { InternalServiceGuard } from '../common/security/internal-service.guard'; +import { InternalTenantServiceGuard } from '../common/security/internal-tenant-service.guard'; +import { AiAdvisoryAuthorityService } from './ai-advisory-authority.service'; import { AiAdvisoryService } from "./ai-advisory.service"; @Controller("ai-advisories") export class AiAdvisoryController { - constructor(private readonly aiAdvisoryService: AiAdvisoryService) {} + constructor( + private readonly aiAdvisoryService: AiAdvisoryService, + private readonly authorityService: AiAdvisoryAuthorityService + ) {} @Post() @UseGuards(InternalServiceGuard) @@ -16,6 +33,20 @@ export class AiAdvisoryController { return this.aiAdvisoryService.createAdvisory(body); } + @Post('authority-proofs') + @UseGuards(InternalTenantServiceGuard) + createAuthorityProof( + @CurrentInternalTenant() authenticatedTenantId: string, + @Body() body: SastAiAdvisoryAuthorityProofIntent + ) { + if (body?.tenantId !== authenticatedTenantId) { + throw new ForbiddenException( + 'AI authority proof tenant does not match the authenticated internal tenant.' + ); + } + return this.authorityService.createProof(body); + } + @Get(":advisoryId") @UseGuards(SessionAuthGuard) read(@Param("advisoryId") advisoryId: string, @CurrentTenant() tenantId: string) { diff --git a/apps/api/src/ai-plane/ai-plane.module.ts b/apps/api/src/ai-plane/ai-plane.module.ts index ab95aab..e3efa0b 100644 --- a/apps/api/src/ai-plane/ai-plane.module.ts +++ b/apps/api/src/ai-plane/ai-plane.module.ts @@ -1,12 +1,15 @@ import { Module } from "@nestjs/common"; import { AiAdvisoryController } from "./ai-advisory.controller"; +import { AiAdvisoryAuthorityService } from './ai-advisory-authority.service'; import { AiAdvisoryRuntimeClient } from "./ai-advisory-runtime.client"; import { AiAdvisoryService } from "./ai-advisory.service"; import { ConfigModule } from "../config/config.module"; import { PrismaModule } from "../prisma/prisma.module"; import { ScanPlaneModule } from '../scan-plane/scan-plane.module'; import { PrismaSastAiAdvisoryStore } from './prisma-sast-ai-advisory.store'; +import { PrismaSastAiAdvisoryAuthorityStore } from './prisma-sast-ai-advisory-authority.store'; +import { SastAiAdvisoryAuthorityStore } from './sast-ai-advisory-authority.store'; import { SastAiAdvisoryStore } from './sast-ai-advisory.store'; @Module({ @@ -14,13 +17,19 @@ import { SastAiAdvisoryStore } from './sast-ai-advisory.store'; controllers: [AiAdvisoryController], providers: [ AiAdvisoryService, + AiAdvisoryAuthorityService, AiAdvisoryRuntimeClient, PrismaSastAiAdvisoryStore, + PrismaSastAiAdvisoryAuthorityStore, { provide: SastAiAdvisoryStore, useExisting: PrismaSastAiAdvisoryStore + }, + { + provide: SastAiAdvisoryAuthorityStore, + useExisting: PrismaSastAiAdvisoryAuthorityStore } ], - exports: [AiAdvisoryService] + exports: [AiAdvisoryService, AiAdvisoryAuthorityService] }) export class AiPlaneModule {} diff --git a/apps/api/src/ai-plane/prisma-sast-ai-advisory-authority.store.ts b/apps/api/src/ai-plane/prisma-sast-ai-advisory-authority.store.ts new file mode 100644 index 0000000..58dbd8d --- /dev/null +++ b/apps/api/src/ai-plane/prisma-sast-ai-advisory-authority.store.ts @@ -0,0 +1,701 @@ +import { + SAST_AI_ADVISORY_AUTHORITY_LIMITS, + SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION, + buildSastAiAdvisoryAuthorityProof, + buildSastAiAdvisoryAuthorityStateSnapshot, + isSastAiAdvisoryAuthorityProofShapeValid, + isSastAiAdvisoryPolicyReferenceShapeValid, + type SastAiAdvisoryAuthorityProof, + type SastAiAdvisoryAuthorityProofScope, + type SastAiAdvisoryAuthorityStateSnapshot, + type SastAiAdvisoryPolicyReference +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + digestAuthorityCanonical, + stableAuthorityJson +} from './sast-ai-advisory-authority-canonical'; +import { + SastAiAdvisoryAuthorityPersistenceError, + SastAiAdvisoryAuthorityStore, + type PersistedSastAiAdvisoryAuthorityProof +} from './sast-ai-advisory-authority.store'; + +const SERIALIZABLE_RETRIES = 3; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 10_000; + +interface AuthorityContext { + scope: SastAiAdvisoryAuthorityProofScope; + advisoryCreatedAt: string; + lineageId: string; + lifecycleContextKey: string; +} + +interface AuthorityProofRow { + id: string; + advisoryId: string; + handoffId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + normalizedFindingId: string; + findingFingerprint: string; + requestDigest: string; + handoffDigest: string; + normalizedFindingCount: number; + normalizedFindingSetDigest: string; + targetFindingDigest: string; + lifecycleStateCount: number; + lifecycleStateSetDigest: string; + policyDecisionCount: number; + policyDecisionSetDigest: string; + waiverCount: number; + waiverSetDigest: string; + suppressionCount: number; + suppressionSetDigest: string; + beforeStateDigest: string; + afterStateDigest: string; + findingCreateAuthority: boolean; + findingStatusMutationAuthority: boolean; + findingSeverityMutationAuthority: boolean; + lifecycleMutationAuthority: boolean; + waiverMutationAuthority: boolean; + suppressionMutationAuthority: boolean; + policyOverrideAuthority: boolean; + blockDecisionAuthority: boolean; + publicationAuthority: boolean; + scmWriteAuthority: boolean; + advisoryOnly: boolean; + proofLedgerWritten: boolean; + authoritativeFindingWritten: boolean; + lifecycleStateWritten: boolean; + policyDecisionWritten: boolean; + waiverWritten: boolean; + suppressionWritten: boolean; + callerAuthorityFieldsAccepted: boolean; + advisoryContentStored: boolean; + sourceContentStored: boolean; + secretValueStored: boolean; + verifiedAt: Date; + proofDigest: string; +} + +@Injectable() +export class PrismaSastAiAdvisoryAuthorityStore extends SastAiAdvisoryAuthorityStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async createProof(input: { + tenantId: string; + advisoryId: string; + verifiedAt: string; + }): Promise { + try { + return await this.runSerializable(async (tx) => { + await acquireAdvisoryContextFence(tx, input); + const context = await loadAuthorityContext(tx, input); + if (!context) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'CONTEXT_DRIFT' + ); + } + + await acquireAuthorityFence(tx, context); + + const existing = + await tx.sastAiAdvisoryAuthorityProof.findUnique({ + where: { advisoryId: input.advisoryId } + }); + if (existing) { + const replayed = replayProof(existing, context); + const current = await captureAuthorityState(tx, context); + if ( + current.stateDigest !== replayed.proof.before.stateDigest + ) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'STATE_DRIFT' + ); + } + return replayed; + } + + const before = await captureAuthorityState(tx, context); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: context.scope, + before, + after: before, + verifiedAt: input.verifiedAt, + digestCanonical: digestAuthorityCanonical + }); + if (!proof) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'OUTPUT_INVALID' + ); + } + + const created = + await tx.sastAiAdvisoryAuthorityProof.create({ + data: proofData(proof) + }); + return replayProof(created, context, false); + }); + } catch (error) { + if (!isUniqueConflict(error)) throw error; + return this.runSerializable(async (tx) => { + await acquireAdvisoryContextFence(tx, input); + const context = await loadAuthorityContext(tx, input); + if (!context) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'REPLAY_CONFLICT' + ); + } + await acquireAuthorityFence(tx, context); + const existing = + await tx.sastAiAdvisoryAuthorityProof.findUnique({ + where: { advisoryId: input.advisoryId } + }); + if (!existing) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'REPLAY_CONFLICT' + ); + } + const replayed = replayProof(existing, context); + const current = await captureAuthorityState(tx, context); + if (current.stateDigest !== replayed.proof.before.stateDigest) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'STATE_DRIFT' + ); + } + return replayed; + }); + } + } + + async verifyPolicyReference(input: { + tenantId: string; + normalizedFindingId: string; + reference: Readonly; + }): Promise { + if (!isSastAiAdvisoryPolicyReferenceShapeValid(input.reference)) { + return false; + } + const row = await this.prisma.sastAiAdvisoryAuthorityProof.findFirst({ + where: { + id: input.reference.authorityProofId, + proofDigest: input.reference.authorityProofDigest, + advisoryId: input.reference.advisoryId, + tenantId: input.tenantId, + normalizedFindingId: input.normalizedFindingId + } + }); + if (!row) return false; + const proof = proofFromRow(row); + return proof !== null && + proof.proofId === input.reference.authorityProofId && + proof.proofDigest === input.reference.authorityProofDigest && + proof.scope.advisoryId === input.reference.advisoryId; + } + + private async runSerializable( + operation: (tx: Prisma.TransactionClient) => Promise + ): Promise { + for (let attempt = 1; attempt <= SERIALIZABLE_RETRIES; attempt += 1) { + try { + return await this.prisma.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: SERIALIZABLE_TIMEOUT_MILLISECONDS, + timeout: SERIALIZABLE_TIMEOUT_MILLISECONDS + }); + } catch (error) { + if ( + !isSerializableConflict(error) || + attempt === SERIALIZABLE_RETRIES + ) { + throw error; + } + await new Promise((resolve) => + setTimeout( + resolve, + 20 * attempt + Math.floor(Math.random() * 20) + ) + ); + } + } + throw new SastAiAdvisoryAuthorityPersistenceError( + 'REPLAY_CONFLICT' + ); + } +} + +async function acquireAdvisoryContextFence( + tx: Prisma.TransactionClient, + input: Readonly<{ tenantId: string; advisoryId: string }> +): Promise { + const rows = await tx.$queryRaw< + Array<{ lockedContextCount: bigint | number }> + >` + SELECT "acquire_sast_ai_advisory_context_fence"( + ${input.tenantId}, + ${input.advisoryId} + ) AS "lockedContextCount" + `; + if (rows.length !== 1 || Number(rows[0].lockedContextCount) !== 1) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'CONTEXT_DRIFT' + ); + } +} + +async function acquireAuthorityFence( + tx: Prisma.TransactionClient, + context: Readonly +): Promise { + const rows = await tx.$queryRaw>` + SELECT "acquire_sast_ai_advisory_authority_fence"( + ${context.scope.tenantId}, + ${context.scope.scanRequestId}, + ${context.scope.repositoryBindingId}, + ${context.lifecycleContextKey}, + ${context.lineageId}, + ${context.scope.normalizedFindingId} + ) AS "lockedScopeCount" + `; + if (rows.length !== 1 || Number(rows[0].lockedScopeCount) !== 3) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'CONTEXT_DRIFT' + ); + } +} + +async function loadAuthorityContext( + client: Prisma.TransactionClient, + input: { tenantId: string; advisoryId: string; verifiedAt: string } +): Promise { + const advisory = await client.aiAdvisoryMetadata.findFirst({ + where: { id: input.advisoryId, tenantId: input.tenantId }, + select: { + id: true, + sastHandoffId: true, + tenantId: true, + scanRequestId: true, + findingId: true, + advisoryOnly: true, + redactedEvidenceOnly: true, + createdAt: true + } + }); + if ( + !advisory || + !advisory.sastHandoffId || + advisory.advisoryOnly !== true || + advisory.redactedEvidenceOnly !== true || + Date.parse(input.verifiedAt) < advisory.createdAt.getTime() + ) { + return null; + } + + const handoff = await client.sastAiAdvisoryHandoff.findUnique({ + where: { id: advisory.sastHandoffId }, + select: { + id: true, + advisoryId: true, + tenantId: true, + repositoryBindingId: true, + scanRequestId: true, + attemptId: true, + occurrenceId: true, + normalizedFindingId: true, + findingFingerprint: true, + requestDigest: true, + handoffDigest: true, + advisoryOnly: true, + policyAuthority: true, + publicationAuthority: true, + lifecycleMutationAuthority: true, + scmWriteAuthority: true + } + }); + if ( + !handoff || + handoff.advisoryId !== advisory.id || + handoff.tenantId !== advisory.tenantId || + handoff.scanRequestId !== advisory.scanRequestId || + handoff.normalizedFindingId !== advisory.findingId || + handoff.advisoryOnly !== true || + handoff.policyAuthority !== false || + handoff.publicationAuthority !== false || + handoff.lifecycleMutationAuthority !== false || + handoff.scmWriteAuthority !== false + ) { + return null; + } + + const occurrence = await client.sastFindingOccurrence.findFirst({ + where: { + id: handoff.occurrenceId, + tenantId: handoff.tenantId, + repositoryBindingId: handoff.repositoryBindingId, + scanRequestId: handoff.scanRequestId, + attemptId: handoff.attemptId, + normalizedFindingId: handoff.normalizedFindingId, + stableFingerprint: handoff.findingFingerprint + }, + select: { + id: true, + lineageId: true, + observationBatch: { + select: { lifecycleContextKey: true } + } + } + }); + if (!occurrence) return null; + + return { + scope: { + tenantId: handoff.tenantId, + repositoryBindingId: handoff.repositoryBindingId, + scanRequestId: handoff.scanRequestId, + attemptId: handoff.attemptId, + advisoryId: handoff.advisoryId, + handoffId: handoff.id, + requestDigest: handoff.requestDigest as `sha256:${string}`, + handoffDigest: handoff.handoffDigest as `sha256:${string}`, + normalizedFindingId: handoff.normalizedFindingId, + occurrenceId: handoff.occurrenceId, + findingFingerprint: + handoff.findingFingerprint as `sha256:${string}` + }, + advisoryCreatedAt: advisory.createdAt.toISOString(), + lineageId: occurrence.lineageId, + lifecycleContextKey: + occurrence.observationBatch.lifecycleContextKey + }; +} + +async function captureAuthorityState( + tx: Prisma.TransactionClient, + context: Readonly +): Promise { + const scope = context.scope; + const [ + findings, + lifecycleStates, + policyDecisions, + waivers, + suppressions + ] = await Promise.all([ + tx.normalizedFinding.findMany({ + where: { + tenantId: scope.tenantId, + scanRequestId: scope.scanRequestId + }, + select: { + id: true, + status: true, + severity: true, + updatedAt: true + }, + orderBy: { id: 'asc' }, + take: + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumNormalizedFindings + 1 + }), + tx.sastFindingLifecycleState.findMany({ + where: { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lineageId: context.lineageId, + lifecycleContextKey: context.lifecycleContextKey + }, + select: { + id: true, + targetRef: true, + status: true, + revision: true, + lastObservedBatchId: true, + lastObservedScanRequestId: true, + lastObservedCommitSha: true, + lastObservedAt: true, + lastReconciliationSequence: true, + fixedAt: true, + reopenedAt: true, + updatedAt: true + }, + orderBy: { id: 'asc' }, + take: 2 + }), + tx.policyDecision.findMany({ + where: { + tenantId: scope.tenantId, + findingId: scope.normalizedFindingId + }, + select: { + id: true, + enforcementAction: true, + commentAllowed: true, + dashboardVisible: true, + ticketRequested: true, + blockRequested: true, + reasonCodes: true, + requiredCoverage: true, + waiverApplied: true, + staleSuppressed: true, + aiAdvisoryVisible: true, + createdAt: true, + updatedAt: true + }, + orderBy: { id: 'asc' }, + take: SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumPolicyDecisions + 1 + }), + tx.waiver.findMany({ + where: { + tenantId: scope.tenantId, + scope: `finding:${scope.normalizedFindingId}` + }, + select: { + id: true, + owner: true, + reason: true, + scope: true, + expiresAt: true, + lastReviewedAt: true, + createdAt: true, + updatedAt: true + }, + orderBy: { id: 'asc' }, + take: SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumWaivers + 1 + }), + tx.suppression.findMany({ + where: { + tenantId: scope.tenantId, + findingId: scope.normalizedFindingId + }, + select: { + id: true, + scanRequestId: true, + findingId: true, + reason: true, + createdAt: true, + updatedAt: true + }, + orderBy: { id: 'asc' }, + take: SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumSuppressions + 1 + }) + ]); + + if (lifecycleStates.length === 0) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'CONTEXT_DRIFT' + ); + } + if ( + findings.length > + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumNormalizedFindings || + lifecycleStates.length > 1 || + policyDecisions.length > + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumPolicyDecisions || + waivers.length > SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumWaivers || + suppressions.length > + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumSuppressions + ) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'STATE_TOO_BROAD' + ); + } + + const findingDigests = findings + .map((row) => rowDigest('normalized-finding', row)) + .sort(); + const targetIndex = findings.findIndex( + (row) => row.id === scope.normalizedFindingId + ); + if (targetIndex < 0) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const targetFindingDigest = rowDigest( + 'normalized-finding', + findings[targetIndex] + ); + const snapshot = buildSastAiAdvisoryAuthorityStateSnapshot({ + normalizedFindingDigests: findingDigests, + targetFindingDigest, + lifecycleStateDigests: lifecycleStates + .map((row) => rowDigest('finding-lifecycle-state', row)) + .sort(), + policyDecisionDigests: policyDecisions + .map((row) => rowDigest('policy-decision', row)) + .sort(), + waiverDigests: waivers + .map((row) => rowDigest('waiver', row)) + .sort(), + suppressionDigests: suppressions + .map((row) => rowDigest('suppression', row)) + .sort(), + digestCanonical: digestAuthorityCanonical + }); + if (!snapshot) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'OUTPUT_INVALID' + ); + } + return snapshot; +} + +function proofData(proof: Readonly) { + const state = proof.before; + return { + id: proof.proofId, + advisoryId: proof.scope.advisoryId, + handoffId: proof.scope.handoffId, + tenantId: proof.scope.tenantId, + repositoryBindingId: proof.scope.repositoryBindingId, + scanRequestId: proof.scope.scanRequestId, + attemptId: proof.scope.attemptId, + occurrenceId: proof.scope.occurrenceId, + normalizedFindingId: proof.scope.normalizedFindingId, + findingFingerprint: proof.scope.findingFingerprint, + requestDigest: proof.scope.requestDigest, + handoffDigest: proof.scope.handoffDigest, + normalizedFindingCount: state.normalizedFindingCount, + normalizedFindingSetDigest: state.normalizedFindingSetDigest, + targetFindingDigest: state.targetFindingDigest, + lifecycleStateCount: state.lifecycleStateCount, + lifecycleStateSetDigest: state.lifecycleStateSetDigest, + policyDecisionCount: state.policyDecisionCount, + policyDecisionSetDigest: state.policyDecisionSetDigest, + waiverCount: state.waiverCount, + waiverSetDigest: state.waiverSetDigest, + suppressionCount: state.suppressionCount, + suppressionSetDigest: state.suppressionSetDigest, + beforeStateDigest: proof.before.stateDigest, + afterStateDigest: proof.after.stateDigest, + ...proof.authority, + ...proof.audit, + verifiedAt: new Date(proof.verifiedAt), + proofDigest: proof.proofDigest + }; +} + +function replayProof( + row: AuthorityProofRow, + context: Readonly, + replayed = true +): PersistedSastAiAdvisoryAuthorityProof { + const proof = proofFromRow(row); + if ( + !proof || + stableAuthorityJson(proof.scope) !== + stableAuthorityJson(context.scope) || + Date.parse(proof.verifiedAt) < + Date.parse(context.advisoryCreatedAt) + ) { + throw new SastAiAdvisoryAuthorityPersistenceError( + 'REPLAY_CONFLICT' + ); + } + return { proof, replayed }; +} + +function proofFromRow( + row: AuthorityProofRow +): SastAiAdvisoryAuthorityProof | null { + const snapshot: SastAiAdvisoryAuthorityStateSnapshot = { + normalizedFindingCount: row.normalizedFindingCount, + normalizedFindingSetDigest: + row.normalizedFindingSetDigest as `sha256:${string}`, + targetFindingDigest: row.targetFindingDigest as `sha256:${string}`, + lifecycleStateCount: row.lifecycleStateCount as 1, + lifecycleStateSetDigest: + row.lifecycleStateSetDigest as `sha256:${string}`, + policyDecisionCount: row.policyDecisionCount, + policyDecisionSetDigest: + row.policyDecisionSetDigest as `sha256:${string}`, + waiverCount: row.waiverCount, + waiverSetDigest: row.waiverSetDigest as `sha256:${string}`, + suppressionCount: row.suppressionCount, + suppressionSetDigest: + row.suppressionSetDigest as `sha256:${string}`, + stateDigest: row.beforeStateDigest as `sha256:${string}` + }; + const proof: SastAiAdvisoryAuthorityProof = { + version: SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION, + proofId: row.id, + scope: { + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + scanRequestId: row.scanRequestId, + attemptId: row.attemptId, + advisoryId: row.advisoryId, + handoffId: row.handoffId, + requestDigest: row.requestDigest as `sha256:${string}`, + handoffDigest: row.handoffDigest as `sha256:${string}`, + normalizedFindingId: row.normalizedFindingId, + occurrenceId: row.occurrenceId, + findingFingerprint: row.findingFingerprint as `sha256:${string}` + }, + before: snapshot, + after: { + ...snapshot, + stateDigest: row.afterStateDigest as `sha256:${string}` + }, + authority: { + findingCreateAuthority: row.findingCreateAuthority as false, + findingStatusMutationAuthority: + row.findingStatusMutationAuthority as false, + findingSeverityMutationAuthority: + row.findingSeverityMutationAuthority as false, + lifecycleMutationAuthority: row.lifecycleMutationAuthority as false, + waiverMutationAuthority: row.waiverMutationAuthority as false, + suppressionMutationAuthority: + row.suppressionMutationAuthority as false, + policyOverrideAuthority: row.policyOverrideAuthority as false, + blockDecisionAuthority: row.blockDecisionAuthority as false, + publicationAuthority: row.publicationAuthority as false, + scmWriteAuthority: row.scmWriteAuthority as false, + advisoryOnly: row.advisoryOnly as true + }, + audit: { + proofLedgerWritten: row.proofLedgerWritten as true, + authoritativeFindingWritten: + row.authoritativeFindingWritten as false, + lifecycleStateWritten: row.lifecycleStateWritten as false, + policyDecisionWritten: row.policyDecisionWritten as false, + waiverWritten: row.waiverWritten as false, + suppressionWritten: row.suppressionWritten as false, + callerAuthorityFieldsAccepted: + row.callerAuthorityFieldsAccepted as false, + advisoryContentStored: row.advisoryContentStored as false, + sourceContentStored: row.sourceContentStored as false, + secretValueStored: row.secretValueStored as false + }, + verifiedAt: row.verifiedAt.toISOString(), + proofDigest: row.proofDigest as `sha256:${string}` + }; + return isSastAiAdvisoryAuthorityProofShapeValid( + proof, + digestAuthorityCanonical + ) + ? proof + : null; +} + +function rowDigest(kind: string, row: object): `sha256:${string}` { + return digestAuthorityCanonical(stableAuthorityJson({ kind, row })); +} + +function isSerializableConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034'; +} + +function isUniqueConflict(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002'; +} diff --git a/apps/api/src/ai-plane/sast-ai-advisory-authority-canonical.ts b/apps/api/src/ai-plane/sast-ai-advisory-authority-canonical.ts new file mode 100644 index 0000000..aaefdf6 --- /dev/null +++ b/apps/api/src/ai-plane/sast-ai-advisory-authority-canonical.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto'; + +export function digestAuthorityCanonical( + value: string +): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +export function stableAuthorityJson(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableAuthorityJson(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${stableAuthorityJson(record[key])}` + ) + .join(',')}}`; +} diff --git a/apps/api/src/ai-plane/sast-ai-advisory-authority.store.ts b/apps/api/src/ai-plane/sast-ai-advisory-authority.store.ts new file mode 100644 index 0000000..34a4df6 --- /dev/null +++ b/apps/api/src/ai-plane/sast-ai-advisory-authority.store.ts @@ -0,0 +1,38 @@ +import type { + SastAiAdvisoryAuthorityProof, + SastAiAdvisoryPolicyReference +} from '@aegisai/shared'; + +export interface PersistedSastAiAdvisoryAuthorityProof { + proof: SastAiAdvisoryAuthorityProof; + replayed: boolean; +} + +export class SastAiAdvisoryAuthorityPersistenceError extends Error { + constructor( + readonly reason: + | 'CONTEXT_DRIFT' + | 'OUTPUT_INVALID' + | 'REPLAY_CONFLICT' + | 'STATE_DRIFT' + | 'STATE_TOO_BROAD' + ) { + super('The SAST AI advisory authority proof conflicts with durable state.'); + this.name = 'SastAiAdvisoryAuthorityPersistenceError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export abstract class SastAiAdvisoryAuthorityStore { + abstract createProof(input: { + tenantId: string; + advisoryId: string; + verifiedAt: string; + }): Promise; + + abstract verifyPolicyReference(input: { + tenantId: string; + normalizedFindingId: string; + reference: Readonly; + }): Promise; +} diff --git a/apps/api/src/common/security/current-internal-tenant.decorator.ts b/apps/api/src/common/security/current-internal-tenant.decorator.ts new file mode 100644 index 0000000..d2522a0 --- /dev/null +++ b/apps/api/src/common/security/current-internal-tenant.decorator.ts @@ -0,0 +1,22 @@ +import { + createParamDecorator, + type ExecutionContext, + UnauthorizedException +} from '@nestjs/common'; + +import type { InternalTenantRequest } from './internal-tenant-service.guard'; + +export const CurrentInternalTenant = createParamDecorator( + (_data: unknown, context: ExecutionContext) => { + const request = context + .switchToHttp() + .getRequest(); + if (!request.internalTenantId) { + throw new UnauthorizedException({ + errorCode: 'INTERNAL_TENANT_CONTEXT_REQUIRED', + message: 'An authenticated internal tenant context is required.' + }); + } + return request.internalTenantId; + } +); diff --git a/apps/api/src/common/security/internal-tenant-service.guard.ts b/apps/api/src/common/security/internal-tenant-service.guard.ts new file mode 100644 index 0000000..82701ee --- /dev/null +++ b/apps/api/src/common/security/internal-tenant-service.guard.ts @@ -0,0 +1,90 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import type { Request } from 'express'; + +import { ConfigService } from '../../config/config.service'; + +const TENANT_HEADER = 'x-aegis-internal-tenant-id'; +const CREDENTIAL_CONTEXT = 'aegis-internal-tenant-credential-v1'; + +export interface InternalTenantRequest extends Request { + internalTenantId?: string; +} + +@Injectable() +export class InternalTenantServiceGuard implements CanActivate { + constructor(private readonly config: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest(); + const tenantId = request.header(TENANT_HEADER) ?? ''; + const authorization = request.header('authorization'); + const token = authorization?.startsWith('Bearer ') + ? authorization.slice(7) + : ''; + const secret = this.config.get('INTERNAL_API_SECRET'); + const expected = deriveTenantBoundInternalCredential( + secret, + tenantId + ); + + if (!expected || !safeEqual(token, expected)) { + throw new UnauthorizedException({ + errorCode: 'INTERNAL_TENANT_AUTHENTICATION_REQUIRED', + message: + 'A valid tenant-bound internal service credential is required.' + }); + } + + request.internalTenantId = tenantId; + return true; + } +} + +export function deriveTenantBoundInternalCredential( + secret: string, + tenantId: string +): string | null { + if ( + secret.length === 0 || + !isBoundedTenantId(tenantId) + ) { + return null; + } + const mac = createHmac('sha256', secret) + .update(CREDENTIAL_CONTEXT, 'utf8') + .update('\0', 'utf8') + .update(tenantId, 'utf8') + .digest('hex'); + return `v1.${mac}`; +} + +function isBoundedTenantId(value: string): boolean { + return value.length > 0 && + value.trim() === value && + !hasAsciiControl(value) && + new TextEncoder().encode(value).length <= 512; +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function safeEqual(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer); +} diff --git a/apps/api/src/common/security/security.module.ts b/apps/api/src/common/security/security.module.ts index cd60253..909353f 100644 --- a/apps/api/src/common/security/security.module.ts +++ b/apps/api/src/common/security/security.module.ts @@ -4,11 +4,21 @@ import { AuthModule } from '../../auth/auth.module'; import { ConfigModule } from '../../config/config.module'; import { GithubWebhookSignatureGuard } from './github-webhook-signature.guard'; import { InternalServiceGuard } from './internal-service.guard'; +import { InternalTenantServiceGuard } from './internal-tenant-service.guard'; @Global() @Module({ imports: [AuthModule, ConfigModule], - providers: [GithubWebhookSignatureGuard, InternalServiceGuard], - exports: [AuthModule, GithubWebhookSignatureGuard, InternalServiceGuard] + providers: [ + GithubWebhookSignatureGuard, + InternalServiceGuard, + InternalTenantServiceGuard + ], + exports: [ + AuthModule, + GithubWebhookSignatureGuard, + InternalServiceGuard, + InternalTenantServiceGuard + ] }) export class SecurityModule {} diff --git a/apps/api/src/policy/policy-decision.store.ts b/apps/api/src/policy/policy-decision.store.ts new file mode 100644 index 0000000..95d2d05 --- /dev/null +++ b/apps/api/src/policy/policy-decision.store.ts @@ -0,0 +1,14 @@ +import type { PolicyDecision } from '@aegisai/shared'; + +export type PolicyDecisionCreate = Omit; + +export abstract class PolicyDecisionStore { + abstract create( + input: Readonly + ): Promise; + + abstract findByTenantAndId( + tenantId: string, + policyDecisionId: string + ): Promise; +} diff --git a/apps/api/src/policy/policy-engine.service.ts b/apps/api/src/policy/policy-engine.service.ts index ae03756..5a9ab17 100644 --- a/apps/api/src/policy/policy-engine.service.ts +++ b/apps/api/src/policy/policy-engine.service.ts @@ -1,82 +1,121 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import type { - PolicyDecision, - PolicyEvaluationInput, - ScannerKind +import { + BadRequestException, + Injectable, + NotFoundException, + Optional +} from '@nestjs/common'; + +import { + isSastAiAdvisoryPolicyReferenceShapeValid, + type PolicyDecision, + type PolicyEvaluationInput, + type ScannerKind } from '@aegisai/shared'; - -const REQUIRED_SCANNER_COVERAGE: ScannerKind[] = ["OPENGREP", "TRIVY", "SYFT"]; +import { AiAdvisoryAuthorityService } from '../ai-plane/ai-advisory-authority.service'; +import { PolicyDecisionStore } from './policy-decision.store'; + +/* + * Policy enforcement is derived only from deterministic findings and scanner + * coverage. A T044 proof may make an advisory visible, but it is never an + * action, severity, lifecycle, waiver, suppression, or blocking input. + */ +const REQUIRED_SCANNER_COVERAGE: ScannerKind[] = [ + 'OPENGREP', + 'TRIVY', + 'SYFT' +]; @Injectable() export class PolicyEngineService { - private readonly policyDecisions: PolicyDecision[] = []; - private decisionSequence = 0; - - evaluate(input: PolicyEvaluationInput): PolicyDecision { + constructor( + private readonly policyDecisionStore: PolicyDecisionStore, + @Optional() + private readonly authorityVerifier?: AiAdvisoryAuthorityService + ) {} + + async evaluate(input: PolicyEvaluationInput): Promise { + const aiAdvisoryVisible = await this.verifyAdvisoryReference(input); const reasonCodes = this.reasonCodesFor(input); const enforcementAction = this.enforcementActionFor(input); - const decision: PolicyDecision = { - id: `policy_decision_${++this.decisionSequence}`, + return this.policyDecisionStore.create({ tenantId: input.tenantId, scanRequestId: input.scanRequestId, findingId: input.finding.id, enforcementAction, - commentAllowed: enforcementAction !== "DASHBOARD_ONLY", + commentAllowed: enforcementAction !== 'DASHBOARD_ONLY', dashboardVisible: true, - ticketRequested: enforcementAction === "BLOCK", - blockRequested: enforcementAction === "BLOCK", + ticketRequested: enforcementAction === 'BLOCK', + blockRequested: enforcementAction === 'BLOCK', reasonCodes, requiredCoverage: REQUIRED_SCANNER_COVERAGE, waiverApplied: false, staleSuppressed: false, - aiAdvisoryVisible: input.aiAdvisory?.visible === true - }; - - this.policyDecisions.push(decision); + aiAdvisoryVisible + }); + } - return decision; + getPolicyDecision( + tenantId: string, + policyDecisionId: string + ): Promise { + return this.readPolicyDecision(tenantId, policyDecisionId); } - getPolicyDecision(tenantId: string, policyDecisionId: string): PolicyDecision { - const decision = this.policyDecisions.find( - (policyDecision) => - policyDecision.id === policyDecisionId && policyDecision.tenantId === tenantId + private async readPolicyDecision( + tenantId: string, + policyDecisionId: string + ): Promise { + const decision = await this.policyDecisionStore.findByTenantAndId( + tenantId, + policyDecisionId ); if (!decision) { - throw new NotFoundException("Policy decision was not found for tenant."); + throw new NotFoundException( + 'Policy decision was not found for tenant.' + ); } - return decision; } - private enforcementActionFor(input: PolicyEvaluationInput): PolicyDecision["enforcementAction"] { - if (input.finding.severity === "CRITICAL") { - return "BLOCK"; - } - - if (input.finding.severity === "HIGH") { - return "WARN"; - } - - if (input.finding.severity === "MEDIUM") { - return "COMMENT"; - } - - return "DASHBOARD_ONLY"; + private enforcementActionFor( + input: PolicyEvaluationInput + ): PolicyDecision['enforcementAction'] { + if (input.finding.severity === 'CRITICAL') return 'BLOCK'; + if (input.finding.severity === 'HIGH') return 'WARN'; + if (input.finding.severity === 'MEDIUM') return 'COMMENT'; + return 'DASHBOARD_ONLY'; } private reasonCodesFor(input: PolicyEvaluationInput): string[] { const reasonCodes = [`SEVERITY_${input.finding.severity}`]; - const hasRequiredCoverage = REQUIRED_SCANNER_COVERAGE.every((scanner) => - input.scannerCoverage.includes(scanner) + const hasRequiredCoverage = REQUIRED_SCANNER_COVERAGE.every( + (scanner) => input.scannerCoverage.includes(scanner) ); - if (input.scanLane === "DEEP" && !hasRequiredCoverage) { - reasonCodes.push("MISSING_REQUIRED_SCANNER_COVERAGE"); + if (input.scanLane === 'DEEP' && !hasRequiredCoverage) { + reasonCodes.push('MISSING_REQUIRED_SCANNER_COVERAGE'); } - return reasonCodes; } + + private async verifyAdvisoryReference( + input: Readonly + ): Promise { + if (input.aiAdvisory === undefined) return false; + if ( + !isSastAiAdvisoryPolicyReferenceShapeValid(input.aiAdvisory) || + !this.authorityVerifier || + !(await this.authorityVerifier.verifyPolicyReference({ + tenantId: input.tenantId, + normalizedFindingId: input.finding.id, + reference: input.aiAdvisory + })) + ) { + throw new BadRequestException( + 'AI advisory reference is invalid or unavailable.' + ); + } + return true; + } } diff --git a/apps/api/src/policy/policy-lifecycle.service.ts b/apps/api/src/policy/policy-lifecycle.service.ts index 7f5e8c0..4843c86 100644 --- a/apps/api/src/policy/policy-lifecycle.service.ts +++ b/apps/api/src/policy/policy-lifecycle.service.ts @@ -7,93 +7,96 @@ import type { WaiverCreateInput, WaiverUpdateInput } from '@aegisai/shared'; - -const FORBIDDEN_LIFECYCLE_KEYS = [ - "accessToken", - "refreshToken", - "tokenValue", - "secretValue", - "sourceArchive", - "fullRepository", - "rawScannerPayload", - "aiOverride", - "policyOverride", - "findingOverride", - "enforcementAction", - "blockRequested" -]; +import { PolicyLifecycleStore } from './policy-lifecycle.store'; + +const WAIVER_CREATE_KEYS = [ + 'tenantId', + 'owner', + 'reason', + 'scope', + 'expiresAt' +] as const; +const WAIVER_UPDATE_KEYS = [ + 'tenantId', + 'owner', + 'reason', + 'scope', + 'expiresAt', + 'lastReviewedAt' +] as const; +const SUPPRESSION_CREATE_KEYS = [ + 'tenantId', + 'scanRequestId', + 'findingId', + 'reason' +] as const; @Injectable() export class PolicyLifecycleService { - private readonly waivers: Waiver[] = []; - private readonly suppressions: Suppression[] = []; - private waiverSequence = 0; - private suppressionSequence = 0; + constructor(private readonly store: PolicyLifecycleStore) {} - createWaiver(input: WaiverCreateInput): Waiver { - this.assertSafeLifecyclePayload(input); + async createWaiver(input: WaiverCreateInput): Promise { + this.assertExactLifecyclePayload( + input, + WAIVER_CREATE_KEYS, + WAIVER_CREATE_KEYS + ); this.assertRequiredString(input.tenantId, "tenantId"); this.assertRequiredString(input.owner, "owner"); this.assertRequiredString(input.reason, "reason"); this.assertRequiredString(input.scope, "scope"); this.assertRequiredString(input.expiresAt, "expiresAt"); - - const waiver: Waiver = { - id: `waiver_${++this.waiverSequence}`, - tenantId: input.tenantId, - owner: input.owner, - reason: input.reason, - scope: input.scope, - expiresAt: input.expiresAt - }; - - this.waivers.push(waiver); - - return waiver; + this.assertIsoInstant(input.expiresAt, 'expiresAt'); + return this.store.createWaiver(input); } - updateWaiver(waiverId: string, input: WaiverUpdateInput): Waiver { - this.assertSafeLifecyclePayload(input); - this.assertRequiredString(input.tenantId, "tenantId"); - - const waiver = this.waivers.find( - (candidate) => candidate.id === waiverId && candidate.tenantId === input.tenantId + async updateWaiver( + waiverId: string, + input: WaiverUpdateInput + ): Promise { + this.assertExactLifecyclePayload( + input, + WAIVER_UPDATE_KEYS, + ['tenantId'] ); - - if (!waiver) { - throw new NotFoundException("Waiver was not found for tenant."); - } + this.assertRequiredString(input.tenantId, "tenantId"); if (input.owner !== undefined) { this.assertRequiredString(input.owner, "owner"); - waiver.owner = input.owner; } if (input.reason !== undefined) { this.assertRequiredString(input.reason, "reason"); - waiver.reason = input.reason; } if (input.scope !== undefined) { this.assertRequiredString(input.scope, "scope"); - waiver.scope = input.scope; } if (input.expiresAt !== undefined) { this.assertRequiredString(input.expiresAt, "expiresAt"); - waiver.expiresAt = input.expiresAt; + this.assertIsoInstant(input.expiresAt, 'expiresAt'); } if (input.lastReviewedAt !== undefined) { this.assertRequiredString(input.lastReviewedAt, "lastReviewedAt"); - waiver.lastReviewedAt = input.lastReviewedAt; + this.assertIsoInstant(input.lastReviewedAt, 'lastReviewedAt'); + } + const waiver = await this.store.updateWaiver(waiverId, input); + if (!waiver) { + throw new NotFoundException("Waiver was not found for tenant."); } - return waiver; } - createSuppression(input: SuppressionCreateInput): Suppression { - this.assertSafeLifecyclePayload(input); + async createSuppression( + input: SuppressionCreateInput + ): Promise { + this.assertExactLifecyclePayload( + input, + SUPPRESSION_CREATE_KEYS, + ['tenantId', 'scanRequestId', 'reason'] + ); this.assertRequiredString(input.tenantId, "tenantId"); this.assertRequiredString(input.scanRequestId, "scanRequestId"); @@ -101,26 +104,36 @@ export class PolicyLifecycleService { throw new BadRequestException("Suppression reason is invalid."); } - const suppression: Suppression = { - id: `suppression_${++this.suppressionSequence}`, - tenantId: input.tenantId, - scanRequestId: input.scanRequestId, - findingId: input.findingId, - reason: input.reason - }; - - this.suppressions.push(suppression); - - return suppression; + if (input.findingId !== undefined) { + this.assertRequiredString(input.findingId, 'findingId'); + } + return this.store.createSuppression(input); } - private assertSafeLifecyclePayload(input: unknown): void { - const serialized = JSON.stringify(input); - - for (const forbiddenKey of FORBIDDEN_LIFECYCLE_KEYS) { - if (new RegExp(forbiddenKey, "i").test(serialized)) { - throw new BadRequestException("Lifecycle payload contains forbidden sensitive or authority content."); - } + private assertExactLifecyclePayload( + input: unknown, + allowedKeys: readonly string[], + requiredKeys: readonly string[] + ): asserts input is Record { + if ( + input === null || + typeof input !== 'object' || + Array.isArray(input) + ) { + throw new BadRequestException( + 'Lifecycle payload must be an exact object.' + ); + } + const record = input as Record; + if ( + Object.keys(record).some((key) => !allowedKeys.includes(key)) || + requiredKeys.some( + (key) => !Object.prototype.hasOwnProperty.call(record, key) + ) + ) { + throw new BadRequestException( + 'Lifecycle payload contains unknown or missing fields.' + ); } } @@ -129,4 +142,15 @@ export class PolicyLifecycleService { throw new BadRequestException(`Lifecycle payload is missing required field: ${fieldName}.`); } } + + private assertIsoInstant(value: string, fieldName: string): void { + if ( + !Number.isFinite(Date.parse(value)) || + new Date(value).toISOString() !== value + ) { + throw new BadRequestException( + `Lifecycle payload field must be an ISO instant: ${fieldName}.` + ); + } + } } diff --git a/apps/api/src/policy/policy-lifecycle.store.ts b/apps/api/src/policy/policy-lifecycle.store.ts new file mode 100644 index 0000000..2ed68a9 --- /dev/null +++ b/apps/api/src/policy/policy-lifecycle.store.ts @@ -0,0 +1,22 @@ +import type { + Suppression, + SuppressionCreateInput, + Waiver, + WaiverCreateInput, + WaiverUpdateInput +} from '@aegisai/shared'; + +export abstract class PolicyLifecycleStore { + abstract createWaiver( + input: Readonly + ): Promise; + + abstract updateWaiver( + waiverId: string, + input: Readonly + ): Promise; + + abstract createSuppression( + input: Readonly + ): Promise; +} diff --git a/apps/api/src/policy/policy.module.ts b/apps/api/src/policy/policy.module.ts index 34e1111..064261b 100644 --- a/apps/api/src/policy/policy.module.ts +++ b/apps/api/src/policy/policy.module.ts @@ -1,14 +1,34 @@ import { Module } from "@nestjs/common"; +import { AiPlaneModule } from '../ai-plane/ai-plane.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PolicyDecisionStore } from './policy-decision.store'; import { PolicyDecisionsController } from "./policy-decisions.controller"; import { PolicyEngineService } from "./policy-engine.service"; +import { PolicyLifecycleStore } from './policy-lifecycle.store'; import { PolicyLifecycleService } from "./policy-lifecycle.service"; +import { PrismaPolicyDecisionStore } from './prisma-policy-decision.store'; +import { PrismaPolicyLifecycleStore } from './prisma-policy-lifecycle.store'; import { SuppressionsController } from "./suppressions.controller"; import { WaiversController } from "./waivers.controller"; @Module({ + imports: [AiPlaneModule, PrismaModule], controllers: [PolicyDecisionsController, WaiversController, SuppressionsController], - providers: [PolicyEngineService, PolicyLifecycleService], + providers: [ + PolicyEngineService, + PolicyLifecycleService, + PrismaPolicyDecisionStore, + PrismaPolicyLifecycleStore, + { + provide: PolicyDecisionStore, + useExisting: PrismaPolicyDecisionStore + }, + { + provide: PolicyLifecycleStore, + useExisting: PrismaPolicyLifecycleStore + } + ], exports: [PolicyEngineService, PolicyLifecycleService] }) export class PolicyModule {} diff --git a/apps/api/src/policy/prisma-policy-decision.store.ts b/apps/api/src/policy/prisma-policy-decision.store.ts new file mode 100644 index 0000000..11f7cb1 --- /dev/null +++ b/apps/api/src/policy/prisma-policy-decision.store.ts @@ -0,0 +1,124 @@ +import { + SCANNER_KINDS, + type PolicyAction, + type PolicyDecision, + type ScannerKind +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + PolicyDecisionStore, + type PolicyDecisionCreate +} from './policy-decision.store'; + +const SUPPORTED_SCANNER_KINDS = new Set(SCANNER_KINDS); + +interface PolicyDecisionRow { + id: string; + tenantId: string; + scanRequestId: string; + findingId: string | null; + enforcementAction: PolicyAction; + commentAllowed: boolean; + dashboardVisible: boolean; + ticketRequested: boolean; + blockRequested: boolean; + reasonCodes: Prisma.JsonValue; + requiredCoverage: Prisma.JsonValue; + waiverApplied: boolean; + staleSuppressed: boolean; + aiAdvisoryVisible: boolean; +} + +@Injectable() +export class PrismaPolicyDecisionStore extends PolicyDecisionStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async create( + input: Readonly + ): Promise { + const requiredCoverage = scannerKinds( + [...input.requiredCoverage], + 'requiredCoverage' + ); + const row = await this.prisma.policyDecision.create({ + data: { + tenantId: input.tenantId, + scanRequestId: input.scanRequestId, + findingId: input.findingId ?? null, + enforcementAction: input.enforcementAction, + commentAllowed: input.commentAllowed, + dashboardVisible: input.dashboardVisible, + ticketRequested: input.ticketRequested, + blockRequested: input.blockRequested, + reasonCodes: input.reasonCodes, + requiredCoverage, + waiverApplied: input.waiverApplied, + staleSuppressed: input.staleSuppressed, + aiAdvisoryVisible: input.aiAdvisoryVisible + } + }); + return policyDecisionFromRow(row); + } + + async findByTenantAndId( + tenantId: string, + policyDecisionId: string + ): Promise { + const row = await this.prisma.policyDecision.findFirst({ + where: { id: policyDecisionId, tenantId } + }); + return row ? policyDecisionFromRow(row) : null; + } +} + +function policyDecisionFromRow( + row: PolicyDecisionRow +): PolicyDecision { + return { + id: row.id, + tenantId: row.tenantId, + scanRequestId: row.scanRequestId, + ...(row.findingId ? { findingId: row.findingId } : {}), + enforcementAction: row.enforcementAction, + commentAllowed: row.commentAllowed, + dashboardVisible: row.dashboardVisible, + ticketRequested: row.ticketRequested, + blockRequested: row.blockRequested, + reasonCodes: stringArray(row.reasonCodes, 'reasonCodes'), + requiredCoverage: scannerKinds( + row.requiredCoverage, + 'requiredCoverage' + ), + waiverApplied: row.waiverApplied, + staleSuppressed: row.staleSuppressed, + aiAdvisoryVisible: row.aiAdvisoryVisible + }; +} + +function stringArray(value: Prisma.JsonValue, field: string): string[] { + if ( + !Array.isArray(value) || + !value.every((item): item is string => typeof item === 'string') + ) { + throw new Error(`Persisted policy decision ${field} is invalid.`); + } + return [...value]; +} + +function scannerKinds( + value: Prisma.JsonValue, + field: string +): ScannerKind[] { + const values = stringArray(value, field); + if (!values.every((value) => SUPPORTED_SCANNER_KINDS.has(value))) { + throw new Error( + `Policy decision ${field} contains an unsupported scanner kind.` + ); + } + return values as ScannerKind[]; +} diff --git a/apps/api/src/policy/prisma-policy-lifecycle.store.ts b/apps/api/src/policy/prisma-policy-lifecycle.store.ts new file mode 100644 index 0000000..cd80653 --- /dev/null +++ b/apps/api/src/policy/prisma-policy-lifecycle.store.ts @@ -0,0 +1,126 @@ +import type { + Suppression, + SuppressionCreateInput, + Waiver, + WaiverCreateInput, + WaiverUpdateInput +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { PolicyLifecycleStore } from './policy-lifecycle.store'; + +interface WaiverRow { + id: string; + tenantId: string; + owner: string; + reason: string; + scope: string; + expiresAt: Date; + lastReviewedAt: Date | null; +} + +interface SuppressionRow { + id: string; + tenantId: string; + scanRequestId: string; + findingId: string | null; + reason: Suppression['reason']; +} + +@Injectable() +export class PrismaPolicyLifecycleStore extends PolicyLifecycleStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async createWaiver( + input: Readonly + ): Promise { + const row = await this.prisma.waiver.create({ + data: { + tenantId: input.tenantId, + owner: input.owner, + reason: input.reason, + scope: input.scope, + expiresAt: new Date(input.expiresAt) + } + }); + return waiverFromRow(row); + } + + async updateWaiver( + waiverId: string, + input: Readonly + ): Promise { + return this.prisma.$transaction( + async (tx: Prisma.TransactionClient) => { + const existing = await tx.waiver.findFirst({ + where: { id: waiverId, tenantId: input.tenantId }, + select: { id: true } + }); + if (!existing) return null; + const row = await tx.waiver.update({ + where: { id: existing.id }, + data: { + ...(input.owner !== undefined + ? { owner: input.owner } + : {}), + ...(input.reason !== undefined + ? { reason: input.reason } + : {}), + ...(input.scope !== undefined + ? { scope: input.scope } + : {}), + ...(input.expiresAt !== undefined + ? { expiresAt: new Date(input.expiresAt) } + : {}), + ...(input.lastReviewedAt !== undefined + ? { lastReviewedAt: new Date(input.lastReviewedAt) } + : {}) + } + }); + return waiverFromRow(row); + } + ); + } + + async createSuppression( + input: Readonly + ): Promise { + const row = await this.prisma.suppression.create({ + data: { + tenantId: input.tenantId, + scanRequestId: input.scanRequestId, + findingId: input.findingId ?? null, + reason: input.reason + } + }); + return suppressionFromRow(row); + } +} + +function waiverFromRow(row: WaiverRow): Waiver { + return { + id: row.id, + tenantId: row.tenantId, + owner: row.owner, + reason: row.reason, + scope: row.scope, + expiresAt: row.expiresAt.toISOString(), + ...(row.lastReviewedAt + ? { lastReviewedAt: row.lastReviewedAt.toISOString() } + : {}) + }; +} + +function suppressionFromRow(row: SuppressionRow): Suppression { + return { + id: row.id, + tenantId: row.tenantId, + scanRequestId: row.scanRequestId, + ...(row.findingId ? { findingId: row.findingId } : {}), + reason: row.reason + }; +} diff --git a/apps/api/test/ai-plane/ai-advisory-authority.service.e2e-spec.ts b/apps/api/test/ai-plane/ai-advisory-authority.service.e2e-spec.ts new file mode 100644 index 0000000..baaef60 --- /dev/null +++ b/apps/api/test/ai-plane/ai-advisory-authority.service.e2e-spec.ts @@ -0,0 +1,151 @@ +import { AiAdvisoryAuthorityService } from '../../src/ai-plane/ai-advisory-authority.service'; +import { SastAiAdvisoryAuthorityPersistenceError } from '../../src/ai-plane/sast-ai-advisory-authority.store'; +import { + aiAuthorityProof, + aiPolicyReference +} from '../support/sast-ai-advisory-fixture'; + +describe('AiAdvisoryAuthorityService T044 boundary', () => { + it('returns only an immutable proof and display-only policy reference', async () => { + const proof = aiAuthorityProof(); + const store = { + createProof: jest.fn().mockResolvedValue({ proof, replayed: false }), + verifyPolicyReference: jest.fn() + }; + const service = new AiAdvisoryAuthorityService(store as never); + + const result = await service.createProof( + { + tenantId: proof.scope.tenantId, + advisoryId: proof.scope.advisoryId + }, + () => proof.verifiedAt + ); + + expect(result).toEqual({ + proof, + replayed: false, + policyReference: aiPolicyReference() + }); + expect(result.proof.before.stateDigest).toBe( + result.proof.after.stateDigest + ); + expect(result.proof.authority).toEqual( + expect.objectContaining({ + findingCreateAuthority: false, + findingStatusMutationAuthority: false, + findingSeverityMutationAuthority: false, + lifecycleMutationAuthority: false, + waiverMutationAuthority: false, + suppressionMutationAuthority: false, + policyOverrideAuthority: false, + blockDecisionAuthority: false, + advisoryOnly: true + }) + ); + expect(JSON.stringify(result)).not.toMatch( + /detectorSignals|plannerSteps|rationale|"sourceContent"\s*:|"secretValue"\s*:/u + ); + }); + + it('rejects caller finding, lifecycle, waiver, suppression, and policy fields', async () => { + const store = { + createProof: jest.fn(), + verifyPolicyReference: jest.fn() + }; + const service = new AiAdvisoryAuthorityService(store as never); + const proof = aiAuthorityProof(); + + await expect( + service.createProof({ + tenantId: proof.scope.tenantId, + advisoryId: proof.scope.advisoryId, + findingStatus: 'FIXED', + severity: 'INFO', + waiver: true, + suppression: true, + policyOverride: 'BLOCK' + } as never) + ).rejects.toThrow( + 'AI authority proof intent must contain only tenant and advisory identifiers.' + ); + expect(store.createProof).not.toHaveBeenCalled(); + }); + + it('fails closed when a policy reference is missing, drifted, or unavailable', async () => { + const store = { + createProof: jest.fn(), + verifyPolicyReference: jest.fn().mockResolvedValue(true) + }; + const service = new AiAdvisoryAuthorityService(store as never); + const reference = aiPolicyReference(); + + await expect( + service.verifyPolicyReference({ + tenantId: 'tenant-ai', + normalizedFindingId: 'normalized-finding-ai', + reference + }) + ).resolves.toBe(true); + await expect( + service.verifyPolicyReference({ + tenantId: 'tenant-ai', + normalizedFindingId: 'normalized-finding-ai', + reference: { ...reference, suggestedAction: 'BLOCK' } as never + }) + ).resolves.toBe(false); + store.verifyPolicyReference.mockResolvedValueOnce(false); + await expect( + service.verifyPolicyReference({ + tenantId: 'tenant-ai', + normalizedFindingId: 'normalized-finding-ai', + reference + }) + ).resolves.toBe(false); + store.verifyPolicyReference.mockRejectedValueOnce( + new Error('database unavailable') + ); + await expect( + service.verifyPolicyReference({ + tenantId: 'tenant-ai', + normalizedFindingId: 'normalized-finding-ai', + reference + }) + ).resolves.toBe(false); + expect(store.verifyPolicyReference).toHaveBeenCalledTimes(3); + }); + + it('maps missing, conflicting, and operational failures to distinct opaque statuses', async () => { + const proof = aiAuthorityProof(); + const store = { + createProof: jest.fn(), + verifyPolicyReference: jest.fn() + }; + const service = new AiAdvisoryAuthorityService(store as never); + const intent = { + tenantId: proof.scope.tenantId, + advisoryId: proof.scope.advisoryId + }; + + store.createProof.mockRejectedValueOnce( + new SastAiAdvisoryAuthorityPersistenceError('CONTEXT_DRIFT') + ); + await expect( + service.createProof(intent, () => proof.verifiedAt) + ).rejects.toMatchObject({ status: 404 }); + + store.createProof.mockRejectedValueOnce( + new SastAiAdvisoryAuthorityPersistenceError('STATE_DRIFT') + ); + await expect( + service.createProof(intent, () => proof.verifiedAt) + ).rejects.toMatchObject({ status: 409 }); + + store.createProof.mockRejectedValueOnce( + new Error('database unavailable') + ); + await expect( + service.createProof(intent, () => proof.verifiedAt) + ).rejects.toMatchObject({ status: 503 }); + }); +}); diff --git a/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts b/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts index 20d5daf..f914111 100644 --- a/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts +++ b/apps/api/test/ai-plane/ai-advisory.e2e-spec.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { SessionAuthGuard } from '../../src/auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../../src/common/security/internal-service.guard'; +import { deriveTenantBoundInternalCredential } from '../../src/common/security/internal-tenant-service.guard'; import { TestInternalServiceGuard, TestSessionAuthGuard @@ -31,6 +32,8 @@ describe('AI advisory API T043 boundary (e2e)', () => { process.env.FRONTEND_URL = 'http://localhost:5173'; process.env.TOKEN_ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + process.env.INTERNAL_API_SECRET = + 'test-internal-secret-value-at-least-32-characters'; const [{ AppModule }, { PrismaService }] = await Promise.all([ import('../../src/app.module'), @@ -92,4 +95,53 @@ describe('AI advisory API T043 boundary (e2e)', () => { /secretValue|sourceArchive|rawScannerPayload|accessToken/i ); }); + + it('accepts only tenant and advisory identity for T044 proof creation', async () => { + const advisoryId = `sast-ai-advisory://${'a'.repeat(64)}`; + const tenantId = 'tenant-ai'; + const credential = deriveTenantBoundInternalCredential( + process.env.INTERNAL_API_SECRET ?? '', + tenantId + ); + expect(credential).not.toBeNull(); + await request(app.getHttpServer()) + .post('/api/ai-advisories/authority-proofs') + .set('x-aegis-internal-tenant-id', tenantId) + .set('authorization', `Bearer ${credential}`) + .send({ + tenantId, + advisoryId, + findingStatus: 'FIXED', + waiver: true, + policyOverride: 'BLOCK' + }) + .expect(400); + + const response = await request(app.getHttpServer()) + .post('/api/ai-advisories/authority-proofs') + .set('x-aegis-internal-tenant-id', tenantId) + .set('authorization', `Bearer ${credential}`) + .send({ tenantId, advisoryId }) + .expect(503); + expect(JSON.stringify(response.body)).toMatch( + /authority proof service is temporarily unavailable/i + ); + }); + + it('rejects a proof tenant that is not bound to the internal credential', async () => { + const authenticatedTenantId = 'tenant-ai'; + const credential = deriveTenantBoundInternalCredential( + process.env.INTERNAL_API_SECRET ?? '', + authenticatedTenantId + ); + await request(app.getHttpServer()) + .post('/api/ai-advisories/authority-proofs') + .set('x-aegis-internal-tenant-id', authenticatedTenantId) + .set('authorization', `Bearer ${credential}`) + .send({ + tenantId: 'foreign-tenant', + advisoryId: `sast-ai-advisory://${'a'.repeat(64)}` + }) + .expect(403); + }); }); diff --git a/apps/api/test/ai-plane/sast-ai-advisory-authority-persistence.e2e-spec.ts b/apps/api/test/ai-plane/sast-ai-advisory-authority-persistence.e2e-spec.ts new file mode 100644 index 0000000..da9055e --- /dev/null +++ b/apps/api/test/ai-plane/sast-ai-advisory-authority-persistence.e2e-spec.ts @@ -0,0 +1,416 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { PrismaSastAiAdvisoryAuthorityStore } from '../../src/ai-plane/prisma-sast-ai-advisory-authority.store'; +import { + aiHandoff, + aiPolicyReference +} from '../support/sast-ai-advisory-fixture'; + +describe('SAST AI advisory authority proof persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql' + ); + const onlineSchema = read( + 'scripts/apply-online-sast-runtime-schema.mjs' + ); + const storeSource = read( + 'src/ai-plane/prisma-sast-ai-advisory-authority.store.ts' + ); + + it('adds a content-free immutable proof ledger with fixed zero authority', () => { + expect(schema).toContain('model SastAiAdvisoryAuthorityProof {'); + expect(migration).toContain( + 'CREATE TABLE "SastAiAdvisoryAuthorityProof"' + ); + expect(migration).toContain( + 'SastAiAdvisoryAuthorityProof_immutable_update' + ); + expect(migration).toContain( + 'SastAiAdvisoryAuthorityProof_immutable_delete' + ); + expect(migration).toContain( + '"beforeStateDigest" = "afterStateDigest"' + ); + for (const fixedBit of [ + '"findingCreateAuthority" IS FALSE', + '"findingStatusMutationAuthority" IS FALSE', + '"findingSeverityMutationAuthority" IS FALSE', + '"lifecycleMutationAuthority" IS FALSE', + '"waiverMutationAuthority" IS FALSE', + '"suppressionMutationAuthority" IS FALSE', + '"policyOverrideAuthority" IS FALSE', + '"blockDecisionAuthority" IS FALSE', + '"authoritativeFindingWritten" IS FALSE', + '"policyDecisionWritten" IS FALSE' + ]) { + expect(migration).toContain(fixedBit); + } + expect(migration).not.toMatch(/JSONB|rationale|prompt|evidenceFragment/u); + expect(schema).not.toMatch( + /model SastAiAdvisoryAuthorityProof \{[\s\S]*?\n\s+(?:proof|advisoryContent|sourceContent)\s+Json/u + ); + }); + + it('installs populated finding scope dependencies through online schema', () => { + expect(onlineSchema).toContain( + 'NormalizedFinding_ai_authority_scope_key' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryHandoff_authority_scope_key' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryAuthorityProof_handoff_authority_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryAuthorityProof_occurrence_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastAiAdvisoryAuthorityProof_finding_scope_fkey' + ); + expect(migration).not.toMatch( + /SastAiAdvisoryAuthorityProof_(?:occurrence|finding)_scope_fkey|SastAiAdvisoryAuthorityProof_handoff_authority_scope_fkey/u + ); + }); + + it('uses one fenced serializable snapshot and no authoritative model writes', async () => { + const fixture = prismaFixture(); + const store = new PrismaSastAiAdvisoryAuthorityStore( + fixture.prisma as never + ); + const handoff = aiHandoff(); + + const first = await store.createProof({ + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId, + verifiedAt: '2026-08-11T05:30:00.000Z' + }); + expect(first.replayed).toBe(false); + expect(first.proof.before.stateDigest).toBe( + first.proof.after.stateDigest + ); + expect(fixture.proof.create).toHaveBeenCalledTimes(1); + expect(fixture.fenceQuery).toHaveBeenCalledTimes(2); + expect(fixture.finding.findMany).toHaveBeenCalledTimes(1); + expect(fixture.lifecycle.findMany).toHaveBeenCalledTimes(1); + expect(fixture.policy.findMany).toHaveBeenCalledTimes(1); + expect(fixture.waiver.findMany).toHaveBeenCalledTimes(1); + expect(fixture.suppression.findMany).toHaveBeenCalledTimes(1); + expect(fixture.prisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { + isolationLevel: 'Serializable', + maxWait: 10_000, + timeout: 10_000 + } + ); + expect(migration).toContain( + 'acquire_sast_ai_advisory_authority_fence' + ); + expect(migration).toContain( + 'PolicyDecision_ai_authority_fence' + ); + for (const fenceKeyFunction of [ + 'scan', + 'lifecycle', + 'finding', + 'advisory' + ]) { + expect(migration).toMatch( + new RegExp( + `CREATE FUNCTION "sast_ai_authority_${fenceKeyFunction}_fence_key"[\\s\\S]*?\\r?\\nSTABLE\\r?\\nSTRICT`, + 'u' + ) + ); + } + expect(migration).toContain( + 'fence_sast_ai_authority_normalized_finding_statement' + ); + expect(migration).toContain( + 'fence_sast_ai_authority_lifecycle_state_statement' + ); + expect(migration).toContain('REFERENCING NEW TABLE AS new_rows'); + expect(migration).toContain( + 'REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows' + ); + expect(migration).toContain('FOR EACH STATEMENT'); + expect(migration).not.toMatch( + /"(?:NormalizedFinding|SastFindingLifecycleState)_ai_authority_fence[^\n]*"[\s\S]{0,120}FOR EACH ROW/u + ); + expect(storeSource).toContain('await acquireAuthorityFence(tx, context)'); + expect(storeSource).not.toMatch( + /\b(?:normalizedFinding|sastFindingLifecycleState|policyDecision|waiver|suppression)\.(?:create|createMany|update|updateMany|upsert|delete|deleteMany)\b/u + ); + + await expect( + store.createProof({ + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId, + verifiedAt: '2026-08-11T06:00:00.000Z' + }) + ).resolves.toMatchObject({ replayed: true, proof: first.proof }); + expect(fixture.proof.create).toHaveBeenCalledTimes(1); + }); + + it('verifies only a tenant and finding-bound exact policy reference', async () => { + const fixture = prismaFixture(); + const store = new PrismaSastAiAdvisoryAuthorityStore( + fixture.prisma as never + ); + const handoff = aiHandoff(); + const persisted = await store.createProof({ + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId, + verifiedAt: '2026-08-11T05:30:00.000Z' + }); + const reference = { + ...aiPolicyReference(), + authorityProofId: persisted.proof.proofId, + authorityProofDigest: persisted.proof.proofDigest + }; + + await expect( + store.verifyPolicyReference({ + tenantId: handoff.tenantId, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + reference + }) + ).resolves.toBe(true); + await expect( + store.verifyPolicyReference({ + tenantId: 'foreign-tenant', + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + reference + }) + ).resolves.toBe(false); + await expect( + store.verifyPolicyReference({ + tenantId: handoff.tenantId, + normalizedFindingId: 'foreign-finding', + reference + }) + ).resolves.toBe(false); + await expect( + store.verifyPolicyReference({ + tenantId: handoff.tenantId, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + reference: { + ...reference, + authorityProofDigest: `sha256:${'0'.repeat(64)}` + } + }) + ).resolves.toBe(false); + await expect( + store.verifyPolicyReference({ + tenantId: handoff.tenantId, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + reference: { + ...reference, + advisoryId: `sast-ai-advisory://${'0'.repeat(64)}` + } + }) + ).resolves.toBe(false); + }); + + it('rejects replay after authoritative finding state drift', async () => { + const fixture = prismaFixture(); + const store = new PrismaSastAiAdvisoryAuthorityStore( + fixture.prisma as never + ); + const handoff = aiHandoff(); + const intent = { + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId, + verifiedAt: '2026-08-11T05:30:00.000Z' + }; + await store.createProof(intent); + fixture.finding.findMany.mockResolvedValue([ + { + id: handoff.normalizedFinding.normalizedFindingId, + status: 'FIXED', + severity: 'HIGH', + updatedAt: new Date('2026-08-11T05:45:00.000Z') + } + ]); + + await expect(store.createProof(intent)).rejects.toMatchObject({ + reason: 'STATE_DRIFT' + }); + expect(fixture.proof.create).toHaveBeenCalledTimes(1); + }); + + it('classifies a missing lifecycle state as context drift', async () => { + const fixture = prismaFixture(); + fixture.lifecycle.findMany.mockResolvedValue([]); + const store = new PrismaSastAiAdvisoryAuthorityStore( + fixture.prisma as never + ); + const handoff = aiHandoff(); + + await expect( + store.createProof({ + tenantId: handoff.tenantId, + advisoryId: handoff.advisoryId, + verifiedAt: '2026-08-11T05:30:00.000Z' + }) + ).rejects.toMatchObject({ reason: 'CONTEXT_DRIFT' }); + }); +}); + +function prismaFixture() { + const handoff = aiHandoff(); + let proofRow: Record | null = null; + const proof = { + findUnique: jest.fn(() => Promise.resolve(proofRow)), + findFirst: jest.fn( + ({ where }: { where: Record }) => + Promise.resolve( + proofRow && + Object.entries(where).every( + ([key, value]) => proofRow?.[key] === value + ) + ? proofRow + : null + ) + ), + create: jest.fn(({ data }: { data: Record }) => { + proofRow = data; + return Promise.resolve(data); + }) + }; + const advisory = { + findFirst: jest.fn().mockResolvedValue({ + id: handoff.advisoryId, + sastHandoffId: handoff.handoffId, + tenantId: handoff.tenantId, + scanRequestId: handoff.scanRequestId, + findingId: handoff.normalizedFinding.normalizedFindingId, + advisoryOnly: true, + redactedEvidenceOnly: true, + createdAt: new Date('2026-08-11T04:00:00.500Z') + }) + }; + const handoffModel = { + findUnique: jest.fn().mockResolvedValue({ + id: handoff.handoffId, + advisoryId: handoff.advisoryId, + tenantId: handoff.tenantId, + repositoryBindingId: handoff.repositoryBindingId, + scanRequestId: handoff.scanRequestId, + attemptId: handoff.attemptId, + occurrenceId: handoff.normalizedFinding.occurrenceId, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + findingFingerprint: handoff.normalizedFinding.findingFingerprint, + requestDigest: handoff.requestDigest, + handoffDigest: handoff.handoffDigest, + advisoryOnly: true, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false + }) + }; + const occurrence = { + findFirst: jest.fn().mockResolvedValue({ + id: handoff.normalizedFinding.occurrenceId, + lineageId: `finding-lineage://${'d'.repeat(64)}`, + observationBatch: { + lifecycleContextKey: `sha256:${'e'.repeat(64)}` + } + }) + }; + const finding = { + findMany: jest.fn().mockResolvedValue([ + { + id: handoff.normalizedFinding.normalizedFindingId, + status: 'OPEN', + severity: 'HIGH', + updatedAt: new Date('2026-08-11T04:00:00.000Z') + } + ]) + }; + const lifecycle = { + findMany: jest.fn().mockResolvedValue([ + { + id: `finding-lifecycle-state://${'f'.repeat(64)}`, + targetRef: 'refs/heads/dev', + status: 'OPEN', + revision: 1, + lastObservedBatchId: 'batch-ai', + lastObservedScanRequestId: handoff.scanRequestId, + lastObservedCommitSha: 'a'.repeat(40), + lastObservedAt: new Date('2026-08-11T04:00:00.000Z'), + lastReconciliationSequence: 0, + fixedAt: null, + reopenedAt: null, + updatedAt: new Date('2026-08-11T04:00:00.000Z') + } + ]) + }; + const policy = { + findMany: jest.fn().mockResolvedValue([ + { + id: 'policy-ai', + enforcementAction: 'WARN', + commentAllowed: true, + dashboardVisible: true, + ticketRequested: false, + blockRequested: false, + reasonCodes: ['SEVERITY_HIGH'], + requiredCoverage: ['OPENGREP', 'TRIVY', 'SYFT'], + waiverApplied: false, + staleSuppressed: false, + aiAdvisoryVisible: false, + createdAt: new Date('2026-08-11T04:00:00.000Z'), + updatedAt: new Date('2026-08-11T04:00:00.000Z') + } + ]) + }; + const waiver = { findMany: jest.fn().mockResolvedValue([]) }; + const suppression = { findMany: jest.fn().mockResolvedValue([]) }; + const transaction = { + aiAdvisoryMetadata: advisory, + sastAiAdvisoryHandoff: handoffModel, + sastFindingOccurrence: occurrence, + sastAiAdvisoryAuthorityProof: proof, + normalizedFinding: finding, + sastFindingLifecycleState: lifecycle, + policyDecision: policy, + waiver, + suppression, + $queryRaw: jest.fn((query: TemplateStringsArray) => + Promise.resolve( + query[0].includes('advisory_context_fence') + ? [{ lockedContextCount: 1n }] + : [{ lockedScopeCount: 3n }] + ) + ) + }; + const prisma = { + ...transaction, + $transaction: jest.fn( + (operation: (tx: typeof transaction) => Promise) => + operation(transaction) + ) + }; + return { + prisma, + proof, + finding, + lifecycle, + policy, + waiver, + suppression, + fenceQuery: transaction.$queryRaw + }; +} + +function read(relativePath: string): string { + return readFileSync(resolve(__dirname, '../..', relativePath), 'utf8'); +} diff --git a/apps/api/test/policy/policy-authority-persistence.e2e-spec.ts b/apps/api/test/policy/policy-authority-persistence.e2e-spec.ts new file mode 100644 index 0000000..56e340c --- /dev/null +++ b/apps/api/test/policy/policy-authority-persistence.e2e-spec.ts @@ -0,0 +1,195 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { PrismaPolicyDecisionStore } from '../../src/policy/prisma-policy-decision.store'; +import { PrismaPolicyLifecycleStore } from '../../src/policy/prisma-policy-lifecycle.store'; + +describe('Authoritative policy persistence used by T044 snapshots', () => { + it('persists and reads policy decisions through the PolicyDecision table', async () => { + const row = policyDecisionRow(); + const prisma = { + policyDecision: { + create: jest.fn().mockResolvedValue(row), + findFirst: jest.fn().mockResolvedValue(row) + } + }; + const store = new PrismaPolicyDecisionStore(prisma as never); + + await expect( + store.create({ + tenantId: row.tenantId, + scanRequestId: row.scanRequestId, + findingId: row.findingId, + enforcementAction: row.enforcementAction, + commentAllowed: row.commentAllowed, + dashboardVisible: row.dashboardVisible, + ticketRequested: row.ticketRequested, + blockRequested: row.blockRequested, + reasonCodes: row.reasonCodes, + requiredCoverage: ['OPENGREP', 'TRIVY', 'SYFT'], + waiverApplied: row.waiverApplied, + staleSuppressed: row.staleSuppressed, + aiAdvisoryVisible: row.aiAdvisoryVisible + }) + ).resolves.toMatchObject({ id: row.id }); + await expect( + store.findByTenantAndId(row.tenantId, row.id) + ).resolves.toMatchObject({ id: row.id }); + expect(prisma.policyDecision.create).toHaveBeenCalledTimes(1); + expect(prisma.policyDecision.findFirst).toHaveBeenCalledWith({ + where: { id: row.id, tenantId: row.tenantId } + }); + }); + + it('rejects unsupported scanner coverage before write and after read', async () => { + const row = policyDecisionRow(); + const prisma = { + policyDecision: { + create: jest.fn().mockResolvedValue(row), + findFirst: jest.fn().mockResolvedValue({ + ...row, + requiredCoverage: ['OPENGREP', 'UNSUPPORTED'] + }) + } + }; + const store = new PrismaPolicyDecisionStore(prisma as never); + + await expect( + store.create({ + tenantId: row.tenantId, + scanRequestId: row.scanRequestId, + findingId: row.findingId, + enforcementAction: row.enforcementAction, + commentAllowed: row.commentAllowed, + dashboardVisible: row.dashboardVisible, + ticketRequested: row.ticketRequested, + blockRequested: row.blockRequested, + reasonCodes: row.reasonCodes, + requiredCoverage: ['OPENGREP', 'UNSUPPORTED'] as never, + waiverApplied: row.waiverApplied, + staleSuppressed: row.staleSuppressed, + aiAdvisoryVisible: row.aiAdvisoryVisible + }) + ).rejects.toThrow('unsupported scanner kind'); + expect(prisma.policyDecision.create).not.toHaveBeenCalled(); + + await expect( + store.findByTenantAndId(row.tenantId, row.id) + ).rejects.toThrow('unsupported scanner kind'); + }); + + it('persists waiver and suppression lifecycle changes through shared authoritative tables', async () => { + const waiver = { + id: 'waiver-durable', + tenantId: 'tenant-policy', + owner: 'security@example.com', + reason: 'Compensating control', + scope: 'finding:finding-policy', + expiresAt: new Date('2026-09-01T00:00:00.000Z'), + lastReviewedAt: null + }; + const updated = { + ...waiver, + reason: 'Reviewed control', + lastReviewedAt: new Date('2026-08-13T00:00:00.000Z') + }; + const suppression = { + id: 'suppression-durable', + tenantId: 'tenant-policy', + scanRequestId: 'scan-policy', + findingId: 'finding-policy', + reason: 'POLICY' as const + }; + const transaction = { + waiver: { + findFirst: jest.fn().mockResolvedValue({ id: waiver.id }), + update: jest.fn().mockResolvedValue(updated) + } + }; + const prisma = { + waiver: { create: jest.fn().mockResolvedValue(waiver) }, + suppression: { + create: jest.fn().mockResolvedValue(suppression) + }, + $transaction: jest.fn( + (operation: (tx: typeof transaction) => Promise) => + operation(transaction) + ) + }; + const store = new PrismaPolicyLifecycleStore(prisma as never); + + await expect( + store.createWaiver({ + tenantId: waiver.tenantId, + owner: waiver.owner, + reason: waiver.reason, + scope: waiver.scope, + expiresAt: waiver.expiresAt.toISOString() + }) + ).resolves.toMatchObject({ id: waiver.id }); + await expect( + store.updateWaiver(waiver.id, { + tenantId: waiver.tenantId, + reason: updated.reason, + lastReviewedAt: updated.lastReviewedAt.toISOString() + }) + ).resolves.toMatchObject({ + id: waiver.id, + reason: updated.reason, + lastReviewedAt: updated.lastReviewedAt.toISOString() + }); + await expect( + store.createSuppression({ + tenantId: suppression.tenantId, + scanRequestId: suppression.scanRequestId, + findingId: suppression.findingId, + reason: suppression.reason + }) + ).resolves.toEqual(suppression); + + expect(prisma.waiver.create).toHaveBeenCalledTimes(1); + expect(transaction.waiver.update).toHaveBeenCalledTimes(1); + expect(prisma.suppression.create).toHaveBeenCalledTimes(1); + }); + + it('keeps application-visible policy and lifecycle services off in-memory authority arrays', () => { + const policyService = read('src/policy/policy-engine.service.ts'); + const lifecycleService = read( + 'src/policy/policy-lifecycle.service.ts' + ); + const migration = read( + 'prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql' + ); + + expect(policyService).toContain('PolicyDecisionStore'); + expect(lifecycleService).toContain('PolicyLifecycleStore'); + expect(policyService).not.toContain('policyDecisions: PolicyDecision[]'); + expect(lifecycleService).not.toContain('waivers: Waiver[]'); + expect(migration).toContain('PolicyDecision_ai_authority_fence'); + expect(migration).toContain('Waiver_ai_authority_fence'); + expect(migration).toContain('Suppression_ai_authority_fence'); + }); +}); + +function policyDecisionRow() { + return { + id: 'policy-decision-durable', + tenantId: 'tenant-policy', + scanRequestId: 'scan-policy', + findingId: 'finding-policy', + enforcementAction: 'WARN' as const, + commentAllowed: true, + dashboardVisible: true, + ticketRequested: false, + blockRequested: false, + reasonCodes: ['SEVERITY_HIGH'], + requiredCoverage: ['OPENGREP', 'TRIVY', 'SYFT'], + waiverApplied: false, + staleSuppressed: false, + aiAdvisoryVisible: false + }; +} + +function read(relativePath: string): string { + return readFileSync(resolve(__dirname, '../..', relativePath), 'utf8'); +} diff --git a/apps/api/test/policy/policy-decisions.e2e-spec.ts b/apps/api/test/policy/policy-decisions.e2e-spec.ts index 5e06d34..d0d5fed 100644 --- a/apps/api/test/policy/policy-decisions.e2e-spec.ts +++ b/apps/api/test/policy/policy-decisions.e2e-spec.ts @@ -3,6 +3,7 @@ import { Test } from "@nestjs/testing"; import request from "supertest"; import { SessionAuthGuard } from '../../src/auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../../src/common/security/internal-service.guard'; +import { PolicyDecisionStore } from '../../src/policy/policy-decision.store'; import { TestInternalServiceGuard, TestSessionAuthGuard } from '../support/security-guards'; describe("Policy decision API (e2e)", () => { @@ -28,6 +29,24 @@ describe("Policy decision API (e2e)", () => { import("../../src/app.module"), import("../../src/prisma/prisma.service") ]); + let decisionSequence = 0; + const decisions = new Map>(); + const policyDecisionStore = { + create: jest.fn(async (input: Record) => { + const decision = { + id: `policy_decision_${++decisionSequence}`, + ...input + }; + decisions.set(String(decision.id), decision); + return decision; + }), + findByTenantAndId: jest.fn( + async (tenantId: string, id: string) => { + const decision = decisions.get(id); + return decision?.tenantId === tenantId ? decision : null; + } + ) + }; const moduleRef = await Test.createTestingModule({ imports: [AppModule] @@ -40,6 +59,8 @@ describe("Policy decision API (e2e)", () => { onModuleDestroy: jest.fn().mockResolvedValue(undefined), $queryRawUnsafe: jest.fn().mockResolvedValue([{ result: 1 }]) }) + .overrideProvider(PolicyDecisionStore) + .useValue(policyDecisionStore) .overrideGuard(SessionAuthGuard) .useClass(TestSessionAuthGuard) .overrideGuard(InternalServiceGuard) @@ -85,11 +106,6 @@ describe("Policy decision API (e2e)", () => { filePath: "src/App.java", lineStart: 42, status: "OPEN" - }, - aiAdvisory: { - visible: true, - suggestedAction: "BLOCK", - summary: "Advisory context only" } }) .expect(201); @@ -102,7 +118,7 @@ describe("Policy decision API (e2e)", () => { scanRequestId: "scan_request_api_1", findingId: "finding_api_1", enforcementAction: "WARN", - aiAdvisoryVisible: true + aiAdvisoryVisible: false }) ); @@ -116,4 +132,32 @@ describe("Policy decision API (e2e)", () => { /accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|aiOverride|policyOverride/i ); }); + + it('rejects legacy AI suggested actions at the policy boundary', async () => { + await request(app.getHttpServer()) + .post('/api/policy-decisions/evaluate') + .send({ + tenantId: 'tenant_policy_api', + scanRequestId: 'scan_request_api_2', + scanLane: 'FAST', + scannerCoverage: ['OPENGREP', 'TRIVY', 'SYFT'], + finding: { + id: 'finding_api_2', + tenantId: 'tenant_policy_api', + scanRequestId: 'scan_request_api_2', + scannerRunId: 'scanner_run_api_2', + title: 'Critical finding', + severity: 'CRITICAL', + scannerProvenance: 'OPENGREP', + filePath: 'src/App.java', + lineStart: 42, + status: 'OPEN' + }, + aiAdvisory: { + visible: true, + suggestedAction: 'DASHBOARD_ONLY' + } + }) + .expect(400); + }); }); diff --git a/apps/api/test/policy/policy-engine.service.e2e-spec.ts b/apps/api/test/policy/policy-engine.service.e2e-spec.ts index 08d4ec0..d7e2733 100644 --- a/apps/api/test/policy/policy-engine.service.e2e-spec.ts +++ b/apps/api/test/policy/policy-engine.service.e2e-spec.ts @@ -16,19 +16,21 @@ describe("PolicyEngineService", () => { status: "OPEN" }; - it("creates deterministic policy decisions from scanner findings and coverage", () => { - const service = new PolicyEngineService(); + it("creates deterministic policy decisions from scanner findings and coverage", async () => { + const verifier = authorityVerifier(true); + const store = policyStore(); + const service = new PolicyEngineService( + store as never, + verifier as never + ); - const decision = service.evaluate({ + const decision = await service.evaluate({ tenantId: "tenant_policy", scanRequestId: "scan_request_1", finding: highFinding, scanLane: "DEEP", scannerCoverage: ["OPENGREP"], - aiAdvisory: { - visible: true, - suggestedAction: "BLOCK" - } + aiAdvisory: policyReference() }); expect(decision).toEqual( @@ -50,12 +52,21 @@ describe("PolicyEngineService", () => { expect(decision.reasonCodes).toEqual( expect.arrayContaining(["SEVERITY_HIGH", "MISSING_REQUIRED_SCANNER_COVERAGE"]) ); + expect(verifier.verifyPolicyReference).toHaveBeenCalledWith({ + tenantId: 'tenant_policy', + normalizedFindingId: 'finding_high', + reference: policyReference() + }); + expect(store.create).toHaveBeenCalledTimes(1); }); - it("blocks critical scanner findings without using AI as the policy authority", () => { - const service = new PolicyEngineService(); + it("blocks critical scanner findings without using AI as the policy authority", async () => { + const service = new PolicyEngineService( + policyStore() as never, + authorityVerifier(true) as never + ); - const decision = service.evaluate({ + const decision = await service.evaluate({ tenantId: "tenant_policy", scanRequestId: "scan_request_2", finding: { @@ -66,10 +77,7 @@ describe("PolicyEngineService", () => { }, scanLane: "FAST", scannerCoverage: ["OPENGREP", "TRIVY", "SYFT"], - aiAdvisory: { - visible: true, - suggestedAction: "DASHBOARD_ONLY" - } + aiAdvisory: policyReference() }); expect(decision.enforcementAction).toBe("BLOCK"); @@ -77,4 +85,65 @@ describe("PolicyEngineService", () => { expect(decision.aiAdvisoryVisible).toBe(true); expect(decision.reasonCodes).toEqual(expect.arrayContaining(["SEVERITY_CRITICAL"])); }); + + it('rejects suggested actions and unknown authority fields before policy evaluation', async () => { + const verifier = authorityVerifier(true); + const service = new PolicyEngineService( + policyStore() as never, + verifier as never + ); + + await expect( + service.evaluate({ + tenantId: 'tenant_policy', + scanRequestId: 'scan_request_1', + finding: highFinding, + scanLane: 'DEEP', + scannerCoverage: ['OPENGREP', 'TRIVY', 'SYFT'], + aiAdvisory: { + ...policyReference(), + suggestedAction: 'BLOCK', + findingStatus: 'FIXED' + } as never + }) + ).rejects.toThrow('AI advisory reference is invalid or unavailable.'); + expect(verifier.verifyPolicyReference).not.toHaveBeenCalled(); + }); }); + +function policyReference() { + return { + version: 'sast-ai-advisory-policy-reference-v1' as const, + advisoryId: `sast-ai-advisory://${'a'.repeat(64)}`, + authorityProofId: `sast-ai-authority-proof://${'b'.repeat(64)}`, + authorityProofDigest: `sha256:${'c'.repeat(64)}` as const, + advisoryOnly: true as const + }; +} + +function authorityVerifier(result: boolean) { + return { + verifyPolicyReference: jest.fn().mockResolvedValue(result) + }; +} + +function policyStore() { + let sequence = 0; + const decisions = new Map>(); + return { + create: jest.fn(async (input: Record) => { + const decision = { + id: `policy_decision_${++sequence}`, + ...input + }; + decisions.set(String(decision.id), decision); + return decision; + }), + findByTenantAndId: jest.fn( + async (tenantId: string, id: string) => { + const decision = decisions.get(id); + return decision?.tenantId === tenantId ? decision : null; + } + ) + }; +} diff --git a/apps/api/test/policy/waiver-suppression-lifecycle.e2e-spec.ts b/apps/api/test/policy/waiver-suppression-lifecycle.e2e-spec.ts index 755cffd..5297788 100644 --- a/apps/api/test/policy/waiver-suppression-lifecycle.e2e-spec.ts +++ b/apps/api/test/policy/waiver-suppression-lifecycle.e2e-spec.ts @@ -2,6 +2,7 @@ import { INestApplication } from "@nestjs/common"; import { Test } from "@nestjs/testing"; import request from "supertest"; import { SessionAuthGuard } from '../../src/auth/guards/session-auth.guard'; +import { PolicyLifecycleStore } from '../../src/policy/policy-lifecycle.store'; import { TestSessionAuthGuard } from '../support/security-guards'; describe("Waiver and suppression lifecycle API (e2e)", () => { @@ -27,6 +28,34 @@ describe("Waiver and suppression lifecycle API (e2e)", () => { import("../../src/app.module"), import("../../src/prisma/prisma.service") ]); + let waiverSequence = 0; + let suppressionSequence = 0; + const waivers = new Map>(); + const policyLifecycleStore = { + createWaiver: jest.fn(async (input: Record) => { + const waiver = { + id: `waiver_${++waiverSequence}`, + ...input + }; + waivers.set(String(waiver.id), waiver); + return waiver; + }), + updateWaiver: jest.fn( + async (id: string, input: Record) => { + const waiver = waivers.get(id); + if (!waiver || waiver.tenantId !== input.tenantId) return null; + const updated = { ...waiver, ...input }; + waivers.set(id, updated); + return updated; + } + ), + createSuppression: jest.fn( + async (input: Record) => ({ + id: `suppression_${++suppressionSequence}`, + ...input + }) + ) + }; const moduleRef = await Test.createTestingModule({ imports: [AppModule] @@ -39,6 +68,8 @@ describe("Waiver and suppression lifecycle API (e2e)", () => { onModuleDestroy: jest.fn().mockResolvedValue(undefined), $queryRawUnsafe: jest.fn().mockResolvedValue([{ result: 1 }]) }) + .overrideProvider(PolicyLifecycleStore) + .useValue(policyLifecycleStore) .overrideGuard(SessionAuthGuard) .useClass(TestSessionAuthGuard) .compile(); @@ -157,5 +188,27 @@ describe("Waiver and suppression lifecycle API (e2e)", () => { fullRepository: "all source" }) .expect(400); + + await request(app.getHttpServer()) + .post('/api/waivers') + .send({ + owner: 'security-reviewer@example.com', + reason: 'AI cannot create this waiver.', + scope: 'finding:finding_waiver_3', + expiresAt: '2026-06-01T00:00:00.000Z', + advisoryId: `sast-ai-advisory://${'a'.repeat(64)}`, + authorityProofId: `sast-ai-authority-proof://${'b'.repeat(64)}` + }) + .expect(400); + + await request(app.getHttpServer()) + .post('/api/suppressions') + .send({ + scanRequestId: 'scan_request_suppression_3', + findingId: 'finding_suppression_3', + reason: 'POLICY', + aiAdvisory: { advisoryOnly: true } + }) + .expect(400); }); }); diff --git a/apps/api/test/security/internal-tenant-service.guard.e2e-spec.ts b/apps/api/test/security/internal-tenant-service.guard.e2e-spec.ts new file mode 100644 index 0000000..ffefdcc --- /dev/null +++ b/apps/api/test/security/internal-tenant-service.guard.e2e-spec.ts @@ -0,0 +1,60 @@ +import type { ExecutionContext } from '@nestjs/common'; + +import { + deriveTenantBoundInternalCredential, + InternalTenantServiceGuard, + type InternalTenantRequest +} from '../../src/common/security/internal-tenant-service.guard'; + +describe('InternalTenantServiceGuard', () => { + const secret = 'test-internal-secret-value-at-least-32-characters'; + + it('authenticates and attaches only the tenant bound into the credential', () => { + const tenantId = 'tenant-bound'; + const credential = deriveTenantBoundInternalCredential( + secret, + tenantId + ); + const { context, request } = requestContext({ + authorization: `Bearer ${credential}`, + 'x-aegis-internal-tenant-id': tenantId + }); + const guard = new InternalTenantServiceGuard({ + get: jest.fn().mockReturnValue(secret) + } as never); + + expect(guard.canActivate(context)).toBe(true); + expect(request.internalTenantId).toBe(tenantId); + }); + + it('rejects replaying one tenant credential under another tenant header', () => { + const credential = deriveTenantBoundInternalCredential( + secret, + 'tenant-a' + ); + const { context, request } = requestContext({ + authorization: `Bearer ${credential}`, + 'x-aegis-internal-tenant-id': 'tenant-b' + }); + const guard = new InternalTenantServiceGuard({ + get: jest.fn().mockReturnValue(secret) + } as never); + + expect(() => guard.canActivate(context)).toThrow( + 'A valid tenant-bound internal service credential is required.' + ); + expect(request.internalTenantId).toBeUndefined(); + }); +}); + +function requestContext(headers: Record) { + const request = { + header: (name: string) => headers[name.toLowerCase()] + } as InternalTenantRequest; + return { + request, + context: { + switchToHttp: () => ({ getRequest: () => request }) + } as ExecutionContext + }; +} diff --git a/apps/api/test/support/sast-ai-advisory-fixture.ts b/apps/api/test/support/sast-ai-advisory-fixture.ts index e30f94d..41a2878 100644 --- a/apps/api/test/support/sast-ai-advisory-fixture.ts +++ b/apps/api/test/support/sast-ai-advisory-fixture.ts @@ -2,9 +2,13 @@ import { createHash } from 'node:crypto'; import { buildSastAiAdvisoryHandoff, + buildSastAiAdvisoryAuthorityProof, + buildSastAiAdvisoryAuthorityStateSnapshot, + buildSastAiAdvisoryPolicyReference, buildSastEvidenceAccessDecision, buildSastEvidenceDeletionSchedule, type SastAiAdvisoryHandoff, + type SastAiAdvisoryAuthorityProof, type SastAiAdvisoryIntent, type SastAiAdvisoryNormalizedFinding, type SastEvidenceAccessDecision, @@ -144,6 +148,52 @@ export function aiHandoff( return handoff; } +export function aiAuthorityProof(): SastAiAdvisoryAuthorityProof { + const handoff = aiHandoff(); + const snapshot = buildSastAiAdvisoryAuthorityStateSnapshot({ + normalizedFindingDigests: [digest('authority-finding')], + targetFindingDigest: digest('authority-finding'), + lifecycleStateDigests: [digest('authority-lifecycle')], + policyDecisionDigests: [digest('authority-policy')], + waiverDigests: [], + suppressionDigests: [], + digestCanonical: digest + }); + if (!snapshot) throw new Error('AI authority snapshot fixture is invalid.'); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: { + tenantId: handoff.tenantId, + repositoryBindingId: handoff.repositoryBindingId, + scanRequestId: handoff.scanRequestId, + attemptId: handoff.attemptId, + advisoryId: handoff.advisoryId, + handoffId: handoff.handoffId, + requestDigest: handoff.requestDigest, + handoffDigest: handoff.handoffDigest, + normalizedFindingId: + handoff.normalizedFinding.normalizedFindingId, + occurrenceId: handoff.normalizedFinding.occurrenceId, + findingFingerprint: + handoff.normalizedFinding.findingFingerprint + }, + before: snapshot, + after: snapshot, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }); + if (!proof) throw new Error('AI authority proof fixture is invalid.'); + return proof; +} + +export function aiPolicyReference() { + const reference = buildSastAiAdvisoryPolicyReference( + aiAuthorityProof(), + digest + ); + if (!reference) throw new Error('AI policy reference fixture is invalid.'); + return reference; +} + export function allowedAiAccess() { const decision = aiAccessDecision(); return { diff --git a/docs/runbooks/sast-ai-authority-proof-hard-purge.md b/docs/runbooks/sast-ai-authority-proof-hard-purge.md new file mode 100644 index 0000000..fa3d107 --- /dev/null +++ b/docs/runbooks/sast-ai-authority-proof-hard-purge.md @@ -0,0 +1,90 @@ +# SAST AI Authority Proof Exceptional Hard Purge + +## Purpose and boundary + +This runbook is the only supported path for a legal or tenant-mandated hard purge of +`SastAiAdvisoryAuthorityProof` records and their T043 advisory/handoff chain. Normal tenant or +repository offboarding must soft-revoke access and retain these immutable, content-free audit +records. The application API, application database role, background workers, and ordinary +operators must not be able to invoke this procedure. + +The proof ledger contains scope identifiers and digests, not source, evidence, secret, prompt, +or advisory content. A request to remove only source or model content therefore does not by +itself authorize removal of this ledger. + +## Required authorization + +Do not begin unless all of the following exist: + +- an approved legal/privacy ticket identifying one tenant and the exact purge basis +- Security and Data Protection approval, with two named operators +- a maintenance window that prevents new scans, policy changes, waivers, suppressions, and AI + advisory work for the tenant +- a dedicated, time-limited database maintenance role that can alter only the two named + immutable delete triggers and delete the approved tenant rows +- a tested backup and rollback point +- an external, append-only audit destination that is outside the database being purged + +Never reuse the application role, share credentials, place credentials in the ticket or audit +export, or broaden the target from an explicit tenant ID. + +## Preflight and audit export + +1. Soft-revoke the tenant and stop/deny all tenant jobs and internal AI proof requests. +2. Wait for active tenant transactions to finish. Confirm that the scan, policy, lifecycle, + waiver, suppression, and advisory queues contain no running work for the tenant. +3. In a read-only session, inventory the exact `AiAdvisoryMetadata`, + `SastAiAdvisoryHandoff`, and `SastAiAdvisoryAuthorityProof` IDs and counts. Confirm every + proof belongs to the requested tenant through both its direct tenant scope and immutable + handoff binding. +4. Export only the minimum required audit fields: change ticket, approvals, tenant ID, row IDs, + scope/proof digests, counts, timestamps, and the planned deletion order. Do not export model + output or other content as part of this procedure. +5. Hash the export, write it to the approved append-only destination, and have the second + operator verify the hash and row counts before any mutation. + +Any scope mismatch, unexplained row, active transaction, missing approval, failed export, or +count difference stops the procedure. + +## Controlled maintenance transaction + +Execute one reviewed transaction from a pinned migration/maintenance artifact. The artifact +must take the approved tenant ID as a bound parameter and must abort unless its preflight counts +equal the signed audit export. + +Within that transaction, perform this exact order: + +1. Acquire the tenant maintenance lock used by the purge artifact and recheck tenant revocation. +2. Materialize the approved proof, advisory, handoff, and authority-fence targets in temporary + tables. Every destructive statement must join those exact targets; unbounded deletes are + prohibited. +3. Disable only `SastAiAdvisoryAuthorityProof_immutable_delete`, delete the approved proof rows, + and immediately re-enable that trigger. +4. Delete the matching `AiAdvisoryMetadata` rows. +5. Disable only `SastAiAdvisoryHandoff_immutable_delete`, delete the now-unreferenced approved + handoff rows, and immediately re-enable that trigger. +6. After all tenant authoritative rows have been removed or tombstoned according to the parent + offboarding plan, delete the tenant's `SastAiAdvisoryAuthorityFence` coordination rows. A + fence key is canonical JSON; its tenant element must exactly equal the approved tenant ID. +7. Verify the targeted rows are absent, both immutable delete triggers are enabled, no + non-target tenant count changed, and every statement count equals the approved inventory. +8. Commit only after the second operator confirms the verification output. Otherwise roll back + the entire transaction. + +Do not disable foreign-key enforcement, update proof rows, drop constraints or functions, +disable all user triggers, or delete parent rows before their approved proof/advisory children. +The restrictive foreign keys are a safety control and must remain active. + +## Post-purge evidence and recovery + +1. Re-run the read-only inventory and record zero remaining targeted rows plus unchanged control + tenant counts. +2. Record trigger-enabled state, transaction ID, database audit event IDs, operator identities, + timestamps, deployed artifact digest, and before/after counts in the external audit record. +3. Run tenant-isolation and health checks before re-enabling shared workers. Keep the purged + tenant revoked unless the approved offboarding plan explicitly says otherwise. +4. Revoke the maintenance role/credential immediately. + +Before commit, recovery is transaction rollback. After commit, recovery requires the approved +backup and a new incident/change record; never reconstruct or reinsert a proof from the external +audit export because it is evidence, not an application restore source. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 37a155a..4c2fc1b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,6 +25,7 @@ export * from './types/sast-scan-freshness'; export * from './types/sast-accepted-evidence'; export * from './types/sast-evidence-access'; export * from './types/sast-ai-advisory-handoff'; +export * from './types/sast-ai-advisory-authority'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/production-architecture.ts b/packages/shared/src/types/production-architecture.ts index af5a67e..b419a37 100644 --- a/packages/shared/src/types/production-architecture.ts +++ b/packages/shared/src/types/production-architecture.ts @@ -1,4 +1,5 @@ import type { AiDetectorAdvisory, AiInferenceFallback, AiModelMetadata, AiPlannerAdvisory } from './ai-inference-runtime'; +import type { SastAiAdvisoryPolicyReference } from './sast-ai-advisory-authority'; export const PRODUCTION_SCAN_ARCHITECTURE_FEATURE_ID = '002-production-scan-architecture'; @@ -158,11 +159,7 @@ export interface PolicyEvaluationInput { finding: NormalizedFinding; scanLane: ScanLane; scannerCoverage: ScannerKind[]; - aiAdvisory?: { - visible: boolean; - suggestedAction?: PolicyAction; - summary?: string; - }; + aiAdvisory?: SastAiAdvisoryPolicyReference; } export interface CommentDispatchPlanRequest { diff --git a/packages/shared/src/types/sast-ai-advisory-authority.ts b/packages/shared/src/types/sast-ai-advisory-authority.ts new file mode 100644 index 0000000..9217888 --- /dev/null +++ b/packages/shared/src/types/sast-ai-advisory-authority.ts @@ -0,0 +1,557 @@ +export const SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION = + 'sast-ai-advisory-authority-proof-v1' as const; + +export const SAST_AI_ADVISORY_POLICY_REFERENCE_VERSION = + 'sast-ai-advisory-policy-reference-v1' as const; + +export const SAST_AI_ADVISORY_AUTHORITY_LIMITS = Object.freeze({ + identifierBytes: 512, + maximumNormalizedFindings: 25_000, + maximumPolicyDecisions: 1_024, + maximumWaivers: 1_024, + maximumSuppressions: 1_024 +}); + +type Sha256Digest = `sha256:${string}`; + +const CONTRACT_ID_PATTERNS = { + advisory: /^sast-ai-advisory:\/\/[a-f0-9]{64}$/u, + handoff: /^sast-ai-handoff:\/\/[a-f0-9]{64}$/u, + occurrence: /^finding-occurrence:\/\/[a-f0-9]{64}$/u, + proof: /^sast-ai-authority-proof:\/\/[a-f0-9]{64}$/u +} as const; + +export interface SastAiAdvisoryAuthorityProofIntent { + tenantId: string; + advisoryId: string; +} + +export interface SastAiAdvisoryAuthorityStateSnapshot { + normalizedFindingCount: number; + normalizedFindingSetDigest: Sha256Digest; + targetFindingDigest: Sha256Digest; + lifecycleStateCount: 1; + lifecycleStateSetDigest: Sha256Digest; + policyDecisionCount: number; + policyDecisionSetDigest: Sha256Digest; + waiverCount: number; + waiverSetDigest: Sha256Digest; + suppressionCount: number; + suppressionSetDigest: Sha256Digest; + stateDigest: Sha256Digest; +} + +export interface SastAiAdvisoryAuthorityProofScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + advisoryId: string; + handoffId: string; + requestDigest: Sha256Digest; + handoffDigest: Sha256Digest; + normalizedFindingId: string; + occurrenceId: string; + findingFingerprint: Sha256Digest; +} + +export interface SastAiAdvisoryZeroAuthority { + findingCreateAuthority: false; + findingStatusMutationAuthority: false; + findingSeverityMutationAuthority: false; + lifecycleMutationAuthority: false; + waiverMutationAuthority: false; + suppressionMutationAuthority: false; + policyOverrideAuthority: false; + blockDecisionAuthority: false; + publicationAuthority: false; + scmWriteAuthority: false; + advisoryOnly: true; +} + +export interface SastAiAdvisoryAuthorityAudit { + proofLedgerWritten: true; + authoritativeFindingWritten: false; + lifecycleStateWritten: false; + policyDecisionWritten: false; + waiverWritten: false; + suppressionWritten: false; + callerAuthorityFieldsAccepted: false; + advisoryContentStored: false; + sourceContentStored: false; + secretValueStored: false; +} + +export interface SastAiAdvisoryAuthorityProof { + version: typeof SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION; + proofId: string; + scope: SastAiAdvisoryAuthorityProofScope; + before: SastAiAdvisoryAuthorityStateSnapshot; + after: SastAiAdvisoryAuthorityStateSnapshot; + authority: SastAiAdvisoryZeroAuthority; + audit: SastAiAdvisoryAuthorityAudit; + verifiedAt: string; + proofDigest: Sha256Digest; +} + +export interface SastAiAdvisoryPolicyReference { + version: typeof SAST_AI_ADVISORY_POLICY_REFERENCE_VERSION; + advisoryId: string; + authorityProofId: string; + authorityProofDigest: Sha256Digest; + advisoryOnly: true; +} + +export type SastAiAdvisoryAuthorityCanonicalDigester = ( + canonicalValue: string +) => Sha256Digest; + +const ZERO_AUTHORITY: SastAiAdvisoryZeroAuthority = Object.freeze({ + findingCreateAuthority: false, + findingStatusMutationAuthority: false, + findingSeverityMutationAuthority: false, + lifecycleMutationAuthority: false, + waiverMutationAuthority: false, + suppressionMutationAuthority: false, + policyOverrideAuthority: false, + blockDecisionAuthority: false, + publicationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true +}); + +const AUDIT: SastAiAdvisoryAuthorityAudit = Object.freeze({ + proofLedgerWritten: true, + authoritativeFindingWritten: false, + lifecycleStateWritten: false, + policyDecisionWritten: false, + waiverWritten: false, + suppressionWritten: false, + callerAuthorityFieldsAccepted: false, + advisoryContentStored: false, + sourceContentStored: false, + secretValueStored: false +}); + +export function buildSastAiAdvisoryAuthorityStateSnapshot(input: { + normalizedFindingDigests: readonly Sha256Digest[]; + targetFindingDigest: Sha256Digest; + lifecycleStateDigests: readonly Sha256Digest[]; + policyDecisionDigests: readonly Sha256Digest[]; + waiverDigests: readonly Sha256Digest[]; + suppressionDigests: readonly Sha256Digest[]; + digestCanonical: SastAiAdvisoryAuthorityCanonicalDigester; +}): SastAiAdvisoryAuthorityStateSnapshot | null { + if ( + !isCanonicalDigestSet( + input.normalizedFindingDigests, + 1, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumNormalizedFindings + ) || + !input.normalizedFindingDigests.includes(input.targetFindingDigest) || + !isCanonicalDigestSet(input.lifecycleStateDigests, 1, 1) || + !isCanonicalDigestSet( + input.policyDecisionDigests, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumPolicyDecisions + ) || + !isCanonicalDigestSet( + input.waiverDigests, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumWaivers + ) || + !isCanonicalDigestSet( + input.suppressionDigests, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumSuppressions + ) + ) { + return null; + } + + const core = { + normalizedFindingCount: input.normalizedFindingDigests.length, + normalizedFindingSetDigest: input.digestCanonical( + stableJson(input.normalizedFindingDigests) + ), + targetFindingDigest: input.targetFindingDigest, + lifecycleStateCount: 1 as const, + lifecycleStateSetDigest: input.digestCanonical( + stableJson(input.lifecycleStateDigests) + ), + policyDecisionCount: input.policyDecisionDigests.length, + policyDecisionSetDigest: input.digestCanonical( + stableJson(input.policyDecisionDigests) + ), + waiverCount: input.waiverDigests.length, + waiverSetDigest: input.digestCanonical( + stableJson(input.waiverDigests) + ), + suppressionCount: input.suppressionDigests.length, + suppressionSetDigest: input.digestCanonical( + stableJson(input.suppressionDigests) + ) + }; + return { + ...core, + stateDigest: input.digestCanonical(stableJson(core)) + }; +} + +export function buildSastAiAdvisoryAuthorityProof(input: { + scope: Readonly; + before: Readonly; + after: Readonly; + verifiedAt: string; + digestCanonical: SastAiAdvisoryAuthorityCanonicalDigester; +}): SastAiAdvisoryAuthorityProof | null { + if ( + !isSastAiAdvisoryAuthorityProofScopeValid(input.scope) || + !isSastAiAdvisoryAuthorityStateSnapshotShapeValid( + input.before, + input.digestCanonical + ) || + !isSastAiAdvisoryAuthorityStateSnapshotShapeValid( + input.after, + input.digestCanonical + ) || + stableJson(input.before) !== stableJson(input.after) || + !isIsoInstant(input.verifiedAt) + ) { + return null; + } + + const identityDigest = input.digestCanonical( + stableJson({ + version: SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION, + tenantId: input.scope.tenantId, + advisoryId: input.scope.advisoryId, + handoffId: input.scope.handoffId, + requestDigest: input.scope.requestDigest + }) + ); + const suffix = stripDigest(identityDigest); + if (!suffix) return null; + + const core = { + version: SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION, + proofId: `sast-ai-authority-proof://${suffix}`, + scope: { ...input.scope }, + before: { ...input.before }, + after: { ...input.after }, + authority: { ...ZERO_AUTHORITY }, + audit: { ...AUDIT }, + verifiedAt: input.verifiedAt + }; + const proof: SastAiAdvisoryAuthorityProof = { + ...core, + proofDigest: input.digestCanonical(stableJson(core)) + }; + return isSastAiAdvisoryAuthorityProofShapeValid( + proof, + input.digestCanonical + ) + ? proof + : null; +} + +export function buildSastAiAdvisoryPolicyReference( + proof: Readonly, + digestCanonical: SastAiAdvisoryAuthorityCanonicalDigester +): SastAiAdvisoryPolicyReference | null { + if ( + !isSastAiAdvisoryAuthorityProofShapeValid( + proof, + digestCanonical + ) + ) { + return null; + } + return { + version: SAST_AI_ADVISORY_POLICY_REFERENCE_VERSION, + advisoryId: proof.scope.advisoryId, + authorityProofId: proof.proofId, + authorityProofDigest: proof.proofDigest, + advisoryOnly: true + }; +} + +export function isSastAiAdvisoryAuthorityProofIntentShapeValid( + value: unknown +): value is SastAiAdvisoryAuthorityProofIntent { + return isRecord(value) && + hasExactKeys(value, ['tenantId', 'advisoryId']) && + isBoundedReference(value.tenantId) && + isContractId(value.advisoryId, 'advisory'); +} + +export function isSastAiAdvisoryPolicyReferenceShapeValid( + value: unknown +): value is SastAiAdvisoryPolicyReference { + return isRecord(value) && + hasExactKeys(value, [ + 'version', + 'advisoryId', + 'authorityProofId', + 'authorityProofDigest', + 'advisoryOnly' + ]) && + value.version === SAST_AI_ADVISORY_POLICY_REFERENCE_VERSION && + isContractId(value.advisoryId, 'advisory') && + isContractId(value.authorityProofId, 'proof') && + isSha256Digest(value.authorityProofDigest) && + value.advisoryOnly === true; +} + +export function isSastAiAdvisoryAuthorityStateSnapshotShapeValid( + value: unknown, + digestCanonical: SastAiAdvisoryAuthorityCanonicalDigester +): value is SastAiAdvisoryAuthorityStateSnapshot { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'normalizedFindingCount', + 'normalizedFindingSetDigest', + 'targetFindingDigest', + 'lifecycleStateCount', + 'lifecycleStateSetDigest', + 'policyDecisionCount', + 'policyDecisionSetDigest', + 'waiverCount', + 'waiverSetDigest', + 'suppressionCount', + 'suppressionSetDigest', + 'stateDigest' + ]) || + !isBoundedCount( + value.normalizedFindingCount, + 1, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumNormalizedFindings + ) || + value.lifecycleStateCount !== 1 || + !isBoundedCount( + value.policyDecisionCount, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumPolicyDecisions + ) || + !isBoundedCount( + value.waiverCount, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumWaivers + ) || + !isBoundedCount( + value.suppressionCount, + 0, + SAST_AI_ADVISORY_AUTHORITY_LIMITS.maximumSuppressions + ) || + ![ + value.normalizedFindingSetDigest, + value.targetFindingDigest, + value.lifecycleStateSetDigest, + value.policyDecisionSetDigest, + value.waiverSetDigest, + value.suppressionSetDigest, + value.stateDigest + ].every(isSha256Digest) + ) { + return false; + } + const core = { ...value } as Record; + delete core.stateDigest; + return digestCanonical(stableJson(core)) === value.stateDigest; +} + +export function isSastAiAdvisoryAuthorityProofShapeValid( + value: unknown, + digestCanonical: SastAiAdvisoryAuthorityCanonicalDigester +): value is SastAiAdvisoryAuthorityProof { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'proofId', + 'scope', + 'before', + 'after', + 'authority', + 'audit', + 'verifiedAt', + 'proofDigest' + ]) || + value.version !== SAST_AI_ADVISORY_AUTHORITY_PROOF_VERSION || + !isContractId(value.proofId, 'proof') || + !isSastAiAdvisoryAuthorityProofScopeValid(value.scope) || + !isSastAiAdvisoryAuthorityStateSnapshotShapeValid( + value.before, + digestCanonical + ) || + !isSastAiAdvisoryAuthorityStateSnapshotShapeValid( + value.after, + digestCanonical + ) || + stableJson(value.before) !== stableJson(value.after) || + !isExactObject(value.authority, ZERO_AUTHORITY) || + !isExactObject(value.audit, AUDIT) || + !isIsoInstant(value.verifiedAt) || + !isSha256Digest(value.proofDigest) + ) { + return false; + } + + const identityDigest = digestCanonical( + stableJson({ + version: value.version, + tenantId: value.scope.tenantId, + advisoryId: value.scope.advisoryId, + handoffId: value.scope.handoffId, + requestDigest: value.scope.requestDigest + }) + ); + const core = { ...value } as Record; + delete core.proofDigest; + return ( + value.proofId === + `sast-ai-authority-proof://${stripDigest(identityDigest)}` && + digestCanonical(stableJson(core)) === value.proofDigest + ); +} + +function isSastAiAdvisoryAuthorityProofScopeValid( + value: unknown +): value is SastAiAdvisoryAuthorityProofScope { + return isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'advisoryId', + 'handoffId', + 'requestDigest', + 'handoffDigest', + 'normalizedFindingId', + 'occurrenceId', + 'findingFingerprint' + ]) && + [ + value.tenantId, + value.repositoryBindingId, + value.scanRequestId, + value.attemptId, + value.normalizedFindingId + ].every(isBoundedReference) && + isContractId(value.advisoryId, 'advisory') && + isContractId(value.handoffId, 'handoff') && + isContractId(value.occurrenceId, 'occurrence') && + isSha256Digest(value.requestDigest) && + isSha256Digest(value.handoffDigest) && + isSha256Digest(value.findingFingerprint); +} + +function isCanonicalDigestSet( + value: readonly Sha256Digest[], + minimum: number, + maximum: number +): boolean { + return Array.isArray(value) && + value.length >= minimum && + value.length <= maximum && + value.every(isSha256Digest) && + new Set(value).size === value.length && + value.every( + (item, index) => index === 0 || String(value[index - 1]) < item + ); +} + +function isBoundedCount( + value: unknown, + minimum: number, + maximum: number +): boolean { + return Number.isInteger(value) && + Number(value) >= minimum && + Number(value) <= maximum; +} + +function isExactObject( + value: unknown, + expected: object +): boolean { + const expectedRecord = expected as Record; + return isRecord(value) && + hasExactKeys(value, Object.keys(expectedRecord)) && + Object.entries(expectedRecord).every( + ([key, expectedValue]) => value[key] === expectedValue + ); +} + +function isBoundedReference(value: unknown): value is string { + return typeof value === 'string' && + value.length > 0 && + value.trim() === value && + !hasAsciiControl(value) && + new TextEncoder().encode(value).length <= + SAST_AI_ADVISORY_AUTHORITY_LIMITS.identifierBytes; +} + +function isContractId( + value: unknown, + kind: keyof typeof CONTRACT_ID_PATTERNS +): value is string { + return typeof value === 'string' && + CONTRACT_ID_PATTERNS[kind].test(value); +} + +function isSha256Digest(value: unknown): value is Sha256Digest { + return typeof value === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(value); +} + +function stripDigest(value: string): string | null { + return isSha256Digest(value) ? value.slice('sha256:'.length) : null; +} + +function isIsoInstant(value: unknown): value is string { + return typeof value === 'string' && + Number.isFinite(Date.parse(value)) && + new Date(value).toISOString() === value; +} + +function hasExactKeys( + value: Record, + expected: readonly string[] +): boolean { + const actual = Object.keys(value).sort(); + const ordered = [...expected].sort(); + return actual.length === ordered.length && + actual.every((key, index) => key === ordered[index]); +} + +function hasAsciiControl(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function isRecord(value: unknown): value is Record { + return value !== null && + typeof value === 'object' && + !Array.isArray(value); +} + +function stableJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; +} diff --git a/packages/shared/test/sast-ai-advisory-authority.test.mjs b/packages/shared/test/sast-ai-advisory-authority.test.mjs new file mode 100644 index 0000000..a64c9ba --- /dev/null +++ b/packages/shared/test/sast-ai-advisory-authority.test.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + buildSastAiAdvisoryAuthorityProof, + buildSastAiAdvisoryAuthorityStateSnapshot, + buildSastAiAdvisoryPolicyReference, + isSastAiAdvisoryAuthorityProofIntentShapeValid, + isSastAiAdvisoryAuthorityProofShapeValid, + isSastAiAdvisoryPolicyReferenceShapeValid +} from '../dist/index.js'; + +test('T044 binds identical authoritative state before and after advisory consumption', () => { + const snapshot = authoritySnapshot(); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: authorityScope(), + before: snapshot, + after: snapshot, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }); + + assert.ok(proof); + assert.equal( + isSastAiAdvisoryAuthorityProofShapeValid(proof, digest), + true + ); + assert.equal(proof.before.stateDigest, proof.after.stateDigest); + assert.deepEqual(proof.authority, { + findingCreateAuthority: false, + findingStatusMutationAuthority: false, + findingSeverityMutationAuthority: false, + lifecycleMutationAuthority: false, + waiverMutationAuthority: false, + suppressionMutationAuthority: false, + policyOverrideAuthority: false, + blockDecisionAuthority: false, + publicationAuthority: false, + scmWriteAuthority: false, + advisoryOnly: true + }); + assert.equal(proof.audit.proofLedgerWritten, true); + assert.equal(proof.audit.authoritativeFindingWritten, false); + assert.equal(proof.audit.policyDecisionWritten, false); + assert.equal(proof.audit.waiverWritten, false); + assert.equal(proof.audit.suppressionWritten, false); +}); + +test('T044 policy references expose only immutable advisory and proof identity', () => { + const snapshot = authoritySnapshot(); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: authorityScope(), + before: snapshot, + after: snapshot, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }); + assert.ok(proof); + const reference = buildSastAiAdvisoryPolicyReference(proof, digest); + assert.ok(reference); + assert.equal( + isSastAiAdvisoryPolicyReferenceShapeValid(reference), + true + ); + assert.deepEqual(Object.keys(reference).sort(), [ + 'advisoryId', + 'advisoryOnly', + 'authorityProofDigest', + 'authorityProofId', + 'version' + ]); + assert.equal( + isSastAiAdvisoryPolicyReferenceShapeValid({ + ...reference, + suggestedAction: 'BLOCK' + }), + false + ); +}); + +test('T044 rejects state drift, caller authority, and non-exact proof intent', () => { + const before = authoritySnapshot(); + const after = buildSastAiAdvisoryAuthorityStateSnapshot({ + normalizedFindingDigests: [digest('finding-a')], + targetFindingDigest: digest('finding-a'), + lifecycleStateDigests: [digest('lifecycle-fixed')], + policyDecisionDigests: [], + waiverDigests: [], + suppressionDigests: [], + digestCanonical: digest + }); + assert.ok(after); + assert.equal( + buildSastAiAdvisoryAuthorityProof({ + scope: authorityScope(), + before, + after, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }), + null + ); + assert.equal( + isSastAiAdvisoryAuthorityProofIntentShapeValid({ + tenantId: 'tenant-ai', + advisoryId: authorityScope().advisoryId, + policyOverride: true + }), + false + ); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: authorityScope(), + before, + after: before, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }); + assert.ok(proof); + assert.equal( + isSastAiAdvisoryAuthorityProofShapeValid( + { + ...proof, + authority: { + ...proof.authority, + policyOverrideAuthority: true + } + }, + digest + ), + false + ); +}); + +test('T044 rejects tampered proof identity, scope, and digest', () => { + const snapshot = authoritySnapshot(); + const proof = buildSastAiAdvisoryAuthorityProof({ + scope: authorityScope(), + before: snapshot, + after: snapshot, + verifiedAt: '2026-08-11T05:30:00.000Z', + digestCanonical: digest + }); + assert.ok(proof); + + for (const tampered of [ + { + ...proof, + proofId: `sast-ai-authority-proof://${'0'.repeat(64)}` + }, + { + ...proof, + scope: { ...proof.scope, tenantId: 'foreign-tenant' } + }, + { ...proof, proofDigest: `sha256:${'0'.repeat(64)}` } + ]) { + assert.equal( + isSastAiAdvisoryAuthorityProofShapeValid(tampered, digest), + false + ); + } +}); + +test('T044 rejects empty, duplicate, unsorted, and target-missing digest sets', () => { + const findingA = digest('finding-a'); + const findingB = digest('finding-b'); + const sorted = [findingA, findingB].sort(); + const common = { + targetFindingDigest: sorted[0], + lifecycleStateDigests: [digest('lifecycle-open')], + policyDecisionDigests: [], + waiverDigests: [], + suppressionDigests: [], + digestCanonical: digest + }; + + for (const normalizedFindingDigests of [ + [], + [findingA, findingA], + [...sorted].reverse() + ]) { + assert.equal( + buildSastAiAdvisoryAuthorityStateSnapshot({ + ...common, + normalizedFindingDigests + }), + null + ); + } + assert.equal( + buildSastAiAdvisoryAuthorityStateSnapshot({ + ...common, + normalizedFindingDigests: sorted, + targetFindingDigest: digest('missing-target') + }), + null + ); +}); + +function authoritySnapshot() { + const snapshot = buildSastAiAdvisoryAuthorityStateSnapshot({ + normalizedFindingDigests: [digest('finding-a')], + targetFindingDigest: digest('finding-a'), + lifecycleStateDigests: [digest('lifecycle-open')], + policyDecisionDigests: [digest('policy-warn')], + waiverDigests: [], + suppressionDigests: [], + digestCanonical: digest + }); + if (!snapshot) throw new Error('authority snapshot fixture is invalid'); + return snapshot; +} + +function authorityScope() { + return { + tenantId: 'tenant-ai', + repositoryBindingId: 'repository-ai', + scanRequestId: 'scan-ai', + attemptId: 'attempt-ai', + advisoryId: contractId('sast-ai-advisory', 'advisory'), + handoffId: contractId('sast-ai-handoff', 'handoff'), + requestDigest: digest('request'), + handoffDigest: digest('handoff'), + normalizedFindingId: 'normalized-finding-ai', + occurrenceId: contractId('finding-occurrence', 'occurrence'), + findingFingerprint: digest('finding-fingerprint') + }; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; +} + +function contractId(prefix, seed) { + return `${prefix}://${createHash('sha256').update(seed, 'utf8').digest('hex')}`; +} diff --git a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md index 8d80978..caa1ad6 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1286,6 +1286,57 @@ export the required audit record, remove `AiAdvisoryMetadata` children, bypass t delete fence only for the identified handoff rows, and then remove parent scope. The restrictive foreign keys intentionally prevent an ordinary cascade from erasing this ledger. +### Advisory output authority proof gate v1 + +`sast-ai-advisory-authority-proof-v1` accepts exactly `tenantId` and `advisoryId` over a +tenant-bound internal credential; the authenticated tenant must equal the body tenant. Caller +finding, severity, status, lifecycle, waiver, suppression, policy action, block request, state +digest, or proof fields are unknown keys and reject before storage. The store reloads the +tenant-bound `AiAdvisoryMetadata`, T043 handoff, T037 occurrence, normalized finding, lineage, +and lifecycle context. Any advisory/handoff/scope/authority drift returns one generic +unavailable result. + +The caller sends `x-aegis-internal-tenant-id` and a `Bearer v1.` credential derived with +HMAC-SHA-256 from the internal root secret, a fixed versioned domain separator, and that exact +tenant ID. A credential derived for one tenant fails under every other tenant header. The root +secret is never sent, stored in a proof, or accepted in the request body. + +Within one bounded serializable transaction, the store first locks the advisory context fence +and then the canonical scan, lifecycle-context, and finding authority fences. Every application +write to normalized findings, lifecycle state, policy decisions, finding-scoped waivers, or +suppressions advances and locks the same database fence before mutation. The store then reads +at most 25,000 normalized findings for the scan and at most 1,024 finding policy decisions, +waivers, and suppressions, plus exactly one T037 lifecycle state. These policy and lifecycle +API paths persist to the same authoritative Prisma tables read by the proof store; they have no +in-memory shadow authority. Canonical row digests include status, severity, lifecycle revision, +policy flags, and row update instants. + +The store inserts only the proof row and projects the single locked state snapshot into both +`before` and `after`; equality is therefore evidence of zero authority, while race exclusion is +provided by the database fence and serializable conflict retry. A concurrent relevant writer +blocks or causes retry instead of being hidden by a transaction snapshot. Over-limit, missing +target/lifecycle, missing fence, reordered, cross-scope, or changed replay state fails closed; +exact advisory replay returns the one existing immutable proof. + +The database row contains scope IDs, counts, component/state/proof digests, verification time, +and fixed booleans only. Checks require every finding-create/status/severity, lifecycle, +waiver, suppression, policy-override, block, publication, and SCM authority bit false; every +authoritative-write audit bit is false and only `proofLedgerWritten` is true. Immutable update +and delete triggers plus delete/update-restrictive foreign keys preserve the audit chain. There is no JSON, +advisory text, rationale, prompt, source, evidence, secret, or policy payload column. + +`sast-ai-advisory-policy-reference-v1` exposes only version, advisory ID, proof ID/digest, and +`advisoryOnly=true`. Policy verifies that reference against the same tenant and normalized +finding before setting display visibility. Enforcement action, reason codes, comment/ticket/ +block requests, finding status/severity, waiver, suppression, and lifecycle remain derived +without AI input. Waiver create/update and suppression create requests use exact key allowlists, +so advisory/proof fields and the legacy `suggestedAction` shape reject rather than being ignored. + +Normal offboarding retains this content-free proof under the tenant tombstone. Exceptional +tenant/legal hard purge follows the two-operator, externally audited procedure in +[`docs/runbooks/sast-ai-authority-proof-hard-purge.md`](../../docs/runbooks/sast-ai-authority-proof-hard-purge.md); +ordinary application roles cannot bypass the immutable fence. + ## Cleanup Contract A scan attempt is not operationally complete until: diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index b31f4c6..30af9f9 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -814,6 +814,46 @@ decisions cannot be inferred from a successful scan or accepted T041 pack. flow that deletes advisory metadata before temporarily bypassing the immutable handoff fence; ordinary application roles cannot perform that operation +### SastAiAdvisoryAuthorityProof + +- one deterministic `sast-ai-authority-proof://` row per T043 advisory/handoff, bound + to tenant, repository, scan, attempt, occurrence, normalized finding, fingerprint, request + digest, and handoff digest +- bounded component counts and SHA-256 digests cover the complete scan finding set, target + status/severity row, exact T037 lifecycle context state/revision, finding policy decisions, + `finding:` waivers, and finding suppressions. Both `before` and `after` + fields project one database-fenced snapshot, so `beforeStateDigest` must equal + `afterStateDigest` +- the transaction first locks advisory, scan, lifecycle-context, and finding fence rows; + relevant authoritative writers advance the same rows, so a concurrent mutation conflicts + and retries. The proof write is the only product/audit mutation in its transaction. Finding creation, + finding status/severity, lifecycle, waiver, suppression, policy override, blocking, + publication, and SCM authority are database-checked false; authoritative-write audit bits + are false and `proofLedgerWritten` alone is true +- no JSON/content column exists. Advisory output, rationale, prompt, source, evidence fragment, + secret value, policy payload, owner, or waiver reason is absent; only component row digests + survive +- policy decisions, waivers, and suppressions returned by application services are persisted in + the same Prisma tables included in the digest; no in-memory authority store exists +- occurrence and normalized-finding composite constraints are installed by the mandatory + online-schema step after their populated referenced indexes are ready. Direct tenant, + repository, scan, handoff, and advisory relations restrict deletion +- ordinary offboarding retains the proof with the advisory audit chain. Exceptional tenant or + legal hard purge uses the reviewed + [`docs/runbooks/sast-ai-authority-proof-hard-purge.md`](../../docs/runbooks/sast-ai-authority-proof-hard-purge.md) + flow; application roles cannot update or delete it + +### SastAiAdvisoryAuthorityFence + +- internal coordination ledger containing only canonical JSON scope keys, monotonic versions, + and creation time; it contains no finding, policy, advisory, source, evidence, or secret data +- advisory metadata writes touch the tenant/advisory key; normalized-finding and lifecycle bulk + writes aggregate distinct affected scope keys once per SQL statement, while policy, waiver, + and suppression writes touch their matching finding keys +- proof creation locks one advisory key before context reload and three authority keys before + snapshot capture. Missing rows fail closed and concurrent changes surface as serializable + conflicts; the fence never grants policy or lifecycle authority + ### SastEvidenceDeletionSchedule - deterministic `sast-evidence-deletion://` schedule and diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index 695ba85..dc79735 100644 --- a/specs/006-production-sast-runtime-design/plan.md +++ b/specs/006-production-sast-runtime-design/plan.md @@ -15,7 +15,7 @@ Issue #276 is an explicitly reclassified adjacent bootstrap, not a new productio Its `ontology/` Neo4j and MITRE CWE work remains local dev/demo data tooling with no Scan, AI, policy, finding, evidence, publication, SCM, tenant, or deployment authority. Work on that bootstrap did not advance or satisfy T040; the formal 006 sequence has since completed -T040, T041, T042, and T043 independently and now proceeds to T044. +T040, T041, T042, T043, and T044 independently and now proceeds to T045. ## Target Boundaries @@ -143,7 +143,16 @@ and derives deterministic `sast-ai-advisory-handoff-v1` identities. Its immutabl only scope references, digests, expiry, and fixed authority/audit bits; request payload, source, secret values, and evidence fragments are absent. The AI runtime receives normalized metadata plus one opaque reduced-evidence reference, an empty snippets array, and zero retrieval, tool, -policy, publication, lifecycle, or SCM authority. T044 output-authority proof is the next gate. +policy, publication, lifecycle, or SCM authority. T044 now accepts only tenant/advisory identity +bound to the authenticated internal tenant, +rebinds the complete T043/T037 scope, and captures bounded finding, lifecycle, policy, waiver, +and suppression digests from one database-fenced authoritative snapshot projected identically +before and after the only permitted immutable proof write in one serializable transaction. Its +content-free ledger fixes every authoritative mutation bit false. Bulk normalized-finding and +lifecycle statements deduplicate affected fence keys before advancing their versions. +Policy accepts only a verified advisory/proof reference for visibility while exact waiver and +suppression request shapes reject AI/proof fields. T045 signed immutable bundle manifests and +compatibility validation are the next gate. ### Slice 6 - Coverage, Failure, Policy, and Evidence @@ -175,7 +184,8 @@ gates. Produce a machine-readable go/no-go record. Hand live cluster/microVM rol - `NormalizedSastFinding`, provenance, occurrence, and correlation - `ScannerCoverageRecord` and `SastCoverageDecision` - `SastEvidencePolicy` and evidence pack reference -- `SastAiAdvisoryIntent`, `SastAiAdvisoryHandoff`, and reference-only advisory ledger +- `SastAiAdvisoryIntent`, `SastAiAdvisoryHandoff`, reference-only advisory ledger, + `SastAiAdvisoryAuthorityProof`, and display-only policy reference - `RuleBundleDescriptor`, promotion evidence, tenant policy, and kill switch - `SastFailureDecision` and sandbox destruction evidence - `SastQualityMeasurements` and immutable go/no-go record diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index f5b6a0c..2f700df 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -330,6 +330,21 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl one opaque reduced-evidence reference, and zero snippets. Retrieval, tools, policy, publication, lifecycle mutation, and SCM write authority are false in every request; legacy direct finding/evidence requests, correlation drift, expiry, and authority widening are denied. +- 100% T044 zero-authority invariant: one serializable operation locks one advisory-context and + three authority fence rows, then writes only an immutable proof row. Every covered writer + advances the same fence; bulk finding and lifecycle writes deduplicate affected keys once per + SQL statement. The bounded scan finding set, target status/severity, exact lifecycle + state/revision, durable finding policy decisions, waivers, and suppressions produce one locked + snapshot projected into identical before/after state digests; authoritative writes and all + authority bits equal zero. +- 100% T044 policy isolation invariant: only a tenant/finding-bound exact advisory/proof + reference can set advisory visibility. AI contributes zero enforcement actions, reason codes, + ticket/block requests, severity/status changes, waivers, suppressions, or lifecycle events. + `suggestedAction`, authority, advisory, proof, unknown, cross-tenant, or drifted fields are + rejected, not ignored. +- 100% T044 content-free/replay invariant: the proof ledger contains only scope references, + counts, SHA-256 digests, time, and fixed booleans. Advisory/rationale/prompt/source/evidence/ + secret/policy payload retention and duplicate proofs equal zero; exact retry returns one row. ## Canary and Continuous Production Gates diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index 5f13431..7aad790 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -518,6 +518,24 @@ portion of Phase 6: - The internal AI runtime receives one normalized metadata projection and one opaque reduced reference with `snippets=[]`. Retrieval, tools, policy, publication, lifecycle mutation, and SCM write authority remain false; the legacy caller-supplied finding/evidence route is denied. +- T044 accepts only tenant and advisory identity under a tenant-bound internal credential, then + locks the advisory context and rebinds the T043 metadata/handoff to the + exact T037 occurrence, normalized finding, lineage, and lifecycle context. In one bounded + serializable transaction it locks the scan, lifecycle-context, and finding authority fences, + hashes the complete scan finding set, target status/severity, lifecycle state/revision, durable + finding policy decisions, finding-scoped waivers, and suppressions once, and projects that + locked snapshot into identical before/after proof fields. Covered writers use the same fence; + bulk normalized-finding and lifecycle statements advance each distinct affected scope once. + Any missing, over-limit, cross-scope, concurrent, or changed replay state fails closed. +- `sast-ai-advisory-authority-proof-v1` stores no JSON or model/content payload. It retains only + scope references, counts, component/state/proof digests, verification time, and database- + checked booleans: proof-ledger written is true while every finding creation/status/severity, + lifecycle, policy, waiver, suppression, block, publication, SCM, and authoritative-write bit + is false. Immutable triggers and restricted parent relations preserve exact replay and audit. +- Policy accepts only a tenant/finding-bound `sast-ai-advisory-policy-reference-v1` to set + display visibility. Deterministic finding severity and coverage alone derive enforcement, + reasons, tickets, and blocks. The legacy `suggestedAction` shape and advisory/proof fields in + exact waiver or suppression payloads fail closed before mutation. This checkpoint proves the provider-facing execution contract but does not claim that the provider microVM platform is live. The non-production opaque credential issuer and test @@ -530,9 +548,9 @@ T038 authority-aware cross-tool correlation, T039 fail-closed scanner/capability T040 stale-scan denial and bounded infrastructure-only retry, T041 bounded accepted-finding evidence with reconstruction-risk checks, and T042 purpose-bound dashboard/AI classification, second-pass secret redaction, seven-day expiry enforcement, and deletion proof are complete; -T043 normalized-finding plus reduced-reference advisory handoff is also complete; T044 is the -next implementation task and proves AI cannot create, suppress, waive, resolve, or override -finding/policy authority. +T043 normalized-finding plus reduced-reference advisory handoff is complete. T044 zero-authority +output proof is also complete; T045 is the next implementation task and introduces signed, +immutable rule-bundle manifests plus compatibility validation. Live deployment eligibility still requires the 005 rollout and the remaining 006 gates. diff --git a/specs/006-production-sast-runtime-design/research.md b/specs/006-production-sast-runtime-design/research.md index a500757..cd9fd4e 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -563,3 +563,37 @@ keeps replay auditable without retaining a second copy of sensitive or expiring content, persisting a full handoff JSON, deriving retry identity from wall-clock invocation time, accepting dashboard-purpose authority, enabling model retrieval/tools, or treating an AI response as finding, policy, publication, lifecycle, or SCM authority. + +## Decision 26: Prove Advisory Consumption with an Immutable Zero-Authority Ledger + +**Decision**: T044 accepts only tenant and T043 advisory identity under a tenant-bound internal +credential. In one bounded serializable transaction it locks the advisory context, reloads the +advisory, immutable handoff, occurrence, normalized finding, and lifecycle context, and then +locks the scan, lifecycle-context, and finding authority fences. All application writers to the +covered authoritative tables advance those same fences; normalized-finding and lifecycle bulk +writes aggregate distinct affected keys once per statement. The transaction hashes one stable scan +finding set, target finding status/severity, T037 lifecycle state/revision, finding policy +decision, finding-scoped waiver, and suppression snapshot, then inserts one +`sast-ai-advisory-authority-proof-v1` row. The proof projects that one locked snapshot into +byte-identical before/after fields; replay must still match current locked state. + +The proof table contains only durable scope references, counts, component/state digests, +verification time, and fixed booleans proving zero finding creation/status/severity, +lifecycle, waiver, suppression, policy override, blocking, publication, and SCM authority. +It stores no advisory output, rationale, prompt, source, evidence, secret, or policy payload. +Policy accepts only a validated `sast-ai-advisory-policy-reference-v1` for display visibility; +deterministic finding severity and coverage remain the only enforcement inputs. Waiver and +suppression payloads use exact key allowlists and reject advisory/proof fields. + +**Rationale**: A TypeScript interface, ignored `suggestedAction`, or two reads from one MVCC +snapshot does not prove that AI could not reach another write path. A database fence shared by +proof creation and every covered writer closes the race; persisting fixed false bits under +database checks and immutable triggers, using the same durable policy/lifecycle tables, and +verifying the proof reference at policy entry make the separation executable and auditable. +Bounded digest sets avoid retaining sensitive content or creating an unbounded proof operation. + +**Rejected**: Trusting a caller-supplied before/after snapshot, relying on two same-transaction +reads without a writer fence, storing advisory or policy JSON +in the proof, allowing AI-suggested actions and merely ignoring them, regex-only lifecycle key +blocking, mutating the authoritative row to mark it checked, creating more than one proof per +advisory, or cascading normal tenant deletion through the immutable audit ledger. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index 5cffb03..c65e3fa 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -390,6 +390,27 @@ incomplete, stale, quarantined, or security-blocked scan. rollback, unknown fields, or authority widening MUST fail closed. - **FR-052**: AI output MUST remain advisory and MUST NOT create, suppress, waive, resolve, re-severity, or block a deterministic finding. +- **FR-052a**: T044 authority-proof intent MUST accept exactly `tenantId` and `advisoryId`, and + the body tenant MUST match a tenant-bound authenticated internal credential. A serializable + transaction MUST first lock the advisory context, then rebind the T043 advisory/handoff to its tenant, repository, + scan, attempt, occurrence, normalized finding, fingerprint, request digest, and handoff + digest. It MUST lock shared scan, lifecycle-context, and finding authority fences before + capturing one bounded canonical authoritative snapshot and projecting that snapshot into the + proof's before/after fields. All covered authoritative writers MUST advance the same fences; + bulk normalized-finding and lifecycle writes MUST deduplicate affected scope keys per statement. + The scan finding set, target status/severity, T037 lifecycle state/revision, finding policy + decisions, finding-scoped waivers, and suppressions MUST come from the same durable tables and + have identical state digests. Missing fences and concurrent or replay drift MUST fail closed. +- **FR-052b**: `sast-ai-advisory-authority-proof-v1` MUST be immutable and MUST store only + scope references, bounded counts, SHA-256 digests, proof time, and fixed zero-authority/audit + bits. Advisory output, rationale, prompt, source, evidence, secrets, policy payloads, and + caller-provided authority state MUST NOT be stored. Exact retry MUST reuse one proof; changed, + cross-tenant, over-limit, or incomplete state MUST fail closed. +- **FR-052c**: Policy evaluation MAY make AI advisory metadata visible only from an exact, + durable advisory/proof reference with `advisoryOnly=true`. AI fields MUST NOT contribute to + enforcement action, reason codes, ticketing, blocking, severity, finding status, waiver, + suppression, or lifecycle decisions. Waiver and suppression APIs MUST reject unknown, + advisory, proof, and authority fields through exact request-shape validation. ### Rule Governance diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 8e06c99..02cfa97 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -72,7 +72,7 @@ - [x] T041 Build bounded accepted-finding evidence with reconstruction-risk checks - [x] T042 Enforce dashboard/AI classification, secret redaction, seven-day expiry, and deletion proof - [x] T043 Send only normalized findings and reduced evidence references to the advisory AI Plane -- [ ] T044 Prove AI cannot create, suppress, waive, resolve, or override authoritative findings/policy +- [x] T044 Prove AI cannot create, suppress, waive, resolve, or override authoritative findings/policy ## Phase 9: Rule Governance Runtime diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index 356a9f5..677462b 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -88,6 +88,7 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Evidence expiry race | A reader returns content while expiry/deletion is claimed or after the final clock check | Check retention before read and after the final awaited confirmation, confirm unchanged schedule/claim/proof, and deny from claim onward | Expiry-before/during/final-confirmation read, late-reader, deletion-race, and clock-rollback fixtures return no content | | False deletion proof or overdue content | A worker marks evidence deleted without provider removal, rejects the original receipt after a finalization retry, or lets polling/batch caps or one corrupt claim create a retention backlog | Deterministic operation, leased owner/token fence, default-unavailable provider, deadline-aware startup/earliest-due scheduling, saturated zero-delay continuation, context-drift quarantine, exact receipt replay, delete-then immutable proof | Unavailable provider, changed/original receipt, stale token, concurrent claim/finalize, drifted-head queue, deadline wakeup, and exact replay corpus | | AI handoff forgery or payload smuggling | A caller supplies a finding, prompt, fragment, stale access reference, or authority bit and causes it to reach the model | Exact four-field intent, double T042 classification, durable T037 source rebind, canonical expiring handoff, empty snippets, exact runtime keys, and fixed zero downstream authority | Legacy/extra-field, cross-scope, drift, expiry, correlation, snippet, secret-key, and authority-widening fixtures deny before provider use | +| AI output authority escalation or proof forgery | An advisory, caller snapshot, suggested action, forged proof, tenant spoof, concurrent writer, or lifecycle payload creates/resolves/re-severities a finding, waives/suppresses it, or overrides policy | Tenant-bound internal credential; exact two-field proof intent; shared advisory/scan/lifecycle/finding database fences; one durable policy/lifecycle source; single locked snapshot projected to before/after digests; proof-only write; fixed-false checks; immutable triggers; tenant/finding-bound display-only proof reference; exact lifecycle key allowlists | State-drift, tenant mismatch, cross-tenant, concurrent writer, replay-conflict, over-limit, suggested-action, proof-injection, and zero-authoritative-write fixtures deny | | AI prompt injection | Evidence text instructs model | Evidence is untrusted data, bounded/redacted, no retrieval/tools/SCM | Advisory label and output schema validation | | Sandbox persistence | Compromise survives next scan | No worker/workspace reuse; new microVM per attempt | Destruction evidence and lag alert | | Operator credential leak | Deployment secrets enter repo/config | 005 reference-only credential handoff | Secret scanning and deployment audit | @@ -162,6 +163,10 @@ The following must always remain true: 19. Advisory AI input is derived only from the exact T042/T037 durable chain. Its ledger is reference-only, its runtime request contains no snippets or retrievable content, and it grants no policy, publication, lifecycle, finding, tool, retrieval, or SCM authority. +20. Advisory output can create only one immutable T044 proof under a tenant-bound internal + credential. Shared database fences hold one authoritative snapshot whose before/after digest + projections are identical; policy accepts only a tenant/finding-bound display reference, and + lifecycle endpoints persist to the covered tables while rejecting every advisory/proof field. ## Required Security Test Corpus @@ -189,6 +194,12 @@ The following must always remain true: policy, publication, lifecycle, and SCM authority widening; duplicate/reordered CWE/CVE sets; oversized advisory/signal/text output; excessive response depth/breadth; latency overflow; provider-error reflection; and unauthorized immutable-ledger purge +- T044 caller-supplied status/severity/lifecycle/waiver/suppression/policy/proof rejection; + cross-tenant advisory or finding binding; missing/multiple/changed lifecycle state; + normalized-finding, policy-decision, waiver, and suppression set drift; over-limit sets; + exact replay and conflict; forged proof/reference digest; legacy `suggestedAction`, block, + resolve, waive, and suppress fields; zero authoritative model writes; content/secret sentinel + absence; immutable update/delete; and controlled hard-purge enforcement - CycloneDX schema/tool/version/source-component rebinding, metadata-tool component count smuggling, vulnerability/VEX and nested/file component extensions, duplicate or mismatched PURL/BOM references, invalid CPE part/field/quoting/wildcard/language forms, diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 4244810..c173197 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -48,6 +48,8 @@ const files = { sharedSastEvidenceAccess: new URL('../../packages/shared/src/types/sast-evidence-access.ts', import.meta.url), sharedSastEvidenceAccessTest: new URL('../../packages/shared/test/sast-evidence-access.test.mjs', import.meta.url), sharedSastAiAdvisoryHandoff: new URL('../../packages/shared/src/types/sast-ai-advisory-handoff.ts', import.meta.url), + sharedSastAiAdvisoryAuthority: new URL('../../packages/shared/src/types/sast-ai-advisory-authority.ts', import.meta.url), + sharedSastAiAdvisoryAuthorityTest: new URL('../../packages/shared/test/sast-ai-advisory-authority.test.mjs', import.meta.url), apiSastPlanner: new URL('../../apps/api/src/control-plane/sast-scan-planner.service.ts', import.meta.url), apiSastQueueAdmission: new URL('../../apps/api/src/control-plane/sast-queue-admission.service.ts', import.meta.url), apiSastPlanningController: new URL('../../apps/api/src/control-plane/sast-planning.controller.ts', import.meta.url), @@ -108,6 +110,14 @@ const files = { apiAiAdvisoryModule: new URL('../../apps/api/src/ai-plane/ai-plane.module.ts', import.meta.url), apiAiAdvisoryServiceTest: new URL('../../apps/api/test/ai-plane/ai-advisory.service.e2e-spec.ts', import.meta.url), apiAiAdvisoryPersistenceTest: new URL('../../apps/api/test/ai-plane/sast-ai-advisory-persistence.e2e-spec.ts', import.meta.url), + apiAiAdvisoryAuthorityService: new URL('../../apps/api/src/ai-plane/ai-advisory-authority.service.ts', import.meta.url), + apiAiAdvisoryAuthorityStore: new URL('../../apps/api/src/ai-plane/prisma-sast-ai-advisory-authority.store.ts', import.meta.url), + apiAiAdvisoryAuthorityServiceTest: new URL('../../apps/api/test/ai-plane/ai-advisory-authority.service.e2e-spec.ts', import.meta.url), + apiAiAdvisoryAuthorityPersistenceTest: new URL('../../apps/api/test/ai-plane/sast-ai-advisory-authority-persistence.e2e-spec.ts', import.meta.url), + apiPolicyEngine: new URL('../../apps/api/src/policy/policy-engine.service.ts', import.meta.url), + apiPolicyLifecycle: new URL('../../apps/api/src/policy/policy-lifecycle.service.ts', import.meta.url), + apiPolicyEngineTest: new URL('../../apps/api/test/policy/policy-engine.service.e2e-spec.ts', import.meta.url), + apiPolicyLifecycleTest: new URL('../../apps/api/test/policy/waiver-suppression-lifecycle.e2e-spec.ts', import.meta.url), aiAdvisoryRuntime: new URL('../../apps/ai/src/advisory-runtime.ts', import.meta.url), aiModelGateway: new URL('../../apps/ai/src/model-gateway.ts', import.meta.url), apiPrismaSchema: new URL('../../apps/api/prisma/schema.prisma', import.meta.url), @@ -119,6 +129,7 @@ const files = { apiSastAcceptedEvidenceMigration: new URL('../../apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql', import.meta.url), apiSastEvidenceAccessMigration: new URL('../../apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql', import.meta.url), apiSastAiAdvisoryMigration: new URL('../../apps/api/prisma/migrations/20260811040000_sast_ai_advisory_handoff/migration.sql', import.meta.url), + apiSastAiAdvisoryAuthorityMigration: new URL('../../apps/api/prisma/migrations/20260811140000_sast_ai_advisory_authority_proof/migration.sql', import.meta.url), apiScanPlaneModule: new URL('../../apps/api/src/scan-plane/scan-plane.module.ts', import.meta.url), completedDeploymentQuickstart: new URL('../../specs/005-production-deployment-operations/quickstart.md', import.meta.url), completedDeploymentTasks: new URL('../../specs/005-production-deployment-operations/tasks.md', import.meta.url), @@ -1277,7 +1288,7 @@ test('SAST T039 coverage feeds T040 freshness and bounded retry authority', () = assert.match(tasks, /- \[x\] T040\b/); assert.match( quickstart, - /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,160}T043[\s\S]{0,160}next implementation task/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,180}T043[\s\S]{0,180}T044[\s\S]{0,120}T045 is the next implementation task/ ); assert.match(contract, /Scan coverage gate v1/); assert.match(contract, /Freshness and bounded retry gate v1/); @@ -1413,14 +1424,14 @@ test('SAST T041 builds bounded accepted-finding evidence and rejects reconstruct assert.match(tasks, /- \[x\] T041\b/); assert.match( quickstart, - /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,160}T043[\s\S]{0,160}next implementation task/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,180}T043[\s\S]{0,180}T044[\s\S]{0,120}T045 is the next implementation task/ ); assert.match(contract, /Accepted-finding evidence gate v1/); assert.match(dataModel, /SastEvidenceBuildDecision/); assert.match(dataModel, /SastAcceptedEvidencePack/); assert.match( plan, - /T040, T041, T042, and T043 independently and now proceeds to T044/ + /T040, T041, T042, T043, and T044 independently and now proceeds to T045/ ); assert.match(spec, /FR-046a/); assert.match( @@ -1596,7 +1607,7 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(tasks, /- \[x\] T042\b/); assert.match( quickstart, - /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,160}T043[\s\S]{0,160}next implementation task/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,180}T043[\s\S]{0,180}T044[\s\S]{0,120}T045 is the next implementation task/ ); assert.match(contract, /Evidence access and deletion gate v1/); assert.match(dataModel, /SastEvidenceAccessDecision/); @@ -1750,13 +1761,13 @@ test('SAST T043 sends only a durable normalized finding and opaque AI reference' assert.match(tasks, /- \[x\] T043\b/); assert.match( quickstart, - /T043 normalized-finding plus reduced-reference advisory handoff is also complete;[\s\S]{0,80}T044 is the[\s\S]{0,80}next implementation task/ + /T043 normalized-finding plus reduced-reference advisory handoff is complete\.[\s\S]{0,180}T045 is the next implementation task/ ); assert.match(contract, /Advisory AI handoff gate v1/); assert.match(dataModel, /### SastAiAdvisoryHandoff/); assert.match( plan, - /T040, T041, T042, and T043 independently and now proceeds to T044/ + /T040, T041, T042, T043, and T044 independently and now proceeds to T045/ ); assert.match(spec, /FR-051a/); assert.match( @@ -1767,6 +1778,153 @@ test('SAST T043 sends only a durable normalized finding and opaque AI reference' assert.match(qualityGates, /100% T043 reference-only invariant/); }); +test('SAST T044 proves AI output has zero finding and policy authority', () => { + const shared = readNormalizedText( + files.sharedSastAiAdvisoryAuthority + ); + const sharedTest = readNormalizedText( + files.sharedSastAiAdvisoryAuthorityTest + ); + const sharedIndex = readNormalizedText(files.sharedIndex); + const service = readNormalizedText( + files.apiAiAdvisoryAuthorityService + ); + const store = readNormalizedText(files.apiAiAdvisoryAuthorityStore); + const serviceTest = readNormalizedText( + files.apiAiAdvisoryAuthorityServiceTest + ); + const persistenceTest = readNormalizedText( + files.apiAiAdvisoryAuthorityPersistenceTest + ); + const policy = readNormalizedText(files.apiPolicyEngine); + const lifecycle = readNormalizedText(files.apiPolicyLifecycle); + const policyTest = readNormalizedText(files.apiPolicyEngineTest); + const lifecycleTest = readNormalizedText( + files.apiPolicyLifecycleTest + ); + const schema = readNormalizedText(files.apiPrismaSchema); + const migration = readNormalizedText( + files.apiSastAiAdvisoryAuthorityMigration + ); + const onlineSchema = readNormalizedText( + files.apiOnlineSastRuntimeSchema + ); + const tasks = readNormalizedText(files.tasks); + const quickstart = readNormalizedText(files.quickstart); + const contract = readNormalizedText(files.contract); + const dataModel = readNormalizedText(files.dataModel); + const plan = readNormalizedText(files.plan); + const spec = readNormalizedText(files.spec); + const research = readNormalizedText(files.research); + const threatModel = readNormalizedText(files.threatModel); + const qualityGates = readNormalizedText(files.qualityGates); + + assert.match(shared, /sast-ai-advisory-authority-proof-v1/); + assert.match(shared, /sast-ai-advisory-policy-reference-v1/); + assert.match( + shared, + /buildSastAiAdvisoryAuthorityStateSnapshot/ + ); + assert.match(shared, /stableJson\(input\.before\) !== stableJson\(input\.after\)/); + assert.match(shared, /findingCreateAuthority: false/); + assert.match(shared, /policyOverrideAuthority: false/); + assert.match(shared, /blockDecisionAuthority: false/); + assert.match(sharedIndex, /sast-ai-advisory-authority/); + assert.match(sharedTest, /rejects state drift, caller authority/); + + assert.match(service, /isSastAiAdvisoryAuthorityProofIntentShapeValid/); + assert.match(service, /buildSastAiAdvisoryPolicyReference/); + assert.match(store, /Prisma\.TransactionIsolationLevel\.Serializable/); + assert.match(store, /captureAuthorityState/); + assert.match(store, /acquireAuthorityFence\(tx, context\)/); + assert.doesNotMatch( + store, + /const after = await captureAuthorityState\(tx, context\)/ + ); + assert.match(store, /sastAiAdvisoryAuthorityProof\.create/); + assert.doesNotMatch( + store, + /\b(?:normalizedFinding|sastFindingLifecycleState|policyDecision|waiver|suppression)\.(?:create|createMany|update|updateMany|upsert|delete|deleteMany)\b/u + ); + assert.match( + policy, + /isSastAiAdvisoryPolicyReferenceShapeValid/ + ); + assert.match(policy, /verifyPolicyReference/); + assert.match(lifecycle, /assertExactLifecyclePayload/); + assert.doesNotMatch(lifecycle, /new RegExp\(forbiddenKey/); + assert.match(serviceTest, /rejects caller finding, lifecycle, waiver/); + assert.match(persistenceTest, /no authoritative model writes/); + assert.match(policyTest, /rejects suggested actions/); + assert.match(lifecycleTest, /authorityProofId/); + + assert.match(schema, /model SastAiAdvisoryAuthorityProof \{/); + assert.match( + migration, + /CREATE TABLE "SastAiAdvisoryAuthorityProof"/ + ); + assert.match( + migration, + /"beforeStateDigest" = "afterStateDigest"/ + ); + assert.match( + migration, + /SastAiAdvisoryAuthorityProof_immutable_update/ + ); + assert.match( + migration, + /acquire_sast_ai_advisory_authority_fence/ + ); + assert.match(migration, /PolicyDecision_ai_authority_fence/); + assert.doesNotMatch(migration, /JSONB/); + assert.match( + onlineSchema, + /NormalizedFinding_ai_authority_scope_key/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryHandoff_authority_scope_key/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryAuthorityProof_handoff_authority_scope_fkey/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryAuthorityProof_occurrence_scope_fkey/ + ); + assert.match( + onlineSchema, + /SastAiAdvisoryAuthorityProof_finding_scope_fkey/ + ); + assert.doesNotMatch( + migration, + /SastAiAdvisoryAuthorityProof_(?:occurrence|finding)_scope_fkey/ + ); + + assert.match(tasks, /- \[x\] T044\b/); + assert.match( + quickstart, + /T044 zero-authority[\s\S]{0,30}output proof is also complete;[\s\S]{0,100}T045 is the[\s\S]{0,100}next implementation task/ + ); + assert.match(contract, /Advisory output authority proof gate v1/); + assert.match(dataModel, /### SastAiAdvisoryAuthorityProof/); + assert.match( + plan, + /T040, T041, T042, T043, and T044 independently and now proceeds to T045/ + ); + assert.match(spec, /FR-052a/); + assert.match( + research, + /Decision 26: Prove Advisory Consumption with an Immutable Zero-Authority Ledger/ + ); + assert.match( + threatModel, + /AI output authority escalation or proof forgery/ + ); + assert.match(qualityGates, /100% T044 zero-authority invariant/); +}); + test('SAST design completion gate stays synchronized between quickstart and CI', () => { const readme = readNormalizedText(files.readme); const ci = readNormalizedText(files.ci); diff --git a/test/github-actions/ontology.test.mjs b/test/github-actions/ontology.test.mjs index ba1469b..3ef3844 100644 --- a/test/github-actions/ontology.test.mjs +++ b/test/github-actions/ontology.test.mjs @@ -85,7 +85,7 @@ test('active 006 spec explicitly reclassifies only the bounded issue 276 bootstr assert.match(plan, /Issue #276 is an explicitly reclassified adjacent bootstrap/); assert.match( plan, - /did not advance or satisfy T040[\s\S]{0,240}completed[\s\S]{0,120}T043[\s\S]{0,120}proceeds to T044/ + /did not advance or satisfy T040[\s\S]{0,240}completed[\s\S]{0,140}T044[\s\S]{0,120}proceeds to T045/ ); assert.match(tasks, /Approved Adjacent Bootstrap \(Does Not Advance 006\)/); assert.match(tasks, /Keep T040 as the next formal active-milestone task/);