From 204105e1bdf5611824cfdea92f576bd922cf0225 Mon Sep 17 00:00:00 2001 From: goodtu02 <161540124+goodtu02@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:33:49 +0900 Subject: [PATCH 1/4] feat: implement evidence access and deletion proof --- .../migration.sql | 349 ++++++ apps/api/prisma/schema.prisma | 301 ++++-- .../dashboard-evidence.controller.ts | 41 + apps/api/src/dashboard/dashboard.module.ts | 6 +- .../prisma-sast-accepted-evidence.store.ts | 37 + .../prisma-sast-evidence-access.store.ts | 920 ++++++++++++++++ .../sast-evidence-access.service.ts | 993 ++++++++++++++++++ .../scan-plane/sast-evidence-access.store.ts | 127 +++ .../sast-evidence-deletion.authority.ts | 39 + .../sast-evidence-deletion.service.ts | 242 +++++ .../scan-plane/sast-evidence-deletion.task.ts | 79 ++ .../sast-evidence-secret-registry.ts | 29 + apps/api/src/scan-plane/scan-plane.module.ts | 33 +- ...-accepted-evidence-persistence.e2e-spec.ts | 7 +- ...st-evidence-access-persistence.e2e-spec.ts | 210 ++++ .../sast-evidence-access.e2e-spec.ts | 872 +++++++++++++++ ...inding-correlation-persistence.e2e-spec.ts | 5 +- ...st-finding-lineage-persistence.e2e-spec.ts | 5 +- ...sast-scan-coverage-persistence.e2e-spec.ts | 5 +- ...ast-scan-freshness-persistence.e2e-spec.ts | 5 +- packages/shared/src/index.ts | 1 + .../shared/src/types/sast-evidence-access.ts | 877 ++++++++++++++++ .../shared/test/sast-evidence-access.test.mjs | 203 ++++ .../contracts/sast-runtime.md | 34 + .../data-model.md | 50 +- .../plan.md | 15 +- .../quality-gates.md | 19 +- .../quickstart.md | 34 +- .../research.md | 32 + .../spec.md | 21 + .../tasks.md | 2 +- .../threat-model.md | 10 + test/github-actions/active-feature.test.mjs | 175 ++- test/github-actions/ontology.test.mjs | 5 +- 34 files changed, 5654 insertions(+), 129 deletions(-) create mode 100644 apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql create mode 100644 apps/api/src/dashboard/dashboard-evidence.controller.ts create mode 100644 apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-access.service.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-access.store.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-deletion.authority.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-deletion.service.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-deletion.task.ts create mode 100644 apps/api/src/scan-plane/sast-evidence-secret-registry.ts create mode 100644 apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts create mode 100644 apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts create mode 100644 packages/shared/src/types/sast-evidence-access.ts create mode 100644 packages/shared/test/sast-evidence-access.test.mjs diff --git a/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql b/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql new file mode 100644 index 0000000..25123bb --- /dev/null +++ b/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql @@ -0,0 +1,349 @@ +-- T042 keeps the immutable T041 pack at zero downstream authority and adds +-- separate purpose-bound access and deletion ledgers. Existing live packs are +-- backfilled by the bounded deletion task; new packs are scheduled in the same +-- serializable transaction that writes the T041 pack. +ALTER TABLE "Tenant" + ADD COLUMN "sastAiAdvisoryOptIn" BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE "RepositoryBinding" + ADD COLUMN "sastAiAdvisoryOptIn" BOOLEAN NOT NULL DEFAULT false; + +CREATE TABLE "SastEvidenceDeletionSchedule" ( + "id" TEXT NOT NULL, + "operationId" TEXT NOT NULL, + "evidencePackId" TEXT NOT NULL, + "buildDecisionId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "sourcePackDigest" TEXT NOT NULL, + "deleteAfter" TIMESTAMP(3) NOT NULL, + "scheduledAt" TIMESTAMP(3) NOT NULL, + "schedule" JSONB NOT NULL, + "scheduleDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastEvidenceDeletionSchedule_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastEvidenceDeletionSchedule_contract_check" CHECK ( + "id" ~ '^sast-evidence-deletion://[a-f0-9]{64}$' + AND "operationId" ~ '^sast-evidence-delete://[a-f0-9]{64}$' + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "buildDecisionId" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "occurrenceId" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "sourcePackDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "scheduleDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("schedule") = 'object' + AND "deleteAfter" > "scheduledAt" + AND "deleteAfter" <= "scheduledAt" + INTERVAL '7 days' + AND ("schedule"->>'maximumRetentionSeconds')::integer = 604800 + ) +); + +CREATE TABLE "SastEvidenceAccessDecision" ( + "id" TEXT NOT NULL, + "buildDecisionId" TEXT NOT NULL, + "deletionScheduleId" TEXT NOT NULL, + "evidencePackId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "findingFingerprint" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "accessPolicyVersion" TEXT NOT NULL, + "secretRegistryVersion" TEXT NOT NULL, + "outcome" TEXT NOT NULL, + "classification" TEXT NOT NULL, + "reasonCodes" JSONB NOT NULL, + "sourcePackDigest" TEXT NOT NULL, + "redactedProjectionDigest" TEXT, + "redactedFragmentCount" INTEGER NOT NULL, + "redactedTotalBytes" INTEGER NOT NULL, + "redactionCount" INTEGER NOT NULL, + "secondPassRedactionDecisionRef" TEXT, + "reducedEvidenceRef" TEXT, + "aiPayloadExpiresAt" TIMESTAMP(3), + "evidenceExpiresAt" TIMESTAMP(3) NOT NULL, + "decision" JSONB NOT NULL, + "decisionDigest" TEXT NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastEvidenceAccessDecision_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastEvidenceAccessDecision_contract_check" CHECK ( + "id" ~ '^sast-evidence-access://[a-f0-9]{64}$' + AND "buildDecisionId" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "deletionScheduleId" ~ '^sast-evidence-deletion://[a-f0-9]{64}$' + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "occurrenceId" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "findingFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "accessPolicyVersion" = 'sast-evidence-access-policy-v1' + AND "purpose" IN ('DASHBOARD', 'AI_ADVISORY') + AND "outcome" IN ('ALLOWED', 'DENIED') + AND "classification" IN ('DASHBOARD_SAFE', 'AI_REDUCED_REFERENCE_SAFE', 'DENIED') + AND jsonb_typeof("reasonCodes") = 'array' + AND jsonb_typeof("decision") = 'object' + AND "sourcePackDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ("redactedProjectionDigest" IS NULL OR "redactedProjectionDigest" ~ '^sha256:[a-f0-9]{64}$') + AND "redactedFragmentCount" BETWEEN 0 AND 5 + AND "redactedTotalBytes" BETWEEN 0 AND 32768 + AND "redactionCount" BETWEEN 0 AND 32768 + AND "decisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ( + ( + "outcome" = 'ALLOWED' + AND jsonb_array_length("reasonCodes") = 0 + AND "redactedProjectionDigest" IS NOT NULL + AND "redactedFragmentCount" BETWEEN 1 AND 5 + AND "redactedTotalBytes" > 0 + AND "secondPassRedactionDecisionRef" ~ '^sast-evidence-access-redaction://[a-f0-9]{64}$' + AND "decidedAt" < "evidenceExpiresAt" + ) + OR + ( + "outcome" = 'DENIED' + AND "classification" = 'DENIED' + AND jsonb_array_length("reasonCodes") > 0 + AND "redactedProjectionDigest" IS NULL + AND "redactedFragmentCount" = 0 + AND "redactedTotalBytes" = 0 + AND "redactionCount" = 0 + AND "secondPassRedactionDecisionRef" IS NULL + AND "reducedEvidenceRef" IS NULL + AND "aiPayloadExpiresAt" IS NULL + ) + ) + AND ( + ( + "purpose" = 'DASHBOARD' + AND "classification" IN ('DASHBOARD_SAFE', 'DENIED') + AND "reducedEvidenceRef" IS NULL + AND "aiPayloadExpiresAt" IS NULL + ) + OR + ( + "purpose" = 'AI_ADVISORY' + AND "classification" IN ('AI_REDUCED_REFERENCE_SAFE', 'DENIED') + AND ( + "outcome" = 'DENIED' + OR ( + "reducedEvidenceRef" ~ '^sast-reduced-evidence://[a-f0-9]{64}$' + AND "aiPayloadExpiresAt" > "decidedAt" + AND "aiPayloadExpiresAt" <= "decidedAt" + INTERVAL '24 hours' + AND "aiPayloadExpiresAt" <= "evidenceExpiresAt" + ) + ) + ) + ) + AND ("decision"#>>'{authority,aiPayloadAllowed}')::boolean IS FALSE + AND ("decision"#>>'{authority,aiProviderCallAllowed}')::boolean IS FALSE + AND ("decision"#>>'{authority,retrievalAllowed}')::boolean IS FALSE + AND ("decision"#>>'{authority,toolsAllowed}')::boolean IS FALSE + AND ("decision"#>>'{authority,policyAuthority}')::boolean IS FALSE + AND ("decision"#>>'{authority,publicationAuthority}')::boolean IS FALSE + AND ("decision"#>>'{authority,lifecycleMutationAuthority}')::boolean IS FALSE + AND ("decision"#>>'{authority,scmWriteAuthority}')::boolean IS FALSE + AND ("decision"#>>'{audit,rawSourceStored}')::boolean IS FALSE + AND ("decision"#>>'{audit,secretValueStored}')::boolean IS FALSE + AND ("decision"#>>'{audit,preRedactionPayloadStored}')::boolean IS FALSE + AND ("decision"#>>'{audit,matchedValueDigestStored}')::boolean IS FALSE + AND ("decision"#>>'{audit,dashboardPayloadPersisted}')::boolean IS FALSE + AND ("decision"#>>'{audit,aiPayloadCreated}')::boolean IS FALSE + AND ("decision"#>>'{audit,aiProviderCalled}')::boolean IS FALSE + ) +); + +CREATE TABLE "SastEvidenceDeletionClaim" ( + "scheduleId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "leaseOwner" TEXT, + "leaseToken" TEXT, + "leaseExpiresAt" TIMESTAMP(3), + "nextAttemptAt" TIMESTAMP(3) NOT NULL, + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastEvidenceDeletionClaim_pkey" PRIMARY KEY ("scheduleId"), + CONSTRAINT "SastEvidenceDeletionClaim_contract_check" CHECK ( + "scheduleId" ~ '^sast-evidence-deletion://[a-f0-9]{64}$' + AND "status" IN ('PENDING', 'CLAIMED', 'COMPLETED') + AND "attemptCount" >= 0 + AND ( + ("status" = 'CLAIMED' AND "leaseOwner" IS NOT NULL AND "leaseToken" IS NOT NULL AND "leaseExpiresAt" IS NOT NULL) + OR + ("status" IN ('PENDING', 'COMPLETED') AND "leaseOwner" IS NULL AND "leaseToken" IS NULL AND "leaseExpiresAt" IS NULL) + ) + ) +); + +CREATE TABLE "SastEvidenceDeletionProof" ( + "id" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "operationId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "evidencePackId" TEXT NOT NULL, + "buildDecisionId" TEXT NOT NULL, + "providerReceiptRef" TEXT NOT NULL, + "providerReceiptDigest" TEXT NOT NULL, + "completedAt" TIMESTAMP(3) NOT NULL, + "proof" JSONB NOT NULL, + "proofDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastEvidenceDeletionProof_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastEvidenceDeletionProof_contract_check" CHECK ( + "id" ~ '^sast-evidence-deletion-proof://[a-f0-9]{64}$' + AND "scheduleId" ~ '^sast-evidence-deletion://[a-f0-9]{64}$' + AND "operationId" ~ '^sast-evidence-delete://[a-f0-9]{64}$' + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "buildDecisionId" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "providerReceiptRef" ~ '^sast-evidence-delete-receipt://[a-f0-9]{64}$' + AND "providerReceiptDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "proofDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("proof") = 'object' + AND ("proof"->>'contentDeleted')::boolean IS TRUE + AND ("proof"->>'fragmentsDeleted')::boolean IS TRUE + AND ("proof"->>'buildDecisionRetained')::boolean IS TRUE + AND ("proof"->>'accessAuthorityRevoked')::boolean IS TRUE + ) +); + +CREATE UNIQUE INDEX "SastEvidenceDeletionSchedule_operationId_key" + ON "SastEvidenceDeletionSchedule"("operationId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionSchedule_evidencePackId_key" + ON "SastEvidenceDeletionSchedule"("evidencePackId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionSchedule_scheduleDigest_key" + ON "SastEvidenceDeletionSchedule"("scheduleDigest"); +CREATE UNIQUE INDEX "SastEvidenceDeletionSchedule_tenant_scope_key" + ON "SastEvidenceDeletionSchedule"("id", "tenantId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionSchedule_operation_scope_key" + ON "SastEvidenceDeletionSchedule"("id", "operationId", "tenantId"); +CREATE INDEX "SastEvidenceDeletionSchedule_tenant_expiry_idx" + ON "SastEvidenceDeletionSchedule"("tenantId", "repositoryBindingId", "deleteAfter"); +CREATE INDEX "SastEvidenceDeletionSchedule_deleteAfter_idx" + ON "SastEvidenceDeletionSchedule"("deleteAfter"); +CREATE INDEX "SastEvidenceDeletionSchedule_buildDecisionId_idx" + ON "SastEvidenceDeletionSchedule"("buildDecisionId"); + +CREATE UNIQUE INDEX "SastEvidenceAccessDecision_decisionDigest_key" + ON "SastEvidenceAccessDecision"("decisionDigest"); +CREATE UNIQUE INDEX "SastEvidenceAccessDecision_tenant_scope_key" + ON "SastEvidenceAccessDecision"("id", "tenantId"); +CREATE INDEX "SastEvidenceAccessDecision_lookup_idx" + ON "SastEvidenceAccessDecision"("tenantId", "repositoryBindingId", "evidencePackId", "purpose"); +CREATE INDEX "SastEvidenceAccessDecision_expiresAt_idx" + ON "SastEvidenceAccessDecision"("evidenceExpiresAt"); +CREATE INDEX "SastEvidenceAccessDecision_aiPayloadExpiresAt_idx" + ON "SastEvidenceAccessDecision"("aiPayloadExpiresAt"); +CREATE INDEX "SastEvidenceAccessDecision_deletionScheduleId_idx" + ON "SastEvidenceAccessDecision"("deletionScheduleId"); + +CREATE UNIQUE INDEX "SastEvidenceDeletionClaim_leaseToken_key" + ON "SastEvidenceDeletionClaim"("leaseToken"); +CREATE UNIQUE INDEX "SastEvidenceDeletionClaim_schedule_scope_key" + ON "SastEvidenceDeletionClaim"("scheduleId", "tenantId"); +CREATE INDEX "SastEvidenceDeletionClaim_due_idx" + ON "SastEvidenceDeletionClaim"("status", "nextAttemptAt", "leaseExpiresAt"); +CREATE INDEX "SastEvidenceDeletionClaim_tenant_status_idx" + ON "SastEvidenceDeletionClaim"("tenantId", "status"); + +CREATE UNIQUE INDEX "SastEvidenceDeletionProof_scheduleId_key" + ON "SastEvidenceDeletionProof"("scheduleId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionProof_operationId_key" + ON "SastEvidenceDeletionProof"("operationId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionProof_evidencePackId_key" + ON "SastEvidenceDeletionProof"("evidencePackId"); +CREATE UNIQUE INDEX "SastEvidenceDeletionProof_proofDigest_key" + ON "SastEvidenceDeletionProof"("proofDigest"); +CREATE UNIQUE INDEX "SastEvidenceDeletionProof_schedule_scope_key" + ON "SastEvidenceDeletionProof"("scheduleId", "operationId", "tenantId"); +CREATE INDEX "SastEvidenceDeletionProof_tenant_completed_idx" + ON "SastEvidenceDeletionProof"("tenantId", "completedAt"); +CREATE INDEX "SastEvidenceDeletionProof_buildDecisionId_idx" + ON "SastEvidenceDeletionProof"("buildDecisionId"); + +ALTER TABLE "SastEvidenceDeletionSchedule" + ADD CONSTRAINT "SastEvidenceDeletionSchedule_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceDeletionSchedule" + ADD CONSTRAINT "SastEvidenceDeletionSchedule_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceDeletionSchedule" + ADD CONSTRAINT "SastEvidenceDeletionSchedule_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceDeletionSchedule" + ADD CONSTRAINT "SastEvidenceDeletionSchedule_build_scope_fkey" + FOREIGN KEY ("buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastEvidenceBuildDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastEvidenceAccessDecision" + ADD CONSTRAINT "SastEvidenceAccessDecision_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceAccessDecision" + ADD CONSTRAINT "SastEvidenceAccessDecision_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceAccessDecision" + ADD CONSTRAINT "SastEvidenceAccessDecision_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceAccessDecision" + ADD CONSTRAINT "SastEvidenceAccessDecision_build_scope_fkey" + FOREIGN KEY ("buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastEvidenceBuildDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastEvidenceAccessDecision" + ADD CONSTRAINT "SastEvidenceAccessDecision_schedule_scope_fkey" + FOREIGN KEY ("deletionScheduleId", "tenantId") + REFERENCES "SastEvidenceDeletionSchedule"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastEvidenceDeletionClaim" + ADD CONSTRAINT "SastEvidenceDeletionClaim_schedule_scope_fkey" + FOREIGN KEY ("scheduleId", "tenantId") + REFERENCES "SastEvidenceDeletionSchedule"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastEvidenceDeletionProof" + ADD CONSTRAINT "SastEvidenceDeletionProof_schedule_scope_fkey" + FOREIGN KEY ("scheduleId", "operationId", "tenantId") + REFERENCES "SastEvidenceDeletionSchedule"("id", "operationId", "tenantId") + ON DELETE RESTRICT ON UPDATE CASCADE; + +CREATE FUNCTION "reject_sast_evidence_access_ledger_update"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'evidence access, schedule, and deletion proof ledgers are immutable' + USING ERRCODE = '55000'; + RETURN OLD; +END; +$$; + +CREATE TRIGGER "SastEvidenceAccessDecision_immutable_update" + BEFORE UPDATE ON "SastEvidenceAccessDecision" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_evidence_access_ledger_update"(); +CREATE TRIGGER "SastEvidenceDeletionSchedule_immutable_update" + BEFORE UPDATE ON "SastEvidenceDeletionSchedule" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_evidence_access_ledger_update"(); +CREATE TRIGGER "SastEvidenceDeletionProof_immutable_update" + BEFORE UPDATE ON "SastEvidenceDeletionProof" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_evidence_access_ledger_update"(); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b19deb7..a727493 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -345,41 +345,44 @@ model Report { } model Tenant { - id String @id @default(uuid()) - slug String @unique - name String - status String @default("ACTIVE") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - integrations ScmIntegration[] - repositoryBindings RepositoryBinding[] - scanRequests ScanRequest[] - scannerRuns ScannerRun[] - normalizedFindings NormalizedFinding[] - evidencePacks EvidencePack[] - policyDecisions PolicyDecision[] - aiAdvisoryMetadata AiAdvisoryMetadata[] - waivers Waiver[] - suppressions Suppression[] - auditEvents AuditEvent[] - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] - sastArtifactIngestions SastArtifactIngestion[] - sastFindingLineages SastFindingLineage[] - sastFindingAliases SastFindingIdentityAlias[] - sastFindingBatches SastFindingObservationBatch[] - sastFindingOccurrences SastFindingOccurrence[] - sastFindingStates SastFindingLifecycleState[] - sastFindingReconciliations SastFindingLifecycleReconciliation[] - sastFindingEvents SastFindingLifecycleEvent[] - sastFindingCorrelations SastFindingCorrelationBatch[] - sastScanCoverageDecisions SastScanCoverageDecision[] - sastScannerCoverageRecords SastScannerCoverageRecord[] - sastTargetObservations SastLatestTargetObservation[] - sastFreshnessDecisions SastScanFreshnessDecision[] - sastRetryDecisions SastScanRetryDecision[] - users User[] + id String @id @default(uuid()) + slug String @unique + name String + status String @default("ACTIVE") + sastAiAdvisoryOptIn Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + integrations ScmIntegration[] + repositoryBindings RepositoryBinding[] + scanRequests ScanRequest[] + scannerRuns ScannerRun[] + normalizedFindings NormalizedFinding[] + evidencePacks EvidencePack[] + policyDecisions PolicyDecision[] + aiAdvisoryMetadata AiAdvisoryMetadata[] + waivers Waiver[] + suppressions Suppression[] + auditEvents AuditEvent[] + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] + sastFindingLineages SastFindingLineage[] + sastFindingAliases SastFindingIdentityAlias[] + sastFindingBatches SastFindingObservationBatch[] + sastFindingOccurrences SastFindingOccurrence[] + sastFindingStates SastFindingLifecycleState[] + sastFindingReconciliations SastFindingLifecycleReconciliation[] + sastFindingEvents SastFindingLifecycleEvent[] + sastFindingCorrelations SastFindingCorrelationBatch[] + sastScanCoverageDecisions SastScanCoverageDecision[] + sastScannerCoverageRecords SastScannerCoverageRecord[] + sastTargetObservations SastLatestTargetObservation[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] + sastEvidenceAccessDecisions SastEvidenceAccessDecision[] + sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] + users User[] } model ScmIntegration { @@ -403,37 +406,40 @@ model ScmIntegration { } model RepositoryBinding { - id String @id @default(uuid()) - tenantId String - scmIntegrationId String - providerRepoId String - fullName String - defaultBranch String - isPrivate Boolean @default(false) - status RepositoryBindingStatus @default(ACTIVE) - revokedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) - scanRequests ScanRequest[] - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] - sastArtifactIngestions SastArtifactIngestion[] - sastFindingLineages SastFindingLineage[] - sastFindingAliases SastFindingIdentityAlias[] - sastFindingBatches SastFindingObservationBatch[] - sastFindingOccurrences SastFindingOccurrence[] - sastFindingStates SastFindingLifecycleState[] - sastFindingReconciliations SastFindingLifecycleReconciliation[] - sastFindingEvents SastFindingLifecycleEvent[] - sastFindingCorrelations SastFindingCorrelationBatch[] - sastScanCoverageDecisions SastScanCoverageDecision[] - sastScannerCoverageRecords SastScannerCoverageRecord[] - sastTargetObservations SastLatestTargetObservation[] - sastFreshnessDecisions SastScanFreshnessDecision[] - sastRetryDecisions SastScanRetryDecision[] + id String @id @default(uuid()) + tenantId String + scmIntegrationId String + providerRepoId String + fullName String + defaultBranch String + isPrivate Boolean @default(false) + status RepositoryBindingStatus @default(ACTIVE) + sastAiAdvisoryOptIn Boolean @default(false) + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) + scanRequests ScanRequest[] + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] + sastFindingLineages SastFindingLineage[] + sastFindingAliases SastFindingIdentityAlias[] + sastFindingBatches SastFindingObservationBatch[] + sastFindingOccurrences SastFindingOccurrence[] + sastFindingStates SastFindingLifecycleState[] + sastFindingReconciliations SastFindingLifecycleReconciliation[] + sastFindingEvents SastFindingLifecycleEvent[] + sastFindingCorrelations SastFindingCorrelationBatch[] + sastScanCoverageDecisions SastScanCoverageDecision[] + sastScannerCoverageRecords SastScannerCoverageRecord[] + sastTargetObservations SastLatestTargetObservation[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] + sastEvidenceAccessDecisions SastEvidenceAccessDecision[] + sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -460,26 +466,28 @@ model ScanRequest { completedAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) - scannerRuns ScannerRun[] - findings NormalizedFinding[] - evidencePacks EvidencePack[] - policyDecisions PolicyDecision[] - aiAdvisoryMetadata AiAdvisoryMetadata[] - suppressions Suppression[] - auditEvents AuditEvent[] - sastQueueReservation SastQueueReservation? - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] - sastArtifactIngestions SastArtifactIngestion[] - sastFindingBatches SastFindingObservationBatch[] - sastFindingReconciliations SastFindingLifecycleReconciliation[] - sastFindingCorrelations SastFindingCorrelationBatch[] - sastScanCoverageDecisions SastScanCoverageDecision[] - sastScannerCoverageRecords SastScannerCoverageRecord[] - sastFreshnessDecisions SastScanFreshnessDecision[] - sastRetryDecisions SastScanRetryDecision[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) + scannerRuns ScannerRun[] + findings NormalizedFinding[] + evidencePacks EvidencePack[] + policyDecisions PolicyDecision[] + aiAdvisoryMetadata AiAdvisoryMetadata[] + suppressions Suppression[] + auditEvents AuditEvent[] + sastQueueReservation SastQueueReservation? + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] + sastFindingBatches SastFindingObservationBatch[] + sastFindingReconciliations SastFindingLifecycleReconciliation[] + sastFindingCorrelations SastFindingCorrelationBatch[] + sastScanCoverageDecisions SastScanCoverageDecision[] + sastScannerCoverageRecords SastScannerCoverageRecord[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] + sastEvidenceAccessDecisions SastEvidenceAccessDecision[] + sastEvidenceDeletionSchedules SastEvidenceDeletionSchedule[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -1440,9 +1448,11 @@ model SastEvidenceBuildDecision { decidedAt DateTime createdAt DateTime @default(now()) - freshnessDecision SastScanFreshnessDecision @relation(fields: [freshnessDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_freshness_scope_fkey") - findingOccurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_occurrence_scope_fkey") + freshnessDecision SastScanFreshnessDecision @relation(fields: [freshnessDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_freshness_scope_fkey") + findingOccurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_occurrence_scope_fkey") evidencePack SastAcceptedEvidencePack? + accessDecisions SastEvidenceAccessDecision[] + deletionSchedules SastEvidenceDeletionSchedule[] @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastEvidenceBuildDecision_scope_key") @@unique([tenantId, occurrenceId, policyVersion, candidateSetDigest], map: "SastEvidenceBuildDecision_replay_key") @@ -1531,6 +1541,121 @@ model SastAcceptedEvidenceFragment { @@index([contentDigest], map: "SastAcceptedEvidenceFragment_contentDigest_idx") } +model SastEvidenceAccessDecision { + id String @id + buildDecisionId String + deletionScheduleId String + evidencePackId String + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + findingFingerprint String + purpose String + accessPolicyVersion String + secretRegistryVersion String + outcome String + classification String + reasonCodes Json + sourcePackDigest String + redactedProjectionDigest String? + redactedFragmentCount Int + redactedTotalBytes Int + redactionCount Int + secondPassRedactionDecisionRef String? + reducedEvidenceRef String? + aiPayloadExpiresAt DateTime? + evidenceExpiresAt DateTime + decision Json + decisionDigest String @unique + decidedAt DateTime + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastEvidenceAccessDecision_scan_scope_fkey") + buildDecision SastEvidenceBuildDecision @relation(fields: [buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceAccessDecision_build_scope_fkey") + deletionSchedule SastEvidenceDeletionSchedule @relation(fields: [deletionScheduleId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceAccessDecision_schedule_scope_fkey") + + @@unique([id, tenantId], map: "SastEvidenceAccessDecision_tenant_scope_key") + @@index([tenantId, repositoryBindingId, evidencePackId, purpose], map: "SastEvidenceAccessDecision_lookup_idx") + @@index([evidenceExpiresAt], map: "SastEvidenceAccessDecision_expiresAt_idx") + @@index([aiPayloadExpiresAt], map: "SastEvidenceAccessDecision_aiPayloadExpiresAt_idx") + @@index([deletionScheduleId], map: "SastEvidenceAccessDecision_deletionScheduleId_idx") +} + +model SastEvidenceDeletionSchedule { + id String @id + operationId String @unique + evidencePackId String @unique + buildDecisionId String + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + sourcePackDigest String + deleteAfter DateTime + scheduledAt DateTime + schedule Json + scheduleDigest String @unique + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceDeletionSchedule_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastEvidenceDeletionSchedule_scan_scope_fkey") + buildDecision SastEvidenceBuildDecision @relation(fields: [buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceDeletionSchedule_build_scope_fkey") + accessDecisions SastEvidenceAccessDecision[] + claim SastEvidenceDeletionClaim? + proof SastEvidenceDeletionProof? + + @@unique([id, tenantId], map: "SastEvidenceDeletionSchedule_tenant_scope_key") + @@unique([id, operationId, tenantId], map: "SastEvidenceDeletionSchedule_operation_scope_key") + @@index([tenantId, repositoryBindingId, deleteAfter], map: "SastEvidenceDeletionSchedule_tenant_expiry_idx") + @@index([deleteAfter], map: "SastEvidenceDeletionSchedule_deleteAfter_idx") + @@index([buildDecisionId], map: "SastEvidenceDeletionSchedule_buildDecisionId_idx") +} + +model SastEvidenceDeletionClaim { + scheduleId String @id + tenantId String + status String @default("PENDING") + leaseOwner String? + leaseToken String? @unique + leaseExpiresAt DateTime? + nextAttemptAt DateTime + attemptCount Int @default(0) + updatedAt DateTime @updatedAt + + schedule SastEvidenceDeletionSchedule @relation(fields: [scheduleId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastEvidenceDeletionClaim_schedule_scope_fkey") + + @@unique([scheduleId, tenantId], map: "SastEvidenceDeletionClaim_schedule_scope_key") + @@index([status, nextAttemptAt, leaseExpiresAt], map: "SastEvidenceDeletionClaim_due_idx") + @@index([tenantId, status], map: "SastEvidenceDeletionClaim_tenant_status_idx") +} + +model SastEvidenceDeletionProof { + id String @id + scheduleId String @unique + operationId String @unique + tenantId String + evidencePackId String @unique + buildDecisionId String + providerReceiptRef String + providerReceiptDigest String + completedAt DateTime + proof Json + proofDigest String @unique + createdAt DateTime @default(now()) + + schedule SastEvidenceDeletionSchedule @relation(fields: [scheduleId, operationId, tenantId], references: [id, operationId, tenantId], onDelete: Restrict, map: "SastEvidenceDeletionProof_schedule_scope_fkey") + + @@unique([scheduleId, operationId, tenantId], map: "SastEvidenceDeletionProof_schedule_scope_key") + @@index([tenantId, completedAt], map: "SastEvidenceDeletionProof_tenant_completed_idx") + @@index([buildDecisionId], map: "SastEvidenceDeletionProof_buildDecisionId_idx") +} + model SastFindingCorrelationEdge { id String @id correlationBatchId String diff --git a/apps/api/src/dashboard/dashboard-evidence.controller.ts b/apps/api/src/dashboard/dashboard-evidence.controller.ts new file mode 100644 index 0000000..8afb136 --- /dev/null +++ b/apps/api/src/dashboard/dashboard-evidence.controller.ts @@ -0,0 +1,41 @@ +import type { AuthUser } from '@aegisai/shared'; +import { + Controller, + Get, + NotFoundException, + Param, + Query, + UseGuards +} from '@nestjs/common'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; +import { SastEvidenceAccessService } from '../scan-plane/sast-evidence-access.service'; + +@Controller('dashboard/evidence') +@UseGuards(SessionAuthGuard) +export class DashboardEvidenceController { + constructor( + private readonly evidenceAccess: SastEvidenceAccessService + ) {} + + @Get(':evidencePackId') + async getEvidence( + @CurrentUser() user: AuthUser, + @Param('evidencePackId') evidencePackId: string, + @Query('repositoryBindingId') repositoryBindingId: string + ) { + const result = await this.evidenceAccess.readDashboard({ + tenantId: user.tenantId, + repositoryBindingId, + evidencePackId + }); + if (result.outcome !== 'ALLOWED' || !result.dashboardEvidence) { + throw new NotFoundException({ + message: 'Evidence is not available.', + errorCode: 'EVIDENCE_NOT_AVAILABLE' + }); + } + return result.dashboardEvidence; + } +} diff --git a/apps/api/src/dashboard/dashboard.module.ts b/apps/api/src/dashboard/dashboard.module.ts index f16ed1e..c391ddf 100644 --- a/apps/api/src/dashboard/dashboard.module.ts +++ b/apps/api/src/dashboard/dashboard.module.ts @@ -3,12 +3,14 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '../config/config.module'; import { PrismaModule } from '../prisma/prisma.module'; +import { ScanPlaneModule } from '../scan-plane/scan-plane.module'; +import { DashboardEvidenceController } from './dashboard-evidence.controller'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @Module({ - imports: [AuthModule, ConfigModule, PrismaModule], - controllers: [DashboardController], + imports: [AuthModule, ConfigModule, PrismaModule, ScanPlaneModule], + controllers: [DashboardController, DashboardEvidenceController], providers: [DashboardService], exports: [DashboardService] }) diff --git a/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts b/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts index 36a9306..205c9a2 100644 --- a/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts +++ b/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts @@ -3,6 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { SAST_ACCEPTED_EVIDENCE_POLICY, + buildSastEvidenceDeletionSchedule, isSastAcceptedEvidenceBuildResultShapeValid, isSastFingerprintedFindingShapeValid, isSastScanCoverageDecisionShapeValid, @@ -25,6 +26,7 @@ import { type PersistedSastAcceptedEvidence, type SastAcceptedEvidenceContext } from './sast-accepted-evidence.store'; +import { sastEvidenceAccessScopeFromResult } from './sast-evidence-access.store'; const EVIDENCE_POLICY_VERSION = 'sast-evidence-policy-v1'; const SERIALIZABLE_ATTEMPTS = 3; @@ -211,6 +213,41 @@ export class PrismaSastAcceptedEvidenceStore createdAt: new Date(pack.createdAt) })) }); + const deletionSchedule = + buildSastEvidenceDeletionSchedule({ + scope: sastEvidenceAccessScopeFromResult( + input.result + ), + scheduledAt: pack.createdAt, + deleteAfter: pack.expiresAt, + digestCanonical: digest + }); + await transaction.sastEvidenceDeletionSchedule.create({ + data: { + id: deletionSchedule.deletionScheduleId, + operationId: deletionSchedule.operationId, + evidencePackId: pack.evidencePackId, + buildDecisionId: decision.buildDecisionId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + occurrenceId: scope.occurrenceId, + sourcePackDigest: pack.packDigest, + deleteAfter: new Date(pack.expiresAt), + scheduledAt: new Date(pack.createdAt), + schedule: json(deletionSchedule), + scheduleDigest: deletionSchedule.scheduleDigest, + createdAt: new Date(pack.createdAt), + claim: { + create: { + status: 'PENDING', + nextAttemptAt: new Date(pack.expiresAt), + attemptCount: 0 + } + } + } + }); } return { buildDecisionId: decision.buildDecisionId, diff --git a/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts new file mode 100644 index 0000000..4a5eaee --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts @@ -0,0 +1,920 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { + SAST_ACCEPTED_EVIDENCE_POLICY, + buildSastEvidenceDeletionSchedule, + isSastAcceptedEvidenceBuildResultShapeValid, + isSastEvidenceAccessDecisionShapeValid, + isSastEvidenceDeletionProofShapeValid, + isSastEvidenceDeletionScheduleShapeValid, + isSastScanCoverageDecisionShapeValid, + isSastScanFreshnessDecisionShapeValid, + type SastAcceptedEvidenceBuildResult, + type SastEvidenceAccessDecision, + type SastEvidenceDeletionProof, + type SastEvidenceDeletionSchedule, + type SastScanCoverageDecision, + type SastScanFreshnessDecision +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + SastEvidenceAccessPersistenceError, + SastEvidenceAccessStore, + sastEvidenceAccessScopeFromResult, + type PersistedSastEvidenceAccessDecision, + type SastEvidenceAccessContext, + type SastEvidenceDeletionCandidate +} from './sast-evidence-access.store'; + +const SERIALIZABLE_ATTEMPTS = 3; +const SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS = 10; +const SERIALIZABLE_MAX_WAIT_MILLISECONDS = 5_000; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000; + +const scheduleInclude = Prisma.validator()({ + claim: true, + proof: true, + tenant: { select: { sastAiAdvisoryOptIn: true } }, + repositoryBinding: { + select: { sastAiAdvisoryOptIn: true } + }, + buildDecision: { + include: { + evidencePack: { + include: { + fragments: { orderBy: { ordinal: 'asc' } } + } + }, + freshnessDecision: { + include: { coverageDecision: true } + } + } + } +}); + +const packInclude = Prisma.validator()({ + fragments: { orderBy: { ordinal: 'asc' } }, + buildDecision: { + include: { + freshnessDecision: { + include: { coverageDecision: true } + } + } + } +}); + +type ScheduleRow = Prisma.SastEvidenceDeletionScheduleGetPayload<{ + include: typeof scheduleInclude; +}>; +type PackRow = Prisma.SastAcceptedEvidencePackGetPayload<{ + include: typeof packInclude; +}>; +type EvidenceTransaction = Prisma.TransactionClient; + +@Injectable() +export class PrismaSastEvidenceAccessStore + extends SastEvidenceAccessStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async load(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + referenceTime: string; + }): Promise { + return this.runSerializable(async (transaction) => { + let row = await this.readSchedule(transaction, input); + if (!row) { + const pack = await transaction.sastAcceptedEvidencePack.findFirst({ + where: { + id: input.evidencePackId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId + }, + include: packInclude + }); + if (!pack) return null; + await this.createSchedule(transaction, pack); + row = await this.readSchedule(transaction, input); + } + return row ? contextFromRow(row) : null; + }); + } + + async persistDecision(input: { + context: Readonly; + decision: Readonly; + }): Promise { + if ( + !isSastEvidenceAccessDecisionShapeValid( + input.decision, + digest + ) || + stableJson(input.decision.scope) !== + stableJson(input.context.schedule.scope) || + input.decision.deletionScheduleId !== + input.context.schedule.deletionScheduleId || + input.decision.deletionScheduleDigest !== + input.context.schedule.scheduleDigest + ) { + throw new SastEvidenceAccessPersistenceError( + 'OUTPUT_INVALID' + ); + } + return this.runSerializable(async (transaction) => { + const scope = input.decision.scope; + const currentRow = await this.readSchedule(transaction, { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + evidencePackId: scope.evidencePackId + }); + if (!currentRow) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const current = contextFromRow(currentRow); + if (stableJson(current) !== stableJson(input.context)) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const existing = + await transaction.sastEvidenceAccessDecision.findUnique({ + where: { id: input.decision.accessDecisionId } + }); + if (existing) { + return replayDecision(existing.decision, input.decision); + } + const decision = input.decision; + await transaction.sastEvidenceAccessDecision.create({ + data: { + id: decision.accessDecisionId, + buildDecisionId: scope.buildDecisionId, + deletionScheduleId: decision.deletionScheduleId, + evidencePackId: scope.evidencePackId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + occurrenceId: scope.occurrenceId, + findingFingerprint: scope.findingFingerprint, + purpose: decision.purpose, + accessPolicyVersion: decision.accessPolicyVersion, + secretRegistryVersion: decision.secretRegistryVersion, + outcome: decision.outcome, + classification: decision.classification, + reasonCodes: json(decision.reasonCodes), + sourcePackDigest: scope.sourcePackDigest, + redactedProjectionDigest: + decision.redactedProjectionDigest, + redactedFragmentCount: + decision.redactedFragmentCount, + redactedTotalBytes: decision.redactedTotalBytes, + redactionCount: decision.redactionCount, + secondPassRedactionDecisionRef: + decision.secondPassRedactionDecisionRef, + reducedEvidenceRef: decision.reducedEvidenceRef, + aiPayloadExpiresAt: decision.aiPayloadExpiresAt + ? new Date(decision.aiPayloadExpiresAt) + : null, + evidenceExpiresAt: new Date( + decision.evidenceExpiresAt + ), + decision: json(decision), + decisionDigest: decision.decisionDigest, + decidedAt: new Date(decision.decidedAt), + createdAt: new Date(decision.decidedAt) + } + }); + return { + decision: decision as SastEvidenceAccessDecision, + replayed: false + }; + }); + } + + async confirmAccess(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + accessDecisionId: string; + purpose: 'DASHBOARD' | 'AI_ADVISORY'; + secretRegistryVersion: string; + redactedProjectionDigest: `sha256:${string}`; + referenceTime: string; + }): Promise { + return this.runSerializable(async (transaction) => { + const [row, accessRow] = await Promise.all([ + this.readSchedule(transaction, input), + transaction.sastEvidenceAccessDecision.findFirst({ + where: { + id: input.accessDecisionId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + evidencePackId: input.evidencePackId, + purpose: input.purpose, + secretRegistryVersion: + input.secretRegistryVersion, + outcome: 'ALLOWED', + redactedProjectionDigest: + input.redactedProjectionDigest, + evidenceExpiresAt: { + gt: new Date(input.referenceTime) + } + } + }) + ]); + if (!row || !accessRow) return null; + const decision = + accessRow.decision as unknown as SastEvidenceAccessDecision; + if ( + !isSastEvidenceAccessDecisionShapeValid(decision, digest) || + decision.accessDecisionId !== accessRow.id || + decision.decisionDigest !== accessRow.decisionDigest || + decision.outcome !== 'ALLOWED' || + decision.purpose !== input.purpose || + decision.secretRegistryVersion !== + input.secretRegistryVersion || + decision.redactedProjectionDigest !== + input.redactedProjectionDigest || + decision.evidenceExpiresAt !== + accessRow.evidenceExpiresAt.toISOString() + ) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const context = contextFromRow(row); + if ( + stableJson(decision.scope) !== + stableJson(context.schedule.scope) || + decision.deletionScheduleId !== + context.schedule.deletionScheduleId || + decision.deletionScheduleDigest !== + context.schedule.scheduleDigest + ) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + return context.deletionState === 'ACTIVE' ? context : null; + }); + } + + async backfillDeletionSchedules(input: { + referenceTime: string; + limit: number; + }): Promise { + const limit = Math.max(1, Math.min(128, input.limit)); + const rows = await this.prisma.$queryRaw< + Array<{ + id: string; + tenantId: string; + repositoryBindingId: string; + }> + >(Prisma.sql` + SELECT p."id", p."tenantId", p."repositoryBindingId" + FROM "SastAcceptedEvidencePack" p + LEFT JOIN "SastEvidenceDeletionSchedule" s + ON s."evidencePackId" = p."id" + WHERE s."id" IS NULL + ORDER BY p."expiresAt" ASC, p."id" ASC + LIMIT ${limit} + `); + let scheduled = 0; + for (const row of rows) { + const loaded = await this.load({ + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + evidencePackId: row.id, + referenceTime: input.referenceTime + }); + if (loaded) scheduled += 1; + } + return scheduled; + } + + async claimDeletion(input: { + referenceTime: string; + leaseOwner: string; + leaseExpiresAt: string; + }): Promise { + return this.runSerializable(async (transaction) => { + const referenceTime = new Date(input.referenceTime); + const claim = + await transaction.sastEvidenceDeletionClaim.findFirst({ + where: { + nextAttemptAt: { lte: referenceTime }, + OR: [ + { status: 'PENDING' }, + { + status: 'CLAIMED', + leaseExpiresAt: { lte: referenceTime } + } + ], + schedule: { + deleteAfter: { lte: referenceTime }, + proof: { is: null } + } + }, + orderBy: [ + { nextAttemptAt: 'asc' }, + { scheduleId: 'asc' } + ], + include: { + schedule: { include: scheduleInclude } + } + }); + if (!claim) return null; + const context = contextFromRow(claim.schedule); + if ( + context.deletionState === 'DELETED' || + !context.result?.pack || + context.result.pack.packDigest !== + claim.schedule.sourcePackDigest || + Date.parse(context.result.pack.expiresAt) > + referenceTime.getTime() + ) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const leaseToken = randomUUID(); + const updated = + await transaction.sastEvidenceDeletionClaim.updateMany({ + where: { + scheduleId: claim.scheduleId, + OR: [ + { status: 'PENDING' }, + { + status: 'CLAIMED', + leaseExpiresAt: { lte: referenceTime } + } + ] + }, + data: { + status: 'CLAIMED', + leaseOwner: input.leaseOwner, + leaseToken, + leaseExpiresAt: new Date(input.leaseExpiresAt), + attemptCount: { increment: 1 } + } + }); + if (updated.count !== 1) return null; + return { + schedule: context.schedule, + leaseOwner: input.leaseOwner, + leaseToken, + leaseExpiresAt: input.leaseExpiresAt + }; + }); + } + + async finalizeDeletion(input: { + candidate: Readonly; + receipt: Readonly<{ + operationId: string; + providerReceiptRef: string; + providerReceiptDigest: `sha256:${string}`; + completedAt: string; + }>; + proof: Readonly; + }): Promise<{ proof: SastEvidenceDeletionProof; replayed: boolean }> { + if ( + !isSastEvidenceDeletionProofShapeValid(input.proof, digest) || + input.receipt.operationId !== input.candidate.schedule.operationId || + input.proof.operationId !== input.receipt.operationId || + input.proof.providerReceiptRef !== + input.receipt.providerReceiptRef || + input.proof.providerReceiptDigest !== + input.receipt.providerReceiptDigest || + input.proof.completedAt !== input.receipt.completedAt + ) { + throw new SastEvidenceAccessPersistenceError( + 'OUTPUT_INVALID' + ); + } + return this.runSerializable(async (transaction) => { + const row = await transaction.sastEvidenceDeletionSchedule.findUnique({ + where: { id: input.candidate.schedule.deletionScheduleId }, + include: scheduleInclude + }); + if (!row) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + if (row.proof) { + const existing = + row.proof.proof as unknown as SastEvidenceDeletionProof; + if ( + isSastEvidenceDeletionProofShapeValid(existing, digest) && + stableJson(existing) === stableJson(input.proof) + ) { + return { proof: existing, replayed: true }; + } + throw new SastEvidenceAccessPersistenceError( + 'REPLAY_CONFLICT' + ); + } + if ( + stableJson(row.schedule) !== + stableJson(input.candidate.schedule) || + !row.claim || + row.claim.status !== 'CLAIMED' || + row.claim.leaseOwner !== input.candidate.leaseOwner || + row.claim.leaseToken !== input.candidate.leaseToken || + row.claim.leaseExpiresAt?.toISOString() !== + input.candidate.leaseExpiresAt || + Date.parse(input.receipt.completedAt) < + row.deleteAfter.getTime() || + Date.parse(input.receipt.completedAt) > + Date.parse(input.candidate.leaseExpiresAt) || + !row.buildDecision.evidencePack || + row.buildDecision.evidencePack.id !== row.evidencePackId || + row.buildDecision.evidencePack.packDigest !== + row.sourcePackDigest + ) { + throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + } + await transaction.sastEvidenceDeletionProof.create({ + data: { + id: input.proof.deletionProofId, + scheduleId: row.id, + operationId: row.operationId, + tenantId: row.tenantId, + evidencePackId: row.evidencePackId, + buildDecisionId: row.buildDecisionId, + providerReceiptRef: + input.proof.providerReceiptRef, + providerReceiptDigest: + input.proof.providerReceiptDigest, + completedAt: new Date(input.proof.completedAt), + proof: json(input.proof), + proofDigest: input.proof.proofDigest, + createdAt: new Date(input.proof.completedAt) + } + }); + await transaction.sastAcceptedEvidencePack.delete({ + where: { id: row.evidencePackId } + }); + const completed = + await transaction.sastEvidenceDeletionClaim.updateMany({ + where: { + scheduleId: row.id, + status: 'CLAIMED', + leaseToken: input.candidate.leaseToken + }, + data: { + status: 'COMPLETED', + leaseOwner: null, + leaseToken: null, + leaseExpiresAt: null, + nextAttemptAt: new Date(input.proof.completedAt) + } + }); + if (completed.count !== 1) { + throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + } + return { + proof: input.proof as SastEvidenceDeletionProof, + replayed: false + }; + }); + } + + async releaseDeletion(input: { + candidate: Readonly; + retryAt: string; + }): Promise { + const updated = + await this.prisma.sastEvidenceDeletionClaim.updateMany({ + where: { + scheduleId: + input.candidate.schedule.deletionScheduleId, + status: 'CLAIMED', + leaseOwner: input.candidate.leaseOwner, + leaseToken: input.candidate.leaseToken + }, + data: { + status: 'PENDING', + leaseOwner: null, + leaseToken: null, + leaseExpiresAt: null, + nextAttemptAt: new Date(input.retryAt) + } + }); + if (updated.count !== 1) { + throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + } + } + + private readSchedule( + reader: EvidenceTransaction, + input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + } + ): Promise { + return reader.sastEvidenceDeletionSchedule.findFirst({ + where: { + evidencePackId: input.evidencePackId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId + }, + include: scheduleInclude + }); + } + + private async createSchedule( + transaction: EvidenceTransaction, + packRow: PackRow + ): Promise { + const result = resultFromPackRow(packRow); + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + result, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) || + !result.pack + ) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + const schedule = buildSastEvidenceDeletionSchedule({ + scope: sastEvidenceAccessScopeFromResult(result), + scheduledAt: result.pack.createdAt, + deleteAfter: result.pack.expiresAt, + digestCanonical: digest + }); + await transaction.sastEvidenceDeletionSchedule.create({ + data: { + id: schedule.deletionScheduleId, + operationId: schedule.operationId, + evidencePackId: result.pack.evidencePackId, + buildDecisionId: result.decision.buildDecisionId, + tenantId: schedule.scope.tenantId, + repositoryBindingId: + schedule.scope.repositoryBindingId, + scanRequestId: schedule.scope.scanRequestId, + attemptId: schedule.scope.attemptId, + occurrenceId: schedule.scope.occurrenceId, + sourcePackDigest: schedule.scope.sourcePackDigest, + deleteAfter: new Date(schedule.deleteAfter), + scheduledAt: new Date(schedule.scheduledAt), + schedule: json(schedule), + scheduleDigest: schedule.scheduleDigest, + createdAt: new Date(schedule.scheduledAt), + claim: { + create: { + status: 'PENDING', + nextAttemptAt: new Date(schedule.deleteAfter), + attemptCount: 0 + } + } + } + }); + } + + private async runSerializable( + operation: (transaction: EvidenceTransaction) => Promise + ): Promise { + let lastError: unknown; + for ( + let attempt = 1; + attempt <= SERIALIZABLE_ATTEMPTS; + attempt += 1 + ) { + try { + return await this.prisma.$transaction(operation, { + isolationLevel: + Prisma.TransactionIsolationLevel.Serializable, + maxWait: SERIALIZABLE_MAX_WAIT_MILLISECONDS, + timeout: SERIALIZABLE_TIMEOUT_MILLISECONDS + }); + } catch (error) { + lastError = error; + if ( + !isRetryableTransactionError(error) || + attempt === SERIALIZABLE_ATTEMPTS + ) { + throw error; + } + await delay( + SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS * attempt + ); + } + } + throw lastError; + } +} + +function contextFromRow(row: ScheduleRow): SastEvidenceAccessContext { + const schedule = row.schedule as unknown as SastEvidenceDeletionSchedule; + if ( + !isSastEvidenceDeletionScheduleShapeValid(schedule, digest) || + schedule.deletionScheduleId !== row.id || + schedule.operationId !== row.operationId || + schedule.scheduleDigest !== row.scheduleDigest || + schedule.scope.evidencePackId !== row.evidencePackId || + schedule.scope.buildDecisionId !== row.buildDecisionId || + schedule.scope.tenantId !== row.tenantId || + schedule.scope.repositoryBindingId !== + row.repositoryBindingId || + schedule.scope.scanRequestId !== row.scanRequestId || + schedule.scope.attemptId !== row.attemptId || + schedule.scope.occurrenceId !== row.occurrenceId || + schedule.scope.sourcePackDigest !== row.sourcePackDigest || + schedule.scheduledAt !== row.scheduledAt.toISOString() || + schedule.deleteAfter !== row.deleteAfter.toISOString() || + !row.claim + ) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + const proof = row.proof?.proof as unknown; + const canonicalProof = row.proof + ? (proof as SastEvidenceDeletionProof) + : null; + if ( + canonicalProof && + (!isSastEvidenceDeletionProofShapeValid( + canonicalProof, + digest + ) || + canonicalProof.deletionScheduleId !== row.id || + canonicalProof.operationId !== row.operationId || + canonicalProof.proofDigest !== row.proof?.proofDigest || + canonicalProof.providerReceiptDigest !== + row.proof?.providerReceiptDigest) + ) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + const packRow = row.buildDecision.evidencePack; + if ((canonicalProof && packRow) || (!canonicalProof && !packRow)) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + let result: SastAcceptedEvidenceBuildResult | null = null; + if (packRow) { + result = resultFromScheduleRow(row); + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + result, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) || + stableJson(sastEvidenceAccessScopeFromResult(result)) !== + stableJson(schedule.scope) + ) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + } + const freshness = + row.buildDecision.freshnessDecision + .decision as unknown as SastScanFreshnessDecision; + const coverage = + row.buildDecision.freshnessDecision.coverageDecision + .decision as unknown as SastScanCoverageDecision; + const decisionsValid = + isSastScanFreshnessDecisionShapeValid(freshness, digest) && + isSastScanCoverageDecisionShapeValid(coverage, digest) && + freshness.freshnessDecisionId === + row.buildDecision.freshnessDecision.id && + freshness.decisionDigest === + row.buildDecision.freshnessDecision.decisionDigest && + coverage.coverageDecisionId === + row.buildDecision.freshnessDecision.coverageDecision.id && + coverage.decisionDigest === + row.buildDecision.freshnessDecision.coverageDecision + .decisionDigest && + freshness.scope.coverageDecisionId === + coverage.coverageDecisionId && + freshness.scope.coverageDecisionDigest === + coverage.decisionDigest; + if (!decisionsValid) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + const deletionState = canonicalProof + ? 'DELETED' + : row.claim.status === 'CLAIMED' + ? 'DELETION_PENDING' + : 'ACTIVE'; + if ( + (canonicalProof && row.claim.status !== 'COMPLETED') || + (!canonicalProof && row.claim.status === 'COMPLETED') + ) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + return { + result, + schedule, + deletionState, + deletionProof: canonicalProof, + tenantAiAdvisoryOptIn: + row.tenant.sastAiAdvisoryOptIn, + repositoryAiAdvisoryOptIn: + row.repositoryBinding.sastAiAdvisoryOptIn, + freshnessEligible: + freshness.latestTargetAuthority === 'VERIFIED' && + freshness.staleStatus === 'FRESH' && + freshness.comparabilityStatus === 'COMPARABLE' && + freshness.reasonCodes.length === 0 && + !freshness.aiAdvisoryAllowed && + !freshness.publicationAttempted, + coverageComplete: + coverage.state === 'COMPLETE' && + coverage.reasonCodes.length === 0 && + row.buildDecision.freshnessDecision.coverageDecision + .state === 'COMPLETE' + }; +} + +function resultFromScheduleRow( + row: ScheduleRow +): SastAcceptedEvidenceBuildResult { + const packRow = row.buildDecision.evidencePack; + if (!packRow) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + const result = { + decision: + row.buildDecision.decision as unknown as SastAcceptedEvidenceBuildResult['decision'], + pack: packRow.pack as unknown as SastAcceptedEvidenceBuildResult['pack'] + }; + if ( + !result.pack || + packRow.fragments.length !== result.pack.fragments.length || + packRow.fragments.some( + (fragment, index) => + stableJson(fragment.fragment) !== + stableJson(result.pack?.fragments[index]) + ) || + !packScalarsMatch(packRow, result, row.buildDecision) + ) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + return result; +} + +function resultFromPackRow( + row: PackRow +): SastAcceptedEvidenceBuildResult { + const result = { + decision: + row.buildDecision.decision as unknown as SastAcceptedEvidenceBuildResult['decision'], + pack: row.pack as unknown as SastAcceptedEvidenceBuildResult['pack'] + }; + if ( + !result.pack || + row.fragments.length !== result.pack.fragments.length || + row.fragments.some( + (fragment, index) => + stableJson(fragment.fragment) !== + stableJson(result.pack?.fragments[index]) + ) || + !packScalarsMatch(row, result, row.buildDecision) + ) { + throw new SastEvidenceAccessPersistenceError('CONTEXT_DRIFT'); + } + return result; +} + +function packScalarsMatch( + row: { + id: string; + buildDecisionId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + findingFingerprint: string; + packDigest: string; + dashboardSafe: boolean; + aiSafe: boolean; + classificationDecisionRef: string | null; + deletionScheduleRef: string | null; + createdAt: Date; + expiresAt: Date; + }, + result: Readonly, + buildDecision: { + id: string; + outcome: string; + decisionDigest: string; + evidencePackId: string | null; + evidencePackDigest: string | null; + } +): boolean { + const pack = result.pack; + const decision = result.decision; + return ( + !!pack && + row.id === pack.evidencePackId && + row.buildDecisionId === decision.buildDecisionId && + buildDecision.id === decision.buildDecisionId && + buildDecision.outcome === decision.outcome && + buildDecision.decisionDigest === decision.decisionDigest && + buildDecision.evidencePackId === pack.evidencePackId && + buildDecision.evidencePackDigest === pack.packDigest && + row.tenantId === pack.scope.tenantId && + row.repositoryBindingId === pack.scope.repositoryBindingId && + row.scanRequestId === pack.scope.scanRequestId && + row.attemptId === pack.scope.attemptId && + row.occurrenceId === pack.scope.occurrenceId && + row.findingFingerprint === pack.scope.findingFingerprint && + row.packDigest === pack.packDigest && + row.dashboardSafe === false && + row.aiSafe === false && + row.classificationDecisionRef === null && + row.deletionScheduleRef === null && + row.createdAt.toISOString() === pack.createdAt && + row.expiresAt.toISOString() === pack.expiresAt + ); +} + +function replayDecision( + stored: Prisma.JsonValue, + requested: Readonly +): PersistedSastEvidenceAccessDecision { + const decision = stored as unknown as SastEvidenceAccessDecision; + if ( + !isSastEvidenceAccessDecisionShapeValid(decision, digest) || + stableJson(replayProjection(decision)) !== + stableJson(replayProjection(requested)) + ) { + throw new SastEvidenceAccessPersistenceError( + 'REPLAY_CONFLICT' + ); + } + return { decision, replayed: true }; +} + +function replayProjection( + decision: Readonly +): unknown { + const { + decidedAt: _decidedAt, + decisionDigest: _decisionDigest, + aiPayloadExpiresAt: _aiPayloadExpiresAt, + ...stable + } = decision; + void _decidedAt; + void _decisionDigest; + void _aiPayloadExpiresAt; + return stable; +} + +function isRetryableTransactionError(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { + return false; + } + if (error.code === 'P2034') return true; + if (error.code !== 'P2002') return false; + const modelName = error.meta?.modelName; + return ( + modelName === 'SastEvidenceAccessDecision' || + modelName === 'SastEvidenceDeletionSchedule' || + modelName === 'SastEvidenceDeletionProof' + ); +} + +function json(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return '[' + value.map(stableJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + const record = value as Record; + return ( + '{' + + Object.keys(record) + .sort() + .map( + (key) => + JSON.stringify(key) + ':' + stableJson(record[key]) + ) + .join(',') + + '}' + ); + } + return JSON.stringify(value); +} diff --git a/apps/api/src/scan-plane/sast-evidence-access.service.ts b/apps/api/src/scan-plane/sast-evidence-access.service.ts new file mode 100644 index 0000000..d37d6ea --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-access.service.ts @@ -0,0 +1,993 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_ACCEPTED_EVIDENCE_POLICY, + SAST_APPROVED_PROFILE_DIGESTS, + SAST_DASHBOARD_EVIDENCE_VERSION, + SAST_REDUCED_EVIDENCE_REFERENCE_VERSION, + SAST_SECRET_REDACTION_LIMITS, + buildSastEvidenceAccessDecision, + canonicalizeSastEvidenceSafeFragments, + isSafeNormalizedPath, + isSastAcceptedEvidenceBuildResultShapeValid, + isSastEvidenceAccessDecisionShapeValid, + isSastEvidenceAccessScopeValid, + isSastEvidenceDeletionScheduleShapeValid, + isSastEvidenceSafeFragmentShapeValid, + type SastDashboardEvidence, + type SastEvidenceAccessDecision, + type SastEvidenceAccessPurpose, + type SastEvidenceAccessReasonCode, + type SastEvidenceAccessScope, + type SastEvidenceSafeFragment, + type SastReducedEvidenceReference +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { + SastEvidenceAccessPersistenceError, + SastEvidenceAccessStore, + sastEvidenceAccessScopeFromResult, + type SastEvidenceAccessContext +} from './sast-evidence-access.store'; +import { + SastEvidenceSecretRegistry, + type SastEvidenceSecretRegistryResult +} from './sast-evidence-secret-registry'; + +const REDACTION_TOKEN = '[REDACTED]'; +const UNCHECKED_REGISTRY_VERSION = 'not-checked-v1'; +const KNOWN_SECRET_PATTERNS = [ + /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]{0,4096}?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/gu, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, + /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/gu, + /\b(?:glpat|gloas|gldt|glrt|glrtr|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{8,255}\b/gu, + /\beyJ[A-Za-z0-9_-]{5,511}\.[A-Za-z0-9_-]{8,2048}\.[A-Za-z0-9_-]{8,2048}\b/gu, + /(?|=|:)\s*(?:"[^"\r\n]{1,512}"|'[^'\r\n]{1,512}'|[^\s,;]{4,512})/giu +] as const; +const ENTROPY_TOKEN_PATTERN = + /(? string; + +export type SastEvidenceAccessResult = + | { + outcome: 'ALLOWED'; + decision: SastEvidenceAccessDecision; + replayed: boolean; + dashboardEvidence: SastDashboardEvidence | null; + reducedEvidenceReference: SastReducedEvidenceReference | null; + } + | { + outcome: 'DENIED'; + reasonCode: SastEvidenceAccessReasonCode; + decision: SastEvidenceAccessDecision | null; + replayed: boolean; + dashboardEvidence: null; + reducedEvidenceReference: null; + }; + +interface RedactionProjection { + fragments: SastEvidenceSafeFragment[]; + projectionDigest: `sha256:${string}`; + totalBytes: number; + redactionCount: number; +} + +interface InternalAllowedResult { + outcome: 'ALLOWED'; + decision: SastEvidenceAccessDecision; + context: SastEvidenceAccessContext; + registryVersion: string; + projection: RedactionProjection; + replayed: boolean; +} + +@Injectable() +export class SastEvidenceAccessService { + constructor( + private readonly store: SastEvidenceAccessStore, + private readonly secretRegistry: SastEvidenceSecretRegistry + ) {} + + async readDashboard( + input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + }, + clock: EvidenceClock = () => new Date().toISOString() + ): Promise { + const startedAt = readClock(clock); + if (!startedAt) { + return denied('EVIDENCE_ACCESS_INPUT_INVALID'); + } + const classified = await this.classifyAt( + { ...input, purpose: 'DASHBOARD' }, + startedAt + ); + if (classified.outcome === 'DENIED') return classified; + + const currentRegistry = await this.readRegistry(input); + if ( + currentRegistry.status !== 'VERIFIED' || + currentRegistry.registryVersion !== classified.registryVersion + ) { + return denied( + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + classified.decision + ); + } + const secrets = normalizeSecretRegistry(currentRegistry); + if (!secrets) { + return denied( + 'EVIDENCE_ACCESS_REDACTION_FAILED', + classified.decision + ); + } + const refreshedAt = readClock(clock); + if (!refreshedAt || Date.parse(refreshedAt) < Date.parse(startedAt)) { + return denied( + 'EVIDENCE_ACCESS_INPUT_INVALID', + classified.decision + ); + } + const refreshed = await this.confirmAccess({ + ...input, + accessDecisionId: classified.decision.accessDecisionId, + purpose: 'DASHBOARD', + secretRegistryVersion: classified.registryVersion, + redactedProjectionDigest: + classified.projection.projectionDigest, + referenceTime: refreshedAt + }); + const refreshedProjection = + refreshed?.result?.pack === undefined || + refreshed.result.pack === null + ? null + : redactPack(refreshed.result.pack.fragments, secrets); + if ( + !refreshed || + refreshed.deletionState !== 'ACTIVE' || + !refreshedProjection || + refreshedProjection.projectionDigest !== + classified.decision.redactedProjectionDigest + ) { + return denied( + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + classified.decision + ); + } + const completedAt = readClock(clock); + if ( + !completedAt || + Date.parse(completedAt) < Date.parse(refreshedAt) || + Date.parse(completedAt) >= + Date.parse(classified.decision.evidenceExpiresAt) + ) { + return denied( + 'EVIDENCE_ACCESS_EXPIRED', + classified.decision + ); + } + const confirmed = await this.confirmAccess({ + ...input, + accessDecisionId: classified.decision.accessDecisionId, + purpose: 'DASHBOARD', + secretRegistryVersion: classified.registryVersion, + redactedProjectionDigest: + classified.projection.projectionDigest, + referenceTime: completedAt + }); + if (!confirmed || confirmed.deletionState !== 'ACTIVE') { + return denied( + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + classified.decision + ); + } + return { + outcome: 'ALLOWED', + decision: classified.decision, + replayed: classified.replayed, + dashboardEvidence: dashboardEvidence( + classified.decision, + refreshedProjection, + classified.context.result?.pack?.truncated ?? false + ), + reducedEvidenceReference: null + }; + } + + async classifyForAi( + input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + }, + clock: EvidenceClock = () => new Date().toISOString() + ): Promise { + const decidedAt = readClock(clock); + if (!decidedAt) { + return denied('EVIDENCE_ACCESS_INPUT_INVALID'); + } + const classified = await this.classifyAt( + { ...input, purpose: 'AI_ADVISORY' }, + decidedAt + ); + if (classified.outcome === 'DENIED') return classified; + + const currentRegistry = await this.readRegistry(input); + if ( + currentRegistry.status !== 'VERIFIED' || + currentRegistry.registryVersion !== classified.registryVersion + ) { + return denied( + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + classified.decision + ); + } + const secrets = normalizeSecretRegistry(currentRegistry); + if (!secrets) { + return denied( + 'EVIDENCE_ACCESS_REDACTION_FAILED', + classified.decision + ); + } + const completedAt = readClock(clock); + if ( + !completedAt || + Date.parse(completedAt) < Date.parse(decidedAt) || + Date.parse(completedAt) >= + Date.parse(classified.decision.evidenceExpiresAt) || + !classified.decision.aiPayloadExpiresAt || + Date.parse(completedAt) >= + Date.parse(classified.decision.aiPayloadExpiresAt) + ) { + return denied( + 'EVIDENCE_ACCESS_EXPIRED', + classified.decision + ); + } + const confirmed = await this.confirmAccess({ + ...input, + accessDecisionId: classified.decision.accessDecisionId, + purpose: 'AI_ADVISORY', + secretRegistryVersion: classified.registryVersion, + redactedProjectionDigest: + classified.projection.projectionDigest, + referenceTime: completedAt + }); + const confirmedProjection = confirmed?.result?.pack + ? redactPack(confirmed.result.pack.fragments, secrets) + : null; + if ( + !confirmed || + confirmed.deletionState !== 'ACTIVE' || + !confirmedProjection || + confirmedProjection.projectionDigest !== + classified.decision.redactedProjectionDigest + ) { + return denied( + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + classified.decision + ); + } + return { + outcome: 'ALLOWED', + decision: classified.decision, + replayed: classified.replayed, + dashboardEvidence: null, + reducedEvidenceReference: reducedReference( + classified.decision + ) + }; + } + + private async classifyAt( + input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + purpose: SastEvidenceAccessPurpose; + }, + decidedAt: string + ): Promise> { + if (!isClassificationInputValid(input)) { + return denied('EVIDENCE_ACCESS_INPUT_INVALID'); + } + let context: SastEvidenceAccessContext | null; + try { + context = await this.store.load({ + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + evidencePackId: input.evidencePackId, + referenceTime: decidedAt + }); + } catch { + return denied('EVIDENCE_ACCESS_CONTEXT_UNAVAILABLE'); + } + if (!context) { + return denied('EVIDENCE_ACCESS_CONTEXT_UNAVAILABLE'); + } + if ( + !isSastEvidenceDeletionScheduleShapeValid( + context.schedule, + digest + ) || + !isSastEvidenceAccessScopeValid(context.schedule.scope) || + context.schedule.scope.tenantId !== input.tenantId || + context.schedule.scope.repositoryBindingId !== + input.repositoryBindingId || + context.schedule.scope.evidencePackId !== input.evidencePackId + ) { + return denied('EVIDENCE_ACCESS_CONTEXT_DRIFT'); + } + if (context.deletionState === 'DELETED') { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_DELETED', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + if (context.deletionState === 'DELETION_PENDING') { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_DELETION_PENDING', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + if ( + Date.parse(decidedAt) >= Date.parse(context.schedule.deleteAfter) + ) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_EXPIRED', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + const validated = validateActiveContext(context); + if (validated.status === 'DENIED') { + return this.persistDenied( + context, + input.purpose, + validated.reasonCode, + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + if (!context.coverageComplete || !context.freshnessEligible) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_COVERAGE_INELIGIBLE', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + const profile = validated.scope.profileId; + if ( + SAST_APPROVED_PROFILE_DIGESTS[profile] !== + validated.scope.profileDigest + ) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_PROFILE_UNAPPROVED', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + if ( + input.purpose === 'AI_ADVISORY' && + (!context.tenantAiAdvisoryOptIn || + !context.repositoryAiAdvisoryOptIn) + ) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_OPT_IN_REQUIRED', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + const registry = await this.readRegistry(input); + if (registry.status !== 'VERIFIED') { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_SECRET_AUTHORITY_UNAVAILABLE', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + const secrets = normalizeSecretRegistry(registry); + if (!secrets) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_REDACTION_FAILED', + UNCHECKED_REGISTRY_VERSION, + decidedAt + ); + } + const pack = context.result?.pack; + if (!pack) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_CONTEXT_DRIFT', + registry.registryVersion, + decidedAt + ); + } + const pathReason = classifyPaths( + pack.fragments.map((fragment) => fragment.normalizedPath), + secrets + ); + if (pathReason) { + return this.persistDenied( + context, + input.purpose, + pathReason, + registry.registryVersion, + decidedAt + ); + } + const projection = redactPack(pack.fragments, secrets); + if (!projection) { + return this.persistDenied( + context, + input.purpose, + 'EVIDENCE_ACCESS_REDACTION_FAILED', + registry.registryVersion, + decidedAt + ); + } + const decision = buildSastEvidenceAccessDecision({ + purpose: input.purpose, + scope: validated.scope, + schedule: context.schedule, + secretRegistryVersion: registry.registryVersion, + outcome: 'ALLOWED', + reasonCodes: [], + redactedProjectionDigest: projection.projectionDigest, + redactedFragmentCount: projection.fragments.length, + redactedTotalBytes: projection.totalBytes, + redactionCount: projection.redactionCount, + evidenceExpiresAt: pack.expiresAt, + decidedAt, + digestCanonical: digest + }); + const persisted = await this.persist(context, decision); + if (persisted.outcome === 'DENIED') return persisted; + if ( + Date.parse(persisted.decision.decidedAt) > + Date.parse(decidedAt) + ) { + return denied( + 'EVIDENCE_ACCESS_INPUT_INVALID', + persisted.decision, + persisted.replayed + ); + } + return { + outcome: 'ALLOWED', + decision: persisted.decision, + context, + registryVersion: registry.registryVersion, + projection, + replayed: persisted.replayed + }; + } + + private async persistDenied( + context: SastEvidenceAccessContext, + purpose: SastEvidenceAccessPurpose, + reasonCode: SastEvidenceAccessReasonCode, + secretRegistryVersion: string, + decidedAt: string + ): Promise> { + const decision = buildSastEvidenceAccessDecision({ + purpose, + scope: context.schedule.scope, + schedule: context.schedule, + secretRegistryVersion, + outcome: 'DENIED', + reasonCodes: [reasonCode], + redactedProjectionDigest: null, + redactedFragmentCount: 0, + redactedTotalBytes: 0, + redactionCount: 0, + evidenceExpiresAt: context.schedule.deleteAfter, + decidedAt, + digestCanonical: digest + }); + const persisted = await this.persist(context, decision); + if (persisted.outcome === 'DENIED') return persisted; + return denied(reasonCode, persisted.decision, persisted.replayed); + } + + private async persist( + context: SastEvidenceAccessContext, + decision: SastEvidenceAccessDecision + ): Promise< + | { outcome: 'ALLOWED'; decision: SastEvidenceAccessDecision; replayed: boolean } + | Extract + > { + if (!isSastEvidenceAccessDecisionShapeValid(decision, digest)) { + return denied('EVIDENCE_ACCESS_OUTPUT_INVALID'); + } + try { + const persisted = await this.store.persistDecision({ + context, + decision + }); + if ( + !isSastEvidenceAccessDecisionShapeValid( + persisted.decision, + digest + ) || + persisted.decision.accessDecisionId !== + decision.accessDecisionId || + persisted.decision.purpose !== decision.purpose || + persisted.decision.scope.sourcePackDigest !== + decision.scope.sourcePackDigest + ) { + return denied('EVIDENCE_ACCESS_PERSISTENCE_CONFLICT'); + } + if (persisted.decision.outcome === 'DENIED') { + return denied( + persisted.decision.reasonCodes[0] ?? + 'EVIDENCE_ACCESS_OUTPUT_INVALID', + persisted.decision, + persisted.replayed + ); + } + return { + outcome: 'ALLOWED', + decision: persisted.decision, + replayed: persisted.replayed + }; + } catch (error) { + if (error instanceof SastEvidenceAccessPersistenceError) { + return denied( + error.reason === 'CONTEXT_DRIFT' + ? 'EVIDENCE_ACCESS_CONTEXT_DRIFT' + : error.reason === 'OUTPUT_INVALID' + ? 'EVIDENCE_ACCESS_OUTPUT_INVALID' + : 'EVIDENCE_ACCESS_PERSISTENCE_CONFLICT' + ); + } + return denied('EVIDENCE_ACCESS_PERSISTENCE_CONFLICT'); + } + } + + private async readRegistry(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + }): Promise { + try { + return await this.secretRegistry.read(input); + } catch { + return { status: 'UNAVAILABLE' }; + } + } + + private async confirmAccess(input: Parameters< + SastEvidenceAccessStore['confirmAccess'] + >[0]): Promise { + try { + return await this.store.confirmAccess(input); + } catch { + return null; + } + } +} + +function validateActiveContext( + context: Readonly +): + | { status: 'ACCEPTED'; scope: SastEvidenceAccessScope } + | { status: 'DENIED'; reasonCode: SastEvidenceAccessReasonCode } { + const result = context.result; + if ( + !result || + !isSastAcceptedEvidenceBuildResultShapeValid( + result, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) || + result.decision.outcome !== 'ACCEPTED' || + !result.pack || + result.decision.evidencePackId !== result.pack.evidencePackId || + result.decision.evidencePackDigest !== result.pack.packDigest || + result.pack.dashboardSafe !== false || + result.pack.aiSafe !== false || + result.pack.classificationDecisionRef !== null || + result.pack.deletionScheduleRef !== null || + result.pack.authority.dashboardAccessAllowed !== false || + result.pack.authority.aiPayloadAllowed !== false + ) { + return { + status: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_TAMPERED' + }; + } + const scope = sastEvidenceAccessScopeFromResult(result); + if ( + !isSastEvidenceAccessScopeValid(scope) || + stableJson(scope) !== stableJson(context.schedule.scope) || + result.pack.createdAt !== context.schedule.scheduledAt || + result.pack.expiresAt !== context.schedule.deleteAfter + ) { + return { + status: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_CONTEXT_DRIFT' + }; + } + return { status: 'ACCEPTED', scope }; +} + +export function toSastEvidenceAccessScope( + result: Readonly> +): SastEvidenceAccessScope { + return sastEvidenceAccessScopeFromResult(result); +} + +function classifyPaths( + paths: readonly string[], + secrets: readonly string[] +): SastEvidenceAccessReasonCode | null { + for (const path of paths) { + if (!isSafeNormalizedPath(path)) { + return 'EVIDENCE_ACCESS_PATH_UNSAFE'; + } + if ( + containsKnownSecret(path) || + secrets.some((secret) => path.includes(secret)) || + path + .split('/') + .some( + (segment) => + segment.length >= 24 && looksHighEntropy(segment) + ) + ) { + return 'EVIDENCE_ACCESS_IDENTIFIER_UNSAFE'; + } + } + return null; +} + +function redactPack( + fragments: ReadonlyArray<{ + fragmentId: string; + ordinal: number; + role: 'PRIMARY' | 'RELATED'; + normalizedPath: string; + startLine: number; + endLine: number; + redactedContent: string; + }>, + secrets: readonly string[] +): RedactionProjection | null { + const safe: SastEvidenceSafeFragment[] = []; + let totalBytes = 0; + let redactionCount = 0; + for (const fragment of [...fragments].sort( + (left, right) => left.ordinal - right.ordinal + )) { + let content = fragment.redactedContent; + const expectedLines = countLines(content); + for (const pattern of KNOWN_SECRET_PATTERNS) { + pattern.lastIndex = 0; + content = content.replace(pattern, (matched) => { + redactionCount += 1; + return redactionReplacement(matched); + }); + } + for (const secret of secrets) { + const parts = content.split(secret); + if (parts.length > 1) { + redactionCount += parts.length - 1; + content = parts.join(redactionReplacement(secret)); + } + } + ENTROPY_TOKEN_PATTERN.lastIndex = 0; + content = content.replace(ENTROPY_TOKEN_PATTERN, (matched) => { + if (!looksHighEntropy(matched)) return matched; + redactionCount += 1; + return redactionReplacement(matched); + }); + const byteSize = Buffer.byteLength(content, 'utf8'); + const projected: SastEvidenceSafeFragment = { + fragmentId: fragment.fragmentId, + ordinal: fragment.ordinal, + role: fragment.role, + normalizedPath: fragment.normalizedPath, + startLine: fragment.startLine, + endLine: fragment.endLine, + redactedContent: content, + byteSize, + contentDigest: digest(content) + }; + if ( + countLines(content) !== expectedLines || + byteSize <= 0 || + byteSize > SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentBytes || + totalBytes > + SAST_ACCEPTED_EVIDENCE_POLICY.maxTotalBytes - byteSize || + !isSastEvidenceSafeFragmentShapeValid(projected) + ) { + return null; + } + totalBytes += byteSize; + safe.push(projected); + } + if ( + safe.length === 0 || + safe.length > SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentCount + ) { + return null; + } + return { + fragments: safe, + projectionDigest: digest( + canonicalizeSastEvidenceSafeFragments(safe) + ), + totalBytes, + redactionCount + }; +} + +function normalizeSecretRegistry( + registry: Extract< + SastEvidenceSecretRegistryResult, + { status: 'VERIFIED' } + > +): string[] | null { + if ( + !isBoundedText(registry.registryVersion, 2048) || + !Array.isArray(registry.platformSecretValues) || + registry.platformSecretValues.length > + SAST_SECRET_REDACTION_LIMITS.maximumPlatformSecretValues + ) { + return null; + } + const seen = new Set(); + let totalBytes = 0; + for (const value of registry.platformSecretValues as readonly unknown[]) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.normalize('NFC') || + value.includes(REDACTION_TOKEN) || + !/\S/u.test(value) || + /\p{Cc}/u.test(value) + ) { + return null; + } + const bytes = Buffer.byteLength(value, 'utf8'); + if ( + bytes < 8 || + bytes > + SAST_SECRET_REDACTION_LIMITS.maximumPlatformSecretValueBytes || + totalBytes > + SAST_SECRET_REDACTION_LIMITS + .maximumPlatformSecretValueTotalBytes - + bytes || + seen.has(value) + ) { + return null; + } + totalBytes += bytes; + seen.add(value); + } + if ( + containsKnownSecret(registry.registryVersion) || + [...seen].some((secret) => registry.registryVersion.includes(secret)) + ) { + return null; + } + return [...seen].sort( + (left, right) => + right.length - left.length || compareCodeUnits(left, right) + ); +} + +function containsKnownSecret(value: string): boolean { + for (const pattern of KNOWN_SECRET_PATTERNS) { + pattern.lastIndex = 0; + if (pattern.test(value)) { + pattern.lastIndex = 0; + return true; + } + } + return false; +} + +function dashboardEvidence( + decision: Readonly, + projection: Readonly, + truncated: boolean +): SastDashboardEvidence { + return { + version: SAST_DASHBOARD_EVIDENCE_VERSION, + accessDecisionId: decision.accessDecisionId, + accessDecisionDigest: decision.decisionDigest, + evidencePackId: decision.scope.evidencePackId, + findingFingerprint: decision.scope.findingFingerprint, + fragments: projection.fragments, + totalBytes: projection.totalBytes, + truncated, + expiresAt: decision.evidenceExpiresAt, + advisoryOnly: true + }; +} + +function reducedReference( + decision: Readonly +): SastReducedEvidenceReference { + if ( + !decision.reducedEvidenceRef || + !decision.redactedProjectionDigest || + !decision.aiPayloadExpiresAt + ) { + throw new Error('Allowed AI classification is incomplete.'); + } + return { + version: SAST_REDUCED_EVIDENCE_REFERENCE_VERSION, + reducedEvidenceRef: decision.reducedEvidenceRef, + accessDecisionId: decision.accessDecisionId, + accessDecisionDigest: decision.decisionDigest, + evidencePackId: decision.scope.evidencePackId, + findingFingerprint: decision.scope.findingFingerprint, + redactedProjectionDigest: decision.redactedProjectionDigest, + fragmentCount: decision.redactedFragmentCount, + payloadExpiresAt: decision.aiPayloadExpiresAt, + aiPayloadCreated: false, + aiProviderCalled: false, + retrievalAllowed: false, + toolsAllowed: false, + advisoryOnly: true + }; +} + +function denied( + reasonCode: SastEvidenceAccessReasonCode, + decision: SastEvidenceAccessDecision | null = null, + replayed = false +): Extract { + return { + outcome: 'DENIED', + reasonCode, + decision, + replayed, + dashboardEvidence: null, + reducedEvidenceReference: null + }; +} + +function isClassificationInputValid(value: unknown): value is { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + purpose: SastEvidenceAccessPurpose; +} { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const record = value as Record; + return ( + Object.keys(record).length === 4 && + ['tenantId', 'repositoryBindingId', 'evidencePackId', 'purpose'].every( + (key) => Object.hasOwn(record, key) + ) && + isBoundedText(record.tenantId, 255) && + isBoundedText(record.repositoryBindingId, 255) && + typeof record.evidencePackId === 'string' && + /^sast-evidence-pack:\/\/[a-f0-9]{64}$/u.test( + record.evidencePackId + ) && + (record.purpose === 'DASHBOARD' || + record.purpose === 'AI_ADVISORY') + ); +} + +function isBoundedText(value: unknown, maximum: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maximum && + value === value.normalize('NFC') && + value === value.trim() && + !/\p{Cc}/u.test(value) + ); +} + +function looksHighEntropy(value: string): boolean { + if (value.length < 24 || value === REDACTION_TOKEN) return false; + const counts = new Map(); + for (const character of value) { + counts.set(character, (counts.get(character) ?? 0) + 1); + } + let entropy = 0; + for (const count of counts.values()) { + const probability = count / value.length; + entropy -= probability * Math.log2(probability); + } + const classes = [/[a-z]/u, /[A-Z]/u, /[0-9]/u, /[^A-Za-z0-9]/u].filter( + (pattern) => pattern.test(value) + ).length; + return entropy >= 3.5 && classes >= 2; +} + +function redactionReplacement(value: string): string { + return REDACTION_TOKEN + '\n'.repeat(countNewlines(value)); +} + +function countLines(value: string): number { + return value.split('\n').length; +} + +function countNewlines(value: string): number { + let count = 0; + for (const character of value) { + if (character === '\n') count += 1; + } + return count; +} + +function compareCodeUnits(left: string, right: string): number { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const difference = + left.charCodeAt(index) - right.charCodeAt(index); + if (difference !== 0) return difference; + } + return left.length - right.length; +} + +function readClock(clock: EvidenceClock): string | null { + try { + const value = clock(); + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) && + new Date(milliseconds).toISOString() === value + ? value + : null; + } catch { + return null; + } +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return '[' + value.map(stableJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + const record = value as Record; + return ( + '{' + + Object.keys(record) + .sort() + .map( + (key) => + JSON.stringify(key) + ':' + stableJson(record[key]) + ) + .join(',') + + '}' + ); + } + return JSON.stringify(value); +} diff --git a/apps/api/src/scan-plane/sast-evidence-access.store.ts b/apps/api/src/scan-plane/sast-evidence-access.store.ts new file mode 100644 index 0000000..621d429 --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-access.store.ts @@ -0,0 +1,127 @@ +import type { + SastAcceptedEvidenceBuildResult, + SastEvidenceAccessDecision, + SastEvidenceAccessScope, + SastEvidenceAccessPurpose, + SastEvidenceDeletionProof, + SastEvidenceDeletionReceipt, + SastEvidenceDeletionSchedule +} from '@aegisai/shared'; + +export type SastEvidenceDeletionState = + | 'ACTIVE' + | 'DELETION_PENDING' + | 'DELETED'; + +export interface SastEvidenceAccessContext { + result: SastAcceptedEvidenceBuildResult | null; + schedule: SastEvidenceDeletionSchedule; + deletionState: SastEvidenceDeletionState; + deletionProof: SastEvidenceDeletionProof | null; + tenantAiAdvisoryOptIn: boolean; + repositoryAiAdvisoryOptIn: boolean; + freshnessEligible: boolean; + coverageComplete: boolean; +} + +export interface PersistedSastEvidenceAccessDecision { + decision: SastEvidenceAccessDecision; + replayed: boolean; +} + +export interface SastEvidenceDeletionCandidate { + schedule: SastEvidenceDeletionSchedule; + leaseOwner: string; + leaseToken: string; + leaseExpiresAt: string; +} + +export class SastEvidenceAccessPersistenceError extends Error { + constructor( + readonly reason: + | 'CONTEXT_DRIFT' + | 'OUTPUT_INVALID' + | 'REPLAY_CONFLICT' + | 'LEASE_LOST' + ) { + super('The evidence access/deletion ledger conflicts with durable state.'); + this.name = 'SastEvidenceAccessPersistenceError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export abstract class SastEvidenceAccessStore { + abstract load(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + referenceTime: string; + }): Promise; + + abstract persistDecision(input: { + context: Readonly; + decision: Readonly; + }): Promise; + + abstract confirmAccess(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + accessDecisionId: string; + purpose: SastEvidenceAccessPurpose; + secretRegistryVersion: string; + redactedProjectionDigest: `sha256:${string}`; + referenceTime: string; + }): Promise; + + abstract backfillDeletionSchedules(input: { + referenceTime: string; + limit: number; + }): Promise; + + abstract claimDeletion(input: { + referenceTime: string; + leaseOwner: string; + leaseExpiresAt: string; + }): Promise; + + abstract finalizeDeletion(input: { + candidate: Readonly; + receipt: Readonly; + proof: Readonly; + }): Promise<{ proof: SastEvidenceDeletionProof; replayed: boolean }>; + + abstract releaseDeletion(input: { + candidate: Readonly; + retryAt: string; + }): Promise; +} + +export function sastEvidenceAccessScopeFromResult( + result: Readonly +): SastEvidenceAccessScope { + const pack = result.pack; + if (!pack) { + throw new SastEvidenceAccessPersistenceError('OUTPUT_INVALID'); + } + return { + tenantId: pack.scope.tenantId, + repositoryBindingId: pack.scope.repositoryBindingId, + scanRequestId: pack.scope.scanRequestId, + attemptId: pack.scope.attemptId, + occurrenceId: pack.scope.occurrenceId, + buildDecisionId: result.decision.buildDecisionId, + evidencePackId: pack.evidencePackId, + findingFingerprint: + pack.scope.findingFingerprint as `sha256:${string}`, + profileId: pack.scope.profileId, + profileDigest: pack.scope.profileDigest as `sha256:${string}`, + freshnessDecisionId: pack.scope.freshnessDecisionId, + freshnessDecisionDigest: + pack.scope.freshnessDecisionDigest as `sha256:${string}`, + coverageDecisionId: pack.scope.coverageDecisionId, + coverageDecisionDigest: + pack.scope.coverageDecisionDigest as `sha256:${string}`, + sourcePackDigest: pack.packDigest as `sha256:${string}` + }; +} diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.authority.ts b/apps/api/src/scan-plane/sast-evidence-deletion.authority.ts new file mode 100644 index 0000000..f02a52d --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-deletion.authority.ts @@ -0,0 +1,39 @@ +import type { + SastEvidenceAccessScope, + SastEvidenceDeletionReceipt +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +export interface DeleteSastEvidenceInput { + operationId: string; + deletionScheduleId: string; + deletionScheduleDigest: `sha256:${string}`; + scope: SastEvidenceAccessScope; +} + +export abstract class SastEvidenceDeletionAuthority { + /** + * Implementations delete by operationId and must return the same receipt for + * an exact replay. They must not expose a content read capability. + */ + abstract delete( + input: Readonly + ): Promise; +} + +export class SastEvidenceDeletionAuthorityUnavailableError extends Error { + constructor() { + super('No production evidence deletion authority is installed.'); + this.name = 'SastEvidenceDeletionAuthorityUnavailableError'; + } +} + +@Injectable() +export class UnavailableSastEvidenceDeletionAuthority + extends SastEvidenceDeletionAuthority { + delete(): Promise { + return Promise.reject( + new SastEvidenceDeletionAuthorityUnavailableError() + ); + } +} diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.service.ts b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts new file mode 100644 index 0000000..99979f5 --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts @@ -0,0 +1,242 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { + buildSastEvidenceDeletionProof, + isSastEvidenceDeletionProofShapeValid, + isSastEvidenceDeletionScheduleShapeValid, + type SastEvidenceDeletionReceipt +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { + SastEvidenceAccessPersistenceError, + SastEvidenceAccessStore, + type SastEvidenceDeletionCandidate +} from './sast-evidence-access.store'; +import { + SastEvidenceDeletionAuthority, + SastEvidenceDeletionAuthorityUnavailableError +} from './sast-evidence-deletion.authority'; + +const DELETION_LEASE_MILLISECONDS = 60_000; +const DELETION_RETRY_MILLISECONDS = 60_000; + +export type SastEvidenceDeletionProcessingResult = + | 'IDLE' + | 'DELETED' + | 'REPLAYED' + | 'RETRY_SCHEDULED' + | 'LEASE_LOST'; + +@Injectable() +export class SastEvidenceDeletionService { + constructor( + private readonly store: SastEvidenceAccessStore, + private readonly authority: SastEvidenceDeletionAuthority + ) {} + + backfill(referenceTime = new Date(), limit = 32): Promise { + return this.store.backfillDeletionSchedules({ + referenceTime: referenceTime.toISOString(), + limit + }); + } + + async processNext( + referenceTime: Date, + workerId: string, + clock: () => string = () => new Date().toISOString() + ): Promise { + if ( + !Number.isFinite(referenceTime.getTime()) || + !isBoundedIdentifier(workerId) + ) { + return 'IDLE'; + } + const reference = referenceTime.toISOString(); + const candidate = await this.store.claimDeletion({ + referenceTime: reference, + leaseOwner: workerId, + leaseExpiresAt: new Date( + referenceTime.getTime() + DELETION_LEASE_MILLISECONDS + ).toISOString() + }); + if (!candidate) return 'IDLE'; + if (!isCandidateValid(candidate, reference)) { + await this.safeRelease(candidate, referenceTime); + return 'RETRY_SCHEDULED'; + } + + let receipt: SastEvidenceDeletionReceipt; + try { + receipt = await this.authority.delete({ + operationId: candidate.schedule.operationId, + deletionScheduleId: + candidate.schedule.deletionScheduleId, + deletionScheduleDigest: + candidate.schedule.scheduleDigest, + scope: candidate.schedule.scope + }); + } catch (error) { + await this.safeRelease(candidate, referenceTime); + if ( + error instanceof + SastEvidenceDeletionAuthorityUnavailableError + ) { + return 'RETRY_SCHEDULED'; + } + return 'RETRY_SCHEDULED'; + } + + const observedAt = readClock(clock); + if ( + !observedAt || + !isReceiptValid(receipt, candidate, reference, observedAt) + ) { + await this.safeRelease(candidate, referenceTime); + return 'RETRY_SCHEDULED'; + } + const proof = buildSastEvidenceDeletionProof({ + schedule: candidate.schedule, + receipt, + digestCanonical: digest + }); + if (!isSastEvidenceDeletionProofShapeValid(proof, digest)) { + await this.safeRelease(candidate, referenceTime); + return 'RETRY_SCHEDULED'; + } + try { + const finalized = await this.store.finalizeDeletion({ + candidate, + receipt, + proof + }); + if ( + !isSastEvidenceDeletionProofShapeValid( + finalized.proof, + digest + ) || + finalized.proof.deletionProofId !== + proof.deletionProofId || + finalized.proof.providerReceiptDigest !== + proof.providerReceiptDigest + ) { + return 'RETRY_SCHEDULED'; + } + return finalized.replayed ? 'REPLAYED' : 'DELETED'; + } catch (error) { + if ( + error instanceof SastEvidenceAccessPersistenceError && + error.reason === 'LEASE_LOST' + ) { + return 'LEASE_LOST'; + } + await this.safeRelease(candidate, referenceTime); + return 'RETRY_SCHEDULED'; + } + } + + private async safeRelease( + candidate: SastEvidenceDeletionCandidate, + referenceTime: Date + ): Promise { + try { + await this.store.releaseDeletion({ + candidate, + retryAt: new Date( + referenceTime.getTime() + DELETION_RETRY_MILLISECONDS + ).toISOString() + }); + } catch { + // A lost lease is already fenced by the durable claim token. + } + } +} + +export function createSastEvidenceDeletionWorkerId(): string { + return `evidence-deletion:${randomUUID()}`; +} + +function isCandidateValid( + candidate: Readonly, + referenceTime: string +): boolean { + return ( + isSastEvidenceDeletionScheduleShapeValid( + candidate.schedule, + digest + ) && + isBoundedIdentifier(candidate.leaseOwner) && + /^[a-f0-9-]{36}$/u.test(candidate.leaseToken) && + isCanonicalTimestamp(candidate.leaseExpiresAt) && + Date.parse(candidate.schedule.deleteAfter) <= + Date.parse(referenceTime) && + Date.parse(candidate.leaseExpiresAt) > + Date.parse(referenceTime) + ); +} + +function isReceiptValid( + receipt: unknown, + candidate: Readonly, + referenceTime: string, + observedAt: string +): receipt is SastEvidenceDeletionReceipt { + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) { + return false; + } + const value = receipt as Record; + const keys = [ + 'operationId', + 'providerReceiptRef', + 'providerReceiptDigest', + 'completedAt' + ]; + return ( + Object.keys(value).length === keys.length && + keys.every((key) => Object.hasOwn(value, key)) && + value.operationId === candidate.schedule.operationId && + typeof value.providerReceiptRef === 'string' && + /^sast-evidence-delete-receipt:\/\/[a-f0-9]{64}$/u.test( + value.providerReceiptRef + ) && + typeof value.providerReceiptDigest === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(value.providerReceiptDigest) && + isCanonicalTimestamp(value.completedAt) && + Date.parse(value.completedAt) >= Date.parse(referenceTime) && + Date.parse(value.completedAt) <= Date.parse(observedAt) + ); +} + +function isBoundedIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 2048 && + value === value.normalize('NFC') && + value === value.trim() && + !/\p{Cc}/u.test(value) + ); +} + +function isCanonicalTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const milliseconds = Date.parse(value); + return ( + Number.isFinite(milliseconds) && + new Date(milliseconds).toISOString() === value + ); +} + +function readClock(clock: () => string): string | null { + try { + const value = clock(); + return isCanonicalTimestamp(value) ? value : null; + } catch { + return null; + } +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.task.ts b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts new file mode 100644 index 0000000..4cc233a --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts @@ -0,0 +1,79 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit +} from '@nestjs/common'; + +import { ConfigService } from '../config/config.service'; +import { + SastEvidenceDeletionService, + createSastEvidenceDeletionWorkerId +} from './sast-evidence-deletion.service'; + +const EVIDENCE_DELETION_INTERVAL_MILLISECONDS = 15 * 60 * 1000; +const MAXIMUM_DELETIONS_PER_TICK = 16; +const MAXIMUM_BACKFILLS_PER_TICK = 32; + +@Injectable() +export class SastEvidenceDeletionTask + implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger( + SastEvidenceDeletionTask.name + ); + private readonly workerId = createSastEvidenceDeletionWorkerId(); + private timer: NodeJS.Timeout | null = null; + private inFlight = false; + + constructor( + private readonly service: SastEvidenceDeletionService, + private readonly config: ConfigService + ) {} + + onModuleInit(): void { + if (this.config.isTest()) return; + this.timer = setInterval(() => { + if (this.inFlight) return; + this.inFlight = true; + void this.processBatch() + .catch((error: unknown) => { + this.logger.error( + 'Failed to process bounded evidence deletion.', + error instanceof Error ? error.name : 'UnknownError' + ); + }) + .finally(() => { + this.inFlight = false; + }); + }, EVIDENCE_DELETION_INTERVAL_MILLISECONDS); + this.timer.unref?.(); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + async processBatch(referenceTime = new Date()): Promise { + await this.service.backfill( + referenceTime, + MAXIMUM_BACKFILLS_PER_TICK + ); + let processed = 0; + for ( + let index = 0; + index < MAXIMUM_DELETIONS_PER_TICK; + index += 1 + ) { + const result = await this.service.processNext( + referenceTime, + this.workerId + ); + if (result === 'IDLE') break; + if (result !== 'LEASE_LOST') processed += 1; + } + return processed; + } +} diff --git a/apps/api/src/scan-plane/sast-evidence-secret-registry.ts b/apps/api/src/scan-plane/sast-evidence-secret-registry.ts new file mode 100644 index 0000000..307fba4 --- /dev/null +++ b/apps/api/src/scan-plane/sast-evidence-secret-registry.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; + +export interface SastEvidenceSecretRegistryScope { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; +} + +export type SastEvidenceSecretRegistryResult = + | { status: 'UNAVAILABLE' } + | { + status: 'VERIFIED'; + registryVersion: string; + platformSecretValues: readonly string[]; + }; + +export abstract class SastEvidenceSecretRegistry { + abstract read( + scope: Readonly + ): Promise; +} + +@Injectable() +export class UnavailableSastEvidenceSecretRegistry + extends SastEvidenceSecretRegistry { + read(): Promise { + return Promise.resolve({ status: 'UNAVAILABLE' }); + } +} diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index 4d610c7..f234d2a 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -139,6 +139,19 @@ import { import { SastAcceptedEvidenceService } from './sast-accepted-evidence.service'; +import { SastEvidenceAccessService } from './sast-evidence-access.service'; +import { SastEvidenceAccessStore } from './sast-evidence-access.store'; +import { PrismaSastEvidenceAccessStore } from './prisma-sast-evidence-access.store'; +import { + SastEvidenceSecretRegistry, + UnavailableSastEvidenceSecretRegistry +} from './sast-evidence-secret-registry'; +import { + SastEvidenceDeletionAuthority, + UnavailableSastEvidenceDeletionAuthority +} from './sast-evidence-deletion.authority'; +import { SastEvidenceDeletionService } from './sast-evidence-deletion.service'; +import { SastEvidenceDeletionTask } from './sast-evidence-deletion.task'; @Module({ imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], @@ -163,6 +176,24 @@ import { SastScanCoverageService, SastScanFreshnessService, SastAcceptedEvidenceService, + SastEvidenceAccessService, + SastEvidenceDeletionService, + SastEvidenceDeletionTask, + PrismaSastEvidenceAccessStore, + { + provide: SastEvidenceAccessStore, + useExisting: PrismaSastEvidenceAccessStore + }, + UnavailableSastEvidenceSecretRegistry, + { + provide: SastEvidenceSecretRegistry, + useExisting: UnavailableSastEvidenceSecretRegistry + }, + UnavailableSastEvidenceDeletionAuthority, + { + provide: SastEvidenceDeletionAuthority, + useExisting: UnavailableSastEvidenceDeletionAuthority + }, PrismaSastAcceptedEvidenceStore, { provide: SastAcceptedEvidenceStore, @@ -292,7 +323,7 @@ import { RepositoryPreflightService, SandboxRuntimeAttestationService, SastScannerRuntimeService, - SastAcceptedEvidenceService + SastEvidenceAccessService ] }) export class ScanPlaneModule {} diff --git a/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts index 2b7ebbe..b2294b5 100644 --- a/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts @@ -251,13 +251,12 @@ describe('SAST accepted-finding evidence persistence contract', () => { ); }); - it('exports only the T041 sequential handoff with unavailable source by default', () => { + it('keeps T041 internal after exporting the T042 sequential handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain( - 'SastAcceptedEvidenceService' - ); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain( 'SastScanFreshnessService' ); diff --git a/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts new file mode 100644 index 0000000..6d04d43 --- /dev/null +++ b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts @@ -0,0 +1,210 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('SAST evidence access and deletion persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql' + ); + const store = read( + 'src/scan-plane/prisma-sast-evidence-access.store.ts' + ); + const service = read( + 'src/scan-plane/sast-evidence-access.service.ts' + ); + const deletion = read( + 'src/scan-plane/sast-evidence-deletion.service.ts' + ); + const authority = read( + 'src/scan-plane/sast-evidence-deletion.authority.ts' + ); + const controller = read( + 'src/dashboard/dashboard-evidence.controller.ts' + ); + const module = read('src/scan-plane/scan-plane.module.ts'); + const shared = readShared('src/types/sast-evidence-access.ts'); + + it('adds separate immutable access, schedule, claim, and proof ledgers', () => { + for (const model of [ + 'SastEvidenceAccessDecision', + 'SastEvidenceDeletionSchedule', + 'SastEvidenceDeletionClaim', + 'SastEvidenceDeletionProof' + ]) { + expect(schema).toContain(`model ${model} {`); + expect(migration).toContain(`CREATE TABLE "${model}"`); + } + expect(migration).toContain( + 'SastEvidenceAccessDecision_build_scope_fkey' + ); + expect(migration).toContain( + 'SastEvidenceAccessDecision_schedule_scope_fkey' + ); + expect(migration).toContain( + 'SastEvidenceDeletionSchedule_build_scope_fkey' + ); + expect(migration).toContain( + 'SastEvidenceDeletionProof_schedule_scope_fkey' + ); + expect(migration).toContain( + 'SastEvidenceDeletionClaim_due_idx' + ); + expect(migration).toContain( + 'SastEvidenceDeletionSchedule_deleteAfter_idx' + ); + expect(migration).toContain( + 'SastEvidenceAccessDecision_aiPayloadExpiresAt_idx' + ); + expect(migration).not.toContain('CONCURRENTLY'); + }); + + it('pins seven-day evidence and 24-hour AI payload retention in code and SQL', () => { + expect(shared).toContain( + 'SAST_EVIDENCE_MAX_RETENTION_SECONDS =\n 7 * 24 * 60 * 60' + ); + expect(shared).toContain( + 'SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS =\n 24 * 60 * 60' + ); + expect(migration).toContain("INTERVAL '7 days'"); + expect(migration).toContain("INTERVAL '24 hours'"); + expect(migration).toContain( + '"deleteAfter" <= "scheduledAt" + INTERVAL \'7 days\'' + ); + expect(service).toContain( + 'Date.parse(decidedAt) >= Date.parse(context.schedule.deleteAfter)' + ); + expect(store).toContain( + 'Date.parse(context.result.pack.expiresAt) >' + ); + }); + + it('persists only bounded decisions and never persists access-time content or secret values', () => { + expect(migration).toContain( + "'{audit,rawSourceStored}')::boolean IS FALSE" + ); + expect(migration).toContain( + "'{audit,secretValueStored}')::boolean IS FALSE" + ); + expect(migration).toContain( + "'{audit,preRedactionPayloadStored}')::boolean IS FALSE" + ); + expect(migration).toContain( + "'{audit,matchedValueDigestStored}')::boolean IS FALSE" + ); + expect(schema).not.toMatch( + /model SastEvidenceAccessDecision \{[\s\S]*?redactedContent[\s\S]*?\n\}/ + ); + expect(store).toMatch( + /redactedProjectionDigest:\s*decision\.redactedProjectionDigest/u + ); + expect(store).not.toContain( + 'redactedContent: decision' + ); + expect(service).toContain('KNOWN_SECRET_PATTERNS'); + expect(service).toContain('ENTROPY_TOKEN_PATTERN'); + expect(service).toContain('platformSecretValues'); + }); + + it('keeps dashboard and AI authority independent and advisory-only', () => { + expect(shared).toContain("'DASHBOARD'"); + expect(shared).toContain("'AI_ADVISORY'"); + expect(shared).toContain('dashboardReadAllowed: boolean'); + expect(shared).toContain( + 'reducedEvidenceReferenceAllowed: boolean' + ); + for (const invariant of [ + 'aiPayloadAllowed: false', + 'aiProviderCallAllowed: false', + 'retrievalAllowed: false', + 'toolsAllowed: false', + 'policyAuthority: false', + 'publicationAuthority: false', + 'lifecycleMutationAuthority: false', + 'scmWriteAuthority: false' + ]) { + expect(shared).toContain(invariant); + } + expect(controller).toContain( + "@Controller('dashboard/evidence')" + ); + expect(controller).toContain('@UseGuards(SessionAuthGuard)'); + expect(module).toContain('SastEvidenceAccessService'); + const exportsBlock = module.match( + /exports:\s*\[([\s\S]*?)\]\s*\}\)\s*export class/ + )?.[1]; + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain( + 'SastAcceptedEvidenceService' + ); + }); + + it('uses serializable replay, claim fencing, and default-unavailable authorities', () => { + expect(store).toContain( + 'Prisma.TransactionIsolationLevel.Serializable' + ); + expect(store).toContain('leaseToken = randomUUID()'); + expect(store).toContain("status: 'CLAIMED'"); + expect(store).toContain("status: 'COMPLETED'"); + expect(store).toContain( + 'transaction.sastAcceptedEvidencePack.delete' + ); + expect(store).toContain( + 'transaction.sastEvidenceDeletionProof.create' + ); + expect(store).toMatch( + /input\.proof\.providerReceiptRef\s*!==\s*input\.receipt\.providerReceiptRef/u + ); + expect(store).toMatch( + /input\.proof\.completedAt\s*!==\s*input\.receipt\.completedAt/u + ); + expect(store).toMatch( + /Date\.parse\(input\.receipt\.completedAt\)\s*<\s*row\.deleteAfter\.getTime\(\)/u + ); + expect(deletion).toContain( + 'buildSastEvidenceDeletionProof' + ); + expect(deletion).toContain( + "return finalized.replayed ? 'REPLAYED' : 'DELETED'" + ); + expect(authority).toContain( + 'UnavailableSastEvidenceDeletionAuthority' + ); + expect(module).toContain( + 'UnavailableSastEvidenceSecretRegistry' + ); + expect(module).toContain( + 'UnavailableSastEvidenceDeletionAuthority' + ); + }); + + it('leaves immutable T041 downstream flags untouched while scheduling at write time', () => { + const acceptedStore = read( + 'src/scan-plane/prisma-sast-accepted-evidence.store.ts' + ); + expect(acceptedStore).toContain( + 'classificationDecisionRef: null' + ); + expect(acceptedStore).toContain( + 'deletionScheduleRef: null' + ); + expect(acceptedStore).toContain('dashboardSafe: false'); + expect(acceptedStore).toContain('aiSafe: false'); + expect(acceptedStore).toContain( + 'buildSastEvidenceDeletionSchedule' + ); + expect(acceptedStore).toContain( + 'transaction.sastEvidenceDeletionSchedule.create' + ); + }); +}); + +function read(relativePath: string): string { + return readFileSync(resolve(__dirname, '../..', relativePath), 'utf8'); +} + +function readShared(relativePath: string): string { + return readFileSync( + resolve(__dirname, '../../../../packages/shared', relativePath), + 'utf8' + ); +} diff --git a/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts new file mode 100644 index 0000000..857db28 --- /dev/null +++ b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts @@ -0,0 +1,872 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_APPROVED_PROFILE_DIGESTS, + buildSastAcceptedEvidence, + buildSastEvidenceDeletionProof, + buildSastEvidenceDeletionSchedule, + canonicalizeSastEvidenceCandidate, + type SastAcceptedEvidenceBuildResult, + type SastAcceptedEvidenceScope, + type SastEvidenceAccessDecision, + type SastEvidenceDeletionProof, + type SastEvidenceDeletionReceipt, + type SastRedactedEvidenceCandidate, + type SastRedactedEvidenceCandidateCore +} from '@aegisai/shared'; + +import { SastEvidenceAccessService } from '../../src/scan-plane/sast-evidence-access.service'; +import { + SastEvidenceAccessPersistenceError, + SastEvidenceAccessStore, + sastEvidenceAccessScopeFromResult, + type PersistedSastEvidenceAccessDecision, + type SastEvidenceAccessContext, + type SastEvidenceDeletionCandidate +} from '../../src/scan-plane/sast-evidence-access.store'; +import { SastEvidenceDeletionAuthority } from '../../src/scan-plane/sast-evidence-deletion.authority'; +import { SastEvidenceDeletionService } from '../../src/scan-plane/sast-evidence-deletion.service'; +import { + SastEvidenceSecretRegistry, + type SastEvidenceSecretRegistryResult +} from '../../src/scan-plane/sast-evidence-secret-registry'; + +const CREATED_AT = '2026-08-10T04:40:00.000Z'; +const BEFORE_EXPIRY = '2026-08-17T04:39:59.000Z'; +const EXPIRES_AT = '2026-08-17T04:40:00.000Z'; +const PLATFORM_SECRET = 'platform-secret-value'; +const ENTROPY_SECRET = 'aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW1xY'; + +describe('SastEvidenceAccessService', () => { + it('rebinds T041 evidence and performs access-time platform, format, and entropy redaction', async () => { + const context = accessContext( + acceptedEvidence([ + 'safe line', + `password = "${PLATFORM_SECRET}"`, + ENTROPY_SECRET + ].join('\n')) + ); + const store = new MemoryAccessStore(context); + const registry = new MemorySecretRegistry({ + status: 'VERIFIED', + registryVersion: 'platform-secret-registry-v1', + platformSecretValues: [PLATFORM_SECRET] + }); + const service = new SastEvidenceAccessService(store, registry); + + const result = await service.readDashboard( + request(context), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + + expect(result.outcome).toBe('ALLOWED'); + if (result.outcome !== 'ALLOWED') return; + const serialized = JSON.stringify(result.dashboardEvidence); + expect(serialized).toContain('[REDACTED]'); + expect(serialized).not.toContain(PLATFORM_SECRET); + expect(serialized).not.toContain(ENTROPY_SECRET); + expect(result.decision.classification).toBe('DASHBOARD_SAFE'); + expect(result.decision.authority).toEqual( + expect.objectContaining({ + dashboardReadAllowed: true, + reducedEvidenceReferenceAllowed: false, + aiPayloadAllowed: false, + aiProviderCallAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false + }) + ); + expect(result.decision.audit).toEqual( + expect.objectContaining({ + rawSourceStored: false, + secretValueStored: false, + preRedactionPayloadStored: false, + matchedValueDigestStored: false, + dashboardPayloadPersisted: false + }) + ); + expect(JSON.stringify(store.decisions)).not.toContain( + PLATFORM_SECRET + ); + expect(context.result?.pack).toEqual( + expect.objectContaining({ + dashboardSafe: false, + aiSafe: false, + classificationDecisionRef: null, + deletionScheduleRef: null + }) + ); + }); + + it('keeps dashboard, AI reference, payload, and provider authority independent', async () => { + const context = accessContext(acceptedEvidence('safe content')); + const store = new MemoryAccessStore(context); + const registry = verifiedRegistry(); + const service = new SastEvidenceAccessService(store, registry); + + const denied = await service.classifyForAi( + request(context), + () => CREATED_AT + ); + expect(denied).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_OPT_IN_REQUIRED' + }); + expect(registry.calls).toBe(0); + + context.tenantAiAdvisoryOptIn = true; + context.repositoryAiAdvisoryOptIn = true; + const allowed = await service.classifyForAi( + request(context), + () => CREATED_AT + ); + expect(allowed.outcome).toBe('ALLOWED'); + if (allowed.outcome !== 'ALLOWED') return; + expect(allowed.dashboardEvidence).toBeNull(); + expect(allowed.reducedEvidenceReference).toEqual( + expect.objectContaining({ + aiPayloadCreated: false, + aiProviderCalled: false, + retrievalAllowed: false, + toolsAllowed: false, + advisoryOnly: true + }) + ); + expect(allowed.decision.authority.dashboardReadAllowed).toBe(false); + expect( + allowed.decision.authority.reducedEvidenceReferenceAllowed + ).toBe(true); + expect(allowed.decision.authority.aiPayloadAllowed).toBe(false); + expect( + Date.parse( + allowed.reducedEvidenceReference!.payloadExpiresAt + ) - Date.parse(allowed.decision.decidedAt) + ).toBeLessThanOrEqual(24 * 60 * 60 * 1000); + }); + + it('denies expired-at-start and expired-during-read without returning content', async () => { + const atStart = accessContext(acceptedEvidence('safe content')); + const first = await new SastEvidenceAccessService( + new MemoryAccessStore(atStart), + verifiedRegistry() + ).readDashboard(request(atStart), () => EXPIRES_AT); + expect(first).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_EXPIRED', + dashboardEvidence: null + }); + + const during = accessContext(acceptedEvidence('safe content')); + const second = await new SastEvidenceAccessService( + new MemoryAccessStore(during), + verifiedRegistry() + ).readDashboard( + request(during), + clock(BEFORE_EXPIRY, BEFORE_EXPIRY, EXPIRES_AT) + ); + expect(second).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_EXPIRED', + dashboardEvidence: null + }); + }); + + it('denies late readers when the secret registry drifts or deletion is claimed', async () => { + const driftContext = accessContext( + acceptedEvidence('safe content') + ); + const drift = await new SastEvidenceAccessService( + new MemoryAccessStore(driftContext), + new SequenceSecretRegistry([ + verifiedRegistryResult('platform-secret-registry-v1'), + verifiedRegistryResult('platform-secret-registry-v2') + ]) + ).readDashboard( + request(driftContext), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + expect(drift).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + dashboardEvidence: null + }); + + const claimedContext = accessContext( + acceptedEvidence('safe content') + ); + const claimed = await new SastEvidenceAccessService( + new MemoryAccessStore(claimedContext), + new SequenceSecretRegistry( + [verifiedRegistryResult('platform-secret-registry-v1')], + (call) => { + if (call === 2) { + claimedContext.deletionState = 'DELETION_PENDING'; + } + } + ) + ).readDashboard( + request(claimedContext), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + expect(claimed).toMatchObject({ + outcome: 'DENIED', + dashboardEvidence: null + }); + + const aiContext = accessContext(acceptedEvidence('safe content')); + aiContext.tenantAiAdvisoryOptIn = true; + aiContext.repositoryAiAdvisoryOptIn = true; + const aiClaimed = await new SastEvidenceAccessService( + new MemoryAccessStore(aiContext), + new SequenceSecretRegistry( + [verifiedRegistryResult('platform-secret-registry-v1')], + (call) => { + if (call === 2) { + aiContext.deletionState = 'DELETION_PENDING'; + } + } + ) + ).classifyForAi(request(aiContext), clock(CREATED_AT, CREATED_AT)); + expect(aiClaimed).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + reducedEvidenceReference: null + }); + }); + + it('fails closed on unavailable secret authority, unsafe identifiers, and cross-tenant access', async () => { + const context = accessContext(acceptedEvidence('safe content')); + const unavailable = await new SastEvidenceAccessService( + new MemoryAccessStore(context), + new MemorySecretRegistry({ status: 'UNAVAILABLE' }) + ).classifyForAi(request(context), () => CREATED_AT); + expect(unavailable).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_OPT_IN_REQUIRED' + }); + + context.tenantAiAdvisoryOptIn = true; + context.repositoryAiAdvisoryOptIn = true; + const unavailableAfterOptIn = + await new SastEvidenceAccessService( + new MemoryAccessStore(context), + new MemorySecretRegistry({ status: 'UNAVAILABLE' }) + ).classifyForAi(request(context), () => CREATED_AT); + expect(unavailableAfterOptIn).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_SECRET_AUTHORITY_UNAVAILABLE' + }); + + const unsafeRegistryContext = accessContext( + acceptedEvidence('safe content') + ); + const unsafeRegistryStore = new MemoryAccessStore( + unsafeRegistryContext + ); + const unsafeRegistry = await new SastEvidenceAccessService( + unsafeRegistryStore, + new MemorySecretRegistry({ + status: 'VERIFIED', + registryVersion: PLATFORM_SECRET, + platformSecretValues: [PLATFORM_SECRET] + }) + ).readDashboard( + request(unsafeRegistryContext), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + expect(unsafeRegistry).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_REDACTION_FAILED' + }); + expect(JSON.stringify(unsafeRegistryStore.decisions)).not.toContain( + PLATFORM_SECRET + ); + + const unsafeResult = acceptedEvidence('safe content', { + normalizedPath: + 'src/aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW1xY.java' + }); + const unsafeContext = accessContext(unsafeResult); + const unsafe = await new SastEvidenceAccessService( + new MemoryAccessStore(unsafeContext), + verifiedRegistry() + ).readDashboard( + request(unsafeContext), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + expect(unsafe).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_IDENTIFIER_UNSAFE' + }); + + const registeredPathContext = accessContext( + acceptedEvidence('safe content', { + normalizedPath: 'src/tenant-secret-path/App.java' + }) + ); + const registeredPath = await new SastEvidenceAccessService( + new MemoryAccessStore(registeredPathContext), + new MemorySecretRegistry({ + status: 'VERIFIED', + registryVersion: 'platform-secret-registry-v1', + platformSecretValues: ['tenant-secret-path'] + }) + ).readDashboard( + request(registeredPathContext), + clock(CREATED_AT, CREATED_AT, CREATED_AT) + ); + expect(registeredPath).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_IDENTIFIER_UNSAFE', + dashboardEvidence: null + }); + + const crossTenant = await new SastEvidenceAccessService( + new MemoryAccessStore(context), + verifiedRegistry() + ).readDashboard( + { ...request(context), tenantId: 'tenant-2' }, + () => CREATED_AT + ); + expect(crossTenant).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_CONTEXT_UNAVAILABLE' + }); + }); + + it('returns one canonical decision for concurrent exact classification', async () => { + const context = accessContext(acceptedEvidence('safe content')); + context.tenantAiAdvisoryOptIn = true; + context.repositoryAiAdvisoryOptIn = true; + const store = new MemoryAccessStore(context); + const service = new SastEvidenceAccessService( + store, + verifiedRegistry() + ); + const [left, right] = await Promise.all([ + service.classifyForAi(request(context), () => CREATED_AT), + service.classifyForAi(request(context), () => CREATED_AT) + ]); + + expect(left.outcome).toBe('ALLOWED'); + expect(right.outcome).toBe('ALLOWED'); + if (left.outcome !== 'ALLOWED' || right.outcome !== 'ALLOWED') { + return; + } + expect(left.decision).toEqual(right.decision); + expect([left.replayed, right.replayed].sort()).toEqual([ + false, + true + ]); + expect(store.decisions).toHaveLength(1); + }); +}); + +describe('SastEvidenceDeletionService', () => { + it('claims by deterministic operation, deletes content, and retains one bounded proof', async () => { + const context = accessContext(acceptedEvidence('safe content')); + const store = new MemoryAccessStore(context); + const authority = new MemoryDeletionAuthority(EXPIRES_AT); + const service = new SastEvidenceDeletionService(store, authority); + + await expect( + service.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => EXPIRES_AT + ) + ).resolves.toBe('DELETED'); + expect(authority.calls).toHaveLength(1); + expect(authority.calls[0]?.operationId).toBe( + context.schedule.operationId + ); + expect(context.result).toBeNull(); + expect(context.deletionState).toBe('DELETED'); + expect(context.deletionProof).toEqual( + expect.objectContaining({ + contentDeleted: true, + fragmentsDeleted: true, + buildDecisionRetained: true, + accessAuthorityRevoked: true + }) + ); + await expect( + service.processNext( + new Date(EXPIRES_AT), + 'worker-2', + () => EXPIRES_AT + ) + ).resolves.toBe('IDLE'); + expect(authority.calls).toHaveLength(1); + }); + + it('releases the claim when deletion authority is unavailable or the clock rolls back', async () => { + const unavailableContext = accessContext( + acceptedEvidence('safe content') + ); + const unavailableStore = new MemoryAccessStore( + unavailableContext + ); + const unavailable = new SastEvidenceDeletionService( + unavailableStore, + new RejectingDeletionAuthority() + ); + await expect( + unavailable.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => EXPIRES_AT + ) + ).resolves.toBe('RETRY_SCHEDULED'); + expect(unavailableContext.deletionState).toBe('ACTIVE'); + expect(unavailableContext.result?.pack).not.toBeNull(); + + const rollbackContext = accessContext( + acceptedEvidence('safe content') + ); + const rollback = new SastEvidenceDeletionService( + new MemoryAccessStore(rollbackContext), + new MemoryDeletionAuthority(EXPIRES_AT) + ); + await expect( + rollback.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => BEFORE_EXPIRY + ) + ).resolves.toBe('RETRY_SCHEDULED'); + expect(rollbackContext.deletionState).toBe('ACTIVE'); + expect(rollbackContext.deletionProof).toBeNull(); + }); + + it('fences concurrent workers and rejects a changed receipt after exact proof replay', async () => { + const concurrentContext = accessContext( + acceptedEvidence('safe content') + ); + const concurrentAuthority = new MemoryDeletionAuthority( + EXPIRES_AT + ); + const concurrentService = new SastEvidenceDeletionService( + new MemoryAccessStore(concurrentContext), + concurrentAuthority + ); + const results = await Promise.all([ + concurrentService.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => EXPIRES_AT + ), + concurrentService.processNext( + new Date(EXPIRES_AT), + 'worker-2', + () => EXPIRES_AT + ) + ]); + expect(results.sort()).toEqual(['DELETED', 'IDLE']); + expect(concurrentAuthority.calls).toHaveLength(1); + + const replayContext = accessContext( + acceptedEvidence('safe content') + ); + const replayStore = new MemoryAccessStore(replayContext); + const candidate = await replayStore.claimDeletion({ + referenceTime: EXPIRES_AT, + leaseOwner: 'worker-1', + leaseExpiresAt: '2026-08-17T04:41:00.000Z' + }); + expect(candidate).not.toBeNull(); + if (!candidate) return; + const receipt = deletionReceipt( + replayContext.schedule.operationId, + 'receipt-a' + ); + const proof = buildSastEvidenceDeletionProof({ + schedule: replayContext.schedule, + receipt, + digestCanonical: digest + }); + await expect( + replayStore.finalizeDeletion({ candidate, receipt, proof }) + ).resolves.toMatchObject({ replayed: false }); + await expect( + replayStore.finalizeDeletion({ candidate, receipt, proof }) + ).resolves.toMatchObject({ replayed: true }); + + const changedReceipt = deletionReceipt( + replayContext.schedule.operationId, + 'receipt-b' + ); + const changedProof = buildSastEvidenceDeletionProof({ + schedule: replayContext.schedule, + receipt: changedReceipt, + digestCanonical: digest + }); + expect(() => + replayStore.finalizeDeletion({ + candidate, + receipt: changedReceipt, + proof: changedProof + }) + ).toThrow(SastEvidenceAccessPersistenceError); + }); +}); + +class MemoryAccessStore extends SastEvidenceAccessStore { + readonly decisions: SastEvidenceAccessDecision[] = []; + private candidate: SastEvidenceDeletionCandidate | null = null; + + constructor(private readonly context: SastEvidenceAccessContext) { + super(); + } + + load(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + }): Promise { + const scope = this.context.schedule.scope; + return Promise.resolve( + input.tenantId === scope.tenantId && + input.repositoryBindingId === scope.repositoryBindingId && + input.evidencePackId === scope.evidencePackId + ? this.context + : null + ); + } + + persistDecision(input: { + decision: Readonly; + }): Promise { + const existing = this.decisions.find( + (decision) => + decision.accessDecisionId === input.decision.accessDecisionId + ); + if (existing) { + if (JSON.stringify(existing) !== JSON.stringify(input.decision)) { + throw new SastEvidenceAccessPersistenceError( + 'REPLAY_CONFLICT' + ); + } + return Promise.resolve({ decision: existing, replayed: true }); + } + const decision = structuredClone(input.decision); + this.decisions.push(decision); + return Promise.resolve({ decision, replayed: false }); + } + + confirmAccess(input: { + tenantId: string; + repositoryBindingId: string; + evidencePackId: string; + accessDecisionId: string; + secretRegistryVersion: string; + redactedProjectionDigest: `sha256:${string}`; + referenceTime: string; + }): Promise { + const decision = this.decisions.find( + (candidate) => + candidate.accessDecisionId === input.accessDecisionId && + candidate.secretRegistryVersion === + input.secretRegistryVersion && + candidate.redactedProjectionDigest === + input.redactedProjectionDigest + ); + return Promise.resolve( + decision && + this.context.deletionState === 'ACTIVE' && + Date.parse(input.referenceTime) < + Date.parse(this.context.schedule.deleteAfter) + ? this.context + : null + ); + } + + backfillDeletionSchedules(): Promise { + return Promise.resolve(0); + } + + claimDeletion(input: { + referenceTime: string; + leaseOwner: string; + leaseExpiresAt: string; + }): Promise { + if ( + this.context.deletionState !== 'ACTIVE' || + !this.context.result?.pack || + Date.parse(input.referenceTime) < + Date.parse(this.context.schedule.deleteAfter) + ) { + return Promise.resolve(null); + } + this.context.deletionState = 'DELETION_PENDING'; + this.candidate = { + schedule: this.context.schedule, + leaseOwner: input.leaseOwner, + leaseToken: '00000000-0000-4000-8000-000000000001', + leaseExpiresAt: input.leaseExpiresAt + }; + return Promise.resolve(this.candidate); + } + + finalizeDeletion(input: { + candidate: Readonly; + receipt: Readonly; + proof: Readonly; + }): Promise<{ proof: SastEvidenceDeletionProof; replayed: boolean }> { + if (this.context.deletionProof) { + if ( + JSON.stringify(this.context.deletionProof) === + JSON.stringify(input.proof) + ) { + return Promise.resolve({ + proof: this.context.deletionProof, + replayed: true + }); + } + throw new SastEvidenceAccessPersistenceError( + 'REPLAY_CONFLICT' + ); + } + if ( + !this.candidate || + this.candidate.leaseToken !== input.candidate.leaseToken || + input.receipt.operationId !== + this.context.schedule.operationId + ) { + throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + } + this.context.result = null; + this.context.deletionState = 'DELETED'; + this.context.deletionProof = structuredClone(input.proof); + this.candidate = null; + return Promise.resolve({ + proof: this.context.deletionProof, + replayed: false + }); + } + + releaseDeletion(input: { + candidate: Readonly; + }): Promise { + if ( + !this.candidate || + this.candidate.leaseToken !== input.candidate.leaseToken + ) { + throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + } + this.candidate = null; + this.context.deletionState = 'ACTIVE'; + return Promise.resolve(); + } +} + +class MemorySecretRegistry extends SastEvidenceSecretRegistry { + calls = 0; + + constructor( + private readonly result: SastEvidenceSecretRegistryResult + ) { + super(); + } + + read(): Promise { + this.calls += 1; + return Promise.resolve(this.result); + } +} + +class SequenceSecretRegistry extends SastEvidenceSecretRegistry { + private calls = 0; + + constructor( + private readonly results: readonly SastEvidenceSecretRegistryResult[], + private readonly onRead?: (call: number) => void + ) { + super(); + } + + read(): Promise { + this.calls += 1; + this.onRead?.(this.calls); + return Promise.resolve( + this.results[Math.min(this.calls - 1, this.results.length - 1)]! + ); + } +} + +class MemoryDeletionAuthority extends SastEvidenceDeletionAuthority { + readonly calls: Array<{ operationId: string }> = []; + + constructor(private readonly completedAt: string) { + super(); + } + + delete(input: { operationId: string }): Promise { + this.calls.push(input); + return Promise.resolve({ + operationId: input.operationId, + providerReceiptRef: id( + 'sast-evidence-delete-receipt', + 'receipt' + ), + providerReceiptDigest: digest('receipt'), + completedAt: this.completedAt + }); + } +} + +class RejectingDeletionAuthority extends SastEvidenceDeletionAuthority { + delete(): Promise { + return Promise.reject(new Error('unavailable')); + } +} + +function accessContext( + result: SastAcceptedEvidenceBuildResult +): SastEvidenceAccessContext { + if (!result.pack) throw new Error('Expected accepted evidence.'); + const schedule = buildSastEvidenceDeletionSchedule({ + scope: sastEvidenceAccessScopeFromResult(result), + scheduledAt: result.pack.createdAt, + deleteAfter: result.pack.expiresAt, + digestCanonical: digest + }); + return { + result, + schedule, + deletionState: 'ACTIVE', + deletionProof: null, + tenantAiAdvisoryOptIn: false, + repositoryAiAdvisoryOptIn: false, + freshnessEligible: true, + coverageComplete: true + }; +} + +function acceptedEvidence( + content: string, + overrides: Partial> = {} +): SastAcceptedEvidenceBuildResult { + const boundedContent = + content.split('\n').length === 3 + ? content + : [content, 'safe line 2', 'safe line 3'].join('\n'); + const scope: SastAcceptedEvidenceScope = { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + targetRef: 'refs/pull/1/head', + commitSha: 'a'.repeat(40), + canonicalScanKey: digest('canonical-scan'), + planDigest: digest('plan'), + profileId: 'JAVA_FAST_V1', + profileDigest: SAST_APPROVED_PROFILE_DIGESTS.JAVA_FAST_V1, + freshnessDecisionId: id('sast-freshness', 'freshness'), + freshnessDecisionDigest: digest('freshness-decision'), + coverageDecisionId: id('sast-coverage', 'coverage'), + coverageDecisionDigest: digest('coverage-decision'), + occurrenceId: id('finding-occurrence', 'occurrence'), + observationBatchId: id('finding-observation', 'observation'), + normalizedFindingId: 'normalized-finding-1', + lineageId: id('finding-lineage', 'lineage'), + findingFingerprint: digest('finding'), + fingerprintVersion: 'sast-fingerprint-v1', + capability: 'SAST', + normalizedPath: + overrides.normalizedPath ?? 'src/main/java/App.java', + findingStartLine: 11, + findingEndLine: 11, + policyVersion: 'sast-evidence-policy-v1' + }; + const core: SastRedactedEvidenceCandidateCore = { + candidateId: id('sast-evidence-candidate', 'candidate'), + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100, + redactedContent: boundedContent, + byteSize: Buffer.byteLength(boundedContent, 'utf8'), + sourceContentDigest: digest(boundedContent), + contentDigest: digest(boundedContent), + sourceAttestationRef: 'source-attestation://candidate', + scannerRedactionDecisionRef: 'scanner-redaction://candidate', + platformRedactionDecisionRef: 'platform-redaction://candidate', + secretRedactionApplied: true, + rawSourceStored: false + }; + const candidate: SastRedactedEvidenceCandidate = { + ...core, + candidateDigest: digest( + canonicalizeSastEvidenceCandidate(core) + ) + }; + return buildSastAcceptedEvidence({ + scope, + candidates: [candidate], + decidedAt: CREATED_AT, + digestCanonical: digest + }); +} + +function request(context: SastEvidenceAccessContext) { + return { + tenantId: context.schedule.scope.tenantId, + repositoryBindingId: + context.schedule.scope.repositoryBindingId, + evidencePackId: context.schedule.scope.evidencePackId + }; +} + +function verifiedRegistry(): MemorySecretRegistry { + return new MemorySecretRegistry( + verifiedRegistryResult('platform-secret-registry-v1') + ); +} + +function verifiedRegistryResult( + registryVersion: string +): SastEvidenceSecretRegistryResult { + return { + status: 'VERIFIED', + registryVersion, + platformSecretValues: [] + }; +} + +function deletionReceipt( + operationId: string, + value: string +): SastEvidenceDeletionReceipt { + return { + operationId, + providerReceiptRef: id( + 'sast-evidence-delete-receipt', + value + ), + providerReceiptDigest: digest(value), + completedAt: EXPIRES_AT + }; +} + +function clock(...values: string[]): () => string { + let index = 0; + return () => values[Math.min(index++, values.length - 1)]!; +} + +function id(prefix: string, value: string): string { + return `${prefix}://${hex(value)}`; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${hex(value)}`; +} + +function hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts index a8f9c56..72d8b50 100644 --- a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts @@ -96,7 +96,7 @@ describe('SAST finding correlation persistence contract', () => { ); }); - it('fences late T037 batches and keeps T038 through T040 internal after T041', () => { + it('fences late T037 batches and keeps T038 through T041 internal after T042', () => { expect(lineageStore).toContain( 'transaction.sastFindingCorrelationBatch' ); @@ -105,7 +105,8 @@ describe('SAST finding correlation persistence contract', () => { ); const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( diff --git a/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts index cbb77d1..8599e98 100644 --- a/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts @@ -150,7 +150,7 @@ describe('SAST finding lineage persistence contract', () => { ); }); - it('keeps T037 through T040 internal while preserving the freshness gate', () => { + it('keeps T037 through T041 internal while preserving the freshness gate', () => { expect(module).toContain( 'UnavailableSastFindingRenameAttestationVerifier' ); @@ -160,7 +160,8 @@ describe('SAST finding lineage persistence contract', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( diff --git a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts index b516c0c..8c542ba 100644 --- a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts @@ -114,10 +114,11 @@ describe('SAST scan coverage persistence contract', () => { ); }); - it('keeps T039 and T040 internal after exposing only the T041 handoff', () => { + it('keeps T039 through T041 internal after exposing only the T042 handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( diff --git a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts index 16b13c8..0af6d41 100644 --- a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts @@ -170,10 +170,11 @@ describe('SAST scan freshness and retry persistence contract', () => { ); }); - it('keeps T040 internal after exporting the T041 sequential handoff', () => { + it('keeps T040 and T041 internal after exporting the T042 handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).toContain('SastEvidenceAccessService'); + expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(module).toMatch( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee63739..c928fab 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,6 +23,7 @@ export * from './types/sast-finding-correlation'; export * from './types/sast-scan-coverage'; export * from './types/sast-scan-freshness'; export * from './types/sast-accepted-evidence'; +export * from './types/sast-evidence-access'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/sast-evidence-access.ts b/packages/shared/src/types/sast-evidence-access.ts new file mode 100644 index 0000000..3494203 --- /dev/null +++ b/packages/shared/src/types/sast-evidence-access.ts @@ -0,0 +1,877 @@ +import { + hasExactKeys, + isBoundedReference, + isRecord, + isSha256Digest, + utf8Length +} from './sast-normalization-validation'; +import { + SAST_PROFILE_IDS, + type SastProfileId +} from './sast-runtime'; +import { SAST_ACCEPTED_EVIDENCE_POLICY } from './sast-accepted-evidence'; + +export const SAST_EVIDENCE_ACCESS_DECISION_VERSION = + 'sast-evidence-access-decision-v1' as const; +export const SAST_EVIDENCE_DELETION_SCHEDULE_VERSION = + 'sast-evidence-deletion-schedule-v1' as const; +export const SAST_EVIDENCE_DELETION_PROOF_VERSION = + 'sast-evidence-deletion-proof-v1' as const; +export const SAST_DASHBOARD_EVIDENCE_VERSION = + 'sast-dashboard-evidence-v1' as const; +export const SAST_REDUCED_EVIDENCE_REFERENCE_VERSION = + 'sast-reduced-evidence-reference-v1' as const; +export const SAST_EVIDENCE_ACCESS_POLICY_VERSION = + 'sast-evidence-access-policy-v1' as const; + +export const SAST_EVIDENCE_MAX_RETENTION_SECONDS = + 7 * 24 * 60 * 60; +export const SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS = + 24 * 60 * 60; + +export const SAST_EVIDENCE_ACCESS_PURPOSES = [ + 'DASHBOARD', + 'AI_ADVISORY' +] as const; +export type SastEvidenceAccessPurpose = + (typeof SAST_EVIDENCE_ACCESS_PURPOSES)[number]; + +export const SAST_EVIDENCE_ACCESS_OUTCOMES = [ + 'ALLOWED', + 'DENIED' +] as const; +export type SastEvidenceAccessOutcome = + (typeof SAST_EVIDENCE_ACCESS_OUTCOMES)[number]; + +export const SAST_EVIDENCE_CLASSIFICATIONS = [ + 'DASHBOARD_SAFE', + 'AI_REDUCED_REFERENCE_SAFE', + 'DENIED' +] as const; +export type SastEvidenceClassification = + (typeof SAST_EVIDENCE_CLASSIFICATIONS)[number]; + +export const SAST_EVIDENCE_ACCESS_REASON_CODES = [ + 'EVIDENCE_ACCESS_INPUT_INVALID', + 'EVIDENCE_ACCESS_CONTEXT_UNAVAILABLE', + 'EVIDENCE_ACCESS_CONTEXT_DRIFT', + 'EVIDENCE_ACCESS_TAMPERED', + 'EVIDENCE_ACCESS_PATH_UNSAFE', + 'EVIDENCE_ACCESS_IDENTIFIER_UNSAFE', + 'EVIDENCE_ACCESS_SECRET_AUTHORITY_UNAVAILABLE', + 'EVIDENCE_ACCESS_REDACTION_FAILED', + 'EVIDENCE_ACCESS_PROFILE_UNAPPROVED', + 'EVIDENCE_ACCESS_COVERAGE_INELIGIBLE', + 'EVIDENCE_ACCESS_OPT_IN_REQUIRED', + 'EVIDENCE_ACCESS_EXPIRED', + 'EVIDENCE_ACCESS_DELETION_PENDING', + 'EVIDENCE_ACCESS_DELETED', + 'EVIDENCE_ACCESS_CLASSIFICATION_STALE', + 'EVIDENCE_ACCESS_OUTPUT_INVALID', + 'EVIDENCE_ACCESS_PERSISTENCE_CONFLICT' +] as const; +export type SastEvidenceAccessReasonCode = + (typeof SAST_EVIDENCE_ACCESS_REASON_CODES)[number]; + +export interface SastEvidenceAccessScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + occurrenceId: string; + buildDecisionId: string; + evidencePackId: string; + findingFingerprint: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + freshnessDecisionId: string; + freshnessDecisionDigest: `sha256:${string}`; + coverageDecisionId: string; + coverageDecisionDigest: `sha256:${string}`; + sourcePackDigest: `sha256:${string}`; +} + +export interface SastEvidenceDeletionSchedule { + version: typeof SAST_EVIDENCE_DELETION_SCHEDULE_VERSION; + deletionScheduleId: string; + operationId: string; + scope: SastEvidenceAccessScope; + scheduledAt: string; + deleteAfter: string; + maximumRetentionSeconds: typeof SAST_EVIDENCE_MAX_RETENTION_SECONDS; + scheduleDigest: `sha256:${string}`; +} + +export type SastEvidenceDeletionScheduleCore = Omit< + SastEvidenceDeletionSchedule, + 'scheduleDigest' +>; + +export interface SastEvidenceAccessAuthority { + dashboardReadAllowed: boolean; + reducedEvidenceReferenceAllowed: boolean; + aiPayloadAllowed: false; + aiProviderCallAllowed: false; + retrievalAllowed: false; + toolsAllowed: false; + policyAuthority: false; + publicationAuthority: false; + lifecycleMutationAuthority: false; + scmWriteAuthority: false; +} + +export interface SastEvidenceAccessAuditProjection { + secondPassRedactionApplied: boolean; + rawSourceStored: false; + secretValueStored: false; + preRedactionPayloadStored: false; + matchedValueDigestStored: false; + dashboardPayloadPersisted: false; + aiPayloadCreated: false; + aiProviderCalled: false; +} + +export interface SastEvidenceAccessDecision { + version: typeof SAST_EVIDENCE_ACCESS_DECISION_VERSION; + accessDecisionId: string; + accessPolicyVersion: typeof SAST_EVIDENCE_ACCESS_POLICY_VERSION; + purpose: SastEvidenceAccessPurpose; + scope: SastEvidenceAccessScope; + deletionScheduleId: string; + deletionScheduleDigest: `sha256:${string}`; + secretRegistryVersion: string; + outcome: SastEvidenceAccessOutcome; + classification: SastEvidenceClassification; + reasonCodes: SastEvidenceAccessReasonCode[]; + secondPassRedactionDecisionRef: string | null; + redactedProjectionDigest: `sha256:${string}` | null; + redactedFragmentCount: number; + redactedTotalBytes: number; + redactionCount: number; + reducedEvidenceRef: string | null; + aiPayloadExpiresAt: string | null; + evidenceExpiresAt: string; + authority: SastEvidenceAccessAuthority; + audit: SastEvidenceAccessAuditProjection; + decidedAt: string; + decisionDigest: `sha256:${string}`; +} + +export type SastEvidenceAccessDecisionCore = Omit< + SastEvidenceAccessDecision, + 'decisionDigest' +>; + +export interface SastEvidenceSafeFragment { + fragmentId: string; + ordinal: number; + role: 'PRIMARY' | 'RELATED'; + normalizedPath: string; + startLine: number; + endLine: number; + redactedContent: string; + byteSize: number; + contentDigest: `sha256:${string}`; +} + +export interface SastDashboardEvidence { + version: typeof SAST_DASHBOARD_EVIDENCE_VERSION; + accessDecisionId: string; + accessDecisionDigest: `sha256:${string}`; + evidencePackId: string; + findingFingerprint: `sha256:${string}`; + fragments: SastEvidenceSafeFragment[]; + totalBytes: number; + truncated: boolean; + expiresAt: string; + advisoryOnly: true; +} + +export interface SastReducedEvidenceReference { + version: typeof SAST_REDUCED_EVIDENCE_REFERENCE_VERSION; + reducedEvidenceRef: string; + accessDecisionId: string; + accessDecisionDigest: `sha256:${string}`; + evidencePackId: string; + findingFingerprint: `sha256:${string}`; + redactedProjectionDigest: `sha256:${string}`; + fragmentCount: number; + payloadExpiresAt: string; + aiPayloadCreated: false; + aiProviderCalled: false; + retrievalAllowed: false; + toolsAllowed: false; + advisoryOnly: true; +} + +export interface SastEvidenceDeletionReceipt { + operationId: string; + providerReceiptRef: string; + providerReceiptDigest: `sha256:${string}`; + completedAt: string; +} + +export interface SastEvidenceDeletionProof { + version: typeof SAST_EVIDENCE_DELETION_PROOF_VERSION; + deletionProofId: string; + deletionScheduleId: string; + deletionScheduleDigest: `sha256:${string}`; + operationId: string; + scope: SastEvidenceAccessScope; + providerReceiptRef: string; + providerReceiptDigest: `sha256:${string}`; + contentDeleted: true; + fragmentsDeleted: true; + buildDecisionRetained: true; + accessAuthorityRevoked: true; + completedAt: string; + proofDigest: `sha256:${string}`; +} + +export type SastEvidenceDeletionProofCore = Omit< + SastEvidenceDeletionProof, + 'proofDigest' +>; + +export type SastEvidenceAccessCanonicalDigester = ( + canonicalValue: string +) => `sha256:${string}`; + +export function canonicalizeSastEvidenceDeletionSchedule( + value: Readonly +): string { + return stableJson(value); +} + +export function canonicalizeSastEvidenceAccessDecision( + value: Readonly +): string { + return stableJson(value); +} + +export function canonicalizeSastEvidenceSafeFragments( + value: readonly SastEvidenceSafeFragment[] +): string { + return stableJson([...value].sort((left, right) => left.ordinal - right.ordinal)); +} + +export function canonicalizeSastEvidenceDeletionProof( + value: Readonly +): string { + return stableJson(value); +} + +export function buildSastEvidenceDeletionSchedule(input: { + scope: Readonly; + scheduledAt: string; + deleteAfter: string; + digestCanonical: SastEvidenceAccessCanonicalDigester; +}): SastEvidenceDeletionSchedule { + const identity = stableJson({ + scope: input.scope, + deleteAfter: input.deleteAfter, + policyVersion: SAST_EVIDENCE_ACCESS_POLICY_VERSION + }); + const suffix = stripDigest(input.digestCanonical(identity)); + const core: SastEvidenceDeletionScheduleCore = { + version: SAST_EVIDENCE_DELETION_SCHEDULE_VERSION, + deletionScheduleId: `sast-evidence-deletion://${suffix}`, + operationId: `sast-evidence-delete://${suffix}`, + scope: { ...input.scope }, + scheduledAt: input.scheduledAt, + deleteAfter: input.deleteAfter, + maximumRetentionSeconds: SAST_EVIDENCE_MAX_RETENTION_SECONDS + }; + return { + ...core, + scheduleDigest: input.digestCanonical( + canonicalizeSastEvidenceDeletionSchedule(core) + ) + }; +} + +export function buildSastEvidenceAccessDecision(input: { + purpose: SastEvidenceAccessPurpose; + scope: Readonly; + schedule: Readonly; + secretRegistryVersion: string; + outcome: SastEvidenceAccessOutcome; + reasonCodes: readonly SastEvidenceAccessReasonCode[]; + redactedProjectionDigest: `sha256:${string}` | null; + redactedFragmentCount: number; + redactedTotalBytes: number; + redactionCount: number; + evidenceExpiresAt: string; + decidedAt: string; + digestCanonical: SastEvidenceAccessCanonicalDigester; +}): SastEvidenceAccessDecision { + const allowed = input.outcome === 'ALLOWED'; + const identity = stableJson({ + purpose: input.purpose, + scope: input.scope, + scheduleDigest: input.schedule.scheduleDigest, + secretRegistryVersion: input.secretRegistryVersion, + outcome: input.outcome, + reasonCodes: input.reasonCodes, + redactedProjectionDigest: input.redactedProjectionDigest, + classificationEpoch: input.decidedAt.slice(0, 10), + policyVersion: SAST_EVIDENCE_ACCESS_POLICY_VERSION + }); + const suffix = stripDigest(input.digestCanonical(identity)); + const accessDecisionId = `sast-evidence-access://${suffix}`; + const secondPassRedactionDecisionRef = allowed + ? `sast-evidence-access-redaction://${suffix}` + : null; + const reducedEvidenceRef = + allowed && input.purpose === 'AI_ADVISORY' + ? `sast-reduced-evidence://${suffix}` + : null; + const evidenceExpiry = Date.parse(input.evidenceExpiresAt); + const decidedAt = Date.parse(input.decidedAt); + const payloadExpiry = new Date( + Math.min( + evidenceExpiry, + decidedAt + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS * 1000 + ) + ).toISOString(); + const core: SastEvidenceAccessDecisionCore = { + version: SAST_EVIDENCE_ACCESS_DECISION_VERSION, + accessDecisionId, + accessPolicyVersion: SAST_EVIDENCE_ACCESS_POLICY_VERSION, + purpose: input.purpose, + scope: { ...input.scope }, + deletionScheduleId: input.schedule.deletionScheduleId, + deletionScheduleDigest: input.schedule.scheduleDigest, + secretRegistryVersion: input.secretRegistryVersion, + outcome: input.outcome, + classification: allowed + ? input.purpose === 'DASHBOARD' + ? 'DASHBOARD_SAFE' + : 'AI_REDUCED_REFERENCE_SAFE' + : 'DENIED', + reasonCodes: [...input.reasonCodes], + secondPassRedactionDecisionRef, + redactedProjectionDigest: allowed + ? input.redactedProjectionDigest + : null, + redactedFragmentCount: allowed + ? input.redactedFragmentCount + : 0, + redactedTotalBytes: allowed ? input.redactedTotalBytes : 0, + redactionCount: allowed ? input.redactionCount : 0, + reducedEvidenceRef, + aiPayloadExpiresAt: + allowed && input.purpose === 'AI_ADVISORY' + ? payloadExpiry + : null, + evidenceExpiresAt: input.evidenceExpiresAt, + authority: accessAuthority(input.purpose, allowed), + audit: { + secondPassRedactionApplied: allowed, + rawSourceStored: false, + secretValueStored: false, + preRedactionPayloadStored: false, + matchedValueDigestStored: false, + dashboardPayloadPersisted: false, + aiPayloadCreated: false, + aiProviderCalled: false + }, + decidedAt: input.decidedAt + }; + return { + ...core, + decisionDigest: input.digestCanonical( + canonicalizeSastEvidenceAccessDecision(core) + ) + }; +} + +export function buildSastEvidenceDeletionProof(input: { + schedule: Readonly; + receipt: Readonly; + digestCanonical: SastEvidenceAccessCanonicalDigester; +}): SastEvidenceDeletionProof { + const identity = stableJson({ + scheduleDigest: input.schedule.scheduleDigest, + operationId: input.receipt.operationId + }); + const core: SastEvidenceDeletionProofCore = { + version: SAST_EVIDENCE_DELETION_PROOF_VERSION, + deletionProofId: + `sast-evidence-deletion-proof://${stripDigest( + input.digestCanonical(identity) + )}`, + deletionScheduleId: input.schedule.deletionScheduleId, + deletionScheduleDigest: input.schedule.scheduleDigest, + operationId: input.receipt.operationId, + scope: { ...input.schedule.scope }, + providerReceiptRef: input.receipt.providerReceiptRef, + providerReceiptDigest: input.receipt.providerReceiptDigest, + contentDeleted: true, + fragmentsDeleted: true, + buildDecisionRetained: true, + accessAuthorityRevoked: true, + completedAt: input.receipt.completedAt + }; + return { + ...core, + proofDigest: input.digestCanonical( + canonicalizeSastEvidenceDeletionProof(core) + ) + }; +} + +export function isSastEvidenceAccessScopeValid( + value: unknown +): value is SastEvidenceAccessScope { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'occurrenceId', + 'buildDecisionId', + 'evidencePackId', + 'findingFingerprint', + 'profileId', + 'profileDigest', + 'freshnessDecisionId', + 'freshnessDecisionDigest', + 'coverageDecisionId', + 'coverageDecisionDigest', + 'sourcePackDigest' + ]) && + [ + value.tenantId, + value.repositoryBindingId, + value.scanRequestId, + value.attemptId + ].every(isBoundedReference) && + isContractId(value.occurrenceId, 'finding-occurrence') && + isContractId(value.buildDecisionId, 'sast-evidence-build') && + isContractId(value.evidencePackId, 'sast-evidence-pack') && + isSha256Digest(value.findingFingerprint) && + SAST_PROFILE_IDS.includes(value.profileId as SastProfileId) && + isSha256Digest(value.profileDigest) && + isContractId(value.freshnessDecisionId, 'sast-freshness') && + isSha256Digest(value.freshnessDecisionDigest) && + isContractId(value.coverageDecisionId, 'sast-coverage') && + isSha256Digest(value.coverageDecisionDigest) && + isSha256Digest(value.sourcePackDigest) + ); +} + +export function isSastEvidenceDeletionScheduleShapeValid( + value: unknown, + digestCanonical?: SastEvidenceAccessCanonicalDigester +): value is SastEvidenceDeletionSchedule { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'deletionScheduleId', + 'operationId', + 'scope', + 'scheduledAt', + 'deleteAfter', + 'maximumRetentionSeconds', + 'scheduleDigest' + ]) || + value.version !== SAST_EVIDENCE_DELETION_SCHEDULE_VERSION || + !isContractId(value.deletionScheduleId, 'sast-evidence-deletion') || + !isContractId(value.operationId, 'sast-evidence-delete') || + !isSastEvidenceAccessScopeValid(value.scope) || + !isCanonicalTimestamp(value.scheduledAt) || + !isCanonicalTimestamp(value.deleteAfter) || + value.maximumRetentionSeconds !== SAST_EVIDENCE_MAX_RETENTION_SECONDS || + !isSha256Digest(value.scheduleDigest) + ) { + return false; + } + const duration = Date.parse(value.deleteAfter) - Date.parse(value.scheduledAt); + if ( + duration <= 0 || + duration > SAST_EVIDENCE_MAX_RETENTION_SECONDS * 1000 + ) { + return false; + } + if (digestCanonical) { + const { scheduleDigest: _digest, ...core } = + value as unknown as SastEvidenceDeletionSchedule; + void _digest; + return ( + digestCanonical(canonicalizeSastEvidenceDeletionSchedule(core)) === + value.scheduleDigest + ); + } + return true; +} + +export function isSastEvidenceAccessDecisionShapeValid( + value: unknown, + digestCanonical?: SastEvidenceAccessCanonicalDigester +): value is SastEvidenceAccessDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'accessDecisionId', + 'accessPolicyVersion', + 'purpose', + 'scope', + 'deletionScheduleId', + 'deletionScheduleDigest', + 'secretRegistryVersion', + 'outcome', + 'classification', + 'reasonCodes', + 'secondPassRedactionDecisionRef', + 'redactedProjectionDigest', + 'redactedFragmentCount', + 'redactedTotalBytes', + 'redactionCount', + 'reducedEvidenceRef', + 'aiPayloadExpiresAt', + 'evidenceExpiresAt', + 'authority', + 'audit', + 'decidedAt', + 'decisionDigest' + ]) || + value.version !== SAST_EVIDENCE_ACCESS_DECISION_VERSION || + value.accessPolicyVersion !== SAST_EVIDENCE_ACCESS_POLICY_VERSION || + !isContractId(value.accessDecisionId, 'sast-evidence-access') || + !SAST_EVIDENCE_ACCESS_PURPOSES.includes( + value.purpose as SastEvidenceAccessPurpose + ) || + !isSastEvidenceAccessScopeValid(value.scope) || + !isContractId(value.deletionScheduleId, 'sast-evidence-deletion') || + !isSha256Digest(value.deletionScheduleDigest) || + !isBoundedReference(value.secretRegistryVersion) || + !SAST_EVIDENCE_ACCESS_OUTCOMES.includes( + value.outcome as SastEvidenceAccessOutcome + ) || + !SAST_EVIDENCE_CLASSIFICATIONS.includes( + value.classification as SastEvidenceClassification + ) || + !isReasonCodes(value.reasonCodes) || + !Number.isSafeInteger(value.redactedFragmentCount) || + !Number.isSafeInteger(value.redactedTotalBytes) || + !Number.isSafeInteger(value.redactionCount) || + (value.redactedFragmentCount as number) < 0 || + (value.redactedTotalBytes as number) < 0 || + (value.redactionCount as number) < 0 || + (value.redactedFragmentCount as number) > + SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentCount || + (value.redactedTotalBytes as number) > + SAST_ACCEPTED_EVIDENCE_POLICY.maxTotalBytes || + (value.redactionCount as number) > + SAST_ACCEPTED_EVIDENCE_POLICY.maxTotalBytes || + !isCanonicalTimestamp(value.evidenceExpiresAt) || + !isCanonicalTimestamp(value.decidedAt) || + !isAccessAuthority(value.authority) || + !isAccessAudit(value.audit) || + !isSha256Digest(value.decisionDigest) + ) { + return false; + } + const allowed = value.outcome === 'ALLOWED'; + if ( + allowed !== (value.reasonCodes.length === 0) || + allowed !== isContractId( + value.secondPassRedactionDecisionRef, + 'sast-evidence-access-redaction' + ) || + allowed !== isSha256Digest(value.redactedProjectionDigest) || + value.authority.dashboardReadAllowed !== + (allowed && value.purpose === 'DASHBOARD') || + value.authority.reducedEvidenceReferenceAllowed !== + (allowed && value.purpose === 'AI_ADVISORY') || + value.audit.secondPassRedactionApplied !== allowed || + (!allowed && + (value.redactedFragmentCount !== 0 || + value.redactedTotalBytes !== 0 || + value.redactionCount !== 0)) || + (allowed && + ((value.redactedFragmentCount as number) <= 0 || + (value.redactedTotalBytes as number) <= 0)) || + (value.purpose === 'DASHBOARD' && + (value.classification !== (allowed ? 'DASHBOARD_SAFE' : 'DENIED') || + value.reducedEvidenceRef !== null || + value.aiPayloadExpiresAt !== null)) || + (value.purpose === 'AI_ADVISORY' && + (value.classification !== + (allowed ? 'AI_REDUCED_REFERENCE_SAFE' : 'DENIED') || + allowed !== + isContractId(value.reducedEvidenceRef, 'sast-reduced-evidence') || + allowed !== isCanonicalTimestamp(value.aiPayloadExpiresAt))) || + (allowed && + Date.parse(value.decidedAt as string) >= + Date.parse(value.evidenceExpiresAt as string)) + ) { + return false; + } + if (value.aiPayloadExpiresAt !== null) { + const payloadDuration = + Date.parse(value.aiPayloadExpiresAt as string) - + Date.parse(value.decidedAt as string); + if ( + payloadDuration <= 0 || + payloadDuration > SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS * 1000 || + Date.parse(value.aiPayloadExpiresAt as string) > + Date.parse(value.evidenceExpiresAt as string) + ) { + return false; + } + } + if (digestCanonical) { + const { decisionDigest: _digest, ...core } = + value as unknown as SastEvidenceAccessDecision; + void _digest; + return ( + digestCanonical(canonicalizeSastEvidenceAccessDecision(core)) === + value.decisionDigest + ); + } + return true; +} + +export function isSastEvidenceDeletionProofShapeValid( + value: unknown, + digestCanonical?: SastEvidenceAccessCanonicalDigester +): value is SastEvidenceDeletionProof { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'deletionProofId', + 'deletionScheduleId', + 'deletionScheduleDigest', + 'operationId', + 'scope', + 'providerReceiptRef', + 'providerReceiptDigest', + 'contentDeleted', + 'fragmentsDeleted', + 'buildDecisionRetained', + 'accessAuthorityRevoked', + 'completedAt', + 'proofDigest' + ]) || + value.version !== SAST_EVIDENCE_DELETION_PROOF_VERSION || + !isContractId(value.deletionProofId, 'sast-evidence-deletion-proof') || + !isContractId(value.deletionScheduleId, 'sast-evidence-deletion') || + !isSha256Digest(value.deletionScheduleDigest) || + !isContractId(value.operationId, 'sast-evidence-delete') || + !isSastEvidenceAccessScopeValid(value.scope) || + !isContractId( + value.providerReceiptRef, + 'sast-evidence-delete-receipt' + ) || + !isSha256Digest(value.providerReceiptDigest) || + value.contentDeleted !== true || + value.fragmentsDeleted !== true || + value.buildDecisionRetained !== true || + value.accessAuthorityRevoked !== true || + !isCanonicalTimestamp(value.completedAt) || + !isSha256Digest(value.proofDigest) + ) { + return false; + } + if (digestCanonical) { + const { proofDigest: _digest, ...core } = + value as unknown as SastEvidenceDeletionProof; + void _digest; + return ( + digestCanonical(canonicalizeSastEvidenceDeletionProof(core)) === + value.proofDigest + ); + } + return true; +} + +export function isSastEvidenceSafeFragmentShapeValid( + value: unknown +): value is SastEvidenceSafeFragment { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'fragmentId', + 'ordinal', + 'role', + 'normalizedPath', + 'startLine', + 'endLine', + 'redactedContent', + 'byteSize', + 'contentDigest' + ]) && + isContractId(value.fragmentId, 'sast-evidence-fragment') && + Number.isSafeInteger(value.ordinal) && + (value.ordinal as number) >= 0 && + (value.role === 'PRIMARY' || value.role === 'RELATED') && + isSafeNormalizedPath(value.normalizedPath) && + Number.isSafeInteger(value.startLine) && + Number.isSafeInteger(value.endLine) && + (value.startLine as number) > 0 && + (value.endLine as number) >= (value.startLine as number) && + typeof value.redactedContent === 'string' && + value.redactedContent === value.redactedContent.normalize('NFC') && + !value.redactedContent.includes('\r') && + Number.isSafeInteger(value.byteSize) && + value.byteSize === utf8Length(value.redactedContent) && + value.byteSize > 0 && + value.byteSize <= SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentBytes && + isSha256Digest(value.contentDigest) + ); +} + +export function isSafeNormalizedPath(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.normalize('NFC') && + value === value.trim() && + utf8Length(value) <= 4096 && + !value.includes('\\') && + !value.startsWith('/') && + !/^[A-Za-z]:/u.test(value) && + !/(^|\/)\.{1,2}(\/|$)/u.test(value) && + !/(^|\/)\.git(?:\/|$)/iu.test(value) && + !/\p{Cc}/u.test(value) + ); +} + +function accessAuthority( + purpose: SastEvidenceAccessPurpose, + allowed: boolean +): SastEvidenceAccessAuthority { + return { + dashboardReadAllowed: allowed && purpose === 'DASHBOARD', + reducedEvidenceReferenceAllowed: + allowed && purpose === 'AI_ADVISORY', + aiPayloadAllowed: false, + aiProviderCallAllowed: false, + retrievalAllowed: false, + toolsAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false, + scmWriteAuthority: false + }; +} + +function isAccessAuthority( + value: unknown +): value is SastEvidenceAccessAuthority { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'dashboardReadAllowed', + 'reducedEvidenceReferenceAllowed', + 'aiPayloadAllowed', + 'aiProviderCallAllowed', + 'retrievalAllowed', + 'toolsAllowed', + 'policyAuthority', + 'publicationAuthority', + 'lifecycleMutationAuthority', + 'scmWriteAuthority' + ]) && + typeof value.dashboardReadAllowed === 'boolean' && + typeof value.reducedEvidenceReferenceAllowed === 'boolean' && + value.aiPayloadAllowed === false && + value.aiProviderCallAllowed === false && + value.retrievalAllowed === false && + value.toolsAllowed === false && + value.policyAuthority === false && + value.publicationAuthority === false && + value.lifecycleMutationAuthority === false && + value.scmWriteAuthority === false && + !(value.dashboardReadAllowed && value.reducedEvidenceReferenceAllowed) + ); +} + +function isAccessAudit( + value: unknown +): value is SastEvidenceAccessAuditProjection { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'secondPassRedactionApplied', + 'rawSourceStored', + 'secretValueStored', + 'preRedactionPayloadStored', + 'matchedValueDigestStored', + 'dashboardPayloadPersisted', + 'aiPayloadCreated', + 'aiProviderCalled' + ]) && + typeof value.secondPassRedactionApplied === 'boolean' && + value.rawSourceStored === false && + value.secretValueStored === false && + value.preRedactionPayloadStored === false && + value.matchedValueDigestStored === false && + value.dashboardPayloadPersisted === false && + value.aiPayloadCreated === false && + value.aiProviderCalled === false + ); +} + +function isReasonCodes( + value: unknown +): value is SastEvidenceAccessReasonCode[] { + return ( + Array.isArray(value) && + value.length <= SAST_EVIDENCE_ACCESS_REASON_CODES.length && + value.every((reason) => + SAST_EVIDENCE_ACCESS_REASON_CODES.includes( + reason as SastEvidenceAccessReasonCode + ) + ) && + new Set(value).size === value.length + ); +} + +function isContractId(value: unknown, prefix: string): value is string { + return ( + typeof value === 'string' && + new RegExp(`^${prefix}:\\/\\/[a-f0-9]{64}$`, 'u').test(value) + ); +} + +function isCanonicalTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const milliseconds = Date.parse(value); + return ( + Number.isFinite(milliseconds) && + new Date(milliseconds).toISOString() === value + ); +} + +function stripDigest(value: `sha256:${string}`): string { + return value.slice('sha256:'.length); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return '[' + value.map(stableJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + const record = value as Record; + return ( + '{' + + Object.keys(record) + .sort() + .map( + (key) => + JSON.stringify(key) + ':' + stableJson(record[key]) + ) + .join(',') + + '}' + ); + } + return JSON.stringify(value); +} diff --git a/packages/shared/test/sast-evidence-access.test.mjs b/packages/shared/test/sast-evidence-access.test.mjs new file mode 100644 index 0000000..383146b --- /dev/null +++ b/packages/shared/test/sast-evidence-access.test.mjs @@ -0,0 +1,203 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS, + buildSastEvidenceAccessDecision, + buildSastEvidenceDeletionProof, + buildSastEvidenceDeletionSchedule, + isSafeNormalizedPath, + isSastEvidenceAccessDecisionShapeValid, + isSastEvidenceDeletionProofShapeValid, + isSastEvidenceDeletionScheduleShapeValid, + isSastEvidenceSafeFragmentShapeValid +} from '../dist/index.js'; + +const CREATED_AT = '2026-08-10T04:40:00.000Z'; +const DECIDED_AT = '2026-08-10T05:00:00.000Z'; +const EXPIRES_AT = '2026-08-17T04:40:00.000Z'; + +test('dashboard and AI access decisions keep purpose authority independent', () => { + const schedule = deletionSchedule(); + const dashboard = accessDecision(schedule, 'DASHBOARD'); + const ai = accessDecision(schedule, 'AI_ADVISORY'); + + assert.equal( + isSastEvidenceAccessDecisionShapeValid(dashboard, digest), + true + ); + assert.equal( + dashboard.authority.dashboardReadAllowed, + true + ); + assert.equal( + dashboard.authority.reducedEvidenceReferenceAllowed, + false + ); + assert.equal(dashboard.reducedEvidenceRef, null); + assert.equal(dashboard.aiPayloadExpiresAt, null); + + assert.equal( + isSastEvidenceAccessDecisionShapeValid(ai, digest), + true + ); + assert.equal(ai.authority.dashboardReadAllowed, false); + assert.equal( + ai.authority.reducedEvidenceReferenceAllowed, + true + ); + assert.equal(ai.authority.aiPayloadAllowed, false); + assert.equal(ai.authority.aiProviderCallAllowed, false); + assert.equal(ai.audit.aiPayloadCreated, false); + assert.ok( + Date.parse(ai.aiPayloadExpiresAt) - Date.parse(ai.decidedAt) <= + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS * 1000 + ); +}); + +test('access validators reject authority widening and retention extension', () => { + const schedule = deletionSchedule(); + assert.equal( + isSastEvidenceDeletionScheduleShapeValid(schedule, digest), + true + ); + const dashboard = accessDecision(schedule, 'DASHBOARD'); + const widened = { + ...dashboard, + authority: { + ...dashboard.authority, + reducedEvidenceReferenceAllowed: true + } + }; + assert.equal( + isSastEvidenceAccessDecisionShapeValid(widened, digest), + false + ); + assert.equal( + isSastEvidenceAccessDecisionShapeValid({ + ...dashboard, + redactedFragmentCount: 6, + redactedTotalBytes: 32_769, + redactionCount: 32_769 + }), + false + ); + assert.equal( + isSastEvidenceSafeFragmentShapeValid({ + fragmentId: contractId('sast-evidence-fragment', 'fragment'), + ordinal: 0, + role: 'PRIMARY', + normalizedPath: 'src/Main.java', + startLine: 1, + endLine: 1, + redactedContent: 'a'.repeat(8193), + byteSize: 8193, + contentDigest: digest('a'.repeat(8193)) + }), + false + ); + const extended = { + ...schedule, + deleteAfter: '2026-08-17T04:40:00.001Z' + }; + assert.equal( + isSastEvidenceDeletionScheduleShapeValid(extended, digest), + false + ); +}); + +test('deletion proof binds the deterministic operation and bounded provider receipt', () => { + const schedule = deletionSchedule(); + const proof = buildSastEvidenceDeletionProof({ + schedule, + receipt: { + operationId: schedule.operationId, + providerReceiptRef: contractId( + 'sast-evidence-delete-receipt', + 'receipt' + ), + providerReceiptDigest: digest('receipt'), + completedAt: EXPIRES_AT + }, + digestCanonical: digest + }); + assert.equal( + isSastEvidenceDeletionProofShapeValid(proof, digest), + true + ); + assert.equal(proof.contentDeleted, true); + assert.equal(proof.fragmentsDeleted, true); + assert.equal(proof.buildDecisionRetained, true); + assert.equal(proof.accessAuthorityRevoked, true); + + assert.equal( + isSastEvidenceDeletionProofShapeValid( + { ...proof, providerReceiptRef: 'unbounded-receipt' }, + digest + ), + false + ); +}); + +test('dashboard path classification rejects traversal and repository metadata', () => { + assert.equal(isSafeNormalizedPath('src/main/App.java'), true); + assert.equal(isSafeNormalizedPath('../secret.env'), false); + assert.equal(isSafeNormalizedPath('.git/config'), false); + assert.equal(isSafeNormalizedPath('C:\\repo\\secret.env'), false); +}); + +function accessDecision(schedule, purpose) { + return buildSastEvidenceAccessDecision({ + purpose, + scope: schedule.scope, + schedule, + secretRegistryVersion: 'platform-secret-registry-v1', + outcome: 'ALLOWED', + reasonCodes: [], + redactedProjectionDigest: digest('projection'), + redactedFragmentCount: 1, + redactedTotalBytes: 12, + redactionCount: 1, + evidenceExpiresAt: EXPIRES_AT, + decidedAt: DECIDED_AT, + digestCanonical: digest + }); +} + +function deletionSchedule() { + return buildSastEvidenceDeletionSchedule({ + scope: { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + occurrenceId: contractId('finding-occurrence', 'occurrence'), + buildDecisionId: contractId( + 'sast-evidence-build', + 'build' + ), + evidencePackId: contractId('sast-evidence-pack', 'pack'), + findingFingerprint: digest('finding'), + profileId: 'JAVA_FAST_V1', + profileDigest: + 'sha256:19743211685c76ac7c63cb8c829823c45bf458da3aee5dac4f5eaba2b44bbe74', + freshnessDecisionId: contractId('sast-freshness', 'freshness'), + freshnessDecisionDigest: digest('freshness-decision'), + coverageDecisionId: contractId('sast-coverage', 'coverage'), + coverageDecisionDigest: digest('coverage-decision'), + sourcePackDigest: digest('pack-digest') + }, + scheduledAt: CREATED_AT, + deleteAfter: EXPIRES_AT, + digestCanonical: digest + }); +} + +function contractId(prefix, seed) { + return `${prefix}://${createHash('sha256').update(seed).digest('hex')}`; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).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 200aa9b..3ed983b 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1189,6 +1189,40 @@ An accepted T041 pack grants only evidence-construction authority. and policy, publication, lifecycle mutation, user access, and AI payload authority remain false until T042. `SastAcceptedEvidenceService` exposes no controller or SCM writer. +### Evidence access and deletion gate v1 + +`sast-evidence-access-decision-v1` accepts only an authenticated scope plus a purpose of +`DASHBOARD` or `AI_ADVISORY`. The persistence boundary reloads the exact T041 build decision, +pack, every fragment, scope, T040 freshness, T039 coverage, and T038/T037 source chain. It also +requires the canonical `sast-evidence-deletion-schedule-v1` row created with the pack. Caller +content, flags, paths, identifiers, timestamps, and digests grant no authority. + +Each purpose independently reruns known-format, registered platform-value, and entropy +redaction, validates canonical paths and identifiers, and recomputes every content, pack, +projection, and decision digest. The registry authority defaults `UNAVAILABLE`. Time is checked +before and immediately after storage confirmation; expiry, an active deletion claim, clock +rollback, registry-version drift, unsafe identifier, or any durable mismatch denies. Neither +raw/pre-redaction content, secret values, matched-value digests, nor access-time redacted +content enters the access ledger, logs, audit, or error response. + +An allowed dashboard decision returns only a second-pass-redacted projection after session +tenant and repository binding authorization. A denied or cross-scope request uses a generic +not-found response. An allowed AI decision returns only a +`sast-evidence-reduced://` reference whose eligibility expires in at most 24 hours and +never later than pack expiry. T042 performs no AI provider request, stores no request payload, +and grants no retrieval, tools, policy, publication, lifecycle, or SCM action. T041 safe flags +and null classification/deletion fields remain unchanged. + +Every accepted pack creates a deterministic `sast-evidence-delete://` operation with +a positive retention window no longer than seven days. Due work uses one leased claim with an +owner and unique fencing token. The deletion provider defaults `UNAVAILABLE`; retry releases +the claim without weakening access denial. Only a bounded receipt bound to the exact operation, +pack, provider, reference, digest, and monotonic completion time may authorize pack/fragment +content deletion and finalization of `sast-evidence-deletion-proof-v1`. The T041 build decision, +schedule, access decisions, canonical proof, and bounded audit state remain retained. Exact +replay is idempotent; a stale token, changed receipt, late reader, deletion race, or clock +rollback fails closed. + AI receives finding metadata and reduced evidence references only after a second redaction pass. AI never receives the result-ingress artifact reference. diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index eafceca..c9fe006 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -730,8 +730,8 @@ T040 creates these ledgers in bounded serializable transactions with exact repla the former permanent external-publication constraint name only after the online-schema step validates the replacement invariant, builds populated-table indexes concurrently, and validates their dependent foreign keys. Effective eligibility comes only from the independent freshness row. -T041 consumes that row internally and `SastAcceptedEvidenceService` is the only sequential -Scan Plane handoff to T042. +T041 consumes that row internally. T042 keeps construction internal and exposes only +`SastEvidenceAccessService` as the sequential Scan Plane handoff to T043. ### SastEvidenceBuildDecision @@ -775,6 +775,52 @@ fragments per file, overlap, adjacency, or combined coverage of at least 2,500 b calculation. The pack remains unavailable to the dashboard and AI Plane until T042; these decisions cannot be inferred from a successful scan or accepted T041 pack. +### SastEvidenceAccessDecision + +- deterministic purpose-bound `sast-evidence-access://` identity for either + `DASHBOARD` or `AI_ADVISORY`; one purpose cannot authorize the other +- exact tenant, repository, scan, attempt, occurrence, fingerprint, T041 build/pack digest, + deletion schedule, access policy, and secret-registry version binding +- `ALLOWED | DENIED`, `DASHBOARD_SAFE | AI_REDUCED_REFERENCE_SAFE | UNSAFE`, canonical reason + codes, counts, second-pass redaction reference, projection digest, and decision digest/time +- dashboard-safe content is returned transiently after the persisted decision and second clock + check; it is never stored in the decision, logs, or audit +- AI decisions contain only a `sast-evidence-reduced://` reference and an expiry no + later than 24 hours or the pack expiry, whichever comes first; no AI payload is persisted +- all policy, publication, lifecycle, SCM, provider-call, retrieval, and tool authority remains + false; T041 `dashboardSafe`, `aiSafe`, and null reference fields are never updated + +### SastEvidenceDeletionSchedule + +- deterministic `sast-evidence-deletion://` schedule and + `sast-evidence-delete://` operation bound to the exact pack/build/scope digest +- created with the accepted T041 pack in the same serializable transaction; `deleteAfter` is + positive and no more than seven days after `scheduledAt` +- immutable canonical schedule JSON and digest; due and tenant-expiry indexes support bounded + backfill and deletion batches +- deliberately has no cascading relation to pack content, so schedule/access/proof audit state + survives pack/fragment content deletion + +### SastEvidenceDeletionClaim + +- one mutable operational row per schedule with `PENDING | CLAIMED | COMPLETED`, bounded + attempt count, next-attempt time, lease owner, unique lease token, and lease expiry +- claim and finalize use serializable compare-and-set semantics; only the current unexpired + owner/token may commit a receipt or release for retry +- a claim blocks dashboard and AI reads, including readers that began before expiry but finish + after the claim + +### SastEvidenceDeletionProof + +- deterministic `sast-evidence-deletion-proof://` proof bound to schedule, operation, + tenant, pack, retained T041 build decision, provider receipt reference/digest, and completion + time +- `contentDeleted`, `fragmentsDeleted`, `buildDecisionRetained`, and + `accessAuthorityRevoked` are all true; a changed receipt cannot replay +- content deletion cascades from pack to fragments only after receipt validation. The T041 + build decision, schedule, access ledgers, proof, and bounded audit projections remain + durable and contain no source or second-pass redacted content + ### RuleBundlePromotionEvidence - immutable bundle descriptor diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index 88b5f9b..d457421 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 and T041 independently and now proceeds to T042. +T040, T041, and T042 independently and now proceeds to T043. ## Target Boundaries @@ -128,9 +128,16 @@ source finding before any evidence source read. The internal source authority de unavailable; verified source is scanner-redacted and platform-redacted in memory, then a canonical pack is limited to 32 KiB, five fragments, 8 KiB per fragment, and five context lines. Full-file spans, more than two fragments per file, overlapping/adjacent intervals, or -25% or greater per-file line coverage reject the complete build. Only -`SastAcceptedEvidenceService` crosses the module boundary; T042 classification, expiry -enforcement, and deletion proof are the next gate. +25% or greater per-file line coverage reject the complete build. T042 now creates an immutable +seven-day deletion schedule with every accepted pack and rebinds the complete T041 pack before +each purpose-specific dashboard or AI decision. It reruns known-format, registered-platform, +and entropy redaction at access time, rejects unsafe paths and identifiers, and exposes only an +authenticated tenant/repository-scoped dashboard projection or a reduced AI reference with an +at-most-24-hour eligibility window. The T041 pack flags remain unchanged. Expiry claims are +leased and fenced; a verified provider receipt is required before content deletion and an +immutable proof, while the T041 build decision remains retained. The default secret registry +and deletion provider authorities fail closed. Only `SastEvidenceAccessService` crosses the +module boundary; T043 advisory AI consumption is the next gate. ### Slice 6 - Coverage, Failure, Policy, and Evidence diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index 2be3720..aa71e97 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -297,8 +297,23 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl overlap, adjacency, or combined coverage at or above 2,500 basis points reject the complete build. Exact replay creates no duplicate decision, pack, or fragment row. - T041 accepted and rejected decisions have zero dashboard/AI/policy/publication/lifecycle - authority. `SastAcceptedEvidenceService` is the only sequential Scan Plane handoff to T042; - the default source authority remains unavailable and no controller or SCM writer is added. + authority. The default source authority remains unavailable and no controller or SCM writer + is added by T041. +- 100% T042 access invariant: dashboard and AI classifications are separate immutable + decisions over a complete durable T041/T040/T039/T038/T037 rebind. Known-format, + registered-platform, and entropy redaction run again at access time; unsafe path/identifier, + unavailable registry, binding/digest drift, cross-tenant/repository scope, clock rollback, + expiry, or deletion claim returns zero content and zero reduced references. +- 100% T042 authority invariant: dashboard responses contain only the authenticated + second-pass-redacted projection; AI classification contains only an at-most-24-hour reduced + reference. Provider requests, AI payload persistence, retrieval/tools, policy, publication, + lifecycle mutation, and SCM actions equal zero. T041 safe/reference fields remain unchanged, + and `SastEvidenceAccessService` is the only sequential Scan Plane handoff to T043. +- 100% T042 deletion-proof invariant: every accepted pack has one deterministic at-most-seven- + day schedule and one fenced claim. Pack/fragment content is removed only after a valid + operation-bound provider receipt; one immutable proof remains with the T041 build decision. + Unavailable providers, stale tokens, changed receipts, concurrent workers, late readers, + clock rollback, and exact replay create zero false proofs, duplicate rows, or restored content. ## 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 d17729b..974b803 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -480,11 +480,30 @@ portion of Phase 6: - Alongside the four established runtime exports (`RepositoryFetchService`, `RepositoryPreflightService`, `SandboxRuntimeAttestationService`, and `SastScannerRuntimeService`), `ScanPlaneModule` exports - `SastAcceptedEvidenceService` as the sole sequential T041 handoff to T042. T040 freshness - and all earlier coverage/correlation/lineage/identity/redaction providers remain internal. + `SastEvidenceAccessService` as the sole sequential T042 handoff to T043. T041 construction, + T040 freshness, and all earlier coverage/correlation/lineage/identity/redaction providers + remain internal. T041 adds no controller, evidence access route, AI payload, policy decision, publisher, or SCM writer; `dashboardSafe` and `aiSafe` remain false and classification/deletion - references remain null until T042. + references remain null. +- T042 creates the immutable `sast-evidence-deletion-schedule-v1` row in the same serializable + transaction as each accepted T041 pack, with a maximum seven-day `deleteAfter`. Every read + reloads and revalidates that durable pack, fragments, scope, T041 build decision, T040 + freshness, T039 coverage, and T038/T037 source bindings. +- Dashboard and AI classification are separate immutable `sast-evidence-access-decision-v1` + decisions. Each purpose reruns known-format, registered-platform-value, and entropy + redaction, rejects unsafe path/identifier material, verifies canonical content and digests, + and checks time both before and immediately after the read. A missing registry, tampered + binding, deletion claim, expiry, or clock rollback denies without returning content. +- The authenticated dashboard route requires the session tenant and repository binding and + returns only the dashboard-safe projection. AI classification returns only a reduced + evidence reference with an at-most-24-hour eligibility window; T042 creates no provider + request, AI payload, retrieval/tool grant, policy mutation, publisher, or SCM action. +- Expiry processing uses a deterministic operation ID, lease owner/token fencing, a deletion + authority that defaults unavailable, and a bounded provider receipt. Only a valid receipt + permits pack/fragment deletion and canonical `sast-evidence-deletion-proof-v1` completion. + The T041 build decision and bounded audit/proof ledgers remain durable, and replay or a + changed receipt cannot mutate the result. 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 @@ -494,10 +513,11 @@ GitHub App/GitLab scoped minting, microVM, artifact object-store/disposition, file-coordinate-attestation, and acceptance-gate adapters. T035 secret redaction, T036 `sast-fingerprint-v1` identity construction, T037 occurrence/exact-lineage lifecycle, and T038 authority-aware cross-tool correlation, T039 fail-closed scanner/capability coverage, -T040 stale-scan denial and bounded infrastructure-only retry, and T041 bounded -accepted-finding evidence with reconstruction-risk checks are complete; T042 dashboard/AI -classification, second-pass secret redaction, seven-day expiry enforcement, and deletion -proof are therefore the next implementation task. +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 and reduced-evidence-reference delivery to the advisory AI Plane is +therefore the next implementation task. 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 629b969..2da6b3c 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -505,3 +505,35 @@ paths/coordinates/redaction flags, storing raw source or platform secrets, per-f best-effort acceptance after reconstruction risk, allowing adjacent snippets, using character counts instead of UTF-8 bytes, mutable last-writer-wins packs, direct dashboard/AI access, or adding an SCM writer in T041. + +## Decision 24: Separate Purpose-Bound Access from Receipt-Proven Content Deletion + +**Decision**: `sast-evidence-access-decision-v1` classifies dashboard and AI access as two +independent immutable decisions after a durable rebind of the complete T041/T040/T039/T038/T037 +chain. The service reruns known-format, registered platform-value, and entropy redaction on +every access, validates paths and identifiers, and checks time before and after the read. The +dashboard receives only an authenticated tenant/repository-scoped redacted projection. AI +classification returns only a reduced reference with an at-most-24-hour eligibility window; +T042 never contacts a provider or constructs an AI payload. T041 pack flags and null +classification/deletion references remain immutable. + +Every accepted pack receives a canonical `sast-evidence-deletion-schedule-v1` row in its +creation transaction. When seven-day retention expires, a deterministic operation is claimed +under a leased owner/token fence. The deletion authority defaults unavailable. A bounded, +operation-bound provider receipt must validate before pack and fragment content is removed and +an immutable `sast-evidence-deletion-proof-v1` is finalized. The original T041 build decision +and bounded audit/proof state remain retained; exact replay is allowed, while changed receipts, +stale claims, late reads, races, and clock rollback deny. + +**Rationale**: Construction safety does not grant user or model access, and a database delete +attempt does not prove that every backing provider removed content. Purpose-specific ledgers +prevent dashboard consent from becoming AI consent, access-time redaction catches registry and +entropy changes, and a fenced provider receipt makes expiry an auditable outcome instead of a +best-effort timer. Retaining the build decision and proof preserves accountability without +retaining repository content. + +**Rejected**: Mutating T041 safe flags, sharing one decision between dashboard and AI, +returning fragments before the second clock check, deriving AI eligibility from dashboard +access, retaining an AI payload for T043, treating a deletion request as deletion proof, +deleting before receipt validation, allowing an unfenced worker to finalize, or erasing the +T041 decision with the content. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index 22bff18..c161c8c 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -340,6 +340,27 @@ incomplete, stale, quarantined, or security-blocked scan. fragment, and five context lines on either side. - **FR-048**: Evidence MUST redact detected and platform-format secrets before persistence and again before AI inference. +- **FR-048a**: T042 MUST classify dashboard and AI access independently from a complete + durable rebind of the T041 build decision, pack, fragments, scope, T040 freshness, T039 + coverage, and T038/T037 source. It MUST rerun known-format, registered platform-value, and + entropy redaction at access time and MUST fail closed on an unavailable registry, unsafe + path/identifier, digest mismatch, clock rollback, expiry, or deletion claim. +- **FR-048b**: T042 MUST NOT mutate T041 `dashboardSafe`, `aiSafe`, classification reference, + or deletion reference fields. Each purpose MUST have a separate immutable canonical + `sast-evidence-access-decision-v1` ledger with explicit zero policy, publication, lifecycle, + SCM, provider-call, retrieval, and tool authority. +- **FR-048c**: Dashboard reads MUST require authenticated tenant and repository binding scope + and return only a second-pass-redacted dashboard projection. AI classification MUST return + only a reduced evidence reference with an eligibility window no longer than 24 hours; T042 + MUST create no AI provider request or request payload. +- **FR-048d**: Every accepted T041 pack MUST receive an immutable deterministic deletion + schedule in the same serializable transaction. A due deletion MUST use a leased, token-fenced + claim and a deletion provider that defaults unavailable. Pack/fragment content MUST be + deleted only after a bounded provider receipt is validated and an immutable canonical proof + is committed; the T041 build decision and bounded proof/audit state MUST remain retained. +- **FR-048e**: Concurrent access, schedule, claim, receipt, and proof operations MUST permit + exact replay only. Late readers, changed receipts, stale lease owners, deletion races, and + reference-time rollback MUST fail closed without returning or restoring content. - **FR-049**: Evidence MUST NOT contain a full file, repository archive, or fragments that can reconstruct a substantial repository portion. - **FR-050**: Evidence retention MUST NOT exceed seven days; AI request payload retention diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 2dc9629..858f051 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -70,7 +70,7 @@ ## Phase 8: Evidence, Policy, and AI Boundary - [x] T041 Build bounded accepted-finding evidence with reconstruction-risk checks -- [ ] T042 Enforce dashboard/AI classification, secret redaction, seven-day expiry, and deletion proof +- [x] T042 Enforce dashboard/AI classification, secret redaction, seven-day expiry, and deletion proof - [ ] 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 diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index de44075..47e9ee4 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -84,6 +84,9 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Incomplete coverage | Successful tool hides required tool failure | Explicit required coverage state | Comment/block/AI denied | | Evidence source forgery | A caller supplies a path/range, finding authority, or fragment that is not the durable accepted occurrence | Rebind exact T040/T039/T038/T037 rows and require an internal source attestation that defaults unavailable | Cross-scope, missing occurrence, changed fingerprint, path/range, and unavailable-source fixtures reject | | Evidence reconstruction | Multiple snippets rebuild source | 32 KiB/five-fragment/8 KiB/five-context caps; per-file maximum two; reject full-file, overlap, adjacency, or at least 25% combined line coverage | Evidence build reject and immutable audit with zero pack | +| Evidence-purpose confusion | Dashboard consent or one stale decision is reused to construct an AI payload | Separate immutable dashboard/AI decisions, complete T041 chain rebind, access-time redaction/classification, and explicit zero provider/tool authority | Purpose swap, opt-in, registry drift, unsafe identifier, cross-tenant, and replay fixtures deny | +| Evidence expiry race | A reader returns content while expiry/deletion is claimed or after the final clock check | Check retention before read, confirm unchanged schedule/claim/proof and monotonic time after classification, and deny from claim onward | Expiry-before/during-read, late-reader, deletion-race, and clock-rollback fixtures return no content | +| False deletion proof | A worker marks evidence deleted without the backing provider removing it, or a stale worker finalizes | Deterministic operation, leased owner/token fence, default-unavailable provider, bounded operation-bound receipt, delete-then immutable proof | Unavailable provider, changed receipt, stale token, concurrent claim/finalize, and exact replay corpus | | 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 | @@ -152,6 +155,9 @@ The following must always remain true: 17. An accepted-finding evidence pack records only the durable accepted occurrence under verified, fresh, and comparable authority. It contains no raw source or secret value and grants no dashboard, AI, policy, publication, or lifecycle mutation authority. +18. Dashboard and AI evidence access are separately classified after access-time redaction and + durable rebinding. Expiry or a deletion claim revokes both; content is deleted only after a + fenced provider receipt and the retained canonical proof cannot restore access. ## Required Security Test Corpus @@ -168,6 +174,10 @@ The following must always remain true: identities, package/database/check-bundle rebinding, and omitted dependency coordinates - Trivy secret fixtures with sentinel values in `Match`, neighboring `Code`, modified-finding `Statement`/`Source`, and untrusted misconfiguration message/trace/rendered-cause fields +- T042 purpose-swap, platform-registry drift, known-format/entropy secret, unsafe path and + identifier, cross-tenant/repository, before/during-read expiry, late-reader, concurrent + classification/deletion, unavailable-provider, stale-fence, changed-receipt, exact-replay, + and clock-rollback fixtures - 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 d09c13d..6e215c8 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -45,6 +45,8 @@ const files = { sharedSastScanFreshnessTest: new URL('../../packages/shared/test/sast-scan-freshness.test.mjs', import.meta.url), sharedSastAcceptedEvidence: new URL('../../packages/shared/src/types/sast-accepted-evidence.ts', import.meta.url), sharedSastAcceptedEvidenceTest: new URL('../../packages/shared/test/sast-accepted-evidence.test.mjs', import.meta.url), + 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), 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), @@ -89,6 +91,15 @@ const files = { apiSastAcceptedEvidenceStore: new URL('../../apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts', import.meta.url), apiSastAcceptedEvidenceTest: new URL('../../apps/api/test/scan-plane/sast-accepted-evidence.e2e-spec.ts', import.meta.url), apiSastAcceptedEvidencePersistenceTest: new URL('../../apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts', import.meta.url), + apiSastEvidenceAccess: new URL('../../apps/api/src/scan-plane/sast-evidence-access.service.ts', import.meta.url), + apiSastEvidenceAccessStore: new URL('../../apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts', import.meta.url), + apiSastEvidenceSecretRegistry: new URL('../../apps/api/src/scan-plane/sast-evidence-secret-registry.ts', import.meta.url), + apiSastEvidenceDeletionAuthority: new URL('../../apps/api/src/scan-plane/sast-evidence-deletion.authority.ts', import.meta.url), + apiSastEvidenceDeletion: new URL('../../apps/api/src/scan-plane/sast-evidence-deletion.service.ts', import.meta.url), + apiSastEvidenceDeletionTask: new URL('../../apps/api/src/scan-plane/sast-evidence-deletion.task.ts', import.meta.url), + apiDashboardEvidenceController: new URL('../../apps/api/src/dashboard/dashboard-evidence.controller.ts', import.meta.url), + apiSastEvidenceAccessTest: new URL('../../apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts', import.meta.url), + apiSastEvidenceAccessPersistenceTest: new URL('../../apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts', import.meta.url), apiPrismaSchema: new URL('../../apps/api/prisma/schema.prisma', import.meta.url), apiOnlineSastRuntimeSchema: new URL('../../apps/api/scripts/apply-online-sast-runtime-schema.mjs', import.meta.url), apiSastFindingLineageMigration: new URL('../../apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql', import.meta.url), @@ -96,6 +107,7 @@ const files = { apiSastScanCoverageMigration: new URL('../../apps/api/prisma/migrations/20260802150000_sast_scan_coverage/migration.sql', import.meta.url), apiSastScanFreshnessMigration: new URL('../../apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql', import.meta.url), 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), 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), @@ -132,7 +144,8 @@ const assertScanPlaneExports = (scanPlaneModule) => { exportsBlock, 'Expected to locate the ScanPlaneModule exports array' ); - assert.match(exportsBlock, /SastAcceptedEvidenceService/); + assert.match(exportsBlock, /SastEvidenceAccessService/); + assert.doesNotMatch(exportsBlock, /SastAcceptedEvidenceService/); assert.doesNotMatch(exportsBlock, /SastScanFreshnessService/); assert.doesNotMatch(exportsBlock, /SastScanCoverageService/); assert.doesNotMatch(exportsBlock, /SastFindingCorrelationService/); @@ -489,7 +502,7 @@ test('SAST T034 Syft CycloneDX ingestion is inventory-only, transient, and fixtu assert.match(tasks, /- \[x\] T034\b/); assert.match( quickstart, - /T035 secret redaction,[\s\S]{0,720}T041 bounded[\s\S]{0,180}are complete/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,120}T043/ ); assert.match(contract, /Syft CycloneDX inventory adapter v1/); assert.match(spec, /FR-031b/); @@ -578,7 +591,7 @@ test('SAST T035 secret redaction is deterministic, fail-closed, and still non-du assert.match(tasks, /- \[x\] T035\b/); assert.match( quickstart, - /T035 secret redaction,[\s\S]{0,720}T041 bounded[\s\S]{0,180}are complete/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,120}T043/ ); assert.match(contract, /Secret redaction gate v1/); assert.match(spec, /FR-031c/); @@ -701,7 +714,7 @@ test('SAST T036 constructs byte-exact stable identity and no downstream authorit assert.match(tasks, /- \[x\] T036\b/); assert.match( quickstart, - /T038 authority-aware cross-tool correlation[\s\S]{0,320}T041 bounded[\s\S]{0,180}are complete; T042/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,120}T043/ ); assert.match(contract, /Finding identity construction gate v1/); assert.match(spec, /FR-034a/); @@ -862,7 +875,7 @@ test('SAST T037 persists complete occurrence lineage and fail-closed lifecycle t assert.match(tasks, /- \[x\] T037\b/); assert.match( quickstart, - /T038 authority-aware cross-tool correlation[\s\S]{0,320}T041 bounded[\s\S]{0,180}are complete; T042/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,120}T043/ ); assert.match(contract, /Finding lineage and lifecycle gate v1/); assert.match(dataModel, /SastFindingLifecycleReconciliation/); @@ -1000,7 +1013,7 @@ test('SAST T038 correlates by scanner authority while preserving every provenanc assert.match(tasks, /- \[x\] T038\b/); assert.match( quickstart, - /T039 fail-closed scanner\/capability coverage[\s\S]{0,220}T041 bounded[\s\S]{0,180}are complete; T042/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,120}T043/ ); assert.match(contract, /Finding correlation gate v1/); assert.match(dataModel, /SastFindingCorrelationProvenance/); @@ -1253,7 +1266,7 @@ test('SAST T039 coverage feeds T040 freshness and bounded retry authority', () = assert.match(tasks, /- \[x\] T040\b/); assert.match( quickstart, - /T040 stale-scan denial and bounded infrastructure-only retry[\s\S]{0,160}T041 bounded[\s\S]{0,160}complete; T042[\s\S]{0,220}next implementation task/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,160}T043[\s\S]{0,160}next implementation task/ ); assert.match(contract, /Scan coverage gate v1/); assert.match(contract, /Freshness and bounded retry gate v1/); @@ -1389,14 +1402,14 @@ test('SAST T041 builds bounded accepted-finding evidence and rejects reconstruct assert.match(tasks, /- \[x\] T041\b/); assert.match( quickstart, - /T041 bounded[\s\S]{0,180}are complete; T042[\s\S]{0,220}next implementation task/ + /T042 purpose-bound dashboard\/AI classification,[\s\S]{0,260}are complete;[\s\S]{0,160}T043[\s\S]{0,160}next implementation task/ ); assert.match(contract, /Accepted-finding evidence gate v1/); assert.match(dataModel, /SastEvidenceBuildDecision/); assert.match(dataModel, /SastAcceptedEvidencePack/); assert.match( plan, - /T040 and T041 independently and now proceeds to T042/ + /T040, T041, and T042 independently and now proceeds to T043/ ); assert.match(spec, /FR-046a/); assert.match( @@ -1410,6 +1423,150 @@ test('SAST T041 builds bounded accepted-finding evidence and rejects reconstruct ); }); +test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', () => { + const shared = readNormalizedText(files.sharedSastEvidenceAccess); + const sharedTest = readNormalizedText( + files.sharedSastEvidenceAccessTest + ); + const sharedIndex = readNormalizedText(files.sharedIndex); + const service = readNormalizedText(files.apiSastEvidenceAccess); + const store = readNormalizedText(files.apiSastEvidenceAccessStore); + const registry = readNormalizedText( + files.apiSastEvidenceSecretRegistry + ); + const deletionAuthority = readNormalizedText( + files.apiSastEvidenceDeletionAuthority + ); + const deletionService = readNormalizedText( + files.apiSastEvidenceDeletion + ); + const deletionTask = readNormalizedText( + files.apiSastEvidenceDeletionTask + ); + const dashboardController = readNormalizedText( + files.apiDashboardEvidenceController + ); + const serviceTest = readNormalizedText( + files.apiSastEvidenceAccessTest + ); + const persistenceTest = readNormalizedText( + files.apiSastEvidenceAccessPersistenceTest + ); + const schema = readNormalizedText(files.apiPrismaSchema); + const migration = readNormalizedText( + files.apiSastEvidenceAccessMigration + ); + const scanPlaneModule = readNormalizedText(files.apiScanPlaneModule); + 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-evidence-access-decision-v1/); + assert.match(shared, /sast-evidence-deletion-schedule-v1/); + assert.match(shared, /sast-evidence-deletion-proof-v1/); + assert.match(shared, /SAST_EVIDENCE_MAX_RETENTION_SECONDS/); + assert.match(shared, /SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS/); + assert.match(shared, /dashboardReadAllowed/); + assert.match(shared, /reducedEvidenceReferenceAllowed/); + assert.match(sharedIndex, /sast-evidence-access/); + assert.match( + sharedTest, + /dashboard and AI access decisions keep purpose authority independent/ + ); + assert.match( + sharedTest, + /deletion proof binds the deterministic operation and bounded provider receipt/ + ); + + assert.match(service, /class SastEvidenceAccessService/); + assert.match(service, /async readDashboard/); + assert.match(service, /async classifyForAi/); + assert.match(service, /KNOWN_SECRET_PATTERNS/); + assert.match(service, /ENTROPY_TOKEN_PATTERN/); + assert.match(service, /confirmAccess/); + assert.doesNotMatch(service, /\bLogger\b|\bconsole\./u); + assert.match(store, /Prisma\.TransactionIsolationLevel\.Serializable/); + assert.match(store, /claimDeletion/); + assert.match(store, /finalizeDeletion/); + assert.match(store, /providerReceiptDigest/); + assert.match(registry, /UnavailableSastEvidenceSecretRegistry/); + assert.match( + registry, + /Promise\.resolve\(\{ status: 'UNAVAILABLE' \}\)/ + ); + assert.match( + deletionAuthority, + /UnavailableSastEvidenceDeletionAuthority/ + ); + assert.match(deletionService, /class SastEvidenceDeletionService/); + assert.match(deletionService, /DELETION_LEASE_MILLISECONDS/); + assert.match(deletionService, /isReceiptValid/); + assert.match(deletionTask, /MAXIMUM_DELETIONS_PER_TICK = 16/); + assert.match(deletionTask, /MAXIMUM_BACKFILLS_PER_TICK = 32/); + assert.match(dashboardController, /@UseGuards\(SessionAuthGuard\)/); + assert.match(dashboardController, /@Get\(':evidencePackId'\)/); + assert.match(dashboardController, /user\.tenantId/); + assert.match( + serviceTest, + /denies expired-at-start and expired-during-read without returning content/ + ); + assert.match( + serviceTest, + /denies late readers when the secret registry drifts or deletion is claimed/ + ); + assert.match( + serviceTest, + /claims by deterministic operation, deletes content, and retains one bounded proof/ + ); + assert.match( + serviceTest, + /fences concurrent workers and rejects a changed receipt after exact proof replay/ + ); + assert.match( + persistenceTest, + /serializable replay, claim fencing, and default-unavailable authorities/ + ); + + for (const model of [ + 'SastEvidenceAccessDecision', + 'SastEvidenceDeletionSchedule', + 'SastEvidenceDeletionClaim', + 'SastEvidenceDeletionProof' + ]) { + assert.match(schema, new RegExp(`model ${model} \\{`)); + assert.match(migration, new RegExp(`CREATE TABLE "${model}"`)); + } + assert.match(migration, /INTERVAL '7 days'/); + assert.match(migration, /INTERVAL '24 hours'/); + assert.match(migration, /SastEvidenceAccessDecision_immutable_update/); + assert.match(migration, /SastEvidenceDeletionSchedule_immutable_update/); + assert.match(migration, /SastEvidenceDeletionProof_immutable_update/); + assertScanPlaneExports(scanPlaneModule); + + 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/ + ); + assert.match(contract, /Evidence access and deletion gate v1/); + assert.match(dataModel, /SastEvidenceAccessDecision/); + assert.match(dataModel, /SastEvidenceDeletionProof/); + assert.match(plan, /Only `SastEvidenceAccessService` crosses the/); + assert.match(spec, /FR-048a/); + assert.match( + research, + /Decision 24: Separate Purpose-Bound Access from Receipt-Proven Content Deletion/ + ); + assert.match(threatModel, /False deletion proof/); + assert.match(qualityGates, /100% T042 deletion-proof 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 676787f..521bc37 100644 --- a/test/github-actions/ontology.test.mjs +++ b/test/github-actions/ontology.test.mjs @@ -80,7 +80,10 @@ test('active 006 spec explicitly reclassifies only the bounded issue 276 bootstr assert.match(spec, /MUST NOT receive Scan Plane, AI Plane, policy/); assert.match(spec, /does not[\s\S]*advance or satisfy T040/); assert.match(plan, /Issue #276 is an explicitly reclassified adjacent bootstrap/); - assert.match(plan, /did not advance or satisfy T040[\s\S]*proceeds to T042/); + assert.match( + plan, + /did not advance or satisfy T040[\s\S]*completed[\s\S]*T042[\s\S]*proceeds to T043/ + ); assert.match(tasks, /Approved Adjacent Bootstrap \(Does Not Advance 006\)/); assert.match(tasks, /Keep T040 as the next formal active-milestone task/); }); From d3ca9fb483ee13cabeb5da9b9ee3ef6815243ce0 Mon Sep 17 00:00:00 2001 From: goodtu02 <161540124+goodtu02@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:55:33 +0900 Subject: [PATCH 2/4] fix: address evidence retention review findings --- .../prisma-sast-evidence-access.store.ts | 25 ++++ .../sast-evidence-access.service.ts | 27 ++++ .../scan-plane/sast-evidence-access.store.ts | 2 + .../sast-evidence-deletion.service.ts | 21 ++- .../scan-plane/sast-evidence-deletion.task.ts | 96 ++++++++++---- ...st-evidence-access-persistence.e2e-spec.ts | 18 +++ .../sast-evidence-access.e2e-spec.ts | 124 +++++++++++++++++- .../contracts/sast-runtime.md | 18 ++- .../quality-gates.md | 7 +- .../quickstart.md | 7 +- .../spec.md | 7 +- .../threat-model.md | 4 +- test/github-actions/active-feature.test.mjs | 15 ++- 13 files changed, 325 insertions(+), 46 deletions(-) diff --git a/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts index 4a5eaee..853f3ba 100644 --- a/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts +++ b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts @@ -301,6 +301,31 @@ export class PrismaSastEvidenceAccessStore return scheduled; } + async nextDeletionDueAt(): Promise { + const rows = await this.prisma.$queryRaw< + Array<{ dueAt: Date | null }> + >(Prisma.sql` + SELECT MIN( + GREATEST( + s."deleteAfter", + CASE + WHEN c."status" = 'CLAIMED' + THEN COALESCE(c."leaseExpiresAt", c."nextAttemptAt") + ELSE c."nextAttemptAt" + END + ) + ) AS "dueAt" + FROM "SastEvidenceDeletionClaim" c + INNER JOIN "SastEvidenceDeletionSchedule" s + ON s."id" = c."scheduleId" + LEFT JOIN "SastEvidenceDeletionProof" p + ON p."scheduleId" = s."id" + WHERE c."status" IN ('PENDING', 'CLAIMED') + AND p."id" IS NULL + `); + return rows[0]?.dueAt?.toISOString() ?? null; + } + async claimDeletion(input: { referenceTime: string; leaseOwner: string; diff --git a/apps/api/src/scan-plane/sast-evidence-access.service.ts b/apps/api/src/scan-plane/sast-evidence-access.service.ts index d37d6ea..be9b448 100644 --- a/apps/api/src/scan-plane/sast-evidence-access.service.ts +++ b/apps/api/src/scan-plane/sast-evidence-access.service.ts @@ -185,6 +185,18 @@ export class SastEvidenceAccessService { classified.decision ); } + const returnedAt = readClock(clock); + if ( + !returnedAt || + Date.parse(returnedAt) < Date.parse(completedAt) || + Date.parse(returnedAt) >= + Date.parse(classified.decision.evidenceExpiresAt) + ) { + return denied( + 'EVIDENCE_ACCESS_EXPIRED', + classified.decision + ); + } return { outcome: 'ALLOWED', decision: classified.decision, @@ -272,6 +284,21 @@ export class SastEvidenceAccessService { classified.decision ); } + const returnedAt = readClock(clock); + if ( + !returnedAt || + Date.parse(returnedAt) < Date.parse(completedAt) || + Date.parse(returnedAt) >= + Date.parse(classified.decision.evidenceExpiresAt) || + !classified.decision.aiPayloadExpiresAt || + Date.parse(returnedAt) >= + Date.parse(classified.decision.aiPayloadExpiresAt) + ) { + return denied( + 'EVIDENCE_ACCESS_EXPIRED', + classified.decision + ); + } return { outcome: 'ALLOWED', decision: classified.decision, diff --git a/apps/api/src/scan-plane/sast-evidence-access.store.ts b/apps/api/src/scan-plane/sast-evidence-access.store.ts index 621d429..0ec6980 100644 --- a/apps/api/src/scan-plane/sast-evidence-access.store.ts +++ b/apps/api/src/scan-plane/sast-evidence-access.store.ts @@ -79,6 +79,8 @@ export abstract class SastEvidenceAccessStore { limit: number; }): Promise; + abstract nextDeletionDueAt(): Promise; + abstract claimDeletion(input: { referenceTime: string; leaseOwner: string; diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.service.ts b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts index 99979f5..a43a1b9 100644 --- a/apps/api/src/scan-plane/sast-evidence-deletion.service.ts +++ b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts @@ -42,6 +42,17 @@ export class SastEvidenceDeletionService { }); } + async nextDueAt(): Promise { + const value = await this.store.nextDeletionDueAt(); + if (value === null) return null; + if (!isCanonicalTimestamp(value)) { + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + return new Date(value); + } + async processNext( referenceTime: Date, workerId: string, @@ -91,7 +102,7 @@ export class SastEvidenceDeletionService { const observedAt = readClock(clock); if ( !observedAt || - !isReceiptValid(receipt, candidate, reference, observedAt) + !isReceiptValid(receipt, candidate, observedAt) ) { await this.safeRelease(candidate, referenceTime); return 'RETRY_SCHEDULED'; @@ -179,7 +190,6 @@ function isCandidateValid( function isReceiptValid( receipt: unknown, candidate: Readonly, - referenceTime: string, observedAt: string ): receipt is SastEvidenceDeletionReceipt { if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) { @@ -203,8 +213,11 @@ function isReceiptValid( typeof value.providerReceiptDigest === 'string' && /^sha256:[a-f0-9]{64}$/u.test(value.providerReceiptDigest) && isCanonicalTimestamp(value.completedAt) && - Date.parse(value.completedAt) >= Date.parse(referenceTime) && - Date.parse(value.completedAt) <= Date.parse(observedAt) + Date.parse(value.completedAt) >= + Date.parse(candidate.schedule.deleteAfter) && + Date.parse(value.completedAt) <= Date.parse(observedAt) && + Date.parse(value.completedAt) <= + Date.parse(candidate.leaseExpiresAt) ); } diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.task.ts b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts index 4cc233a..0d4bbea 100644 --- a/apps/api/src/scan-plane/sast-evidence-deletion.task.ts +++ b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts @@ -11,9 +11,10 @@ import { createSastEvidenceDeletionWorkerId } from './sast-evidence-deletion.service'; -const EVIDENCE_DELETION_INTERVAL_MILLISECONDS = 15 * 60 * 1000; -const MAXIMUM_DELETIONS_PER_TICK = 16; -const MAXIMUM_BACKFILLS_PER_TICK = 32; +const EVIDENCE_DELETION_DISCOVERY_INTERVAL_MILLISECONDS = 60_000; +const EVIDENCE_DELETION_ERROR_RETRY_MILLISECONDS = 1_000; +const MAXIMUM_DELETIONS_PER_BATCH = 64; +const MAXIMUM_BACKFILLS_PER_BATCH = 128; @Injectable() export class SastEvidenceDeletionTask @@ -24,6 +25,8 @@ export class SastEvidenceDeletionTask private readonly workerId = createSastEvidenceDeletionWorkerId(); private timer: NodeJS.Timeout | null = null; private inFlight = false; + private started = false; + private batchSaturated = false; constructor( private readonly service: SastEvidenceDeletionService, @@ -32,48 +35,89 @@ export class SastEvidenceDeletionTask onModuleInit(): void { if (this.config.isTest()) return; - this.timer = setInterval(() => { - if (this.inFlight) return; - this.inFlight = true; - void this.processBatch() - .catch((error: unknown) => { - this.logger.error( - 'Failed to process bounded evidence deletion.', - error instanceof Error ? error.name : 'UnknownError' - ); - }) - .finally(() => { - this.inFlight = false; - }); - }, EVIDENCE_DELETION_INTERVAL_MILLISECONDS); - this.timer.unref?.(); + this.started = true; + this.schedule(0); } onModuleDestroy(): void { + this.started = false; if (this.timer) { - clearInterval(this.timer); + clearTimeout(this.timer); this.timer = null; } } - async processBatch(referenceTime = new Date()): Promise { - await this.service.backfill( - referenceTime, - MAXIMUM_BACKFILLS_PER_TICK + async processBatch(referenceTime?: Date): Promise { + const startedAt = referenceTime ?? new Date(); + const backfilled = await this.service.backfill( + startedAt, + MAXIMUM_BACKFILLS_PER_BATCH ); let processed = 0; + let attempted = 0; + let reachedIdle = false; for ( let index = 0; - index < MAXIMUM_DELETIONS_PER_TICK; + index < MAXIMUM_DELETIONS_PER_BATCH; index += 1 ) { const result = await this.service.processNext( - referenceTime, + referenceTime ?? new Date(), this.workerId ); - if (result === 'IDLE') break; + attempted += 1; + if (result === 'IDLE') { + reachedIdle = true; + break; + } if (result !== 'LEASE_LOST') processed += 1; } + this.batchSaturated = + backfilled === MAXIMUM_BACKFILLS_PER_BATCH || + (!reachedIdle && attempted === MAXIMUM_DELETIONS_PER_BATCH); return processed; } + + private schedule(delayMilliseconds: number): void { + if (!this.started) return; + this.timer = setTimeout(() => { + this.timer = null; + void this.runScheduled(); + }, delayMilliseconds); + this.timer.unref?.(); + } + + private async runScheduled(): Promise { + if (this.inFlight) { + this.schedule(0); + return; + } + this.inFlight = true; + let nextDelay = EVIDENCE_DELETION_ERROR_RETRY_MILLISECONDS; + try { + await this.processBatch(); + if (this.batchSaturated) { + nextDelay = 0; + } else { + const nextDueAt = await this.service.nextDueAt(); + nextDelay = nextDueAt + ? Math.max( + 0, + Math.min( + EVIDENCE_DELETION_DISCOVERY_INTERVAL_MILLISECONDS, + nextDueAt.getTime() - Date.now() + ) + ) + : EVIDENCE_DELETION_DISCOVERY_INTERVAL_MILLISECONDS; + } + } catch (error) { + this.logger.error( + 'Failed to process bounded evidence deletion.', + error instanceof Error ? error.name : 'UnknownError' + ); + } finally { + this.inFlight = false; + this.schedule(nextDelay); + } + } } diff --git a/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts index 6d04d43..d79b874 100644 --- a/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts @@ -15,6 +15,9 @@ describe('SAST evidence access and deletion persistence contract', () => { const deletion = read( 'src/scan-plane/sast-evidence-deletion.service.ts' ); + const deletionTask = read( + 'src/scan-plane/sast-evidence-deletion.task.ts' + ); const authority = read( 'src/scan-plane/sast-evidence-deletion.authority.ts' ); @@ -166,6 +169,21 @@ describe('SAST evidence access and deletion persistence contract', () => { expect(deletion).toContain( "return finalized.replayed ? 'REPLAYED' : 'DELETED'" ); + expect(deletion).toMatch( + /Date\.parse\(value\.completedAt\)\s*>=\s*Date\.parse\(candidate\.schedule\.deleteAfter\)/u + ); + expect(deletion).not.toMatch( + /Date\.parse\(value\.completedAt\)\s*>=\s*Date\.parse\(referenceTime\)/u + ); + expect(store).toContain('async nextDeletionDueAt()'); + expect(store).toContain('SELECT MIN('); + expect(deletionTask).toContain('this.schedule(0)'); + expect(deletionTask).toMatch( + /if \(this\.batchSaturated\) \{\s*nextDelay = 0;/u + ); + expect(deletionTask).toContain( + 'nextDueAt.getTime() - Date.now()' + ); expect(authority).toContain( 'UnavailableSastEvidenceDeletionAuthority' ); diff --git a/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts index 857db28..3d38640 100644 --- a/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts @@ -26,6 +26,7 @@ import { } from '../../src/scan-plane/sast-evidence-access.store'; import { SastEvidenceDeletionAuthority } from '../../src/scan-plane/sast-evidence-deletion.authority'; import { SastEvidenceDeletionService } from '../../src/scan-plane/sast-evidence-deletion.service'; +import { SastEvidenceDeletionTask } from '../../src/scan-plane/sast-evidence-deletion.task'; import { SastEvidenceSecretRegistry, type SastEvidenceSecretRegistryResult @@ -34,6 +35,7 @@ import { const CREATED_AT = '2026-08-10T04:40:00.000Z'; const BEFORE_EXPIRY = '2026-08-17T04:39:59.000Z'; const EXPIRES_AT = '2026-08-17T04:40:00.000Z'; +const AFTER_RETRY = '2026-08-17T04:41:00.000Z'; const PLATFORM_SECRET = 'platform-secret-value'; const ENTROPY_SECRET = 'aB3dE5fG7hJ9kL2mN4pQ6rS8tU0vW1xY'; @@ -171,6 +173,45 @@ describe('SastEvidenceAccessService', () => { reasonCode: 'EVIDENCE_ACCESS_EXPIRED', dashboardEvidence: null }); + + const finalDashboardContext = accessContext( + acceptedEvidence('safe content') + ); + const finalDashboard = await new SastEvidenceAccessService( + new MemoryAccessStore(finalDashboardContext), + verifiedRegistry() + ).readDashboard( + request(finalDashboardContext), + clock( + BEFORE_EXPIRY, + BEFORE_EXPIRY, + BEFORE_EXPIRY, + EXPIRES_AT + ) + ); + expect(finalDashboard).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_EXPIRED', + dashboardEvidence: null + }); + + const finalAiContext = accessContext( + acceptedEvidence('safe content') + ); + finalAiContext.tenantAiAdvisoryOptIn = true; + finalAiContext.repositoryAiAdvisoryOptIn = true; + const finalAi = await new SastEvidenceAccessService( + new MemoryAccessStore(finalAiContext), + verifiedRegistry() + ).classifyForAi( + request(finalAiContext), + clock(BEFORE_EXPIRY, BEFORE_EXPIRY, EXPIRES_AT) + ); + expect(finalAi).toMatchObject({ + outcome: 'DENIED', + reasonCode: 'EVIDENCE_ACCESS_EXPIRED', + reducedEvidenceReference: null + }); }); it('denies late readers when the secret registry drifts or deletion is claimed', async () => { @@ -441,6 +482,32 @@ describe('SastEvidenceDeletionService', () => { expect(rollbackContext.deletionProof).toBeNull(); }); + it('accepts the original deterministic receipt when finalization retries later', async () => { + const context = accessContext(acceptedEvidence('safe content')); + const store = new MemoryAccessStore(context, 1); + const authority = new MemoryDeletionAuthority(EXPIRES_AT); + const service = new SastEvidenceDeletionService(store, authority); + + await expect( + service.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => EXPIRES_AT + ) + ).resolves.toBe('RETRY_SCHEDULED'); + expect(context.deletionState).toBe('ACTIVE'); + + await expect( + service.processNext( + new Date(AFTER_RETRY), + 'worker-2', + () => AFTER_RETRY + ) + ).resolves.toBe('DELETED'); + expect(authority.calls).toHaveLength(2); + expect(context.deletionProof?.completedAt).toBe(EXPIRES_AT); + }); + it('fences concurrent workers and rejects a changed receipt after exact proof replay', async () => { const concurrentContext = accessContext( acceptedEvidence('safe content') @@ -513,11 +580,52 @@ describe('SastEvidenceDeletionService', () => { }); }); +describe('SastEvidenceDeletionTask', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts immediately and wakes at the earliest durable deletion deadline', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(EXPIRES_AT)); + const nextDueAt = new Date( + Date.parse(EXPIRES_AT) + 30_000 + ); + const service = { + backfill: jest.fn().mockResolvedValue(0), + processNext: jest.fn().mockResolvedValue('IDLE'), + nextDueAt: jest + .fn() + .mockResolvedValueOnce(nextDueAt) + .mockResolvedValue(null) + }; + const task = new SastEvidenceDeletionTask( + service as never, + { isTest: () => false } as never + ); + + task.onModuleInit(); + await jest.advanceTimersByTimeAsync(0); + expect(service.backfill).toHaveBeenCalledTimes(1); + expect(service.nextDueAt).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(29_999); + expect(service.backfill).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + expect(service.backfill).toHaveBeenCalledTimes(2); + + task.onModuleDestroy(); + }); +}); + class MemoryAccessStore extends SastEvidenceAccessStore { readonly decisions: SastEvidenceAccessDecision[] = []; private candidate: SastEvidenceDeletionCandidate | null = null; - constructor(private readonly context: SastEvidenceAccessContext) { + constructor( + private readonly context: SastEvidenceAccessContext, + private remainingFinalizeFailures = 0 + ) { super(); } @@ -587,6 +695,16 @@ class MemoryAccessStore extends SastEvidenceAccessStore { return Promise.resolve(0); } + nextDeletionDueAt(): Promise { + if (!this.context.result || this.context.deletionProof) { + return Promise.resolve(null); + } + return Promise.resolve( + this.candidate?.leaseExpiresAt ?? + this.context.schedule.deleteAfter + ); + } + claimDeletion(input: { referenceTime: string; leaseOwner: string; @@ -615,6 +733,10 @@ class MemoryAccessStore extends SastEvidenceAccessStore { receipt: Readonly; proof: Readonly; }): Promise<{ proof: SastEvidenceDeletionProof; replayed: boolean }> { + if (this.remainingFinalizeFailures > 0) { + this.remainingFinalizeFailures -= 1; + return Promise.reject(new Error('transient persistence failure')); + } if (this.context.deletionProof) { if ( JSON.stringify(this.context.deletionProof) === 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 3ed983b..ba1dd71 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1200,8 +1200,9 @@ content, flags, paths, identifiers, timestamps, and digests grant no authority. Each purpose independently reruns known-format, registered platform-value, and entropy redaction, validates canonical paths and identifiers, and recomputes every content, pack, projection, and decision digest. The registry authority defaults `UNAVAILABLE`. Time is checked -before and immediately after storage confirmation; expiry, an active deletion claim, clock -rollback, registry-version drift, unsafe identifier, or any durable mismatch denies. Neither +before storage confirmation and again after the final awaited confirmation, immediately before +return; expiry, an active deletion claim, clock rollback, registry-version drift, unsafe +identifier, or any durable mismatch denies. Neither raw/pre-redaction content, secret values, matched-value digests, nor access-time redacted content enters the access ledger, logs, audit, or error response. @@ -1215,10 +1216,15 @@ and null classification/deletion fields remain unchanged. Every accepted pack creates a deterministic `sast-evidence-delete://` operation with a positive retention window no longer than seven days. Due work uses one leased claim with an -owner and unique fencing token. The deletion provider defaults `UNAVAILABLE`; retry releases -the claim without weakening access denial. Only a bounded receipt bound to the exact operation, -pack, provider, reference, digest, and monotonic completion time may authorize pack/fragment -content deletion and finalization of `sast-evidence-deletion-proof-v1`. The T041 build decision, +owner and unique fencing token. The task runs immediately on startup, wakes at the earliest +durable due time, and gives a saturated bounded batch a zero-delay continuation so a fixed poll +interval or per-tick cap cannot create a retention backlog. The deletion provider defaults +`UNAVAILABLE`; retry releases the claim without weakening access denial. Only a bounded receipt +bound to the exact operation, pack, provider, reference, digest, and monotonic completion time +may authorize pack/fragment content deletion and finalization of +`sast-evidence-deletion-proof-v1`. An exact deterministic provider replay may return the original +receipt from an earlier claim; its completion must remain at or after `deleteAfter` and no later +than the current observation and fencing lease. The T041 build decision, schedule, access decisions, canonical proof, and bounded audit state remain retained. Exact replay is idempotent; a stale token, changed receipt, late reader, deletion race, or clock rollback fails closed. diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index aa71e97..4adff56 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -312,8 +312,11 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl - 100% T042 deletion-proof invariant: every accepted pack has one deterministic at-most-seven- day schedule and one fenced claim. Pack/fragment content is removed only after a valid operation-bound provider receipt; one immutable proof remains with the T041 build decision. - Unavailable providers, stale tokens, changed receipts, concurrent workers, late readers, - clock rollback, and exact replay create zero false proofs, duplicate rows, or restored content. + Startup processing, earliest-due wakeup, and zero-delay continuation for saturated bounded + batches prevent poll/cap backlog. Unavailable providers, stale tokens, changed receipts, + concurrent workers, late readers, clock rollback, exact replay, and an original receipt + recovered after finalization failure create zero false proofs, duplicate rows, overdue + content, or restored content. ## 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 974b803..adb3e2b 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -502,8 +502,11 @@ portion of Phase 6: - Expiry processing uses a deterministic operation ID, lease owner/token fencing, a deletion authority that defaults unavailable, and a bounded provider receipt. Only a valid receipt permits pack/fragment deletion and canonical `sast-evidence-deletion-proof-v1` completion. - The T041 build decision and bounded audit/proof ledgers remain durable, and replay or a - changed receipt cannot mutate the result. + The worker starts immediately, schedules against the earliest durable due timestamp, and + immediately continues a saturated bounded batch. The T041 build decision and bounded + audit/proof ledgers remain durable. An original receipt from an exact deterministic retry + succeeds when it remains deadline/observation/lease bounded; a changed receipt cannot mutate + the result. 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 diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index c161c8c..085a0a8 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -358,9 +358,14 @@ incomplete, stale, quarantined, or security-blocked scan. claim and a deletion provider that defaults unavailable. Pack/fragment content MUST be deleted only after a bounded provider receipt is validated and an immutable canonical proof is committed; the T041 build decision and bounded proof/audit state MUST remain retained. + The deletion task MUST run at startup, target the earliest durable due timestamp, and + immediately continue saturated bounded batches so polling delay or a per-tick cap cannot + extend retention. - **FR-048e**: Concurrent access, schedule, claim, receipt, and proof operations MUST permit exact replay only. Late readers, changed receipts, stale lease owners, deletion races, and - reference-time rollback MUST fail closed without returning or restoring content. + reference-time rollback MUST fail closed without returning or restoring content. An exact + deterministic deletion retry MAY reuse its original receipt when completion is at or after + `deleteAfter` and no later than the current observation and lease. - **FR-049**: Evidence MUST NOT contain a full file, repository archive, or fragments that can reconstruct a substantial repository portion. - **FR-050**: Evidence retention MUST NOT exceed seven days; AI request payload retention diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index 47e9ee4..c52a9c9 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -85,8 +85,8 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Evidence source forgery | A caller supplies a path/range, finding authority, or fragment that is not the durable accepted occurrence | Rebind exact T040/T039/T038/T037 rows and require an internal source attestation that defaults unavailable | Cross-scope, missing occurrence, changed fingerprint, path/range, and unavailable-source fixtures reject | | Evidence reconstruction | Multiple snippets rebuild source | 32 KiB/five-fragment/8 KiB/five-context caps; per-file maximum two; reject full-file, overlap, adjacency, or at least 25% combined line coverage | Evidence build reject and immutable audit with zero pack | | Evidence-purpose confusion | Dashboard consent or one stale decision is reused to construct an AI payload | Separate immutable dashboard/AI decisions, complete T041 chain rebind, access-time redaction/classification, and explicit zero provider/tool authority | Purpose swap, opt-in, registry drift, unsafe identifier, cross-tenant, and replay fixtures deny | -| Evidence expiry race | A reader returns content while expiry/deletion is claimed or after the final clock check | Check retention before read, confirm unchanged schedule/claim/proof and monotonic time after classification, and deny from claim onward | Expiry-before/during-read, late-reader, deletion-race, and clock-rollback fixtures return no content | -| False deletion proof | A worker marks evidence deleted without the backing provider removing it, or a stale worker finalizes | Deterministic operation, leased owner/token fence, default-unavailable provider, bounded operation-bound receipt, delete-then immutable proof | Unavailable provider, changed receipt, stale token, concurrent claim/finalize, and exact replay corpus | +| 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 create a retention backlog | Deterministic operation, leased owner/token fence, default-unavailable provider, deadline-aware startup/earliest-due scheduling, saturated zero-delay continuation, exact receipt replay, delete-then immutable proof | Unavailable provider, changed/original receipt, stale token, concurrent claim/finalize, deadline wakeup, and exact replay corpus | | 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 | diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 6e215c8..b594da6 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -1507,8 +1507,11 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(deletionService, /class SastEvidenceDeletionService/); assert.match(deletionService, /DELETION_LEASE_MILLISECONDS/); assert.match(deletionService, /isReceiptValid/); - assert.match(deletionTask, /MAXIMUM_DELETIONS_PER_TICK = 16/); - assert.match(deletionTask, /MAXIMUM_BACKFILLS_PER_TICK = 32/); + assert.match(deletionTask, /MAXIMUM_DELETIONS_PER_BATCH = 64/); + assert.match(deletionTask, /MAXIMUM_BACKFILLS_PER_BATCH = 128/); + assert.match(deletionTask, /this\.schedule\(0\)/); + assert.match(deletionTask, /if \(this\.batchSaturated\)/); + assert.match(deletionTask, /nextDueAt\.getTime\(\) - Date\.now\(\)/); assert.match(dashboardController, /@UseGuards\(SessionAuthGuard\)/); assert.match(dashboardController, /@Get\(':evidencePackId'\)/); assert.match(dashboardController, /user\.tenantId/); @@ -1528,6 +1531,14 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( serviceTest, /fences concurrent workers and rejects a changed receipt after exact proof replay/ ); + assert.match( + serviceTest, + /accepts the original deterministic receipt when finalization retries later/ + ); + assert.match( + serviceTest, + /starts immediately and wakes at the earliest durable deletion deadline/ + ); assert.match( persistenceTest, /serializable replay, claim fencing, and default-unavailable authorities/ From 9bb3644f5fef9a393ed7416789e1f594dd6f2494 Mon Sep 17 00:00:00 2001 From: goodtu02 <161540124+goodtu02@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:20:22 +0900 Subject: [PATCH 3/4] fix: harden evidence deletion recovery --- .../migration.sql | 38 +++- apps/api/prisma/schema.prisma | Bin 89811 -> 90058 bytes .../prisma-sast-evidence-access.store.ts | 167 +++++++++++++----- .../sast-evidence-access.service.ts | 15 +- .../sast-evidence-deletion.service.ts | 51 ++++-- .../scan-plane/sast-evidence-deletion.task.ts | 10 +- ...-accepted-evidence-persistence.e2e-spec.ts | 6 +- ...st-evidence-access-persistence.e2e-spec.ts | 49 +++-- .../sast-evidence-access.e2e-spec.ts | 90 +++++++++- ...sast-scan-coverage-persistence.e2e-spec.ts | 5 +- ...ast-scan-freshness-persistence.e2e-spec.ts | 5 +- .../test/support/scan-plane-module-source.ts | 9 + .../shared/src/types/sast-evidence-access.ts | 57 ++++-- .../shared/test/sast-evidence-access.test.mjs | 50 ++++++ .../contracts/sast-runtime.md | 13 ++ .../data-model.md | 14 +- .../quality-gates.md | 3 +- .../spec.md | 4 +- .../threat-model.md | 2 +- test/github-actions/active-feature.test.mjs | 22 +++ 20 files changed, 488 insertions(+), 122 deletions(-) create mode 100644 apps/api/test/support/scan-plane-module-source.ts diff --git a/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql b/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql index 25123bb..00baec5 100644 --- a/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql +++ b/apps/api/prisma/migrations/20260810070000_sast_evidence_access_deletion/migration.sql @@ -165,17 +165,39 @@ CREATE TABLE "SastEvidenceDeletionClaim" ( "leaseExpiresAt" TIMESTAMP(3), "nextAttemptAt" TIMESTAMP(3) NOT NULL, "attemptCount" INTEGER NOT NULL DEFAULT 0, + "lastErrorCode" TEXT, + "quarantinedAt" TIMESTAMP(3), "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "SastEvidenceDeletionClaim_pkey" PRIMARY KEY ("scheduleId"), CONSTRAINT "SastEvidenceDeletionClaim_contract_check" CHECK ( "scheduleId" ~ '^sast-evidence-deletion://[a-f0-9]{64}$' - AND "status" IN ('PENDING', 'CLAIMED', 'COMPLETED') + AND "status" IN ('PENDING', 'CLAIMED', 'COMPLETED', 'QUARANTINED') AND "attemptCount" >= 0 + AND ("lastErrorCode" IS NULL OR "lastErrorCode" = 'CONTEXT_DRIFT') AND ( - ("status" = 'CLAIMED' AND "leaseOwner" IS NOT NULL AND "leaseToken" IS NOT NULL AND "leaseExpiresAt" IS NOT NULL) - OR - ("status" IN ('PENDING', 'COMPLETED') AND "leaseOwner" IS NULL AND "leaseToken" IS NULL AND "leaseExpiresAt" IS NULL) + ( + "status" = 'CLAIMED' + AND "leaseOwner" IS NOT NULL + AND "leaseToken" IS NOT NULL + AND "leaseExpiresAt" IS NOT NULL + AND "quarantinedAt" IS NULL + ) + OR ( + "status" IN ('PENDING', 'COMPLETED') + AND "leaseOwner" IS NULL + AND "leaseToken" IS NULL + AND "leaseExpiresAt" IS NULL + AND "quarantinedAt" IS NULL + ) + OR ( + "status" = 'QUARANTINED' + AND "leaseOwner" IS NULL + AND "leaseToken" IS NULL + AND "leaseExpiresAt" IS NULL + AND "lastErrorCode" = 'CONTEXT_DRIFT' + AND "quarantinedAt" IS NOT NULL + ) ) ) ); @@ -241,6 +263,12 @@ CREATE INDEX "SastEvidenceAccessDecision_aiPayloadExpiresAt_idx" ON "SastEvidenceAccessDecision"("aiPayloadExpiresAt"); CREATE INDEX "SastEvidenceAccessDecision_deletionScheduleId_idx" ON "SastEvidenceAccessDecision"("deletionScheduleId"); +CREATE INDEX "SastEvidenceAccessDecision_scan_scope_idx" + ON "SastEvidenceAccessDecision"("scanRequestId", "tenantId", "repositoryBindingId"); +CREATE INDEX "SastEvidenceAccessDecision_build_scope_idx" + ON "SastEvidenceAccessDecision"("buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE INDEX "SastEvidenceDeletionSchedule_scan_scope_idx" + ON "SastEvidenceDeletionSchedule"("scanRequestId", "tenantId", "repositoryBindingId"); CREATE UNIQUE INDEX "SastEvidenceDeletionClaim_leaseToken_key" ON "SastEvidenceDeletionClaim"("leaseToken"); @@ -321,6 +349,8 @@ ALTER TABLE "SastEvidenceDeletionProof" ADD CONSTRAINT "SastEvidenceDeletionProof_schedule_scope_fkey" FOREIGN KEY ("scheduleId", "operationId", "tenantId") REFERENCES "SastEvidenceDeletionSchedule"("id", "operationId", "tenantId") + -- Deliberate audit hold: normal offboarding soft-revokes tenant/repository + -- scope. The documented exceptional purge deletes proof ledgers first. ON DELETE RESTRICT ON UPDATE CASCADE; CREATE FUNCTION "reject_sast_evidence_access_ledger_update"() diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a7274933f85f8f6e8f3cf679a944ac390971e802..acf00439a06eb63a34a24b126adc606d3d835692 100644 GIT binary patch literal 90058 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 W*btG9t!%B8RKgLw50L+9?*9Yob>lJs literal 89811 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 A&j0`b diff --git a/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts index 853f3ba..edad1e0 100644 --- a/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts +++ b/apps/api/src/scan-plane/prisma-sast-evidence-access.store.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, randomInt, randomUUID } from 'node:crypto'; import { setTimeout as delay } from 'node:timers/promises'; import { @@ -33,7 +33,10 @@ import { const SERIALIZABLE_ATTEMPTS = 3; const SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS = 10; const SERIALIZABLE_MAX_WAIT_MILLISECONDS = 5_000; -const SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000; +const SERIALIZABLE_INTERACTIVE_TIMEOUT_MILLISECONDS = 10_000; +const SERIALIZABLE_BACKGROUND_TIMEOUT_MILLISECONDS = 120_000; +const MAXIMUM_CONTEXT_DRIFT_ATTEMPTS = 3; +const CONTEXT_DRIFT_RETRY_MILLISECONDS = 60_000; const scheduleInclude = Prisma.validator()({ claim: true, @@ -75,6 +78,10 @@ type PackRow = Prisma.SastAcceptedEvidencePackGetPayload<{ }>; type EvidenceTransaction = Prisma.TransactionClient; +interface DriftedDeletionClaim { + driftedScheduleId: string; +} + @Injectable() export class PrismaSastEvidenceAccessStore extends SastEvidenceAccessStore { @@ -104,7 +111,7 @@ export class PrismaSastEvidenceAccessStore row = await this.readSchedule(transaction, input); } return row ? contextFromRow(row) : null; - }); + }, SERIALIZABLE_INTERACTIVE_TIMEOUT_MILLISECONDS); } async persistDecision(input: { @@ -197,7 +204,7 @@ export class PrismaSastEvidenceAccessStore decision: decision as SastEvidenceAccessDecision, replayed: false }; - }); + }, SERIALIZABLE_INTERACTIVE_TIMEOUT_MILLISECONDS); } async confirmAccess(input: { @@ -265,7 +272,7 @@ export class PrismaSastEvidenceAccessStore ); } return context.deletionState === 'ACTIVE' ? context : null; - }); + }, SERIALIZABLE_INTERACTIVE_TIMEOUT_MILLISECONDS); } async backfillDeletionSchedules(input: { @@ -273,32 +280,34 @@ export class PrismaSastEvidenceAccessStore limit: number; }): Promise { const limit = Math.max(1, Math.min(128, input.limit)); - const rows = await this.prisma.$queryRaw< - Array<{ - id: string; - tenantId: string; - repositoryBindingId: string; - }> - >(Prisma.sql` - SELECT p."id", p."tenantId", p."repositoryBindingId" - FROM "SastAcceptedEvidencePack" p - LEFT JOIN "SastEvidenceDeletionSchedule" s - ON s."evidencePackId" = p."id" - WHERE s."id" IS NULL - ORDER BY p."expiresAt" ASC, p."id" ASC - LIMIT ${limit} - `); - let scheduled = 0; - for (const row of rows) { - const loaded = await this.load({ - tenantId: row.tenantId, - repositoryBindingId: row.repositoryBindingId, - evidencePackId: row.id, - referenceTime: input.referenceTime - }); - if (loaded) scheduled += 1; - } - return scheduled; + return this.runSerializable(async (transaction) => { + const rows = await transaction.$queryRaw>( + Prisma.sql` + SELECT p."id" + FROM "SastAcceptedEvidencePack" p + WHERE NOT EXISTS ( + SELECT 1 + FROM "SastEvidenceDeletionSchedule" s + WHERE s."evidencePackId" = p."id" + ) + ORDER BY p."expiresAt" ASC, p."id" ASC + FOR UPDATE OF p SKIP LOCKED + LIMIT ${limit} + ` + ); + let scheduled = 0; + for (const row of rows) { + const pack = + await transaction.sastAcceptedEvidencePack.findUnique({ + where: { id: row.id }, + include: packInclude + }); + if (!pack) continue; + await this.createSchedule(transaction, pack); + scheduled += 1; + } + return scheduled; + }); } async nextDeletionDueAt(): Promise { @@ -331,7 +340,9 @@ export class PrismaSastEvidenceAccessStore leaseOwner: string; leaseExpiresAt: string; }): Promise { - return this.runSerializable(async (transaction) => { + const claimed = await this.runSerializable< + SastEvidenceDeletionCandidate | DriftedDeletionClaim | null + >(async (transaction) => { const referenceTime = new Date(input.referenceTime); const claim = await transaction.sastEvidenceDeletionClaim.findFirst({ @@ -358,7 +369,18 @@ export class PrismaSastEvidenceAccessStore } }); if (!claim) return null; - const context = contextFromRow(claim.schedule); + let context: SastEvidenceAccessContext; + try { + context = contextFromRow(claim.schedule); + } catch (error) { + if ( + error instanceof SastEvidenceAccessPersistenceError && + error.reason === 'CONTEXT_DRIFT' + ) { + return { driftedScheduleId: claim.scheduleId }; + } + throw error; + } if ( context.deletionState === 'DELETED' || !context.result?.pack || @@ -367,9 +389,7 @@ export class PrismaSastEvidenceAccessStore Date.parse(context.result.pack.expiresAt) > referenceTime.getTime() ) { - throw new SastEvidenceAccessPersistenceError( - 'CONTEXT_DRIFT' - ); + return { driftedScheduleId: claim.scheduleId }; } const leaseToken = randomUUID(); const updated = @@ -389,7 +409,9 @@ export class PrismaSastEvidenceAccessStore leaseOwner: input.leaseOwner, leaseToken, leaseExpiresAt: new Date(input.leaseExpiresAt), - attemptCount: { increment: 1 } + attemptCount: { increment: 1 }, + lastErrorCode: null, + quarantinedAt: null } }); if (updated.count !== 1) return null; @@ -400,6 +422,16 @@ export class PrismaSastEvidenceAccessStore leaseExpiresAt: input.leaseExpiresAt }; }); + if (claimed && 'driftedScheduleId' in claimed) { + await this.fenceDriftedClaim( + claimed.driftedScheduleId, + input.referenceTime + ); + throw new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ); + } + return claimed; } async finalizeDeletion(input: { @@ -502,7 +534,9 @@ export class PrismaSastEvidenceAccessStore leaseOwner: null, leaseToken: null, leaseExpiresAt: null, - nextAttemptAt: new Date(input.proof.completedAt) + nextAttemptAt: new Date(input.proof.completedAt), + lastErrorCode: null, + quarantinedAt: null } }); if (completed.count !== 1) { @@ -533,7 +567,9 @@ export class PrismaSastEvidenceAccessStore leaseOwner: null, leaseToken: null, leaseExpiresAt: null, - nextAttemptAt: new Date(input.retryAt) + nextAttemptAt: new Date(input.retryAt), + lastErrorCode: null, + quarantinedAt: null } }); if (updated.count !== 1) { @@ -541,6 +577,45 @@ export class PrismaSastEvidenceAccessStore } } + private async fenceDriftedClaim( + scheduleId: string, + referenceTime: string + ): Promise { + const observedAt = new Date(referenceTime); + const retryAt = new Date( + observedAt.getTime() + CONTEXT_DRIFT_RETRY_MILLISECONDS + ); + await this.prisma.$executeRaw(Prisma.sql` + UPDATE "SastEvidenceDeletionClaim" + SET + "status" = CASE + WHEN "attemptCount" + 1 >= ${MAXIMUM_CONTEXT_DRIFT_ATTEMPTS} + THEN 'QUARANTINED' + ELSE 'PENDING' + END, + "leaseOwner" = NULL, + "leaseToken" = NULL, + "leaseExpiresAt" = NULL, + "nextAttemptAt" = ${retryAt}, + "attemptCount" = "attemptCount" + 1, + "lastErrorCode" = 'CONTEXT_DRIFT', + "quarantinedAt" = CASE + WHEN "attemptCount" + 1 >= ${MAXIMUM_CONTEXT_DRIFT_ATTEMPTS} + THEN ${observedAt} + ELSE NULL + END, + "updatedAt" = ${observedAt} + WHERE "scheduleId" = ${scheduleId} + AND ( + "status" = 'PENDING' + OR ( + "status" = 'CLAIMED' + AND "leaseExpiresAt" <= ${observedAt} + ) + ) + `); + } + private readSchedule( reader: EvidenceTransaction, input: { @@ -612,7 +687,9 @@ export class PrismaSastEvidenceAccessStore } private async runSerializable( - operation: (transaction: EvidenceTransaction) => Promise + operation: (transaction: EvidenceTransaction) => Promise, + timeoutMilliseconds = + SERIALIZABLE_BACKGROUND_TIMEOUT_MILLISECONDS ): Promise { let lastError: unknown; for ( @@ -625,7 +702,7 @@ export class PrismaSastEvidenceAccessStore isolationLevel: Prisma.TransactionIsolationLevel.Serializable, maxWait: SERIALIZABLE_MAX_WAIT_MILLISECONDS, - timeout: SERIALIZABLE_TIMEOUT_MILLISECONDS + timeout: timeoutMilliseconds }); } catch (error) { lastError = error; @@ -636,7 +713,10 @@ export class PrismaSastEvidenceAccessStore throw error; } await delay( - SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS * attempt + SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS * attempt + + randomInt( + SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS + 1 + ) ); } } @@ -732,7 +812,8 @@ function contextFromRow(row: ScheduleRow): SastEvidenceAccessContext { } const deletionState = canonicalProof ? 'DELETED' - : row.claim.status === 'CLAIMED' + : row.claim.status === 'CLAIMED' || + row.claim.status === 'QUARANTINED' ? 'DELETION_PENDING' : 'ACTIVE'; if ( diff --git a/apps/api/src/scan-plane/sast-evidence-access.service.ts b/apps/api/src/scan-plane/sast-evidence-access.service.ts index be9b448..7888d53 100644 --- a/apps/api/src/scan-plane/sast-evidence-access.service.ts +++ b/apps/api/src/scan-plane/sast-evidence-access.service.ts @@ -299,14 +299,19 @@ export class SastEvidenceAccessService { classified.decision ); } + const reference = reducedReference(classified.decision); + if (!reference) { + return denied( + 'EVIDENCE_ACCESS_OUTPUT_INVALID', + classified.decision + ); + } return { outcome: 'ALLOWED', decision: classified.decision, replayed: classified.replayed, dashboardEvidence: null, - reducedEvidenceReference: reducedReference( - classified.decision - ) + reducedEvidenceReference: reference }; } @@ -860,13 +865,13 @@ function dashboardEvidence( function reducedReference( decision: Readonly -): SastReducedEvidenceReference { +): SastReducedEvidenceReference | null { if ( !decision.reducedEvidenceRef || !decision.redactedProjectionDigest || !decision.aiPayloadExpiresAt ) { - throw new Error('Allowed AI classification is incomplete.'); + return null; } return { version: SAST_REDUCED_EVIDENCE_REFERENCE_VERSION, diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.service.ts b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts index a43a1b9..9e98e00 100644 --- a/apps/api/src/scan-plane/sast-evidence-deletion.service.ts +++ b/apps/api/src/scan-plane/sast-evidence-deletion.service.ts @@ -6,17 +6,14 @@ import { isSastEvidenceDeletionScheduleShapeValid, type SastEvidenceDeletionReceipt } from '@aegisai/shared'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { SastEvidenceAccessPersistenceError, SastEvidenceAccessStore, type SastEvidenceDeletionCandidate } from './sast-evidence-access.store'; -import { - SastEvidenceDeletionAuthority, - SastEvidenceDeletionAuthorityUnavailableError -} from './sast-evidence-deletion.authority'; +import { SastEvidenceDeletionAuthority } from './sast-evidence-deletion.authority'; const DELETION_LEASE_MILLISECONDS = 60_000; const DELETION_RETRY_MILLISECONDS = 60_000; @@ -30,6 +27,10 @@ export type SastEvidenceDeletionProcessingResult = @Injectable() export class SastEvidenceDeletionService { + private readonly logger = new Logger( + SastEvidenceDeletionService.name + ); + constructor( private readonly store: SastEvidenceAccessStore, private readonly authority: SastEvidenceDeletionAuthority @@ -65,13 +66,28 @@ export class SastEvidenceDeletionService { return 'IDLE'; } const reference = referenceTime.toISOString(); - const candidate = await this.store.claimDeletion({ - referenceTime: reference, - leaseOwner: workerId, - leaseExpiresAt: new Date( - referenceTime.getTime() + DELETION_LEASE_MILLISECONDS - ).toISOString() - }); + let candidate: SastEvidenceDeletionCandidate | null; + try { + candidate = await this.store.claimDeletion({ + referenceTime: reference, + leaseOwner: workerId, + leaseExpiresAt: new Date( + referenceTime.getTime() + + DELETION_LEASE_MILLISECONDS + ).toISOString() + }); + } catch (error) { + if ( + error instanceof SastEvidenceAccessPersistenceError && + error.reason === 'CONTEXT_DRIFT' + ) { + this.logger.warn( + 'Fenced a drifted evidence deletion claim.' + ); + return 'RETRY_SCHEDULED'; + } + throw error; + } if (!candidate) return 'IDLE'; if (!isCandidateValid(candidate, reference)) { await this.safeRelease(candidate, referenceTime); @@ -90,12 +106,11 @@ export class SastEvidenceDeletionService { }); } catch (error) { await this.safeRelease(candidate, referenceTime); - if ( - error instanceof - SastEvidenceDeletionAuthorityUnavailableError - ) { - return 'RETRY_SCHEDULED'; - } + this.logger.warn( + `Evidence deletion authority failed for operation ${candidate.schedule.operationId}: ${ + error instanceof Error ? error.name : 'UnknownError' + }` + ); return 'RETRY_SCHEDULED'; } diff --git a/apps/api/src/scan-plane/sast-evidence-deletion.task.ts b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts index 0d4bbea..1c3c011 100644 --- a/apps/api/src/scan-plane/sast-evidence-deletion.task.ts +++ b/apps/api/src/scan-plane/sast-evidence-deletion.task.ts @@ -47,10 +47,12 @@ export class SastEvidenceDeletionTask } } - async processBatch(referenceTime?: Date): Promise { - const startedAt = referenceTime ?? new Date(); + async processBatch( + referenceTime = new Date(), + attemptClock: () => Date = () => new Date() + ): Promise { const backfilled = await this.service.backfill( - startedAt, + referenceTime, MAXIMUM_BACKFILLS_PER_BATCH ); let processed = 0; @@ -62,7 +64,7 @@ export class SastEvidenceDeletionTask index += 1 ) { const result = await this.service.processNext( - referenceTime ?? new Date(), + attemptClock(), this.workerId ); attempted += 1; diff --git a/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts index b2294b5..d626966 100644 --- a/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts @@ -6,6 +6,8 @@ import { SAST_ACCEPTED_EVIDENCE_POLICY } from '@aegisai/shared'; +import { readScanPlaneExports } from '../support/scan-plane-module-source'; + describe('SAST accepted-finding evidence persistence contract', () => { const schema = read('prisma/schema.prisma'); const migration = read( @@ -252,9 +254,7 @@ describe('SAST accepted-finding evidence persistence contract', () => { }); it('keeps T041 internal after exporting the T042 sequential handoff', () => { - const exportsBlock = - module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? - ''; + const exportsBlock = readScanPlaneExports(module); expect(exportsBlock).toContain('SastEvidenceAccessService'); expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain( diff --git a/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts index d79b874..aa83b6e 100644 --- a/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-evidence-access-persistence.e2e-spec.ts @@ -1,6 +1,13 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS, + SAST_EVIDENCE_MAX_RETENTION_SECONDS +} from '@aegisai/shared'; + +import { readScanPlaneExports } from '../support/scan-plane-module-source'; + describe('SAST evidence access and deletion persistence contract', () => { const schema = read('prisma/schema.prisma'); const migration = read( @@ -49,6 +56,9 @@ describe('SAST evidence access and deletion persistence contract', () => { expect(migration).toContain( 'SastEvidenceDeletionProof_schedule_scope_fkey' ); + expect(migration).toContain( + 'The documented exceptional purge deletes proof ledgers first.' + ); expect(migration).toContain( 'SastEvidenceDeletionClaim_due_idx' ); @@ -58,27 +68,30 @@ describe('SAST evidence access and deletion persistence contract', () => { expect(migration).toContain( 'SastEvidenceAccessDecision_aiPayloadExpiresAt_idx' ); + expect(migration).toContain( + 'SastEvidenceAccessDecision_scan_scope_idx' + ); + expect(migration).toContain( + 'SastEvidenceAccessDecision_build_scope_idx' + ); + expect(migration).toContain( + 'SastEvidenceDeletionSchedule_scan_scope_idx' + ); expect(migration).not.toContain('CONCURRENTLY'); }); it('pins seven-day evidence and 24-hour AI payload retention in code and SQL', () => { - expect(shared).toContain( - 'SAST_EVIDENCE_MAX_RETENTION_SECONDS =\n 7 * 24 * 60 * 60' + expect(SAST_EVIDENCE_MAX_RETENTION_SECONDS).toBe( + 7 * 24 * 60 * 60 ); - expect(shared).toContain( - 'SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS =\n 24 * 60 * 60' + expect(SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS).toBe( + 24 * 60 * 60 ); expect(migration).toContain("INTERVAL '7 days'"); expect(migration).toContain("INTERVAL '24 hours'"); expect(migration).toContain( '"deleteAfter" <= "scheduledAt" + INTERVAL \'7 days\'' ); - expect(service).toContain( - 'Date.parse(decidedAt) >= Date.parse(context.schedule.deleteAfter)' - ); - expect(store).toContain( - 'Date.parse(context.result.pack.expiresAt) >' - ); }); it('persists only bounded decisions and never persists access-time content or secret values', () => { @@ -132,9 +145,7 @@ describe('SAST evidence access and deletion persistence contract', () => { ); expect(controller).toContain('@UseGuards(SessionAuthGuard)'); expect(module).toContain('SastEvidenceAccessService'); - const exportsBlock = module.match( - /exports:\s*\[([\s\S]*?)\]\s*\}\)\s*export class/ - )?.[1]; + const exportsBlock = readScanPlaneExports(module); expect(exportsBlock).toContain('SastEvidenceAccessService'); expect(exportsBlock).not.toContain( 'SastAcceptedEvidenceService' @@ -148,6 +159,14 @@ describe('SAST evidence access and deletion persistence contract', () => { expect(store).toContain('leaseToken = randomUUID()'); expect(store).toContain("status: 'CLAIMED'"); expect(store).toContain("status: 'COMPLETED'"); + expect(migration).toContain("'QUARANTINED'"); + expect(store).toContain('fenceDriftedClaim'); + expect(store).toContain('MAXIMUM_CONTEXT_DRIFT_ATTEMPTS'); + expect(store).toContain('FOR UPDATE OF p SKIP LOCKED'); + expect(store).toContain('randomInt('); + expect(store).toContain( + 'SERIALIZABLE_INTERACTIVE_TIMEOUT_MILLISECONDS' + ); expect(store).toContain( 'transaction.sastAcceptedEvidencePack.delete' ); @@ -184,6 +203,10 @@ describe('SAST evidence access and deletion persistence contract', () => { expect(deletionTask).toContain( 'nextDueAt.getTime() - Date.now()' ); + expect(service).toContain('return null;'); + expect(service).not.toContain( + 'Allowed AI classification is incomplete.' + ); expect(authority).toContain( 'UnavailableSastEvidenceDeletionAuthority' ); diff --git a/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts index 3d38640..47f9d95 100644 --- a/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-evidence-access.e2e-spec.ts @@ -462,7 +462,8 @@ describe('SastEvidenceDeletionService', () => { ) ).resolves.toBe('RETRY_SCHEDULED'); expect(unavailableContext.deletionState).toBe('ACTIVE'); - expect(unavailableContext.result?.pack).not.toBeNull(); + expect(unavailableContext.result).not.toBeNull(); + expect(unavailableContext.result?.pack).toBeTruthy(); const rollbackContext = accessContext( acceptedEvidence('safe content') @@ -508,6 +509,30 @@ describe('SastEvidenceDeletionService', () => { expect(context.deletionProof?.completedAt).toBe(EXPIRES_AT); }); + it('contains a fenced context-drift claim so later deletion work can continue', async () => { + const context = accessContext(acceptedEvidence('safe content')); + const store = new MemoryAccessStore(context, 0, 1); + const service = new SastEvidenceDeletionService( + store, + new MemoryDeletionAuthority(EXPIRES_AT) + ); + + await expect( + service.processNext( + new Date(EXPIRES_AT), + 'worker-1', + () => EXPIRES_AT + ) + ).resolves.toBe('RETRY_SCHEDULED'); + await expect( + service.processNext( + new Date(EXPIRES_AT), + 'worker-2', + () => EXPIRES_AT + ) + ).resolves.toBe('DELETED'); + }); + it('fences concurrent workers and rejects a changed receipt after exact proof replay', async () => { const concurrentContext = accessContext( acceptedEvidence('safe content') @@ -570,13 +595,13 @@ describe('SastEvidenceDeletionService', () => { receipt: changedReceipt, digestCanonical: digest }); - expect(() => + await expect( replayStore.finalizeDeletion({ candidate, receipt: changedReceipt, proof: changedProof }) - ).toThrow(SastEvidenceAccessPersistenceError); + ).rejects.toBeInstanceOf(SastEvidenceAccessPersistenceError); }); }); @@ -616,6 +641,40 @@ describe('SastEvidenceDeletionTask', () => { task.onModuleDestroy(); }); + + it('reads a fresh attempt clock for every item in a batch', async () => { + const attemptTimes = [ + new Date(EXPIRES_AT), + new Date(AFTER_RETRY) + ]; + const service = { + backfill: jest.fn().mockResolvedValue(0), + processNext: jest + .fn() + .mockResolvedValueOnce('DELETED') + .mockResolvedValueOnce('IDLE') + }; + const task = new SastEvidenceDeletionTask( + service as never, + { isTest: () => true } as never + ); + + await task.processBatch( + new Date(CREATED_AT), + () => attemptTimes.shift()! + ); + + expect(service.backfill).toHaveBeenCalledWith( + new Date(CREATED_AT), + 128 + ); + expect(service.processNext.mock.calls[0]?.[0]).toEqual( + new Date(EXPIRES_AT) + ); + expect(service.processNext.mock.calls[1]?.[0]).toEqual( + new Date(AFTER_RETRY) + ); + }); }); class MemoryAccessStore extends SastEvidenceAccessStore { @@ -624,7 +683,8 @@ class MemoryAccessStore extends SastEvidenceAccessStore { constructor( private readonly context: SastEvidenceAccessContext, - private remainingFinalizeFailures = 0 + private remainingFinalizeFailures = 0, + private remainingClaimDrifts = 0 ) { super(); } @@ -710,6 +770,14 @@ class MemoryAccessStore extends SastEvidenceAccessStore { leaseOwner: string; leaseExpiresAt: string; }): Promise { + if (this.remainingClaimDrifts > 0) { + this.remainingClaimDrifts -= 1; + return Promise.reject( + new SastEvidenceAccessPersistenceError( + 'CONTEXT_DRIFT' + ) + ); + } if ( this.context.deletionState !== 'ACTIVE' || !this.context.result?.pack || @@ -747,8 +815,10 @@ class MemoryAccessStore extends SastEvidenceAccessStore { replayed: true }); } - throw new SastEvidenceAccessPersistenceError( - 'REPLAY_CONFLICT' + return Promise.reject( + new SastEvidenceAccessPersistenceError( + 'REPLAY_CONFLICT' + ) ); } if ( @@ -757,7 +827,9 @@ class MemoryAccessStore extends SastEvidenceAccessStore { input.receipt.operationId !== this.context.schedule.operationId ) { - throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + return Promise.reject( + new SastEvidenceAccessPersistenceError('LEASE_LOST') + ); } this.context.result = null; this.context.deletionState = 'DELETED'; @@ -776,7 +848,9 @@ class MemoryAccessStore extends SastEvidenceAccessStore { !this.candidate || this.candidate.leaseToken !== input.candidate.leaseToken ) { - throw new SastEvidenceAccessPersistenceError('LEASE_LOST'); + return Promise.reject( + new SastEvidenceAccessPersistenceError('LEASE_LOST') + ); } this.candidate = null; this.context.deletionState = 'ACTIVE'; diff --git a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts index 8c542ba..fe4b328 100644 --- a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { readScanPlaneExports } from '../support/scan-plane-module-source'; + describe('SAST scan coverage persistence contract', () => { const schema = read('prisma/schema.prisma'); const migration = read( @@ -115,8 +117,7 @@ describe('SAST scan coverage persistence contract', () => { }); it('keeps T039 through T041 internal after exposing only the T042 handoff', () => { - const exportsBlock = - module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; + const exportsBlock = readScanPlaneExports(module); expect(exportsBlock).toContain('SastEvidenceAccessService'); expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); diff --git a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts index 0af6d41..7b68665 100644 --- a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import { readScanPlaneExports } from '../support/scan-plane-module-source'; + describe('SAST scan freshness and retry persistence contract', () => { const schema = read('prisma/schema.prisma'); const migration = read( @@ -171,8 +173,7 @@ describe('SAST scan freshness and retry persistence contract', () => { }); it('keeps T040 and T041 internal after exporting the T042 handoff', () => { - const exportsBlock = - module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; + const exportsBlock = readScanPlaneExports(module); expect(exportsBlock).toContain('SastEvidenceAccessService'); expect(exportsBlock).not.toContain('SastAcceptedEvidenceService'); expect(exportsBlock).not.toContain('SastScanFreshnessService'); diff --git a/apps/api/test/support/scan-plane-module-source.ts b/apps/api/test/support/scan-plane-module-source.ts new file mode 100644 index 0000000..ea1bf73 --- /dev/null +++ b/apps/api/test/support/scan-plane-module-source.ts @@ -0,0 +1,9 @@ +export function readScanPlaneExports(moduleSource: string): string { + const exportsBlock = moduleSource.match( + /exports:\s*\[([\s\S]*?)\]\s*\n\}\)\s*export class ScanPlaneModule/u + )?.[1]; + if (!exportsBlock) { + throw new Error('Expected the ScanPlaneModule exports block.'); + } + return exportsBlock; +} diff --git a/packages/shared/src/types/sast-evidence-access.ts b/packages/shared/src/types/sast-evidence-access.ts index 3494203..ac2b0ec 100644 --- a/packages/shared/src/types/sast-evidence-access.ts +++ b/packages/shared/src/types/sast-evidence-access.ts @@ -326,14 +326,16 @@ export function buildSastEvidenceAccessDecision(input: { allowed && input.purpose === 'AI_ADVISORY' ? `sast-reduced-evidence://${suffix}` : null; - const evidenceExpiry = Date.parse(input.evidenceExpiresAt); - const decidedAt = Date.parse(input.decidedAt); - const payloadExpiry = new Date( - Math.min( - evidenceExpiry, - decidedAt + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS * 1000 - ) - ).toISOString(); + const aiPayloadExpiresAt = + allowed && input.purpose === 'AI_ADVISORY' + ? new Date( + Math.min( + Date.parse(input.evidenceExpiresAt), + Date.parse(input.decidedAt) + + SAST_AI_PAYLOAD_MAX_RETENTION_SECONDS * 1000 + ) + ).toISOString() + : null; const core: SastEvidenceAccessDecisionCore = { version: SAST_EVIDENCE_ACCESS_DECISION_VERSION, accessDecisionId, @@ -360,10 +362,7 @@ export function buildSastEvidenceAccessDecision(input: { redactedTotalBytes: allowed ? input.redactedTotalBytes : 0, redactionCount: allowed ? input.redactionCount : 0, reducedEvidenceRef, - aiPayloadExpiresAt: - allowed && input.purpose === 'AI_ADVISORY' - ? payloadExpiry - : null, + aiPayloadExpiresAt, evidenceExpiresAt: input.evidenceExpiresAt, authority: accessAuthority(input.purpose, allowed), audit: { @@ -580,6 +579,10 @@ export function isSastEvidenceAccessDecisionShapeValid( const allowed = value.outcome === 'ALLOWED'; if ( allowed !== (value.reasonCodes.length === 0) || + (!allowed && + (value.secondPassRedactionDecisionRef !== null || + value.reducedEvidenceRef !== null || + value.aiPayloadExpiresAt !== null)) || allowed !== isContractId( value.secondPassRedactionDecisionRef, 'sast-evidence-access-redaction' @@ -613,7 +616,7 @@ export function isSastEvidenceAccessDecisionShapeValid( ) { return false; } - if (value.aiPayloadExpiresAt !== null) { + if (allowed && value.aiPayloadExpiresAt !== null) { const payloadDuration = Date.parse(value.aiPayloadExpiresAt as string) - Date.parse(value.decidedAt as string); @@ -835,10 +838,34 @@ function isReasonCodes( ); } -function isContractId(value: unknown, prefix: string): value is string { +const CONTRACT_ID_PATTERNS = Object.freeze({ + 'finding-occurrence': /^finding-occurrence:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-build': /^sast-evidence-build:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-pack': /^sast-evidence-pack:\/\/[a-f0-9]{64}$/u, + 'sast-freshness': /^sast-freshness:\/\/[a-f0-9]{64}$/u, + 'sast-coverage': /^sast-coverage:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-deletion': /^sast-evidence-deletion:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-delete': /^sast-evidence-delete:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-access': /^sast-evidence-access:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-access-redaction': + /^sast-evidence-access-redaction:\/\/[a-f0-9]{64}$/u, + 'sast-reduced-evidence': /^sast-reduced-evidence:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-deletion-proof': + /^sast-evidence-deletion-proof:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-delete-receipt': + /^sast-evidence-delete-receipt:\/\/[a-f0-9]{64}$/u, + 'sast-evidence-fragment': /^sast-evidence-fragment:\/\/[a-f0-9]{64}$/u +}); + +type ContractIdPrefix = keyof typeof CONTRACT_ID_PATTERNS; + +function isContractId( + value: unknown, + prefix: ContractIdPrefix +): value is string { return ( typeof value === 'string' && - new RegExp(`^${prefix}:\\/\\/[a-f0-9]{64}$`, 'u').test(value) + CONTRACT_ID_PATTERNS[prefix].test(value) ); } diff --git a/packages/shared/test/sast-evidence-access.test.mjs b/packages/shared/test/sast-evidence-access.test.mjs index 383146b..c63fa91 100644 --- a/packages/shared/test/sast-evidence-access.test.mjs +++ b/packages/shared/test/sast-evidence-access.test.mjs @@ -107,6 +107,56 @@ test('access validators reject authority widening and retention extension', () = ); }); +test('denied decisions keep AI-only fields null and never parse unused expiry', () => { + const schedule = deletionSchedule(); + assert.doesNotThrow(() => + buildSastEvidenceAccessDecision({ + purpose: 'DASHBOARD', + scope: schedule.scope, + schedule, + secretRegistryVersion: 'not-checked-v1', + outcome: 'DENIED', + reasonCodes: ['EVIDENCE_ACCESS_INPUT_INVALID'], + redactedProjectionDigest: null, + redactedFragmentCount: 0, + redactedTotalBytes: 0, + redactionCount: 0, + evidenceExpiresAt: 'invalid-expiry', + decidedAt: 'invalid-decision-time', + digestCanonical: digest + }) + ); + + const denied = buildSastEvidenceAccessDecision({ + purpose: 'AI_ADVISORY', + scope: schedule.scope, + schedule, + secretRegistryVersion: 'not-checked-v1', + outcome: 'DENIED', + reasonCodes: ['EVIDENCE_ACCESS_EXPIRED'], + redactedProjectionDigest: null, + redactedFragmentCount: 0, + redactedTotalBytes: 0, + redactionCount: 0, + evidenceExpiresAt: EXPIRES_AT, + decidedAt: DECIDED_AT, + digestCanonical: digest + }); + assert.equal( + isSastEvidenceAccessDecisionShapeValid(denied, digest), + true + ); + assert.equal( + isSastEvidenceAccessDecisionShapeValid({ + ...denied, + secondPassRedactionDecisionRef: 'invalid', + reducedEvidenceRef: 'invalid', + aiPayloadExpiresAt: 'invalid' + }), + false + ); +}); + test('deletion proof binds the deterministic operation and bounded provider receipt', () => { const schedule = deletionSchedule(); const proof = buildSastEvidenceDeletionProof({ 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 ba1dd71..1599f21 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1229,6 +1229,19 @@ schedule, access decisions, canonical proof, and bounded audit state remain reta replay is idempotent; a stale token, changed receipt, late reader, deletion race, or clock rollback fails closed. +A claim whose durable context fails validation is fenced in a separate write, moved behind +other due work, and quarantined after three failed validations. The service contains that +expected `CONTEXT_DRIFT` result so one corrupt row cannot abort the bounded batch or starve the +deletion queue. Request-path serializable transactions use a short interactive timeout; +backfill uses one bounded background transaction with locked rows, and retry collisions use +bounded jitter. + +Tenant/repository offboarding normally soft-revokes the durable scope. The proof-to-schedule +foreign key deliberately restricts hard parent cascades so deletion evidence is not silently +lost. An exceptional authorized hard purge must revoke access, finish provider deletion for +live content, retain/export the required external audit record, delete the proof ledger first, +and only then remove the tenant or another cascading parent. + AI receives finding metadata and reduced evidence references only after a second redaction pass. AI never receives the result-ingress artifact reference. diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index c9fe006..744c65b 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -803,10 +803,13 @@ decisions cannot be inferred from a successful scan or accepted T041 pack. ### SastEvidenceDeletionClaim -- one mutable operational row per schedule with `PENDING | CLAIMED | COMPLETED`, bounded - attempt count, next-attempt time, lease owner, unique lease token, and lease expiry +- one mutable operational row per schedule with + `PENDING | CLAIMED | COMPLETED | QUARANTINED`, bounded attempt count, next-attempt time, + lease owner, unique lease token, lease expiry, bounded error code, and quarantine timestamp - claim and finalize use serializable compare-and-set semantics; only the current unexpired owner/token may commit a receipt or release for retry +- a deterministic durable-context drift advances the retry cursor in a separate fencing write; + after three failed validations the row is quarantined so it cannot block later due schedules - a claim blocks dashboard and AI reads, including readers that began before expiry but finish after the claim @@ -821,6 +824,13 @@ decisions cannot be inferred from a successful scan or accepted T041 pack. build decision, schedule, access ledgers, proof, and bounded audit projections remain durable and contain no source or second-pass redacted content +Normal tenant and repository offboarding is a soft revocation and never hard-deletes these +audit ledgers. `SastEvidenceDeletionProof_schedule_scope_fkey` therefore uses `RESTRICT` so a +parent cascade cannot silently erase deletion evidence. An exceptional authorized hard purge +must first revoke all access, complete provider deletion for any live pack, retain/export the +required external audit record, delete the proof ledger explicitly, and only then delete the +tenant or another parent scope whose cascade removes schedule/access/build rows. + ### RuleBundlePromotionEvidence - immutable bundle descriptor diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index 4adff56..495b217 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -316,7 +316,8 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl batches prevent poll/cap backlog. Unavailable providers, stale tokens, changed receipts, concurrent workers, late readers, clock rollback, exact replay, and an original receipt recovered after finalization failure create zero false proofs, duplicate rows, overdue - content, or restored content. + content, or restored content. A context-drifted claim is fenced and quarantined after three + validations and cannot starve a later due schedule. ## Canary and Continuous Production Gates diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index 085a0a8..fdca245 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -365,7 +365,9 @@ incomplete, stale, quarantined, or security-blocked scan. exact replay only. Late readers, changed receipts, stale lease owners, deletion races, and reference-time rollback MUST fail closed without returning or restoring content. An exact deterministic deletion retry MAY reuse its original receipt when completion is at or after - `deleteAfter` and no later than the current observation and lease. + `deleteAfter` and no later than the current observation and lease. A context-drifted due row + MUST be durably moved behind other work and quarantined after three failed validations so it + cannot starve the deletion queue. - **FR-049**: Evidence MUST NOT contain a full file, repository archive, or fragments that can reconstruct a substantial repository portion. - **FR-050**: Evidence retention MUST NOT exceed seven days; AI request payload retention diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index c52a9c9..7abfff8 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -86,7 +86,7 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Evidence reconstruction | Multiple snippets rebuild source | 32 KiB/five-fragment/8 KiB/five-context caps; per-file maximum two; reject full-file, overlap, adjacency, or at least 25% combined line coverage | Evidence build reject and immutable audit with zero pack | | Evidence-purpose confusion | Dashboard consent or one stale decision is reused to construct an AI payload | Separate immutable dashboard/AI decisions, complete T041 chain rebind, access-time redaction/classification, and explicit zero provider/tool authority | Purpose swap, opt-in, registry drift, unsafe identifier, cross-tenant, and replay fixtures deny | | 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 create a retention backlog | Deterministic operation, leased owner/token fence, default-unavailable provider, deadline-aware startup/earliest-due scheduling, saturated zero-delay continuation, exact receipt replay, delete-then immutable proof | Unavailable provider, changed/original receipt, stale token, concurrent claim/finalize, deadline wakeup, and exact replay corpus | +| 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 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 | diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index b594da6..8d1a959 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -1483,6 +1483,10 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( sharedTest, /deletion proof binds the deterministic operation and bounded provider receipt/ ); + assert.match( + sharedTest, + /denied decisions keep AI-only fields null and never parse unused expiry/ + ); assert.match(service, /class SastEvidenceAccessService/); assert.match(service, /async readDashboard/); @@ -1495,6 +1499,10 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(store, /claimDeletion/); assert.match(store, /finalizeDeletion/); assert.match(store, /providerReceiptDigest/); + assert.match(store, /fenceDriftedClaim/); + assert.match(store, /QUARANTINED/); + assert.match(store, /FOR UPDATE OF p SKIP LOCKED/); + assert.match(store, /randomInt/); assert.match(registry, /UnavailableSastEvidenceSecretRegistry/); assert.match( registry, @@ -1507,11 +1515,13 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(deletionService, /class SastEvidenceDeletionService/); assert.match(deletionService, /DELETION_LEASE_MILLISECONDS/); assert.match(deletionService, /isReceiptValid/); + assert.match(deletionService, /error\.reason === 'CONTEXT_DRIFT'/); assert.match(deletionTask, /MAXIMUM_DELETIONS_PER_BATCH = 64/); assert.match(deletionTask, /MAXIMUM_BACKFILLS_PER_BATCH = 128/); assert.match(deletionTask, /this\.schedule\(0\)/); assert.match(deletionTask, /if \(this\.batchSaturated\)/); assert.match(deletionTask, /nextDueAt\.getTime\(\) - Date\.now\(\)/); + assert.match(deletionTask, /attemptClock\(\)/); assert.match(dashboardController, /@UseGuards\(SessionAuthGuard\)/); assert.match(dashboardController, /@Get\(':evidencePackId'\)/); assert.match(dashboardController, /user\.tenantId/); @@ -1539,6 +1549,14 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( serviceTest, /starts immediately and wakes at the earliest durable deletion deadline/ ); + assert.match( + serviceTest, + /contains a fenced context-drift claim so later deletion work can continue/ + ); + assert.match( + serviceTest, + /reads a fresh attempt clock for every item in a batch/ + ); assert.match( persistenceTest, /serializable replay, claim fencing, and default-unavailable authorities/ @@ -1558,6 +1576,10 @@ test('SAST T042 classifies purpose-bound evidence and proves fenced deletion', ( assert.match(migration, /SastEvidenceAccessDecision_immutable_update/); assert.match(migration, /SastEvidenceDeletionSchedule_immutable_update/); assert.match(migration, /SastEvidenceDeletionProof_immutable_update/); + assert.match(migration, /SastEvidenceAccessDecision_scan_scope_idx/); + assert.match(migration, /SastEvidenceAccessDecision_build_scope_idx/); + assert.match(migration, /SastEvidenceDeletionSchedule_scan_scope_idx/); + assert.match(migration, /QUARANTINED/); assertScanPlaneExports(scanPlaneModule); assert.match(tasks, /- \[x\] T042\b/); From 9a089d9550c499e5d98f673ebd50c83a92fdf4fa Mon Sep 17 00:00:00 2001 From: goodtu02 <161540124+goodtu02@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:28:13 +0900 Subject: [PATCH 4/4] fix: restore Prisma schema blob --- apps/api/prisma/schema.prisma | Bin 90058 -> 90218 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index acf00439a06eb63a34a24b126adc606d3d835692..1e7b1ef8fc9ffb8bcc22208cc819101a7357d04b 100644 GIT binary patch literal 90218 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 literal 90058 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 W*btG9t!%B8RKgLw50L+9?*9Yob>lJs