diff --git a/README.md b/README.md index 9525fb3..37c1eae 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ Start from its | Legacy MVP | Historical baseline in [`001-aegisai-mvp-foundation`](./specs/001-aegisai-mvp-foundation/) and [`spec 2.2.md`](./spec%202.2.md) | The 006 package makes the SAST runtime implementation-ready before provider execution: -fixed-commit isolated scans, hostile-input validation, deterministic normalization and -finding identity, fail-closed coverage, reduced evidence, and measurable rule/runtime gates. +fixed-commit isolated scans, hostile-input validation, deterministic normalization, +repository-scoped finding lineage with target-scoped lifecycle history, fail-closed coverage, +reduced evidence, and measurable rule/runtime gates. The 005 baseline still governs the later live Kubernetes and provider-specific microVM rollout; production credentials and live provider execution remain deferred. diff --git a/apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql b/apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql new file mode 100644 index 0000000..3987176 --- /dev/null +++ b/apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql @@ -0,0 +1,737 @@ +CREATE TYPE "SastFindingCapability" AS ENUM ( + 'SAST', + 'SECRET_DETECTION', + 'DEPENDENCY_VULNERABILITY', + 'IAC_MISCONFIGURATION' +); + +CREATE TYPE "SastFindingLifecycleStatus" AS ENUM ( + 'OPEN', + 'FIXED' +); + +CREATE TYPE "SastFindingLifecycleEventKind" AS ENUM ( + 'CREATED', + 'RENAMED', + 'FIXED', + 'REOPENED' +); + +-- Rolling compatibility: legacy finding writers can continue writing rows +-- without T037 metadata. The mandatory online-schema step installs and +-- validates the conditional metadata constraint and scope index. +ALTER TABLE "NormalizedFinding" + ALTER COLUMN "filePath" DROP NOT NULL, + ALTER COLUMN "lineStart" DROP NOT NULL, + ADD COLUMN "sastCapability" "SastFindingCapability", + ADD COLUMN "sastFingerprintVersion" TEXT, + ADD COLUMN "sastStableFingerprint" TEXT, + ADD COLUMN "sastFingerprintDecisionDigest" TEXT, + ADD COLUMN "sastLineageId" TEXT, + ADD COLUMN "sastObservationBatchId" TEXT, + ADD COLUMN "sastOccurrenceOrdinal" INTEGER; + +CREATE TABLE "SastFindingLineage" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "capability" "SastFindingCapability" NOT NULL, + "fingerprintVersion" TEXT NOT NULL, + "firstStableFingerprint" TEXT NOT NULL, + "firstObservedAt" TIMESTAMP(3) NOT NULL, + "lastObservedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastFindingLineage_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingLineage_identity_check" CHECK ( + "id" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "fingerprintVersion" = 'sast-fingerprint-v1' + AND "firstStableFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "lastObservedAt" >= "firstObservedAt" + ) +); + +CREATE TABLE "SastFindingIdentityAlias" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "capability" "SastFindingCapability" NOT NULL, + "fingerprintVersion" TEXT NOT NULL, + "stableFingerprint" TEXT NOT NULL, + "normalizedPath" TEXT NOT NULL, + "renameAttestationDigest" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastFindingIdentityAlias_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingIdentityAlias_identity_check" CHECK ( + "id" ~ '^finding-alias://[a-f0-9]{64}$' + AND "fingerprintVersion" = 'sast-fingerprint-v1' + AND "stableFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND octet_length("normalizedPath") <= 4096 + AND "normalizedPath" !~ '[[:cntrl:]]' + AND ( + "normalizedPath" = '' + OR ( + "normalizedPath" NOT LIKE '/%' + AND "normalizedPath" !~ '^[A-Za-z]:' + AND strpos("normalizedPath", chr(92)) = 0 + AND "normalizedPath" NOT LIKE '%//%' + AND "normalizedPath" !~ '(^|/)[.]{1,2}(/|$)' + ) + ) + AND ( + "renameAttestationDigest" IS NULL + OR "renameAttestationDigest" ~ '^sha256:[a-f0-9]{64}$' + ) + ) +); + +CREATE TABLE "SastFindingObservationBatch" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "scannerRunId" TEXT NOT NULL, + "lifecycleContextKey" TEXT NOT NULL, + "targetRef" TEXT NOT NULL, + "commitSha" TEXT NOT NULL, + "lane" "ScanLane" NOT NULL, + "scanner" "ScannerKind" NOT NULL, + "capabilities" JSONB NOT NULL, + "profileId" TEXT NOT NULL, + "profileDigest" TEXT NOT NULL, + "canonicalScanKey" TEXT NOT NULL, + "planDigest" TEXT NOT NULL, + "sourceIdentityBatchDigest" TEXT NOT NULL, + "renameAttestationDigest" TEXT, + "findingCount" INTEGER NOT NULL, + "distinctFingerprintCount" INTEGER NOT NULL, + "createdLineageCount" INTEGER NOT NULL, + "exactMatchCount" INTEGER NOT NULL, + "renamedMatchCount" INTEGER NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastFindingObservationBatch_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingObservationBatch_contract_check" CHECK ( + "id" ~ '^finding-observation://[a-f0-9]{64}$' + AND "lifecycleContextKey" ~ '^sha256:[a-f0-9]{64}$' + AND octet_length("targetRef") BETWEEN 1 AND 2048 + AND "targetRef" !~ '[[:cntrl:]]' + AND "commitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$' + AND "scanner" IN ('OPENGREP', 'TRIVY') + AND "profileId" IN ( + 'JAVA_FAST_V1', + 'JAVA_DEEP_V1', + 'COMMON_DEEP_V1' + ) + AND "profileDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "canonicalScanKey" ~ '^sha256:[a-f0-9]{64}$' + AND "planDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "sourceIdentityBatchDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ( + "renameAttestationDigest" IS NULL + OR "renameAttestationDigest" ~ '^sha256:[a-f0-9]{64}$' + ) + AND jsonb_typeof("capabilities") = 'array' + AND jsonb_array_length("capabilities") BETWEEN 0 AND 4 + AND "capabilities" <@ '[ + "SAST", + "SECRET_DETECTION", + "DEPENDENCY_VULNERABILITY", + "IAC_MISCONFIGURATION" + ]'::jsonb + AND "findingCount" BETWEEN 0 AND 25000 + AND "distinctFingerprintCount" BETWEEN 0 AND "findingCount" + AND "createdLineageCount" >= 0 + AND "exactMatchCount" >= 0 + AND "renamedMatchCount" >= 0 + AND "createdLineageCount" + "exactMatchCount" + "renamedMatchCount" + = "distinctFingerprintCount" + ) +); + +CREATE TABLE "SastFindingOccurrence" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "scannerRunId" TEXT NOT NULL, + "observationBatchId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "normalizedFindingId" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL, + "capability" "SastFindingCapability" NOT NULL, + "fingerprintVersion" TEXT NOT NULL, + "stableFingerprint" TEXT NOT NULL, + "fingerprintDecisionDigest" TEXT NOT NULL, + "sourceFinding" JSONB NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastFindingOccurrence_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingOccurrence_contract_check" CHECK ( + "id" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "lineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "ordinal" BETWEEN 0 AND 24999 + AND "fingerprintVersion" = 'sast-fingerprint-v1' + AND "stableFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "fingerprintDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("sourceFinding") = 'object' + AND COALESCE( + ( + jsonb_typeof("sourceFinding" -> 'fingerprint') = 'object' + AND "sourceFinding" ->> 'capability' = "capability"::text + AND "sourceFinding" #>> '{fingerprint,version}' + = "fingerprintVersion" + AND "sourceFinding" #>> '{fingerprint,stableFingerprint}' + = "stableFingerprint" + AND "sourceFinding" #>> '{fingerprint,decisionDigest}' + = "fingerprintDecisionDigest" + AND "sourceFinding" ->> 'durablePersistenceAllowed' = 'true' + ), + false + ) + ) +); + +CREATE TABLE "SastFindingLifecycleState" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "lifecycleContextKey" TEXT NOT NULL, + "targetRef" TEXT NOT NULL, + "status" "SastFindingLifecycleStatus" NOT NULL DEFAULT 'OPEN', + "revision" INTEGER NOT NULL DEFAULT 1, + "lastObservedBatchId" TEXT, + "lastObservedScanRequestId" TEXT, + "lastObservedCommitSha" TEXT, + "lastObservedAt" TIMESTAMP(3), + "lastReconciliationSequence" INTEGER NOT NULL DEFAULT 0, + "fixedAt" TIMESTAMP(3), + "reopenedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastFindingLifecycleState_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingLifecycleState_contract_check" CHECK ( + "id" ~ '^finding-state://[a-f0-9]{64}$' + AND "lineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "lifecycleContextKey" ~ '^sha256:[a-f0-9]{64}$' + AND octet_length("targetRef") BETWEEN 1 AND 2048 + AND "targetRef" !~ '[[:cntrl:]]' + AND "revision" >= 1 + AND "lastReconciliationSequence" >= 0 + AND ( + ( + "lastObservedBatchId" IS NULL + AND "lastObservedScanRequestId" IS NULL + AND "lastObservedCommitSha" IS NULL + AND "lastObservedAt" IS NULL + ) + OR ( + "lastObservedBatchId" ~ '^finding-observation://[a-f0-9]{64}$' + AND char_length("lastObservedScanRequestId") BETWEEN 1 AND 2048 + AND "lastObservedCommitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$' + AND "lastObservedAt" IS NOT NULL + ) + ) + AND ( + ("status" = 'OPEN' AND "fixedAt" IS NULL) + OR ("status" = 'FIXED' AND "fixedAt" IS NOT NULL) + ) + ) +); + +CREATE TABLE "SastFindingLifecycleReconciliation" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "lifecycleContextKey" TEXT NOT NULL, + "sequence" INTEGER NOT NULL, + "profileId" TEXT NOT NULL, + "profileDigest" TEXT NOT NULL, + "coverageDecision" JSONB NOT NULL, + "coverageDecisionDigest" TEXT NOT NULL, + "eligibleLineageCount" INTEGER NOT NULL, + "observedLineageCount" INTEGER NOT NULL, + "fixedCount" INTEGER NOT NULL, + "reopenedCount" INTEGER NOT NULL, + "unchangedOpenCount" INTEGER NOT NULL, + "unchangedFixedCount" INTEGER NOT NULL, + "reconciledAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastFindingLifecycleReconciliation_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingLifecycleReconciliation_contract_check" CHECK ( + "id" ~ '^finding-reconciliation://[a-f0-9]{64}$' + AND "lifecycleContextKey" ~ '^sha256:[a-f0-9]{64}$' + AND "sequence" > 0 + AND "profileId" IN ( + 'JAVA_FAST_V1', + 'JAVA_DEEP_V1', + 'COMMON_DEEP_V1' + ) + AND "profileDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "coverageDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("coverageDecision") = 'object' + AND COALESCE( + ( + "coverageDecision" ->> 'version' + = 'sast-finding-lifecycle-coverage-v1' + AND "coverageDecision" ->> 'tenantId' = "tenantId" + AND "coverageDecision" ->> 'repositoryBindingId' + = "repositoryBindingId" + AND "coverageDecision" ->> 'scanRequestId' = "scanRequestId" + AND "coverageDecision" ->> 'attemptId' = "attemptId" + AND "coverageDecision" ->> 'lifecycleContextKey' + = "lifecycleContextKey" + AND "coverageDecision" ->> 'profileId' = "profileId" + AND "coverageDecision" ->> 'profileDigest' = "profileDigest" + AND "coverageDecision" ->> 'state' = 'COMPLETE' + AND "coverageDecision" ->> 'stale' = 'false' + AND "coverageDecision" ->> 'comparable' = 'true' + AND ("coverageDecision" ->> 'sequence')::integer = "sequence" + AND "coverageDecision" ->> 'decisionDigest' + = "coverageDecisionDigest" + AND jsonb_typeof( + "coverageDecision" -> 'eligibleLineageIds' + ) = 'array' + AND jsonb_array_length( + "coverageDecision" -> 'eligibleLineageIds' + ) = "eligibleLineageCount" + AND jsonb_typeof( + "coverageDecision" -> 'expectedObservationBatchDigests' + ) = 'array' + AND jsonb_array_length( + "coverageDecision" -> 'expectedObservationBatchDigests' + ) BETWEEN 1 AND 16 + ), + false + ) + AND "eligibleLineageCount" BETWEEN 0 AND 25000 + AND "observedLineageCount" BETWEEN 0 AND "eligibleLineageCount" + AND "fixedCount" >= 0 + AND "reopenedCount" >= 0 + AND "unchangedOpenCount" >= 0 + AND "unchangedFixedCount" >= 0 + AND "fixedCount" + "reopenedCount" + + "unchangedOpenCount" + "unchangedFixedCount" + = "eligibleLineageCount" + AND "reopenedCount" + "unchangedOpenCount" + = "observedLineageCount" + AND "fixedCount" + "unchangedFixedCount" + = "eligibleLineageCount" - "observedLineageCount" + ) +); + +CREATE TABLE "SastFindingLifecycleEvent" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "lifecycleStateId" TEXT NOT NULL, + "lifecycleContextKey" TEXT NOT NULL, + "kind" "SastFindingLifecycleEventKind" NOT NULL, + "previousStatus" "SastFindingLifecycleStatus", + "nextStatus" "SastFindingLifecycleStatus" NOT NULL, + "revision" INTEGER NOT NULL, + "observationBatchId" TEXT, + "reconciliationId" TEXT, + "renameAttestationDigest" TEXT, + "occurredAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastFindingLifecycleEvent_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastFindingLifecycleEvent_transition_check" CHECK ( + "id" ~ '^finding-event://[a-f0-9]{64}$' + AND "lineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "lifecycleStateId" ~ '^finding-state://[a-f0-9]{64}$' + AND "lifecycleContextKey" ~ '^sha256:[a-f0-9]{64}$' + AND "revision" >= 1 + AND ( + ( + "kind" = 'CREATED' + AND "revision" = 1 + AND "previousStatus" IS NULL + AND "nextStatus" = 'OPEN' + AND "observationBatchId" IS NOT NULL + AND "reconciliationId" IS NULL + AND "renameAttestationDigest" IS NULL + ) + OR ( + "kind" = 'RENAMED' + AND "revision" > 1 + AND "previousStatus" = "nextStatus" + AND "observationBatchId" IS NOT NULL + AND "reconciliationId" IS NULL + AND "renameAttestationDigest" ~ '^sha256:[a-f0-9]{64}$' + ) + OR ( + "kind" = 'FIXED' + AND "revision" > 1 + AND "previousStatus" = 'OPEN' + AND "nextStatus" = 'FIXED' + AND "observationBatchId" IS NULL + AND "reconciliationId" IS NOT NULL + AND "renameAttestationDigest" IS NULL + ) + OR ( + "kind" = 'REOPENED' + AND "revision" > 1 + AND "previousStatus" = 'FIXED' + AND "nextStatus" = 'OPEN' + AND "observationBatchId" IS NULL + AND "reconciliationId" IS NOT NULL + AND "renameAttestationDigest" IS NULL + ) + ) + AND ( + "observationBatchId" IS NULL + OR "observationBatchId" ~ '^finding-observation://[a-f0-9]{64}$' + ) + AND ( + "reconciliationId" IS NULL + OR "reconciliationId" ~ '^finding-reconciliation://[a-f0-9]{64}$' + ) + ) +); + +CREATE UNIQUE INDEX "SastFindingLineage_scope_key" + ON "SastFindingLineage"("id", "tenantId", "repositoryBindingId"); +CREATE UNIQUE INDEX "SastFindingLineage_identity_scope_key" + ON "SastFindingLineage"( + "id", + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion" + ); +CREATE INDEX "SastFindingLineage_tenantId_repositoryBindingId_capability_idx" + ON "SastFindingLineage"("tenantId", "repositoryBindingId", "capability"); +CREATE INDEX "SastFindingLineage_lastObservedAt_idx" + ON "SastFindingLineage"("lastObservedAt"); + +CREATE UNIQUE INDEX "SastFindingIdentityAlias_fingerprint_key" + ON "SastFindingIdentityAlias"( + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion", + "stableFingerprint" + ); +CREATE INDEX "SastFindingIdentityAlias_lineageId_idx" + ON "SastFindingIdentityAlias"("lineageId"); +CREATE INDEX "SastFindingIdentityAlias_renameAttestationDigest_idx" + ON "SastFindingIdentityAlias"("renameAttestationDigest"); + +CREATE UNIQUE INDEX "SastFindingObservationBatch_source_digest_key" + ON "SastFindingObservationBatch"("tenantId", "sourceIdentityBatchDigest"); +CREATE UNIQUE INDEX "SastFindingObservationBatch_scanner_run_key" + ON "SastFindingObservationBatch"("tenantId", "scannerRunId"); +CREATE UNIQUE INDEX "SastFindingObservationBatch_event_scope_key" + ON "SastFindingObservationBatch"( + "id", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ); +CREATE UNIQUE INDEX "SastFindingObservationBatch_scope_key" + ON "SastFindingObservationBatch"( + "id", + "tenantId", + "repositoryBindingId", + "scanRequestId", + "attemptId", + "scannerRunId" + ); +CREATE INDEX "SastFindingObservationBatch_context_observed_idx" + ON "SastFindingObservationBatch"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "observedAt" + ); +CREATE INDEX "SastFindingObservationBatch_scanRequestId_idx" + ON "SastFindingObservationBatch"("scanRequestId"); + +CREATE UNIQUE INDEX "SastFindingOccurrence_normalizedFindingId_key" + ON "SastFindingOccurrence"("normalizedFindingId"); +CREATE UNIQUE INDEX "SastFindingOccurrence_batch_ordinal_key" + ON "SastFindingOccurrence"("observationBatchId", "ordinal"); +CREATE UNIQUE INDEX "SastFindingOccurrence_normalized_scope_key" + ON "SastFindingOccurrence"( + "normalizedFindingId", + "tenantId", + "scanRequestId", + "scannerRunId" + ); +CREATE INDEX "SastFindingOccurrence_lineage_observed_idx" + ON "SastFindingOccurrence"( + "tenantId", + "repositoryBindingId", + "lineageId", + "observedAt" + ); +CREATE INDEX "SastFindingOccurrence_scanRequestId_capability_idx" + ON "SastFindingOccurrence"("scanRequestId", "capability"); + +CREATE UNIQUE INDEX "SastFindingLifecycleState_context_lineage_key" + ON "SastFindingLifecycleState"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "lineageId" + ); +CREATE UNIQUE INDEX "SastFindingLifecycleState_scope_key" + ON "SastFindingLifecycleState"( + "id", + "tenantId", + "repositoryBindingId", + "lineageId", + "lifecycleContextKey" + ); +CREATE INDEX "SastFindingLifecycleState_context_status_idx" + ON "SastFindingLifecycleState"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "status" + ); + +CREATE UNIQUE INDEX "SastFindingLifecycleReconciliation_decision_digest_key" + ON "SastFindingLifecycleReconciliation"("coverageDecisionDigest"); +CREATE UNIQUE INDEX "SastFindingLifecycleReconciliation_event_scope_key" + ON "SastFindingLifecycleReconciliation"( + "id", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ); +CREATE UNIQUE INDEX "SastFindingLifecycleReconciliation_context_sequence_key" + ON "SastFindingLifecycleReconciliation"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "sequence" + ); +CREATE INDEX "SastFindingLifecycleReconciliation_scanRequestId_idx" + ON "SastFindingLifecycleReconciliation"("scanRequestId"); +CREATE INDEX "SastFindingLifecycleReconciliation_reconciledAt_idx" + ON "SastFindingLifecycleReconciliation"("reconciledAt"); + +CREATE UNIQUE INDEX "SastFindingLifecycleEvent_state_revision_key" + ON "SastFindingLifecycleEvent"("lifecycleStateId", "revision"); +CREATE INDEX "SastFindingLifecycleEvent_context_occurred_idx" + ON "SastFindingLifecycleEvent"( + "tenantId", + "repositoryBindingId", + "lifecycleContextKey", + "occurredAt" + ); +CREATE INDEX "SastFindingLifecycleEvent_observationBatchId_idx" + ON "SastFindingLifecycleEvent"("observationBatchId"); +CREATE INDEX "SastFindingLifecycleEvent_reconciliationId_idx" + ON "SastFindingLifecycleEvent"("reconciliationId"); + +ALTER TABLE "SastFindingLineage" + ADD CONSTRAINT "SastFindingLineage_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLineage_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingIdentityAlias" + ADD CONSTRAINT "SastFindingIdentityAlias_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingIdentityAlias_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingIdentityAlias_lineage_scope_fkey" + FOREIGN KEY ( + "lineageId", + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion" + ) + REFERENCES "SastFindingLineage"( + "id", + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion" + ) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingObservationBatch" + ADD CONSTRAINT "SastFindingObservationBatch_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingObservationBatch_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingObservationBatch_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingObservationBatch_attempt_scope_fkey" + FOREIGN KEY ("attemptId", "tenantId", "repositoryBindingId", "scanRequestId") + REFERENCES "SastScanAttempt"( + "id", + "tenantId", + "repositoryBindingId", + "scanRequestId" + ) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingOccurrence" + ADD CONSTRAINT "SastFindingOccurrence_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingOccurrence_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingOccurrence_batch_scope_fkey" + FOREIGN KEY ( + "observationBatchId", + "tenantId", + "repositoryBindingId", + "scanRequestId", + "attemptId", + "scannerRunId" + ) + REFERENCES "SastFindingObservationBatch"( + "id", + "tenantId", + "repositoryBindingId", + "scanRequestId", + "attemptId", + "scannerRunId" + ) + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingOccurrence_lineage_scope_fkey" + FOREIGN KEY ( + "lineageId", + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion" + ) + REFERENCES "SastFindingLineage"( + "id", + "tenantId", + "repositoryBindingId", + "capability", + "fingerprintVersion" + ) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingLifecycleState" + ADD CONSTRAINT "SastFindingLifecycleState_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleState_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleState_lineage_scope_fkey" + FOREIGN KEY ("lineageId", "tenantId", "repositoryBindingId") + REFERENCES "SastFindingLineage"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingLifecycleReconciliation" + ADD CONSTRAINT "SastFindingLifecycleReconciliation_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleReconciliation_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleReconciliation_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleReconciliation_attempt_scope_fkey" + FOREIGN KEY ("attemptId", "tenantId", "repositoryBindingId", "scanRequestId") + REFERENCES "SastScanAttempt"( + "id", + "tenantId", + "repositoryBindingId", + "scanRequestId" + ) + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastFindingLifecycleEvent" + ADD CONSTRAINT "SastFindingLifecycleEvent_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleEvent_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleEvent_lineage_scope_fkey" + FOREIGN KEY ("lineageId", "tenantId", "repositoryBindingId") + REFERENCES "SastFindingLineage"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleEvent_state_scope_fkey" + FOREIGN KEY ( + "lifecycleStateId", + "tenantId", + "repositoryBindingId", + "lineageId", + "lifecycleContextKey" + ) + REFERENCES "SastFindingLifecycleState"( + "id", + "tenantId", + "repositoryBindingId", + "lineageId", + "lifecycleContextKey" + ) + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleEvent_observation_scope_fkey" + FOREIGN KEY ( + "observationBatchId", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ) + REFERENCES "SastFindingObservationBatch"( + "id", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ) + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "SastFindingLifecycleEvent_reconciliation_scope_fkey" + FOREIGN KEY ( + "reconciliationId", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ) + REFERENCES "SastFindingLifecycleReconciliation"( + "id", + "tenantId", + "repositoryBindingId", + "lifecycleContextKey" + ) + ON DELETE CASCADE ON UPDATE CASCADE; + +-- The scanner-run and normalized-finding scope keys are installed +-- concurrently by prisma:online-schema. Their dependent foreign keys are +-- added NOT VALID and validated there as well. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f19295c..d2e8cae 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -153,6 +153,25 @@ enum NormalizedFindingStatus { FIXED } +enum SastFindingCapability { + SAST + SECRET_DETECTION + DEPENDENCY_VULNERABILITY + IAC_MISCONFIGURATION +} + +enum SastFindingLifecycleStatus { + OPEN + FIXED +} + +enum SastFindingLifecycleEventKind { + CREATED + RENAMED + FIXED + REOPENED +} + enum EvidenceClassification { SHORT_LIVED_EVIDENCE } @@ -309,21 +328,28 @@ model Tenant { 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[] - users User[] + 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[] + users User[] } model ScmIntegration { @@ -359,12 +385,19 @@ model RepositoryBinding { 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[] + 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[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -391,19 +424,21 @@ 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[] + 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[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -476,13 +511,15 @@ model SastScanAttempt { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanAttempt_repository_scope_fkey") - scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanAttempt_scan_scope_fkey") - scannerRuns ScannerRun[] - artifactIngestions SastArtifactIngestion[] - auditEvents AuditEvent[] @relation("SastScanAttemptAuditEvents") - finalAuditEvent AuditEvent? @relation("SastScanAttemptFinalAuditEvent", fields: [finalAuditEventId, id, tenantId], references: [id, attemptId, tenantId], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_finalAuditEventId_fkey") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanAttempt_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanAttempt_scan_scope_fkey") + scannerRuns ScannerRun[] + artifactIngestions SastArtifactIngestion[] + sastFindingBatches SastFindingObservationBatch[] + sastFindingReconciliations SastFindingLifecycleReconciliation[] + auditEvents AuditEvent[] @relation("SastScanAttemptAuditEvents") + finalAuditEvent AuditEvent? @relation("SastScanAttemptFinalAuditEvent", fields: [finalAuditEventId, id, tenantId], references: [id, attemptId, tenantId], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_finalAuditEventId_fkey") @@unique([scanRequestId, attemptNumber]) @@unique([id, tenantId], map: "SastScanAttempt_id_tenantId_key") @@ -612,13 +649,14 @@ model ScannerRun { completedAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - attempt SastScanAttempt? @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "ScannerRun_attempt_scope_fkey") - findings NormalizedFinding[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + attempt SastScanAttempt? @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "ScannerRun_attempt_scope_fkey") + findings NormalizedFinding[] + sastFindingBatches SastFindingObservationBatch[] // The mapped ingress scope index and relation FK are installed by the mandatory, // blocking prisma:online-schema step before new-version traffic is admitted. - artifactIngestion SastArtifactIngestion? + artifactIngestion SastArtifactIngestion? @@unique([attemptId, scanner]) @@unique([id, attemptId, tenantId, repositoryBindingId, scanRequestId], map: "ScannerRun_ingress_scope_key") @@ -721,32 +759,258 @@ model SastArtifactDispositionDecision { } model NormalizedFinding { - id String @id @default(uuid()) - tenantId String - scanRequestId String - scannerRunId String - title String - severity Severity - scannerProvenance ScannerKind - filePath String - lineStart Int - lineEnd Int? - status NormalizedFindingStatus @default(OPEN) - metadata Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) + tenantId String + scanRequestId String + scannerRunId String + title String + severity Severity + scannerProvenance ScannerKind + filePath String? + lineStart Int? + lineEnd Int? + status NormalizedFindingStatus @default(OPEN) + metadata Json? + sastCapability SastFindingCapability? + sastFingerprintVersion String? + sastStableFingerprint String? + sastFingerprintDecisionDigest String? + sastLineageId String? + sastObservationBatchId String? + sastOccurrenceOrdinal Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + scannerRun ScannerRun @relation(fields: [scannerRunId], references: [id], onDelete: Cascade) policyDecisions PolicyDecision[] suppressions Suppression[] + sastOccurrence SastFindingOccurrence? + // Installed concurrently by the mandatory online-schema step so legacy + // rows remain deployable while T037 metadata is introduced. + @@unique([id, tenantId, scanRequestId, scannerRunId], map: "NormalizedFinding_sast_occurrence_scope_key") @@index([tenantId]) @@index([scanRequestId]) @@index([scannerRunId]) @@index([severity]) @@index([status]) + @@index([sastLineageId], map: "NormalizedFinding_sastLineageId_idx") + @@index([sastObservationBatchId], map: "NormalizedFinding_sastObservationBatchId_idx") +} + +model SastFindingLineage { + id String @id + tenantId String + repositoryBindingId String + capability SastFindingCapability + fingerprintVersion String + firstStableFingerprint String + firstObservedAt DateTime + lastObservedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastFindingLineage_repository_scope_fkey") + aliases SastFindingIdentityAlias[] + occurrences SastFindingOccurrence[] + lifecycleStates SastFindingLifecycleState[] + lifecycleEvents SastFindingLifecycleEvent[] + + @@unique([id, tenantId, repositoryBindingId], map: "SastFindingLineage_scope_key") + @@unique([id, tenantId, repositoryBindingId, capability, fingerprintVersion], map: "SastFindingLineage_identity_scope_key") + @@index([tenantId, repositoryBindingId, capability]) + @@index([lastObservedAt]) +} + +model SastFindingIdentityAlias { + id String @id + tenantId String + repositoryBindingId String + lineageId String + capability SastFindingCapability + fingerprintVersion String + stableFingerprint String + normalizedPath String + renameAttestationDigest String? + 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: "SastFindingIdentityAlias_repository_scope_fkey") + lineage SastFindingLineage @relation(fields: [lineageId, tenantId, repositoryBindingId, capability, fingerprintVersion], references: [id, tenantId, repositoryBindingId, capability, fingerprintVersion], onDelete: Cascade, map: "SastFindingIdentityAlias_lineage_scope_fkey") + + @@unique([tenantId, repositoryBindingId, capability, fingerprintVersion, stableFingerprint], map: "SastFindingIdentityAlias_fingerprint_key") + @@index([lineageId]) + @@index([renameAttestationDigest]) +} + +model SastFindingObservationBatch { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + scannerRunId String + lifecycleContextKey String + targetRef String + commitSha String + lane ScanLane + scanner ScannerKind + capabilities Json + profileId String + profileDigest String + canonicalScanKey String + planDigest String + sourceIdentityBatchDigest String + renameAttestationDigest String? + findingCount Int + distinctFingerprintCount Int + createdLineageCount Int + exactMatchCount Int + renamedMatchCount Int + observedAt 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: "SastFindingObservationBatch_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastFindingObservationBatch_scan_scope_fkey") + attempt SastScanAttempt @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "SastFindingObservationBatch_attempt_scope_fkey") + scannerRun ScannerRun @relation(fields: [scannerRunId, attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, attemptId, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "SastFindingObservationBatch_scanner_scope_fkey") + occurrences SastFindingOccurrence[] + lifecycleEvents SastFindingLifecycleEvent[] + + @@unique([tenantId, sourceIdentityBatchDigest], map: "SastFindingObservationBatch_source_digest_key") + @@unique([tenantId, scannerRunId], map: "SastFindingObservationBatch_scanner_run_key") + @@unique([id, tenantId, repositoryBindingId, lifecycleContextKey], map: "SastFindingObservationBatch_event_scope_key") + @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId, scannerRunId], map: "SastFindingObservationBatch_scope_key") + @@index([tenantId, repositoryBindingId, lifecycleContextKey, observedAt]) + @@index([scanRequestId]) +} + +model SastFindingOccurrence { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + scannerRunId String + observationBatchId String + lineageId String + normalizedFindingId String @unique + ordinal Int + capability SastFindingCapability + fingerprintVersion String + stableFingerprint String + fingerprintDecisionDigest String + sourceFinding Json + observedAt 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: "SastFindingOccurrence_repository_scope_fkey") + observationBatch SastFindingObservationBatch @relation(fields: [observationBatchId, tenantId, repositoryBindingId, scanRequestId, attemptId, scannerRunId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId, scannerRunId], onDelete: Cascade, map: "SastFindingOccurrence_batch_scope_fkey") + lineage SastFindingLineage @relation(fields: [lineageId, tenantId, repositoryBindingId, capability, fingerprintVersion], references: [id, tenantId, repositoryBindingId, capability, fingerprintVersion], onDelete: Cascade, map: "SastFindingOccurrence_lineage_scope_fkey") + normalizedFinding NormalizedFinding @relation(fields: [normalizedFindingId, tenantId, scanRequestId, scannerRunId], references: [id, tenantId, scanRequestId, scannerRunId], onDelete: Cascade, map: "SastFindingOccurrence_normalized_scope_fkey") + + @@unique([observationBatchId, ordinal], map: "SastFindingOccurrence_batch_ordinal_key") + @@unique([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastFindingOccurrence_normalized_scope_key") + @@index([tenantId, repositoryBindingId, lineageId, observedAt]) + @@index([scanRequestId, capability]) +} + +model SastFindingLifecycleState { + id String @id + tenantId String + repositoryBindingId String + lineageId String + lifecycleContextKey String + targetRef String + status SastFindingLifecycleStatus @default(OPEN) + revision Int @default(1) + lastObservedBatchId String? + lastObservedScanRequestId String? + lastObservedCommitSha String? + lastObservedAt DateTime? + lastReconciliationSequence Int @default(0) + fixedAt DateTime? + reopenedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastFindingLifecycleState_repository_scope_fkey") + lineage SastFindingLineage @relation(fields: [lineageId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastFindingLifecycleState_lineage_scope_fkey") + events SastFindingLifecycleEvent[] + + @@unique([tenantId, repositoryBindingId, lifecycleContextKey, lineageId], map: "SastFindingLifecycleState_context_lineage_key") + @@unique([id, tenantId, repositoryBindingId, lineageId, lifecycleContextKey], map: "SastFindingLifecycleState_scope_key") + @@index([tenantId, repositoryBindingId, lifecycleContextKey, status]) +} + +model SastFindingLifecycleReconciliation { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + lifecycleContextKey String + sequence Int + profileId String + profileDigest String + coverageDecision Json + coverageDecisionDigest String + eligibleLineageCount Int + observedLineageCount Int + fixedCount Int + reopenedCount Int + unchangedOpenCount Int + unchangedFixedCount Int + reconciledAt 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: "SastFindingLifecycleReconciliation_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastFindingLifecycleReconciliation_scan_scope_fkey") + attempt SastScanAttempt @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "SastFindingLifecycleReconciliation_attempt_scope_fkey") + lifecycleEvents SastFindingLifecycleEvent[] + + @@unique([coverageDecisionDigest], map: "SastFindingLifecycleReconciliation_decision_digest_key") + @@unique([id, tenantId, repositoryBindingId, lifecycleContextKey], map: "SastFindingLifecycleReconciliation_event_scope_key") + @@unique([tenantId, repositoryBindingId, lifecycleContextKey, sequence], map: "SastFindingLifecycleReconciliation_context_sequence_key") + @@index([scanRequestId]) + @@index([reconciledAt]) +} + +model SastFindingLifecycleEvent { + id String @id + tenantId String + repositoryBindingId String + lineageId String + lifecycleStateId String + lifecycleContextKey String + kind SastFindingLifecycleEventKind + previousStatus SastFindingLifecycleStatus? + nextStatus SastFindingLifecycleStatus + revision Int + observationBatchId String? + reconciliationId String? + renameAttestationDigest String? + occurredAt 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: "SastFindingLifecycleEvent_repository_scope_fkey") + lineage SastFindingLineage @relation(fields: [lineageId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastFindingLifecycleEvent_lineage_scope_fkey") + lifecycleState SastFindingLifecycleState @relation(fields: [lifecycleStateId, tenantId, repositoryBindingId, lineageId, lifecycleContextKey], references: [id, tenantId, repositoryBindingId, lineageId, lifecycleContextKey], onDelete: Cascade, map: "SastFindingLifecycleEvent_state_scope_fkey") + observationBatch SastFindingObservationBatch? @relation(fields: [observationBatchId, tenantId, repositoryBindingId, lifecycleContextKey], references: [id, tenantId, repositoryBindingId, lifecycleContextKey], onDelete: Cascade, map: "SastFindingLifecycleEvent_observation_scope_fkey") + reconciliation SastFindingLifecycleReconciliation? @relation(fields: [reconciliationId, tenantId, repositoryBindingId, lifecycleContextKey], references: [id, tenantId, repositoryBindingId, lifecycleContextKey], onDelete: Cascade, map: "SastFindingLifecycleEvent_reconciliation_scope_fkey") + + @@unique([lifecycleStateId, revision], map: "SastFindingLifecycleEvent_state_revision_key") + @@index([tenantId, repositoryBindingId, lifecycleContextKey, occurredAt]) + @@index([observationBatchId]) + @@index([reconciliationId]) } model EvidencePack { diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index 272550c..b348f89 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -68,6 +68,24 @@ const indexes = [ unique: false, create: 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "SastArtifactIngestion_disposition_claim_idx" ON "SastArtifactIngestion"("dispositionNextAttemptAt", "dispositionLeaseExpiresAt", "receivedAt") WHERE "status" = \'PENDING_VALIDATION\'' + }, + { + name: 'NormalizedFinding_sast_occurrence_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_sast_occurrence_scope_key" ON "NormalizedFinding"("id", "tenantId", "scanRequestId", "scannerRunId")' + }, + { + name: 'NormalizedFinding_sastLineageId_idx', + unique: false, + create: + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_sastLineageId_idx" ON "NormalizedFinding"("sastLineageId")' + }, + { + name: 'NormalizedFinding_sastObservationBatchId_idx', + unique: false, + create: + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_sastObservationBatchId_idx" ON "NormalizedFinding"("sastObservationBatchId")' } ]; @@ -504,6 +522,45 @@ const constraints = [ false ) )` + }, + { + table: 'SastFindingObservationBatch', + name: 'SastFindingObservationBatch_scanner_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("scannerRunId", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId") REFERENCES "ScannerRun"("id", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId") ON DELETE CASCADE ON UPDATE CASCADE' + }, + { + table: 'SastFindingOccurrence', + name: 'SastFindingOccurrence_normalized_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("normalizedFindingId", "tenantId", "scanRequestId", "scannerRunId") REFERENCES "NormalizedFinding"("id", "tenantId", "scanRequestId", "scannerRunId") ON DELETE CASCADE ON UPDATE CASCADE' + }, + { + table: 'NormalizedFinding', + name: 'NormalizedFinding_sast_metadata_check', + type: 'c', + definition: `CHECK ( + ( + "sastCapability" IS NULL + AND "sastFingerprintVersion" IS NULL + AND "sastStableFingerprint" IS NULL + AND "sastFingerprintDecisionDigest" IS NULL + AND "sastLineageId" IS NULL + AND "sastObservationBatchId" IS NULL + AND "sastOccurrenceOrdinal" IS NULL + ) + OR ( + "sastCapability" IS NOT NULL + AND "sastFingerprintVersion" = 'sast-fingerprint-v1' + AND "sastStableFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "sastFingerprintDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "sastLineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "sastObservationBatchId" ~ '^finding-observation://[a-f0-9]{64}$' + AND "sastOccurrenceOrdinal" BETWEEN 0 AND 24999 + ) + )` } ]; diff --git a/apps/api/src/scan-plane/prisma-sast-finding-lineage.store.ts b/apps/api/src/scan-plane/prisma-sast-finding-lineage.store.ts new file mode 100644 index 0000000..aa11c0c --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-finding-lineage.store.ts @@ -0,0 +1,2686 @@ +import { createHash, randomInt } from 'node:crypto'; +import { setTimeout as wait } from 'node:timers/promises'; + +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { + SAST_ARTIFACT_SCHEMA_VERSIONS, + SAST_FINDING_FINGERPRINT_VERSION, + SAST_FINDING_LINEAGE_VERSION, + SAST_PROFILE_IDS, + SAST_SCAN_LANES, + SAST_SCANNER_RESPONSIBILITIES, + buildSastFindingRenameCandidate, + buildSastFindingLifecycleContextPreimage, + buildSastFindingLineageKeyPreimage, + buildSastScanPlanDigestPreimage, + canonicalizeSastFingerprintedFinding, + isSastFindingLifecycleCoverageDecisionShapeValid, + isSastFindingLifecycleContextInputValid, + isSastFindingRenameAttestationShapeValid, + isSastFingerprintedFindingShapeValid, + isSastFingerprintedFindingBatchShapeValid, + isSastScanPlanValid, + orderSastFindingRenameCandidates, + type SastCapability, + type SastFindingLifecycleStatus, + type SastFindingRenameCandidate, + type SastFingerprintedFinding, + type SastScanPlan +} from '@aegisai/shared'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + SastFindingLineageDurableScopeError, + SastFindingLineageObservationIncompleteError, + SastFindingLineageReconciliationOrderError, + SastFindingLineageRenameAmbiguousError, + SastFindingLineageReplayConflictError, + SastFindingLineageStore, + type PersistSastFindingObservationInput, + type PersistSastFindingReconciliationInput, + type PersistedSastFindingObservation, + type PersistedSastFindingReconciliation, + type SastFindingLineageObservationScope, + type SastFindingLineageScanContext, + type SastFindingReconciliationScanContext +} from './sast-finding-lineage.store'; + +const SERIALIZABLE_ATTEMPTS = 3; +const SERIALIZABLE_MAX_WAIT_MILLISECONDS = 5_000; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000; +const SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS = 10; +const SERIALIZABLE_RETRY_MAX_DELAY_MILLISECONDS = 100; +const CREATE_MANY_CHUNK_SIZE = 250; +const READ_MANY_CHUNK_SIZE = 10_000; +const FINDING_CAPABILITIES = [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION' +] as const satisfies readonly Exclude[]; + +type FindingCapability = (typeof FINDING_CAPABILITIES)[number]; + +interface PreparedIdentity { + key: string; + capability: FindingCapability; + stableFingerprint: `sha256:${string}`; + normalizedPath: string; + rename?: Readonly; +} + +interface ResolvedIdentity extends PreparedIdentity { + lineageId: string; + match: 'CREATED' | 'EXACT' | 'RENAMED'; + currentAliasExists: boolean; +} + +@Injectable() +export class PrismaSastFindingLineageStore + extends SastFindingLineageStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async loadObservationContext( + scope: Readonly + ): Promise { + return this.readObservationContext( + this.prisma.scannerRun, + scope + ); + } + + async loadReconciliationContext(input: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + }): Promise { + return this.readReconciliationContext( + this.prisma.sastScanAttempt, + input + ); + } + + async observe( + input: Readonly + ): Promise { + if ( + !isSastFingerprintedFindingBatchShapeValid( + input.batch, + digest, + digest + ) || + !isObservationPersistenceInputValid(input) + ) { + throw new SastFindingLineageDurableScopeError(); + } + const identities = prepareIdentities(input); + return this.runSerializable((transaction) => + this.observeInTransaction(transaction, input, identities) + ); + } + + async reconcile( + input: Readonly + ): Promise { + if ( + !isSastFindingLifecycleCoverageDecisionShapeValid( + input.decision, + digest + ) || + !isCanonicalIsoTimestamp(input.reconciledAt) || + Date.parse(input.reconciledAt) < + Date.parse(input.decision.decidedAt) || + !isReconciliationPersistenceInputValid(input) + ) { + throw new SastFindingLineageDurableScopeError(); + } + return this.runSerializable((transaction) => + this.reconcileInTransaction(transaction, input) + ); + } + + private async observeInTransaction( + transaction: Prisma.TransactionClient, + input: Readonly, + identities: readonly Readonly[] + ): Promise { + const currentContext = await this.readObservationContext( + transaction.scannerRun, + input.batch.scope + ); + if ( + !currentContext || + !sameObservationContext(currentContext, input.context) + ) { + throw new SastFindingLineageDurableScopeError(); + } + if ( + (input.renameAttestation === undefined) !== + (input.renameAttestationDigest === undefined) || + (input.renameAttestation !== undefined && + input.renameAttestation.attestationDigest !== + input.renameAttestationDigest) + ) { + throw new SastFindingLineageDurableScopeError(); + } + if (input.renameAttestation) { + const previousScan = await transaction.scanRequest.findFirst({ + where: { + id: input.renameAttestation.fromScanRequestId, + tenantId: input.context.scope.tenantId, + repositoryBindingId: + input.context.scope.repositoryBindingId, + targetRef: input.context.targetRef, + commitSha: input.renameAttestation.fromCommitSha, + status: 'COMPLETED' + }, + select: { + lane: true, + targetRef: true, + commitSha: true, + canonicalKey: true, + sastQueueReservation: { + select: { immutablePlan: true } + } + } + }); + const previousPlan = parsePlan( + previousScan?.sastQueueReservation?.immutablePlan + ); + if ( + !previousPlan || + !isPlanBoundToScanRequest(previousPlan, { + tenantId: input.context.scope.tenantId, + repositoryBindingId: + input.context.scope.repositoryBindingId, + scanRequestId: + input.renameAttestation.fromScanRequestId, + targetRef: previousScan?.targetRef, + commitSha: previousScan?.commitSha, + canonicalScanKey: previousScan?.canonicalKey, + lane: previousScan?.lane + }) || + previousPlan.profile.id !== + input.renameAttestation.profileId || + previousPlan.profileDigest !== + input.renameAttestation.profileDigest || + previousPlan.repositoryState.targetRef !== + input.context.targetRef + ) { + throw new SastFindingLineageDurableScopeError(); + } + } + + const existing = + await transaction.sastFindingObservationBatch.findFirst({ + where: { + tenantId: input.context.scope.tenantId, + OR: [ + { id: input.observationBatchId }, + { + sourceIdentityBatchDigest: + input.batch.batchDigest + }, + { + scannerRunId: + input.context.scope.scannerRunId + } + ] + } + }); + if (existing) { + return this.replayObservation( + transaction, + input, + existing, + identities + ); + } + + const resolved = await this.resolveIdentities( + transaction, + input, + identities + ); + const counts = { + createdLineageCount: resolved.filter( + (identity) => identity.match === 'CREATED' + ).length, + exactMatchCount: resolved.filter( + (identity) => identity.match === 'EXACT' + ).length, + renamedMatchCount: resolved.filter( + (identity) => identity.match === 'RENAMED' + ).length + }; + const observedAt = new Date(input.observedAt); + const scope = input.context.scope; + const lineageIds = resolved.map( + (identity) => identity.lineageId + ); + + await transaction.sastFindingObservationBatch.create({ + data: { + id: input.observationBatchId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + scannerRunId: scope.scannerRunId, + lifecycleContextKey: input.lifecycleContextKey, + targetRef: input.context.targetRef, + commitSha: input.context.commitSha, + lane: input.context.lane, + scanner: input.context.scanner, + capabilities: observedCapabilities( + input.batch.findings + ) as unknown as Prisma.InputJsonValue, + profileId: input.context.profileId, + profileDigest: input.context.profileDigest, + canonicalScanKey: input.context.canonicalScanKey, + planDigest: input.context.planDigest, + sourceIdentityBatchDigest: input.batch.batchDigest, + renameAttestationDigest: + input.renameAttestationDigest ?? null, + findingCount: input.batch.findings.length, + distinctFingerprintCount: identities.length, + ...counts, + observedAt + } + }); + + await this.createLineagesAndAliases( + transaction, + input, + resolved, + observedAt + ); + await this.recordObservationStates( + transaction, + input, + resolved, + observedAt + ); + await this.createOccurrences( + transaction, + input, + resolved, + observedAt + ); + if (lineageIds.length > 0) { + await transaction.sastFindingLineage.updateMany({ + where: { + id: { in: lineageIds }, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lastObservedAt: { lt: observedAt } + }, + data: { lastObservedAt: observedAt } + }); + } + await transaction.auditEvent.create({ + data: { + id: deterministicId( + 'finding-audit', + `${input.observationBatchId}\0OBSERVED` + ), + tenantId: scope.tenantId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + eventType: 'finding.lineage_observed', + actor: 'scan-plane-finding-lineage', + targetType: 'sast_finding_observation_batch', + targetId: input.observationBatchId, + occurredAt: observedAt, + metadata: { + version: SAST_FINDING_LINEAGE_VERSION, + sourceIdentityBatchDigest: input.batch.batchDigest, + lifecycleContextKey: input.lifecycleContextKey, + findingCount: input.batch.findings.length, + occurrenceCount: input.batch.findings.length, + distinctFingerprintCount: identities.length, + ...counts, + renameAttestationVerified: + input.renameAttestationDigest !== undefined + } + } + }); + + return { + observationBatchId: input.observationBatchId, + sourceIdentityBatchDigest: input.batch.batchDigest, + lifecycleContextKey: input.lifecycleContextKey, + findingCount: input.batch.findings.length, + occurrenceCount: input.batch.findings.length, + distinctFingerprintCount: identities.length, + ...counts, + replayed: false, + observedAt: input.observedAt + }; + } + + private async replayObservation( + transaction: Prisma.TransactionClient, + input: Readonly, + existing: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + lifecycleContextKey: string; + targetRef: string; + commitSha: string; + lane: string; + scanner: string; + capabilities: Prisma.JsonValue; + profileId: string; + profileDigest: string; + canonicalScanKey: string; + planDigest: string; + sourceIdentityBatchDigest: string; + renameAttestationDigest: string | null; + findingCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + observedAt: Date; + }, + identities: readonly Readonly[] + ): Promise { + if ( + !observationBatchReplayMatches( + existing, + input, + identities.length + ) + ) { + throw new SastFindingLineageReplayConflictError(); + } + + const resolved = await this.resolveIdentities( + transaction, + input, + identities + ); + const resolvedByIdentity = new Map( + resolved.map((identity) => [identity.key, identity]) + ); + const occurrenceScope = { + observationBatchId: existing.id, + tenantId: existing.tenantId, + repositoryBindingId: existing.repositoryBindingId, + scanRequestId: existing.scanRequestId, + attemptId: existing.attemptId, + scannerRunId: existing.scannerRunId + }; + const occurrenceCount = + await transaction.sastFindingOccurrence.count({ + where: occurrenceScope + }); + if (occurrenceCount !== existing.findingCount) { + throw new SastFindingLineageReplayConflictError(); + } + for ( + let start = 0; + start < existing.findingCount; + start += CREATE_MANY_CHUNK_SIZE + ) { + const end = Math.min( + start + CREATE_MANY_CHUNK_SIZE, + existing.findingCount + ); + const occurrences = + await transaction.sastFindingOccurrence.findMany({ + where: { + ...occurrenceScope, + ordinal: { + gte: start, + lt: end + } + }, + select: { + id: true, + tenantId: true, + repositoryBindingId: true, + scanRequestId: true, + attemptId: true, + scannerRunId: true, + observationBatchId: true, + lineageId: true, + normalizedFindingId: true, + ordinal: true, + capability: true, + fingerprintVersion: true, + stableFingerprint: true, + fingerprintDecisionDigest: true, + sourceFinding: true, + observedAt: true + }, + orderBy: { ordinal: 'asc' } + }); + if ( + occurrences.length !== end - start || + occurrences.some((occurrence, index) => { + const ordinal = start + index; + const finding = input.batch.findings[ordinal]; + if (!finding) return true; + const resolvedIdentity = resolvedByIdentity.get( + identityKey( + finding.capability, + finding.fingerprint.stableFingerprint + ) + ); + return ( + !resolvedIdentity || + !occurrenceReplayMatches( + occurrence, + finding, + resolvedIdentity.lineageId, + input, + existing.observedAt, + ordinal + ) + ); + }) + ) { + throw new SastFindingLineageReplayConflictError(); + } + } + return { + observationBatchId: existing.id, + sourceIdentityBatchDigest: + existing.sourceIdentityBatchDigest as `sha256:${string}`, + lifecycleContextKey: + existing.lifecycleContextKey as `sha256:${string}`, + findingCount: existing.findingCount, + occurrenceCount, + distinctFingerprintCount: + existing.distinctFingerprintCount, + createdLineageCount: existing.createdLineageCount, + exactMatchCount: existing.exactMatchCount, + renamedMatchCount: existing.renamedMatchCount, + replayed: true, + observedAt: existing.observedAt.toISOString() + }; + } + + private async resolveIdentities( + transaction: Prisma.TransactionClient, + input: Readonly, + identities: readonly Readonly[] + ): Promise { + const scope = input.context.scope; + const fingerprints = new Set(); + for (const identity of identities) { + fingerprints.add(identity.stableFingerprint); + if (identity.rename) { + fingerprints.add( + identity.rename.previousStableFingerprint + ); + } + } + const aliases: Array<{ + lineageId: string; + capability: FindingCapability; + stableFingerprint: string; + normalizedPath: string; + }> = []; + for (const fingerprintChunk of chunks( + [...fingerprints], + READ_MANY_CHUNK_SIZE + )) { + aliases.push( + ...(await transaction.sastFindingIdentityAlias.findMany({ + where: { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + fingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + stableFingerprint: { + in: fingerprintChunk + } + }, + select: { + lineageId: true, + capability: true, + stableFingerprint: true, + normalizedPath: true + } + })) + ); + } + const aliasByIdentity = new Map( + aliases.map((alias) => [ + identityKey( + alias.capability, + alias.stableFingerprint + ), + alias + ]) + ); + const resolved: ResolvedIdentity[] = []; + for (const identity of identities) { + const exact = aliasByIdentity.get(identity.key); + const predecessor = identity.rename + ? aliasByIdentity.get( + identityKey( + identity.capability, + identity.rename.previousStableFingerprint + ) + ) + : undefined; + if (identity.rename && !predecessor) { + throw new SastFindingLineageRenameAmbiguousError(); + } + if ( + (exact && + exact.normalizedPath !== + identity.normalizedPath) || + (predecessor && + predecessor.normalizedPath !== + identity.rename?.fromNormalizedPath) + ) { + throw new SastFindingLineageDurableScopeError(); + } + if ( + exact && + predecessor && + exact.lineageId !== predecessor.lineageId + ) { + throw new SastFindingLineageRenameAmbiguousError(); + } + if (predecessor) { + resolved.push({ + ...identity, + lineageId: predecessor.lineageId, + match: 'RENAMED', + currentAliasExists: exact !== undefined + }); + } else if (exact) { + resolved.push({ + ...identity, + lineageId: exact.lineageId, + match: 'EXACT', + currentAliasExists: true + }); + } else { + resolved.push({ + ...identity, + lineageId: lineageId( + scope.tenantId, + scope.repositoryBindingId, + identity.capability, + identity.stableFingerprint + ), + match: 'CREATED', + currentAliasExists: false + }); + } + } + + const identityByLineage = new Map(); + for (const identity of resolved) { + const previous = identityByLineage.get(identity.lineageId); + if (previous && previous !== identity.key) { + throw new SastFindingLineageRenameAmbiguousError(); + } + identityByLineage.set(identity.lineageId, identity.key); + } + + const persistedLineages = + resolved.length === 0 + ? [] + : await transaction.sastFindingLineage.findMany({ + where: { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + id: { + in: resolved.map( + (identity) => identity.lineageId + ) + } + }, + select: { + id: true, + tenantId: true, + repositoryBindingId: true, + capability: true, + fingerprintVersion: true + } + }); + const lineageById = new Map( + persistedLineages.map((lineage) => [lineage.id, lineage]) + ); + for (const identity of resolved) { + const persisted = lineageById.get(identity.lineageId); + if ( + identity.match === 'CREATED' + ? persisted !== undefined + : !persisted || + persisted.tenantId !== scope.tenantId || + persisted.repositoryBindingId !== + scope.repositoryBindingId || + persisted.capability !== identity.capability || + persisted.fingerprintVersion !== + SAST_FINDING_FINGERPRINT_VERSION + ) { + throw new SastFindingLineageDurableScopeError(); + } + } + return resolved; + } + + private async createLineagesAndAliases( + transaction: Prisma.TransactionClient, + input: Readonly, + identities: readonly Readonly[], + observedAt: Date + ): Promise { + const scope = input.context.scope; + const newLineages: Prisma.SastFindingLineageCreateManyInput[] = + identities + .filter((identity) => identity.match === 'CREATED') + .map((identity) => ({ + id: identity.lineageId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + capability: identity.capability, + fingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + firstStableFingerprint: + identity.stableFingerprint, + firstObservedAt: observedAt, + lastObservedAt: observedAt, + createdAt: observedAt, + updatedAt: observedAt + })); + if (newLineages.length > 0) { + for (const rows of chunks( + newLineages, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingLineage.createMany({ + data: rows + }); + } + } + + const aliases: Prisma.SastFindingIdentityAliasCreateManyInput[] = + identities + .filter((identity) => !identity.currentAliasExists) + .map((identity) => ({ + id: deterministicId( + 'finding-alias', + `${scope.tenantId}\0${scope.repositoryBindingId}\0${identity.capability}\0${SAST_FINDING_FINGERPRINT_VERSION}\0${identity.stableFingerprint}` + ), + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lineageId: identity.lineageId, + capability: identity.capability, + fingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + stableFingerprint: + identity.stableFingerprint, + normalizedPath: identity.normalizedPath, + renameAttestationDigest: + identity.match === 'RENAMED' + ? input.renameAttestationDigest + : null, + createdAt: observedAt + })); + if (aliases.length > 0) { + for (const rows of chunks( + aliases, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingIdentityAlias.createMany({ + data: rows + }); + } + } + } + + private async recordObservationStates( + transaction: Prisma.TransactionClient, + input: Readonly, + identities: readonly Readonly[], + observedAt: Date + ): Promise { + const scope = input.context.scope; + const existingStates = + identities.length === 0 + ? [] + : await transaction.sastFindingLifecycleState.findMany({ + where: { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lifecycleContextKey: + input.lifecycleContextKey, + lineageId: { + in: identities.map( + (identity) => identity.lineageId + ) + } + }, + select: { + id: true, + lineageId: true, + status: true, + revision: true, + targetRef: true, + lastObservedAt: true + } + }); + if ( + existingStates.some( + (state) => + state.targetRef !== input.context.targetRef + ) + ) { + throw new SastFindingLineageDurableScopeError(); + } + const stateByLineage = new Map( + existingStates.map((state) => [state.lineageId, state]) + ); + const newStates: Prisma.SastFindingLifecycleStateCreateManyInput[] = + []; + const events: Prisma.SastFindingLifecycleEventCreateManyInput[] = + []; + const exactExistingStateIds: string[] = []; + const renamedExistingStateIds: string[] = []; + + for (const identity of identities) { + const existing = stateByLineage.get(identity.lineageId); + const stateId = + existing?.id ?? + deterministicId( + 'finding-state', + `${input.lifecycleContextKey}\0${identity.lineageId}` + ); + if (!existing) { + const finalRevision = + identity.match === 'RENAMED' ? 2 : 1; + newStates.push({ + id: stateId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lineageId: identity.lineageId, + lifecycleContextKey: input.lifecycleContextKey, + targetRef: input.context.targetRef, + status: 'OPEN', + revision: finalRevision, + lastObservedBatchId: input.observationBatchId, + lastObservedScanRequestId: scope.scanRequestId, + lastObservedCommitSha: input.context.commitSha, + lastObservedAt: observedAt, + lastReconciliationSequence: 0, + fixedAt: null, + reopenedAt: null, + createdAt: observedAt, + updatedAt: observedAt + }); + events.push( + lifecycleEvent({ + stateId, + identity, + input, + kind: 'CREATED', + previousStatus: null, + nextStatus: 'OPEN', + revision: 1, + occurredAt: observedAt + }) + ); + if (identity.match === 'RENAMED') { + events.push( + lifecycleEvent({ + stateId, + identity, + input, + kind: 'RENAMED', + previousStatus: 'OPEN', + nextStatus: 'OPEN', + revision: 2, + occurredAt: observedAt + }) + ); + } + } else if (identity.match === 'RENAMED') { + if ( + existing.lastObservedAt && + existing.lastObservedAt.getTime() > + observedAt.getTime() + ) { + throw new SastFindingLineageReplayConflictError(); + } + renamedExistingStateIds.push(existing.id); + events.push( + lifecycleEvent({ + stateId: existing.id, + identity, + input, + kind: 'RENAMED', + previousStatus: existing.status, + nextStatus: existing.status, + revision: existing.revision + 1, + occurredAt: observedAt + }) + ); + } else { + exactExistingStateIds.push(existing.id); + } + } + + if (newStates.length > 0) { + for (const rows of chunks( + newStates, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingLifecycleState.createMany({ + data: rows + }); + } + } + if (exactExistingStateIds.length > 0) { + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { in: exactExistingStateIds }, + OR: [ + { lastObservedAt: null }, + { lastObservedAt: { lte: observedAt } } + ] + }, + data: { + lastObservedBatchId: input.observationBatchId, + lastObservedScanRequestId: scope.scanRequestId, + lastObservedCommitSha: input.context.commitSha, + lastObservedAt: observedAt + } + }); + } + if (renamedExistingStateIds.length > 0) { + const updated = + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { in: renamedExistingStateIds }, + OR: [ + { lastObservedAt: null }, + { lastObservedAt: { lte: observedAt } } + ] + }, + data: { + revision: { increment: 1 }, + lastObservedBatchId: input.observationBatchId, + lastObservedScanRequestId: scope.scanRequestId, + lastObservedCommitSha: input.context.commitSha, + lastObservedAt: observedAt + } + }); + if (updated.count !== renamedExistingStateIds.length) { + throw new SastFindingLineageReplayConflictError(); + } + } + if (events.length > 0) { + for (const rows of chunks( + events, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingLifecycleEvent.createMany({ + data: rows + }); + } + } + } + + private async createOccurrences( + transaction: Prisma.TransactionClient, + input: Readonly, + identities: readonly Readonly[], + observedAt: Date + ): Promise { + const resolvedByIdentity = new Map( + identities.map((identity) => [identity.key, identity]) + ); + const normalizedRows: Prisma.NormalizedFindingCreateManyInput[] = + []; + const occurrenceRows: Prisma.SastFindingOccurrenceCreateManyInput[] = + []; + const scope = input.context.scope; + for ( + let ordinal = 0; + ordinal < input.batch.findings.length; + ordinal += 1 + ) { + const finding = input.batch.findings[ordinal]; + if (!finding) { + throw new SastFindingLineageDurableScopeError(); + } + const resolved = resolvedByIdentity.get( + identityKey( + finding.capability, + finding.fingerprint.stableFingerprint + ) + ); + if (!resolved) { + throw new SastFindingLineageDurableScopeError(); + } + const occurrenceId = deterministicId( + 'finding-occurrence', + `${input.observationBatchId}\0${ordinal}\0${finding.fingerprint.decisionDigest}` + ); + const normalizedFindingId = deterministicId( + 'normalized-finding', + occurrenceId + ); + const fileLocation = + finding.location.kind === 'FILE' + ? finding.location + : null; + normalizedRows.push({ + id: normalizedFindingId, + tenantId: scope.tenantId, + scanRequestId: scope.scanRequestId, + scannerRunId: scope.scannerRunId, + title: finding.title, + severity: finding.severity, + scannerProvenance: input.context.scanner, + filePath: fileLocation?.normalizedPath ?? null, + lineStart: fileLocation?.lineStart ?? null, + lineEnd: fileLocation?.lineEnd ?? null, + status: 'OPEN', + metadata: normalizedFindingMetadata( + finding, + input.lifecycleContextKey + ), + sastCapability: finding.capability, + sastFingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + sastStableFingerprint: + finding.fingerprint.stableFingerprint, + sastFingerprintDecisionDigest: + finding.fingerprint.decisionDigest, + sastLineageId: resolved.lineageId, + sastObservationBatchId: input.observationBatchId, + sastOccurrenceOrdinal: ordinal, + createdAt: observedAt, + updatedAt: observedAt + }); + occurrenceRows.push({ + id: occurrenceId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + scannerRunId: scope.scannerRunId, + observationBatchId: input.observationBatchId, + lineageId: resolved.lineageId, + normalizedFindingId, + ordinal, + capability: finding.capability, + fingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + stableFingerprint: + finding.fingerprint.stableFingerprint, + fingerprintDecisionDigest: + finding.fingerprint.decisionDigest, + sourceFinding: + finding as unknown as Prisma.InputJsonValue, + observedAt, + createdAt: observedAt + }); + } + for (const rows of chunks( + normalizedRows, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.normalizedFinding.createMany({ + data: rows + }); + } + for (const rows of chunks( + occurrenceRows, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingOccurrence.createMany({ + data: rows + }); + } + } + + private async reconcileInTransaction( + transaction: Prisma.TransactionClient, + input: Readonly + ): Promise { + const decision = input.decision; + const currentContext = await this.readReconciliationContext( + transaction.sastScanAttempt, + { + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId + } + ); + if ( + !currentContext || + !sameReconciliationContext(currentContext, input.context) + ) { + throw new SastFindingLineageDurableScopeError(); + } + + const existing = + await transaction.sastFindingLifecycleReconciliation.findFirst( + { + where: { + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + OR: [ + { id: input.reconciliationId }, + { + coverageDecisionDigest: + decision.decisionDigest + }, + { + lifecycleContextKey: + decision.lifecycleContextKey, + sequence: decision.sequence + } + ] + } + } + ); + if (existing) { + if ( + existing.id !== input.reconciliationId || + existing.coverageDecisionDigest !== + decision.decisionDigest || + existing.tenantId !== decision.tenantId || + existing.repositoryBindingId !== + decision.repositoryBindingId || + existing.scanRequestId !== decision.scanRequestId || + existing.attemptId !== decision.attemptId || + existing.lifecycleContextKey !== + decision.lifecycleContextKey || + existing.sequence !== decision.sequence || + existing.profileId !== decision.profileId || + existing.profileDigest !== decision.profileDigest || + existing.eligibleLineageCount !== + decision.eligibleLineageIds.length || + !isSastFindingLifecycleCoverageDecisionShapeValid( + existing.coverageDecision, + digest + ) || + existing.coverageDecision.decisionDigest !== + decision.decisionDigest + ) { + throw new SastFindingLineageReplayConflictError(); + } + return reconciliationFromRow(existing, true); + } + + const latest = + await transaction.sastFindingLifecycleReconciliation.findFirst( + { + where: { + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + lifecycleContextKey: + decision.lifecycleContextKey + }, + orderBy: { sequence: 'desc' } + } + ); + if ( + (latest && + (decision.sequence !== latest.sequence + 1 || + decision.previousScanRequestId !== + latest.scanRequestId || + Date.parse(input.reconciledAt) <= + latest.reconciledAt.getTime())) || + (!latest && decision.sequence !== 1) + ) { + throw new SastFindingLineageReconciliationOrderError(); + } + const previousScan = await transaction.scanRequest.findFirst({ + where: { + id: decision.previousScanRequestId, + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + targetRef: input.context.targetRef, + commitSha: decision.previousCommitSha, + status: 'COMPLETED' + }, + select: { + lane: true, + targetRef: true, + commitSha: true, + canonicalKey: true, + sastQueueReservation: { + select: { immutablePlan: true } + } + } + }); + const previousPlan = parsePlan( + previousScan?.sastQueueReservation?.immutablePlan + ); + if ( + !previousPlan || + !isPlanBoundToScanRequest(previousPlan, { + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + scanRequestId: decision.previousScanRequestId, + targetRef: previousScan?.targetRef, + commitSha: previousScan?.commitSha, + canonicalScanKey: previousScan?.canonicalKey, + lane: previousScan?.lane + }) || + previousPlan.profile.id !== decision.profileId || + previousPlan.profileDigest !== decision.profileDigest + ) { + throw new SastFindingLineageDurableScopeError(); + } + + const batches = + await transaction.sastFindingObservationBatch.findMany({ + where: { + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId + }, + select: { + id: true, + sourceIdentityBatchDigest: true, + lifecycleContextKey: true, + capabilities: true, + scanner: true, + targetRef: true, + commitSha: true, + lane: true, + profileId: true, + profileDigest: true, + canonicalScanKey: true, + planDigest: true, + observedAt: true + } + }); + const decisionDecidedAt = Date.parse(decision.decidedAt); + const reconciledAtTime = Date.parse(input.reconciledAt); + const durableBatchInvalid = batches.some((batch) => { + try { + readCapabilities(batch.capabilities, batch.scanner); + } catch { + return true; + } + return ( + batch.lifecycleContextKey !== + decision.lifecycleContextKey || + batch.targetRef !== input.context.targetRef || + batch.commitSha !== decision.commitSha || + batch.lane !== input.context.lane || + batch.profileId !== decision.profileId || + batch.profileDigest !== decision.profileDigest || + batch.canonicalScanKey !== + decision.canonicalScanKey || + batch.planDigest !== decision.planDigest || + batch.observedAt.getTime() > decisionDecidedAt || + batch.observedAt.getTime() > reconciledAtTime + ); + }); + const observedBatchDigests = batches + .map((batch) => batch.sourceIdentityBatchDigest) + .sort(); + if ( + durableBatchInvalid || + !sameStringArray( + observedBatchDigests, + decision.expectedObservationBatchDigests + ) + ) { + throw new SastFindingLineageObservationIncompleteError(); + } + const relevantBatches = batches; + + const eligibleIds = decision.eligibleLineageIds; + const lineages = + eligibleIds.length === 0 + ? [] + : await transaction.sastFindingLineage.findMany({ + where: { + id: { in: eligibleIds }, + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + capability: { + in: decision.completeCapabilities + } + }, + select: { + id: true, + capability: true, + fingerprintVersion: true + } + }); + if ( + lineages.length !== eligibleIds.length || + lineages.some( + (lineage) => + lineage.fingerprintVersion !== + SAST_FINDING_FINGERPRINT_VERSION || + !decision.completeCapabilities.includes( + lineage.capability + ) + ) + ) { + throw new SastFindingLineageObservationIncompleteError(); + } + + const observedRows = + relevantBatches.length === 0 + ? [] + : await transaction.sastFindingOccurrence.findMany({ + where: { + observationBatchId: { + in: relevantBatches.map((batch) => batch.id) + }, + capability: { + in: decision.completeCapabilities + } + }, + select: { + lineageId: true, + capability: true, + fingerprintVersion: true, + lineage: { + select: { + capability: true, + fingerprintVersion: true + } + } + }, + distinct: ['lineageId'] + }); + const eligible = new Set(eligibleIds); + if ( + observedRows.some( + (row) => + !eligible.has(row.lineageId) || + row.capability !== row.lineage.capability || + row.fingerprintVersion !== + SAST_FINDING_FINGERPRINT_VERSION || + row.lineage.fingerprintVersion !== + SAST_FINDING_FINGERPRINT_VERSION + ) + ) { + throw new SastFindingLineageObservationIncompleteError(); + } + const observed = new Set( + observedRows.map((row) => row.lineageId) + ); + const states = + eligibleIds.length === 0 + ? [] + : await transaction.sastFindingLifecycleState.findMany({ + where: { + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + lifecycleContextKey: + decision.lifecycleContextKey, + lineageId: { in: eligibleIds } + }, + select: { + id: true, + lineageId: true, + status: true, + revision: true, + targetRef: true, + lastReconciliationSequence: true + } + }); + const expectedPreviousSequence = latest?.sequence ?? 0; + if ( + states.length !== eligibleIds.length || + states.some( + (state) => + state.targetRef !== input.context.targetRef || + state.lastReconciliationSequence > + expectedPreviousSequence + ) + ) { + throw new SastFindingLineageObservationIncompleteError(); + } + + const fixed = states.filter( + (state) => + state.status === 'OPEN' && !observed.has(state.lineageId) + ); + const reopened = states.filter( + (state) => + state.status === 'FIXED' && observed.has(state.lineageId) + ); + const unchangedOpen = states.filter( + (state) => + state.status === 'OPEN' && observed.has(state.lineageId) + ); + const unchangedFixed = states.filter( + (state) => + state.status === 'FIXED' && + !observed.has(state.lineageId) + ); + const reconciledAt = new Date(input.reconciledAt); + + await updateLifecycleStates( + transaction, + fixed, + reopened, + unchangedOpen, + unchangedFixed, + decision.sequence, + reconciledAt + ); + const counts = { + eligibleLineageCount: eligibleIds.length, + observedLineageCount: observed.size, + fixedCount: fixed.length, + reopenedCount: reopened.length, + unchangedOpenCount: unchangedOpen.length, + unchangedFixedCount: unchangedFixed.length + }; + await transaction.sastFindingLifecycleReconciliation.create({ + data: { + id: input.reconciliationId, + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId, + lifecycleContextKey: + decision.lifecycleContextKey, + sequence: decision.sequence, + profileId: decision.profileId, + profileDigest: decision.profileDigest, + coverageDecision: + decision as unknown as Prisma.InputJsonValue, + coverageDecisionDigest: decision.decisionDigest, + ...counts, + reconciledAt + } + }); + const transitionEvents = [ + ...fixed.map((state) => + reconciliationEvent( + state, + input, + 'FIXED', + 'OPEN', + 'FIXED', + reconciledAt + ) + ), + ...reopened.map((state) => + reconciliationEvent( + state, + input, + 'REOPENED', + 'FIXED', + 'OPEN', + reconciledAt + ) + ) + ]; + if (transitionEvents.length > 0) { + for (const rows of chunks( + transitionEvents, + CREATE_MANY_CHUNK_SIZE + )) { + await transaction.sastFindingLifecycleEvent.createMany({ + data: rows + }); + } + } + await transaction.auditEvent.create({ + data: { + id: deterministicId( + 'finding-audit', + `${input.reconciliationId}\0RECONCILED` + ), + tenantId: decision.tenantId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId, + eventType: 'finding.lifecycle_reconciled', + actor: 'scan-plane-finding-lineage', + targetType: 'sast_finding_lifecycle_reconciliation', + targetId: input.reconciliationId, + occurredAt: reconciledAt, + metadata: { + version: SAST_FINDING_LINEAGE_VERSION, + coverageDecisionDigest: decision.decisionDigest, + sourceCoverageDecisionDigest: + decision.sourceCoverageDecisionDigest, + lifecycleContextKey: + decision.lifecycleContextKey, + sequence: decision.sequence, + ...counts + } + } + }); + return { + reconciliationId: input.reconciliationId, + coverageDecisionDigest: decision.decisionDigest, + lifecycleContextKey: + decision.lifecycleContextKey, + sequence: decision.sequence, + ...counts, + replayed: false, + reconciledAt: input.reconciledAt + }; + } + + private async readObservationContext( + delegate: Prisma.TransactionClient['scannerRun'], + scope: Readonly + ): Promise { + const row = await delegate.findFirst({ + where: { + id: scope.scannerRunId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + status: 'COMPLETED', + artifactIngestion: { + status: 'ACCEPTED', + dispositionDecision: { + disposition: 'ACCEPTED', + normalizationEligible: true + } + } + }, + select: { + scanner: true, + scannerVersion: true, + wrapperDigest: true, + scannerImageDigest: true, + scannerSetDigest: true, + ruleBundleDigest: true, + databaseDigest: true, + schemaBundleDigest: true, + normalizerBundleDigest: true, + profileId: true, + profileDigest: true, + preflightAttestationRef: true, + preflightInventoryDigest: true, + scannerWorkspaceInventoryDigest: true, + artifactSchema: true, + artifactSchemaVersion: true, + scanRequest: { + select: { + lane: true, + targetRef: true, + commitSha: true, + canonicalKey: true, + sastQueueReservation: { + select: { immutablePlan: true } + } + } + }, + artifactIngestion: { + select: { + id: true, + envelopeDigest: true, + observedContentDigest: true, + retentionExpiresAt: true, + dispositionDecision: { + select: { + validationResultDigest: true, + decisionDigest: true, + retentionExpiresAt: true + } + } + } + } + } + }); + if (!row) return null; + const plan = parsePlan( + row.scanRequest.sastQueueReservation?.immutablePlan + ); + const ingestion = row.artifactIngestion; + const disposition = ingestion?.dispositionDecision; + const findingScanner = isFindingScannerKind(row.scanner) + ? row.scanner + : null; + const scannerRuntime = + plan && findingScanner + ? plan.scannerSet.scanners[findingScanner] + : null; + const expectedRuleBundle = + plan && findingScanner + ? plan.scannerSet.ruleBundles.find( + (bundle) => bundle.scanner === findingScanner + ) + : undefined; + const expectedArtifactSchema = findingScanner + ? SAST_SCANNER_RESPONSIBILITIES[findingScanner] + .outputSchema + : null; + if ( + !plan || + !ingestion || + !disposition || + !ingestion.observedContentDigest || + !ingestion.retentionExpiresAt || + !disposition.retentionExpiresAt || + ingestion.retentionExpiresAt.getTime() !== + disposition.retentionExpiresAt.getTime() || + !isDigest(row.scannerImageDigest) || + !isDigest(row.wrapperDigest) || + !isDigest(row.scannerSetDigest) || + !isDigest(row.schemaBundleDigest) || + !isDigest(row.normalizerBundleDigest) || + !isDigest(row.profileDigest) || + !isDigest(row.preflightInventoryDigest) || + !isDigest(row.scannerWorkspaceInventoryDigest) || + !isDigest(ingestion.envelopeDigest) || + !isDigest(ingestion.observedContentDigest) || + !isDigest(disposition.validationResultDigest) || + !isDigest(disposition.decisionDigest) || + typeof row.preflightAttestationRef !== 'string' || + row.preflightAttestationRef.length === 0 || + typeof row.artifactSchema !== 'string' || + row.artifactSchema.length === 0 || + typeof row.artifactSchemaVersion !== 'string' || + row.artifactSchemaVersion.length === 0 || + !row.profileId || + !SAST_PROFILE_IDS.includes( + row.profileId as (typeof SAST_PROFILE_IDS)[number] + ) || + !SAST_SCAN_LANES.includes(row.scanRequest.lane) || + !findingScanner || + !scannerRuntime || + !expectedRuleBundle || + !isDigest(row.ruleBundleDigest) || + (row.scanner === 'TRIVY' + ? !isDigest(row.databaseDigest) + : row.databaseDigest !== null) || + plan.tenantId !== scope.tenantId || + plan.scanRequestId !== scope.scanRequestId || + plan.repositoryState.repositoryBindingId !== + scope.repositoryBindingId || + plan.repositoryState.targetRef !== + row.scanRequest.targetRef || + plan.repositoryState.fixedCommitSha !== + row.scanRequest.commitSha || + plan.canonicalScanKey !== row.scanRequest.canonicalKey || + plan.profile.lane !== row.scanRequest.lane || + plan.profile.id !== row.profileId || + plan.profileDigest !== row.profileDigest || + !( + plan.profile.requiredScanners.includes( + findingScanner + ) || + plan.profile.optionalScanners.includes( + findingScanner + ) + ) || + row.scannerVersion !== scannerRuntime.version || + row.wrapperDigest !== scannerRuntime.wrapper.digest || + row.scannerImageDigest !== scannerRuntime.digest || + row.scannerSetDigest !== + plan.scannerSet.scannerSetDigest || + row.ruleBundleDigest !== expectedRuleBundle.digest || + (findingScanner === 'TRIVY' + ? row.databaseDigest !== + plan.scannerSet.vulnerabilityDatabase.digest + : row.databaseDigest !== null) || + row.schemaBundleDigest !== + plan.scannerSet.schemaBundle.digest || + row.normalizerBundleDigest !== + plan.scannerSet.normalizerBundle.digest || + row.preflightAttestationRef !== + plan.repositoryState.attestationRef || + row.preflightInventoryDigest !== + plan.repositoryState.inventoryDigest || + row.scannerWorkspaceInventoryDigest !== + plan.repositoryState.inventoryDigest || + row.artifactSchema !== expectedArtifactSchema || + row.artifactSchemaVersion !== + SAST_ARTIFACT_SCHEMA_VERSIONS[ + expectedArtifactSchema + ] + ) { + return null; + } + return { + scope: { ...scope }, + targetRef: row.scanRequest.targetRef, + lane: row.scanRequest.lane, + commitSha: row.scanRequest.commitSha, + canonicalScanKey: + row.scanRequest.canonicalKey as `sha256:${string}`, + planDigest: digest( + buildSastScanPlanDigestPreimage(plan) + ), + profileId: + row.profileId as (typeof SAST_PROFILE_IDS)[number], + profileDigest: row.profileDigest, + scanner: findingScanner, + source: { + ingestionId: ingestion.id, + scannerVersion: row.scannerVersion, + scannerImageDigest: row.scannerImageDigest, + ...(isDigest(row.ruleBundleDigest) + ? { ruleBundleDigest: row.ruleBundleDigest } + : {}), + ...(isDigest(row.databaseDigest) + ? { + vulnerabilityDatabaseDigest: + row.databaseDigest + } + : {}), + schemaBundleDigest: row.schemaBundleDigest, + normalizerBundleDigest: + row.normalizerBundleDigest, + preflightAttestationRef: + row.preflightAttestationRef, + preflightInventoryDigest: + row.preflightInventoryDigest, + artifactSchema: row.artifactSchema, + artifactSchemaVersion: + row.artifactSchemaVersion, + envelopeDigest: ingestion.envelopeDigest, + artifactDigest: ingestion.observedContentDigest, + validationResultDigest: + disposition.validationResultDigest, + dispositionDecisionDigest: + disposition.decisionDigest, + retentionExpiresAt: + ingestion.retentionExpiresAt.toISOString() + } + }; + } + + private async readReconciliationContext( + delegate: Prisma.TransactionClient['sastScanAttempt'], + input: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + } + ): Promise { + const row = await delegate.findFirst({ + where: { + id: input.attemptId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + stage: { + in: ['SCANNING', 'CLEANUP_PENDING', 'COMPLETED'] + }, + scanRequest: { + status: { in: ['RUNNING', 'COMPLETED'] } + } + }, + select: { + scanRequest: { + select: { + lane: true, + targetRef: true, + commitSha: true, + canonicalKey: true, + sastQueueReservation: { + select: { immutablePlan: true } + } + } + } + } + }); + if (!row) return null; + const plan = parsePlan( + row.scanRequest.sastQueueReservation?.immutablePlan + ); + if ( + !plan || + plan.tenantId !== input.tenantId || + plan.scanRequestId !== input.scanRequestId || + plan.repositoryState.repositoryBindingId !== + input.repositoryBindingId || + plan.repositoryState.targetRef !== + row.scanRequest.targetRef || + plan.repositoryState.fixedCommitSha !== + row.scanRequest.commitSha || + plan.canonicalScanKey !== row.scanRequest.canonicalKey || + plan.profile.lane !== row.scanRequest.lane + ) { + return null; + } + return { + ...input, + targetRef: row.scanRequest.targetRef, + lane: row.scanRequest.lane, + commitSha: row.scanRequest.commitSha, + canonicalScanKey: + row.scanRequest.canonicalKey as `sha256:${string}`, + planDigest: digest( + buildSastScanPlanDigestPreimage(plan) + ), + profileId: plan.profile.id, + profileDigest: plan.profileDigest + }; + } + + private async runSerializable( + operation: ( + transaction: Prisma.TransactionClient + ) => Promise + ): Promise { + let lastError: unknown; + for ( + let attempt = 0; + 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)) throw error; + if (attempt + 1 < SERIALIZABLE_ATTEMPTS) { + await wait(serializableRetryDelay(attempt)); + } + } + } + throw lastError; + } +} + +async function updateLifecycleStates( + transaction: Prisma.TransactionClient, + fixed: readonly { id: string }[], + reopened: readonly { id: string }[], + unchangedOpen: readonly { id: string }[], + unchangedFixed: readonly { id: string }[], + sequence: number, + reconciledAt: Date +): Promise { + const previousSequence = sequence - 1; + if (fixed.length > 0) { + const updated = + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { in: fixed.map((state) => state.id) }, + status: 'OPEN', + lastReconciliationSequence: { + lte: previousSequence + } + }, + data: { + status: 'FIXED', + revision: { increment: 1 }, + lastReconciliationSequence: sequence, + fixedAt: reconciledAt, + updatedAt: reconciledAt + } + }); + if (updated.count !== fixed.length) { + throw new SastFindingLineageReconciliationOrderError(); + } + } + if (reopened.length > 0) { + const updated = + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { in: reopened.map((state) => state.id) }, + status: 'FIXED', + lastReconciliationSequence: { + lte: previousSequence + } + }, + data: { + status: 'OPEN', + revision: { increment: 1 }, + lastReconciliationSequence: sequence, + fixedAt: null, + reopenedAt: reconciledAt, + updatedAt: reconciledAt + } + }); + if (updated.count !== reopened.length) { + throw new SastFindingLineageReconciliationOrderError(); + } + } + if (unchangedOpen.length > 0) { + const updated = + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { + in: unchangedOpen.map((state) => state.id) + }, + status: 'OPEN', + lastReconciliationSequence: { + lte: previousSequence + } + }, + data: { + lastReconciliationSequence: sequence, + updatedAt: reconciledAt + } + }); + if (updated.count !== unchangedOpen.length) { + throw new SastFindingLineageReconciliationOrderError(); + } + } + if (unchangedFixed.length > 0) { + const updated = + await transaction.sastFindingLifecycleState.updateMany({ + where: { + id: { + in: unchangedFixed.map((state) => state.id) + }, + status: 'FIXED', + lastReconciliationSequence: { + lte: previousSequence + } + }, + data: { + lastReconciliationSequence: sequence, + updatedAt: reconciledAt + } + }); + if (updated.count !== unchangedFixed.length) { + throw new SastFindingLineageReconciliationOrderError(); + } + } +} + +function isObservationPersistenceInputValid( + input: Readonly +): boolean { + if ( + !observationBatchMatchesContext( + input.batch, + input.context + ) || + !isCanonicalIsoTimestamp(input.observedAt) || + Date.parse(input.observedAt) >= + Date.parse(input.context.source.retentionExpiresAt) + ) { + return false; + } + const lifecycleContext = { + tenantId: input.context.scope.tenantId, + repositoryBindingId: + input.context.scope.repositoryBindingId, + targetRef: input.context.targetRef + }; + if ( + !isSastFindingLifecycleContextInputValid( + lifecycleContext + ) || + digest( + buildSastFindingLifecycleContextPreimage( + lifecycleContext + ) + ) !== input.lifecycleContextKey + ) { + return false; + } + if (input.renameAttestation === undefined) { + return ( + input.renameAttestationDigest === undefined && + input.renameCandidates.length === 0 + ); + } + const attestation = input.renameAttestation; + if ( + !isSastFindingRenameAttestationShapeValid( + attestation, + digest + ) || + input.renameAttestationDigest !== + attestation.attestationDigest || + attestation.tenantId !== + input.context.scope.tenantId || + attestation.repositoryBindingId !== + input.context.scope.repositoryBindingId || + attestation.lifecycleContextKey !== + input.lifecycleContextKey || + attestation.toScanRequestId !== + input.context.scope.scanRequestId || + attestation.toCommitSha !== input.context.commitSha || + attestation.profileId !== input.context.profileId || + attestation.profileDigest !== + input.context.profileDigest || + Date.parse(attestation.issuedAt) > + Date.parse(input.observedAt) + ) { + return false; + } + const entriesByTargetPath = new Map( + attestation.entries.map((entry) => [ + entry.toNormalizedPath, + entry + ]) + ); + const derivedCandidates: SastFindingRenameCandidate[] = []; + for (const finding of input.batch.findings) { + const entry = entriesByTargetPath.get( + finding.fingerprint.normalizedPath + ); + if (!entry) continue; + const candidate = buildSastFindingRenameCandidate( + finding, + entry, + digest + ); + if (!candidate) return false; + derivedCandidates.push(candidate); + } + const expectedCandidates = orderSastFindingRenameCandidates( + derivedCandidates + ); + return ( + expectedCandidates.length > 0 && + JSON.stringify(expectedCandidates) === + JSON.stringify(input.renameCandidates) + ); +} + +function isReconciliationPersistenceInputValid( + input: Readonly +): boolean { + const context = input.context; + const lifecycleContext = { + tenantId: context.tenantId, + repositoryBindingId: context.repositoryBindingId, + targetRef: context.targetRef + }; + return ( + isSastFindingLifecycleContextInputValid( + lifecycleContext + ) && + digest( + buildSastFindingLifecycleContextPreimage( + lifecycleContext + ) + ) === input.decision.lifecycleContextKey && + input.decision.tenantId === context.tenantId && + input.decision.repositoryBindingId === + context.repositoryBindingId && + input.decision.scanRequestId === context.scanRequestId && + input.decision.attemptId === context.attemptId && + input.decision.commitSha === context.commitSha && + input.decision.canonicalScanKey === + context.canonicalScanKey && + input.decision.planDigest === context.planDigest && + input.decision.profileId === context.profileId && + input.decision.profileDigest === context.profileDigest + ); +} + +function observationBatchMatchesContext( + batch: Readonly, + context: Readonly +): boolean { + return ( + batch.scope.tenantId === context.scope.tenantId && + batch.scope.repositoryBindingId === + context.scope.repositoryBindingId && + batch.scope.scanRequestId === context.scope.scanRequestId && + batch.scope.attemptId === context.scope.attemptId && + batch.scope.scannerRunId === context.scope.scannerRunId && + batch.scannerRunId === context.scope.scannerRunId && + batch.scanner === context.scanner && + batch.lane === context.lane && + batch.commitSha === context.commitSha && + batch.canonicalScanKey === context.canonicalScanKey && + batch.planDigest === context.planDigest && + batch.ingestionId === context.source.ingestionId && + batch.scannerVersion === context.source.scannerVersion && + batch.scannerImageDigest === + context.source.scannerImageDigest && + batch.ruleBundleDigest === + context.source.ruleBundleDigest && + batch.vulnerabilityDatabaseDigest === + context.source.vulnerabilityDatabaseDigest && + batch.schemaBundleDigest === + context.source.schemaBundleDigest && + batch.normalizerBundleDigest === + context.source.normalizerBundleDigest && + batch.preflightAttestationRef === + context.source.preflightAttestationRef && + batch.preflightInventoryDigest === + context.source.preflightInventoryDigest && + batch.artifactSchema === context.source.artifactSchema && + batch.artifactSchemaVersion === + context.source.artifactSchemaVersion && + batch.envelopeDigest === context.source.envelopeDigest && + batch.artifactDigest === context.source.artifactDigest && + batch.validationResultDigest === + context.source.validationResultDigest && + batch.dispositionDecisionDigest === + context.source.dispositionDecisionDigest && + batch.retentionExpiresAt === + context.source.retentionExpiresAt + ); +} + +function prepareIdentities( + input: Readonly +): PreparedIdentity[] { + const renameByCurrent = new Map( + input.renameCandidates.map((candidate) => [ + identityKey( + candidate.capability, + candidate.currentStableFingerprint + ), + candidate + ]) + ); + const identities = new Map(); + for (const finding of input.batch.findings) { + const key = identityKey( + finding.capability, + finding.fingerprint.stableFingerprint + ); + if (!identities.has(key)) { + identities.set(key, { + key, + capability: finding.capability, + stableFingerprint: + finding.fingerprint.stableFingerprint, + normalizedPath: + finding.fingerprint.normalizedPath, + ...(renameByCurrent.has(key) + ? { rename: renameByCurrent.get(key) } + : {}) + }); + } + } + if ( + [...renameByCurrent.keys()].some( + (key) => !identities.has(key) + ) + ) { + throw new SastFindingLineageDurableScopeError(); + } + return [...identities.values()].sort((left, right) => + left.key.localeCompare(right.key) + ); +} + +function lifecycleEvent(input: { + stateId: string; + identity: Readonly; + input: Readonly; + kind: 'CREATED' | 'RENAMED'; + previousStatus: SastFindingLifecycleStatus | null; + nextStatus: SastFindingLifecycleStatus; + revision: number; + occurredAt: Date; +}): Prisma.SastFindingLifecycleEventCreateManyInput { + const scope = input.input.context.scope; + return { + id: deterministicId( + 'finding-event', + `${input.stateId}\0${input.revision}\0${input.kind}\0${input.input.observationBatchId}` + ), + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + lineageId: input.identity.lineageId, + lifecycleStateId: input.stateId, + lifecycleContextKey: input.input.lifecycleContextKey, + kind: input.kind, + previousStatus: input.previousStatus, + nextStatus: input.nextStatus, + revision: input.revision, + observationBatchId: input.input.observationBatchId, + reconciliationId: null, + renameAttestationDigest: + input.kind === 'RENAMED' + ? input.input.renameAttestationDigest + : null, + occurredAt: input.occurredAt, + createdAt: input.occurredAt + }; +} + +function reconciliationEvent( + state: { + id: string; + lineageId: string; + revision: number; + }, + input: Readonly, + kind: 'FIXED' | 'REOPENED', + previousStatus: SastFindingLifecycleStatus, + nextStatus: SastFindingLifecycleStatus, + occurredAt: Date +): Prisma.SastFindingLifecycleEventCreateManyInput { + return { + id: deterministicId( + 'finding-event', + `${state.id}\0${state.revision + 1}\0${kind}\0${input.reconciliationId}` + ), + tenantId: input.decision.tenantId, + repositoryBindingId: + input.decision.repositoryBindingId, + lineageId: state.lineageId, + lifecycleStateId: state.id, + lifecycleContextKey: + input.decision.lifecycleContextKey, + kind, + previousStatus, + nextStatus, + revision: state.revision + 1, + observationBatchId: null, + reconciliationId: input.reconciliationId, + renameAttestationDigest: null, + occurredAt, + createdAt: occurredAt + }; +} + +function normalizedFindingMetadata( + finding: Readonly, + lifecycleContextKey: `sha256:${string}` +): Prisma.InputJsonValue { + return { + version: SAST_FINDING_LINEAGE_VERSION, + lifecycleContextKey, + description: finding.description, + confidence: finding.confidence, + cweIds: [...finding.cweIds], + cveIds: [...finding.cveIds], + location: finding.location, + identityMaterial: finding.identityMaterial, + provenance: finding.provenance, + notes: [...finding.notes], + lifecycleStatusAuthority: false, + lifecycleStatusStoredSeparately: true + } as unknown as Prisma.InputJsonValue; +} + +function reconciliationFromRow( + row: { + id: string; + coverageDecisionDigest: string; + lifecycleContextKey: string; + sequence: number; + eligibleLineageCount: number; + observedLineageCount: number; + fixedCount: number; + reopenedCount: number; + unchangedOpenCount: number; + unchangedFixedCount: number; + reconciledAt: Date; + }, + replayed: boolean +): PersistedSastFindingReconciliation { + return { + reconciliationId: row.id, + coverageDecisionDigest: + row.coverageDecisionDigest as `sha256:${string}`, + lifecycleContextKey: + row.lifecycleContextKey as `sha256:${string}`, + sequence: row.sequence, + eligibleLineageCount: row.eligibleLineageCount, + observedLineageCount: row.observedLineageCount, + fixedCount: row.fixedCount, + reopenedCount: row.reopenedCount, + unchangedOpenCount: row.unchangedOpenCount, + unchangedFixedCount: row.unchangedFixedCount, + replayed, + reconciledAt: row.reconciledAt.toISOString() + }; +} + +function observationBatchReplayMatches( + existing: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + lifecycleContextKey: string; + targetRef: string; + commitSha: string; + lane: string; + scanner: string; + capabilities: Prisma.JsonValue; + profileId: string; + profileDigest: string; + canonicalScanKey: string; + planDigest: string; + sourceIdentityBatchDigest: string; + renameAttestationDigest: string | null; + findingCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + observedAt: Date; + }, + input: Readonly, + identityCount: number +): boolean { + let capabilities: FindingCapability[]; + try { + capabilities = readCapabilities( + existing.capabilities, + existing.scanner + ); + } catch { + return false; + } + const scope = input.context.scope; + return ( + existing.id === input.observationBatchId && + existing.tenantId === scope.tenantId && + existing.repositoryBindingId === + scope.repositoryBindingId && + existing.scanRequestId === scope.scanRequestId && + existing.attemptId === scope.attemptId && + existing.scannerRunId === scope.scannerRunId && + existing.lifecycleContextKey === + input.lifecycleContextKey && + existing.targetRef === input.context.targetRef && + existing.commitSha === input.context.commitSha && + existing.lane === input.context.lane && + existing.scanner === input.context.scanner && + sameStringArray( + capabilities, + observedCapabilities(input.batch.findings) + ) && + existing.profileId === input.context.profileId && + existing.profileDigest === input.context.profileDigest && + existing.canonicalScanKey === + input.context.canonicalScanKey && + existing.planDigest === input.context.planDigest && + existing.sourceIdentityBatchDigest === + input.batch.batchDigest && + existing.renameAttestationDigest === + (input.renameAttestationDigest ?? null) && + existing.findingCount === input.batch.findings.length && + existing.distinctFingerprintCount === identityCount && + existing.createdLineageCount >= 0 && + existing.exactMatchCount >= 0 && + existing.renamedMatchCount >= 0 && + existing.createdLineageCount + + existing.exactMatchCount + + existing.renamedMatchCount === + identityCount && + existing.observedAt instanceof Date && + Number.isFinite(existing.observedAt.getTime()) + ); +} + +function occurrenceReplayMatches( + occurrence: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + observationBatchId: string; + lineageId: string; + normalizedFindingId: string; + ordinal: number; + capability: FindingCapability; + fingerprintVersion: string; + stableFingerprint: string; + fingerprintDecisionDigest: string; + sourceFinding: Prisma.JsonValue; + observedAt: Date; + }, + finding: Readonly, + lineageId: string, + input: Readonly, + observedAt: Date, + ordinal: number +): boolean { + const scope = input.context.scope; + const occurrenceId = deterministicId( + 'finding-occurrence', + `${input.observationBatchId}\0${ordinal}\0${finding.fingerprint.decisionDigest}` + ); + return ( + occurrence.id === occurrenceId && + occurrence.tenantId === scope.tenantId && + occurrence.repositoryBindingId === + scope.repositoryBindingId && + occurrence.scanRequestId === scope.scanRequestId && + occurrence.attemptId === scope.attemptId && + occurrence.scannerRunId === scope.scannerRunId && + occurrence.observationBatchId === + input.observationBatchId && + occurrence.lineageId === lineageId && + occurrence.normalizedFindingId === + deterministicId('normalized-finding', occurrenceId) && + occurrence.ordinal === ordinal && + occurrence.capability === finding.capability && + occurrence.fingerprintVersion === + SAST_FINDING_FINGERPRINT_VERSION && + occurrence.stableFingerprint === + finding.fingerprint.stableFingerprint && + occurrence.fingerprintDecisionDigest === + finding.fingerprint.decisionDigest && + occurrence.observedAt.getTime() === observedAt.getTime() && + samePersistedFinding(occurrence.sourceFinding, finding) + ); +} + +function samePersistedFinding( + persisted: Prisma.JsonValue, + expected: Readonly +): boolean { + try { + return ( + isSastFingerprintedFindingShapeValid( + persisted, + digest, + digest + ) && + canonicalizeSastFingerprintedFinding(persisted) === + canonicalizeSastFingerprintedFinding(expected) + ); + } catch { + return false; + } +} + +function isPlanBoundToScanRequest( + plan: Readonly, + input: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + targetRef: unknown; + commitSha: unknown; + canonicalScanKey: unknown; + lane: unknown; + } +): boolean { + return ( + plan.tenantId === input.tenantId && + plan.scanRequestId === input.scanRequestId && + plan.repositoryState.repositoryBindingId === + input.repositoryBindingId && + plan.repositoryState.targetRef === input.targetRef && + plan.repositoryState.fixedCommitSha === input.commitSha && + plan.canonicalScanKey === input.canonicalScanKey && + plan.profile.lane === input.lane + ); +} + +function authoritativeScannerCapabilities( + scanner: string +): FindingCapability[] { + if (!isFindingScannerKind(scanner)) { + throw new SastFindingLineageDurableScopeError(); + } + const authoritativeCapabilities = + SAST_SCANNER_RESPONSIBILITIES[scanner] + .authoritativeCapabilities as readonly string[]; + return FINDING_CAPABILITIES.filter((capability) => + authoritativeCapabilities.includes(capability) + ); +} + +function readCapabilities( + value: Prisma.JsonValue, + scanner: string +): FindingCapability[] { + if ( + !Array.isArray(value) || + value.some( + (candidate) => + typeof candidate !== 'string' || + !FINDING_CAPABILITIES.includes( + candidate as FindingCapability + ) + ) + ) { + throw new SastFindingLineageDurableScopeError(); + } + const capabilities = value as FindingCapability[]; + if ( + !sameStringArray( + capabilities, + FINDING_CAPABILITIES.filter((capability) => + capabilities.includes(capability) + ) + ) || + capabilities.some( + (capability) => + !authoritativeScannerCapabilities(scanner).includes( + capability + ) + ) + ) { + throw new SastFindingLineageDurableScopeError(); + } + return capabilities; +} + +function observedCapabilities( + findings: readonly Readonly[] +): FindingCapability[] { + const observed = new Set( + findings.map((finding) => finding.capability) + ); + return FINDING_CAPABILITIES.filter((capability) => + observed.has(capability) + ); +} + +function parsePlan(value: Prisma.JsonValue | undefined): SastScanPlan | null { + const candidate = value as unknown as SastScanPlan; + return candidate && isSastScanPlanValid(candidate) + ? candidate + : null; +} + +function identityKey( + capability: string, + stableFingerprint: string +): string { + return `${capability}\0${stableFingerprint}`; +} + +function lineageId( + tenantId: string, + repositoryBindingId: string, + capability: FindingCapability, + stableFingerprint: `sha256:${string}` +): string { + return deterministicId( + 'finding-lineage', + buildSastFindingLineageKeyPreimage({ + tenantId, + repositoryBindingId, + capability, + fingerprintVersion: + SAST_FINDING_FINGERPRINT_VERSION, + stableFingerprint + }) + ); +} + +function deterministicId(prefix: string, value: string): string { + return `${prefix}://${digestHex(value)}`; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${digestHex(value)}`; +} + +function digestHex(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} + +function isDigest(value: unknown): value is `sha256:${string}` { + return ( + typeof value === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(value) + ); +} + +function isCanonicalIsoTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const parsed = Date.parse(value); + return ( + Number.isFinite(parsed) && + new Date(parsed).toISOString() === value + ); +} + +function isFindingScannerKind( + value: string +): value is 'OPENGREP' | 'TRIVY' { + return value === 'OPENGREP' || value === 'TRIVY'; +} + +function sameObservationContext( + left: Readonly, + right: Readonly +): boolean { + return ( + sameObjectKeys(left, right) && + sameObjectKeys(left.scope, right.scope) && + sameObjectKeys(left.source, right.source) && + left.scope.tenantId === right.scope.tenantId && + left.scope.repositoryBindingId === + right.scope.repositoryBindingId && + left.scope.scanRequestId === right.scope.scanRequestId && + left.scope.attemptId === right.scope.attemptId && + left.scope.scannerRunId === right.scope.scannerRunId && + left.targetRef === right.targetRef && + left.lane === right.lane && + left.commitSha === right.commitSha && + left.canonicalScanKey === right.canonicalScanKey && + left.planDigest === right.planDigest && + left.profileId === right.profileId && + left.profileDigest === right.profileDigest && + left.scanner === right.scanner && + left.source.ingestionId === right.source.ingestionId && + left.source.scannerVersion === + right.source.scannerVersion && + left.source.scannerImageDigest === + right.source.scannerImageDigest && + left.source.ruleBundleDigest === + right.source.ruleBundleDigest && + left.source.vulnerabilityDatabaseDigest === + right.source.vulnerabilityDatabaseDigest && + left.source.schemaBundleDigest === + right.source.schemaBundleDigest && + left.source.normalizerBundleDigest === + right.source.normalizerBundleDigest && + left.source.preflightAttestationRef === + right.source.preflightAttestationRef && + left.source.preflightInventoryDigest === + right.source.preflightInventoryDigest && + left.source.artifactSchema === + right.source.artifactSchema && + left.source.artifactSchemaVersion === + right.source.artifactSchemaVersion && + left.source.envelopeDigest === + right.source.envelopeDigest && + left.source.artifactDigest === + right.source.artifactDigest && + left.source.validationResultDigest === + right.source.validationResultDigest && + left.source.dispositionDecisionDigest === + right.source.dispositionDecisionDigest && + left.source.retentionExpiresAt === + right.source.retentionExpiresAt + ); +} + +function sameReconciliationContext( + left: Readonly, + right: Readonly +): boolean { + return ( + sameObjectKeys(left, right) && + left.tenantId === right.tenantId && + left.repositoryBindingId === right.repositoryBindingId && + left.scanRequestId === right.scanRequestId && + left.attemptId === right.attemptId && + left.targetRef === right.targetRef && + left.lane === right.lane && + left.commitSha === right.commitSha && + left.canonicalScanKey === right.canonicalScanKey && + left.planDigest === right.planDigest && + left.profileId === right.profileId && + left.profileDigest === right.profileDigest + ); +} + +function sameStringArray( + left: readonly string[], + right: readonly string[] +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function sameObjectKeys( + left: Readonly, + right: Readonly +): boolean { + const leftKeys = Object.keys(left); + return ( + leftKeys.length === Object.keys(right).length && + leftKeys.every((key) => + Object.prototype.hasOwnProperty.call(right, key) + ) + ); +} + +function chunks( + values: readonly T[], + size: number +): T[][] { + const result: T[][] = []; + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)); + } + return result; +} + +function serializableRetryDelay(attempt: number): number { + const ceiling = Math.min( + SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS * + 2 ** attempt, + SERIALIZABLE_RETRY_MAX_DELAY_MILLISECONDS + ); + return randomInt(1, ceiling + 1); +} + +function isRetryableTransactionError(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + (error.code === 'P2034' || error.code === 'P2002') + ); +} diff --git a/apps/api/src/scan-plane/sast-finding-lifecycle-coverage.gate.ts b/apps/api/src/scan-plane/sast-finding-lifecycle-coverage.gate.ts new file mode 100644 index 0000000..8572e5e --- /dev/null +++ b/apps/api/src/scan-plane/sast-finding-lifecycle-coverage.gate.ts @@ -0,0 +1,25 @@ +import type { + SastFindingLifecycleCoverageDecision +} from '@aegisai/shared'; + +export type SastFindingLifecycleCoverageVerification = + | 'VERIFIED' + | 'REJECTED' + | 'UNAVAILABLE'; + +/** + * T039 owns coverage calculation. T037 can only consume a complete, + * non-stale, comparable decision that this boundary verifies. + */ +export abstract class SastFindingLifecycleCoverageGate { + abstract verify( + decision: Readonly + ): Promise; +} + +export class UnavailableSastFindingLifecycleCoverageGate + extends SastFindingLifecycleCoverageGate { + async verify(): Promise { + return 'UNAVAILABLE'; + } +} diff --git a/apps/api/src/scan-plane/sast-finding-lineage.service.ts b/apps/api/src/scan-plane/sast-finding-lineage.service.ts new file mode 100644 index 0000000..02f5f5b --- /dev/null +++ b/apps/api/src/scan-plane/sast-finding-lineage.service.ts @@ -0,0 +1,730 @@ +import { createHash } from 'node:crypto'; +import { setImmediate as yieldToEventLoop } from 'node:timers/promises'; + +import { Injectable } from '@nestjs/common'; +import { + SAST_FINDING_LINEAGE_LIMITS, + SAST_FINDING_LINEAGE_VERSION, + buildSastFindingLifecycleContextPreimage, + buildSastFindingRenameCandidate, + canonicalizeSastFindingLifecycleReconciliationResult, + canonicalizeSastFindingLineageObservationResult, + canonicalizeSastFindingLineageRejection, + isSastFindingLifecycleCoverageDecisionShapeValid, + isSastFindingLifecycleContextInputValid, + isSastFindingLifecycleReconciliationResultShapeValid, + isSastFindingLineageObservationResultShapeValid, + isSastFindingRenameAttestationShapeValid, + isSastFingerprintedFindingBatchShapeValid, + orderSastFindingLineageRejectionReasons, + orderSastFindingRenameCandidates, + sastFindingLineageAuthority, + type SastFindingLifecycleCoverageDecision, + type SastFindingLifecycleReconciliationOutcome, + type SastFindingLifecycleReconciliationResult, + type SastFindingLifecycleReconciliationResultCore, + type SastFindingLineageObservationOutcome, + type SastFindingLineageObservationResult, + type SastFindingLineageObservationResultCore, + type SastFindingLineageOperation, + type SastFindingLineageRejection, + type SastFindingLineageRejectionCore, + type SastFindingLineageRejectionReasonCode, + type SastFindingRenameAttestation, + type SastFindingRenameCandidate, + type SastFingerprintedFindingBatch +} from '@aegisai/shared'; + +import { + SastFindingLifecycleCoverageGate +} from './sast-finding-lifecycle-coverage.gate'; +import { + SastFindingLineageDurableScopeError, + SastFindingLineageObservationIncompleteError, + SastFindingLineageReconciliationOrderError, + SastFindingLineageRenameAmbiguousError, + SastFindingLineageReplayConflictError, + SastFindingLineageStore, + type SastFindingLineageScanContext +} from './sast-finding-lineage.store'; +import { + SastFindingRenameAttestationVerifier +} from './sast-finding-rename-attestation.verifier'; + +export interface ObserveSastFindingLineageInput { + /** + * The complete T036 durable handoff. Individual findings and raw scanner + * payloads are intentionally not accepted at this persistence boundary. + */ + batch: Readonly; + renameAttestation?: Readonly; +} + +export interface ReconcileSastFindingLifecycleInput { + /** + * T039 calculates this decision. T037 only verifies and applies it. + */ + coverageDecision: Readonly; +} + +@Injectable() +export class SastFindingLineageService { + constructor( + private readonly store: SastFindingLineageStore, + private readonly renameVerifier: + SastFindingRenameAttestationVerifier, + private readonly coverageGate: + SastFindingLifecycleCoverageGate + ) {} + + async observe( + input: Readonly, + clock: () => Date = () => new Date() + ): Promise { + if (!isNotOverFindingLimit(input?.batch)) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_INPUT_INVALID' + ]); + } + if ( + !isSastFingerprintedFindingBatchShapeValid( + input?.batch, + digest, + digest + ) + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_INPUT_INVALID' + ]); + } + const batch = input.batch; + const firstReferenceTime = readReferenceTime(clock); + const retentionExpiresAt = Date.parse(batch.retentionExpiresAt); + if ( + !Number.isFinite(firstReferenceTime) || + !Number.isFinite(retentionExpiresAt) + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RETENTION_INVALID' + ]); + } + if (firstReferenceTime >= retentionExpiresAt) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RETENTION_EXPIRED' + ]); + } + + try { + const context = await this.store.loadObservationContext( + batch.scope + ); + if (!context || !observationContextMatches(batch, context)) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ]); + } + const lifecycleContext = { + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + targetRef: context.targetRef + }; + if ( + !isSastFindingLifecycleContextInputValid( + lifecycleContext + ) + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ]); + } + const lifecycleContextKey = digest( + buildSastFindingLifecycleContextPreimage( + lifecycleContext + ) + ); + + let renameCandidates: SastFindingRenameCandidate[] = []; + if (input.renameAttestation !== undefined) { + const renameResult = await this.prepareRenameCandidates( + batch, + context, + lifecycleContextKey, + input.renameAttestation, + firstReferenceTime + ); + if ('rejection' in renameResult) { + return renameResult.rejection; + } + renameCandidates = renameResult.candidates; + } else if ( + !(await this.revalidateDistinctFingerprintCount(batch)) + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_INPUT_INVALID' + ]); + } + + const secondReferenceTime = readReferenceTime(clock); + if ( + !Number.isFinite(secondReferenceTime) || + secondReferenceTime < firstReferenceTime + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RETENTION_INVALID' + ]); + } + if (secondReferenceTime >= retentionExpiresAt) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RETENTION_EXPIRED' + ]); + } + + const observationBatchId = + `finding-observation://${digestHex( + `${SAST_FINDING_LINEAGE_VERSION}\0${batch.batchDigest}\0${lifecycleContextKey}` + )}`; + const persisted = await this.store.observe({ + observationBatchId, + lifecycleContextKey, + observedAt: new Date(secondReferenceTime).toISOString(), + batch, + context, + ...(input.renameAttestation + ? { + renameAttestationDigest: + input.renameAttestation.attestationDigest, + renameAttestation: input.renameAttestation + } + : {}), + renameCandidates + }); + if ( + persisted.observationBatchId !== observationBatchId || + persisted.sourceIdentityBatchDigest !== + batch.batchDigest || + persisted.lifecycleContextKey !== + lifecycleContextKey || + persisted.findingCount !== batch.findings.length || + persisted.occurrenceCount !== batch.findings.length || + persisted.distinctFingerprintCount !== + batch.identity.distinctFingerprintCount + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ]); + } + const core: SastFindingLineageObservationResultCore = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'OBSERVED', + operation: 'OBSERVE', + ...persisted, + authority: sastFindingLineageAuthority() + }; + const result: SastFindingLineageObservationResult = { + ...core, + resultDigest: digest( + canonicalizeSastFindingLineageObservationResult(core) + ) + }; + if ( + !isSastFindingLineageObservationResultShapeValid( + result, + digest + ) + ) { + return this.reject('OBSERVE', [ + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ]); + } + return result; + } catch (error) { + return this.reject('OBSERVE', [ + mapPersistenceError(error) + ]); + } + } + + async reconcile( + input: Readonly, + clock: () => Date = () => new Date() + ): Promise { + const coarseReasons = inspectCoverageState( + input?.coverageDecision + ); + if (coarseReasons.length > 0) { + return this.reject('RECONCILE', coarseReasons); + } + if ( + !isSastFindingLifecycleCoverageDecisionShapeValid( + input?.coverageDecision, + digest + ) + ) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_COVERAGE_DECISION_INVALID' + ]); + } + const decision = input.coverageDecision; + const verification = await safelyVerifyCoverage( + this.coverageGate, + decision + ); + if (verification === 'UNAVAILABLE') { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_COVERAGE_AUTHORITY_UNAVAILABLE' + ]); + } + if (verification !== 'VERIFIED') { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_COVERAGE_DECISION_INVALID' + ]); + } + + const referenceTime = readReferenceTime(clock); + if ( + !Number.isFinite(referenceTime) || + referenceTime < Date.parse(decision.decidedAt) + ) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_COVERAGE_DECISION_INVALID' + ]); + } + + try { + const context = await this.store.loadReconciliationContext({ + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId + }); + if (!context || !reconciliationContextMatches(decision, context)) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ]); + } + const lifecycleContext = { + tenantId: context.tenantId, + repositoryBindingId: context.repositoryBindingId, + targetRef: context.targetRef + }; + if ( + !isSastFindingLifecycleContextInputValid( + lifecycleContext + ) + ) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ]); + } + const expectedContextKey = digest( + buildSastFindingLifecycleContextPreimage( + lifecycleContext + ) + ); + if (expectedContextKey !== decision.lifecycleContextKey) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ]); + } + + const reconciliationId = + `finding-reconciliation://${digestHex( + `${SAST_FINDING_LINEAGE_VERSION}\0${decision.decisionDigest}` + )}`; + const persisted = await this.store.reconcile({ + reconciliationId, + reconciledAt: new Date(referenceTime).toISOString(), + decision, + context + }); + if ( + persisted.reconciliationId !== reconciliationId || + persisted.coverageDecisionDigest !== + decision.decisionDigest || + persisted.lifecycleContextKey !== + decision.lifecycleContextKey || + persisted.sequence !== decision.sequence || + persisted.eligibleLineageCount !== + decision.eligibleLineageIds.length + ) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ]); + } + const core: SastFindingLifecycleReconciliationResultCore = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'RECONCILED', + operation: 'RECONCILE', + ...persisted, + authority: sastFindingLineageAuthority() + }; + const result: SastFindingLifecycleReconciliationResult = { + ...core, + resultDigest: digest( + canonicalizeSastFindingLifecycleReconciliationResult(core) + ) + }; + if ( + !isSastFindingLifecycleReconciliationResultShapeValid( + result, + digest + ) + ) { + return this.reject('RECONCILE', [ + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ]); + } + return result; + } catch (error) { + return this.reject('RECONCILE', [ + mapPersistenceError(error) + ]); + } + } + + protected async yieldEventLoop(): Promise { + await yieldToEventLoop(); + } + + private async prepareRenameCandidates( + batch: Readonly, + context: Readonly, + lifecycleContextKey: `sha256:${string}`, + attestation: Readonly, + referenceTime: number + ): Promise< + | { candidates: SastFindingRenameCandidate[] } + | { rejection: SastFindingLineageRejection } + > { + if ( + !isSastFindingRenameAttestationShapeValid( + attestation, + digest + ) || + attestation.tenantId !== context.scope.tenantId || + attestation.repositoryBindingId !== + context.scope.repositoryBindingId || + attestation.lifecycleContextKey !== lifecycleContextKey || + attestation.toScanRequestId !== + context.scope.scanRequestId || + attestation.toCommitSha !== context.commitSha || + attestation.profileId !== context.profileId || + attestation.profileDigest !== context.profileDigest || + Date.parse(attestation.issuedAt) > referenceTime + ) { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID' + ]) + }; + } + const verification = await safelyVerifyRename( + this.renameVerifier, + attestation + ); + if (verification === 'UNAVAILABLE') { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RENAME_AUTHORITY_UNAVAILABLE' + ]) + }; + } + if (verification !== 'VERIFIED') { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID' + ]) + }; + } + + const entryByTargetPath = new Map( + attestation.entries.map((entry) => [ + entry.toNormalizedPath, + entry + ]) + ); + const candidates: SastFindingRenameCandidate[] = []; + const identityKeys = new Set(); + for (let index = 0; index < batch.findings.length; index += 1) { + if ( + index > 0 && + index % + SAST_FINDING_LINEAGE_LIMITS.yieldFindingInterval === + 0 + ) { + await this.yieldEventLoop(); + } + const finding = batch.findings[index]; + if (!finding) { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_INPUT_INVALID' + ]) + }; + } + identityKeys.add( + `${finding.capability}\0${finding.fingerprint.stableFingerprint}` + ); + const entry = entryByTargetPath.get( + finding.fingerprint.normalizedPath + ); + if (!entry) continue; + const candidate = buildSastFindingRenameCandidate( + finding, + entry, + digest + ); + if (!candidate) { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID' + ]) + }; + } + candidates.push(candidate); + } + if ( + identityKeys.size !== + batch.identity.distinctFingerprintCount + ) { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_INPUT_INVALID' + ]) + }; + } + const orderedCandidates = + orderSastFindingRenameCandidates(candidates); + if (orderedCandidates.length === 0) { + return { + rejection: this.reject('OBSERVE', [ + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID' + ]) + }; + } + return { candidates: orderedCandidates }; + } + + private reject( + operation: SastFindingLineageOperation, + reasons: Iterable + ): SastFindingLineageRejection { + const core: SastFindingLineageRejectionCore = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'REJECTED', + operation, + reasonCodes: + orderSastFindingLineageRejectionReasons(reasons), + sourceBatchDigestStored: false, + sourceFindingStored: false, + renamePathsStored: false, + eligibleLineageIdsStored: false, + secretValueStored: false + }; + return { + ...core, + rejectionDigest: digest( + canonicalizeSastFindingLineageRejection(core) + ) + }; + } + + private async revalidateDistinctFingerprintCount( + batch: Readonly + ): Promise { + const identities = new Set(); + for (let index = 0; index < batch.findings.length; index += 1) { + if ( + index > 0 && + index % + SAST_FINDING_LINEAGE_LIMITS.yieldFindingInterval === + 0 + ) { + await this.yieldEventLoop(); + } + const finding = batch.findings[index]; + if (!finding) return false; + identities.add( + `${finding.capability}\0${finding.fingerprint.stableFingerprint}` + ); + } + return ( + identities.size === + batch.identity.distinctFingerprintCount + ); + } +} + +function observationContextMatches( + batch: Readonly, + context: Readonly +): boolean { + return ( + batch.scope.tenantId === context.scope.tenantId && + batch.scope.repositoryBindingId === + context.scope.repositoryBindingId && + batch.scope.scanRequestId === context.scope.scanRequestId && + batch.scope.attemptId === context.scope.attemptId && + batch.scope.scannerRunId === context.scope.scannerRunId && + batch.scannerRunId === context.scope.scannerRunId && + batch.scanner === context.scanner && + batch.lane === context.lane && + batch.commitSha === context.commitSha && + batch.canonicalScanKey === context.canonicalScanKey && + batch.planDigest === context.planDigest && + batch.ingestionId === context.source.ingestionId && + batch.scannerVersion === context.source.scannerVersion && + batch.scannerImageDigest === + context.source.scannerImageDigest && + batch.ruleBundleDigest === context.source.ruleBundleDigest && + batch.vulnerabilityDatabaseDigest === + context.source.vulnerabilityDatabaseDigest && + batch.schemaBundleDigest === + context.source.schemaBundleDigest && + batch.normalizerBundleDigest === + context.source.normalizerBundleDigest && + batch.preflightAttestationRef === + context.source.preflightAttestationRef && + batch.preflightInventoryDigest === + context.source.preflightInventoryDigest && + batch.artifactSchema === context.source.artifactSchema && + batch.artifactSchemaVersion === + context.source.artifactSchemaVersion && + batch.envelopeDigest === context.source.envelopeDigest && + batch.artifactDigest === context.source.artifactDigest && + batch.validationResultDigest === + context.source.validationResultDigest && + batch.dispositionDecisionDigest === + context.source.dispositionDecisionDigest && + batch.retentionExpiresAt === + context.source.retentionExpiresAt + ); +} + +function reconciliationContextMatches( + decision: Readonly, + context: Readonly<{ + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + commitSha: string; + canonicalScanKey: string; + planDigest: string; + profileId: string; + profileDigest: string; + }> +): boolean { + return ( + decision.tenantId === context.tenantId && + decision.repositoryBindingId === context.repositoryBindingId && + decision.scanRequestId === context.scanRequestId && + decision.attemptId === context.attemptId && + decision.commitSha === context.commitSha && + decision.canonicalScanKey === context.canonicalScanKey && + decision.planDigest === context.planDigest && + decision.profileId === context.profileId && + decision.profileDigest === context.profileDigest + ); +} + +function inspectCoverageState( + value: unknown +): SastFindingLineageRejectionReasonCode[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return ['FINDING_LINEAGE_COVERAGE_DECISION_INVALID']; + } + const candidate = value as { + state?: unknown; + stale?: unknown; + comparable?: unknown; + }; + const reasons: SastFindingLineageRejectionReasonCode[] = []; + if (candidate.comparable !== true) { + reasons.push('FINDING_LINEAGE_SCAN_NOT_COMPARABLE'); + } + if (candidate.stale !== false) { + reasons.push('FINDING_LINEAGE_SCAN_STALE'); + } + if (candidate.state !== 'COMPLETE') { + reasons.push('FINDING_LINEAGE_SCAN_INCOMPLETE'); + } + return reasons; +} + +function mapPersistenceError( + error: unknown +): SastFindingLineageRejectionReasonCode { + if (error instanceof SastFindingLineageDurableScopeError) { + return 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID'; + } + if (error instanceof SastFindingLineageRenameAmbiguousError) { + return 'FINDING_LINEAGE_RENAME_AMBIGUOUS'; + } + if (error instanceof SastFindingLineageReplayConflictError) { + return 'FINDING_LINEAGE_REPLAY_CONFLICT'; + } + if ( + error instanceof SastFindingLineageReconciliationOrderError + ) { + return 'FINDING_LINEAGE_RECONCILIATION_OUT_OF_ORDER'; + } + if ( + error instanceof SastFindingLineageObservationIncompleteError + ) { + return 'FINDING_LINEAGE_OBSERVATION_INCOMPLETE'; + } + return 'FINDING_LINEAGE_PERSISTENCE_FAILED'; +} + +async function safelyVerifyRename( + verifier: SastFindingRenameAttestationVerifier, + attestation: Readonly +) { + try { + return await verifier.verify(attestation); + } catch { + return 'REJECTED' as const; + } +} + +async function safelyVerifyCoverage( + gate: SastFindingLifecycleCoverageGate, + decision: Readonly +) { + try { + return await gate.verify(decision); + } catch { + return 'REJECTED' as const; + } +} + +function isNotOverFindingLimit(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return true; + } + const findings = (value as { findings?: unknown }).findings; + return ( + !Array.isArray(findings) || + findings.length <= + SAST_FINDING_LINEAGE_LIMITS.maximumFindings + ); +} + +function readReferenceTime(clock: () => Date): number { + try { + const value = clock(); + return value instanceof Date ? value.getTime() : Number.NaN; + } catch { + return Number.NaN; + } +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${digestHex(value)}`; +} + +function digestHex(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex'); +} diff --git a/apps/api/src/scan-plane/sast-finding-lineage.store.ts b/apps/api/src/scan-plane/sast-finding-lineage.store.ts new file mode 100644 index 0000000..06f49d4 --- /dev/null +++ b/apps/api/src/scan-plane/sast-finding-lineage.store.ts @@ -0,0 +1,164 @@ +import type { + SastFindingLifecycleCoverageDecision, + SastFindingRenameAttestation, + SastFingerprintedFindingBatch, + SastFindingRenameCandidate, + SastProfileId, + SastScanLane, + SastScannerKind +} from '@aegisai/shared'; + +export interface SastFindingLineageObservationScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; +} + +export interface SastFindingLineageScanContext { + scope: SastFindingLineageObservationScope; + targetRef: string; + lane: SastScanLane; + commitSha: string; + canonicalScanKey: `sha256:${string}`; + planDigest: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + scanner: SastScannerKind; + source: { + ingestionId: string; + scannerVersion: string; + scannerImageDigest: `sha256:${string}`; + ruleBundleDigest?: `sha256:${string}`; + vulnerabilityDatabaseDigest?: `sha256:${string}`; + schemaBundleDigest: `sha256:${string}`; + normalizerBundleDigest: `sha256:${string}`; + preflightAttestationRef: string; + preflightInventoryDigest: `sha256:${string}`; + artifactSchema: string; + artifactSchemaVersion: string; + envelopeDigest: `sha256:${string}`; + artifactDigest: `sha256:${string}`; + validationResultDigest: `sha256:${string}`; + dispositionDecisionDigest: `sha256:${string}`; + retentionExpiresAt: string; + }; +} + +export interface SastFindingReconciliationScanContext { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + targetRef: string; + lane: SastScanLane; + commitSha: string; + canonicalScanKey: `sha256:${string}`; + planDigest: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; +} + +export interface PersistSastFindingObservationInput { + observationBatchId: string; + lifecycleContextKey: `sha256:${string}`; + observedAt: string; + batch: Readonly; + context: Readonly; + renameAttestationDigest?: `sha256:${string}`; + renameAttestation?: Readonly; + renameCandidates: readonly Readonly[]; +} + +export interface PersistedSastFindingObservation { + observationBatchId: string; + sourceIdentityBatchDigest: `sha256:${string}`; + lifecycleContextKey: `sha256:${string}`; + findingCount: number; + occurrenceCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + replayed: boolean; + observedAt: string; +} + +export interface PersistSastFindingReconciliationInput { + reconciliationId: string; + reconciledAt: string; + decision: Readonly; + context: Readonly; +} + +export interface PersistedSastFindingReconciliation { + reconciliationId: string; + coverageDecisionDigest: `sha256:${string}`; + lifecycleContextKey: `sha256:${string}`; + sequence: number; + eligibleLineageCount: number; + observedLineageCount: number; + fixedCount: number; + reopenedCount: number; + unchangedOpenCount: number; + unchangedFixedCount: number; + replayed: boolean; + reconciledAt: string; +} + +export class SastFindingLineageReplayConflictError extends Error { + constructor() { + super('The finding-lineage operation conflicts with persisted state.'); + this.name = 'SastFindingLineageReplayConflictError'; + } +} + +export class SastFindingLineageRenameAmbiguousError extends Error { + constructor() { + super('The rename would bind more than one finding lineage.'); + this.name = 'SastFindingLineageRenameAmbiguousError'; + } +} + +export class SastFindingLineageDurableScopeError extends Error { + constructor() { + super('The finding-lineage durable scope is not valid.'); + this.name = 'SastFindingLineageDurableScopeError'; + } +} + +export class SastFindingLineageReconciliationOrderError extends Error { + constructor() { + super('The finding lifecycle reconciliation is out of order.'); + this.name = 'SastFindingLineageReconciliationOrderError'; + } +} + +export class SastFindingLineageObservationIncompleteError extends Error { + constructor() { + super('The finding lifecycle observation set is incomplete.'); + this.name = 'SastFindingLineageObservationIncompleteError'; + } +} + +export abstract class SastFindingLineageStore { + abstract loadObservationContext( + scope: Readonly + ): Promise; + + abstract loadReconciliationContext(input: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + }): Promise; + + abstract observe( + input: Readonly + ): Promise; + + abstract reconcile( + input: Readonly + ): Promise; +} diff --git a/apps/api/src/scan-plane/sast-finding-rename-attestation.verifier.ts b/apps/api/src/scan-plane/sast-finding-rename-attestation.verifier.ts new file mode 100644 index 0000000..c1155ee --- /dev/null +++ b/apps/api/src/scan-plane/sast-finding-rename-attestation.verifier.ts @@ -0,0 +1,21 @@ +import type { + SastFindingRenameAttestation +} from '@aegisai/shared'; + +export type SastFindingRenameVerification = + | 'VERIFIED' + | 'REJECTED' + | 'UNAVAILABLE'; + +export abstract class SastFindingRenameAttestationVerifier { + abstract verify( + attestation: Readonly + ): Promise; +} + +export class UnavailableSastFindingRenameAttestationVerifier + extends SastFindingRenameAttestationVerifier { + async verify(): Promise { + return 'UNAVAILABLE'; + } +} diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index ddc82c5..aba52b7 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -74,6 +74,23 @@ import { TrivyJsonNormalizer } from './trivy-json-normalizer'; import { SyftCycloneDxInventoryIngestor } from './syft-cyclonedx-inventory-ingestor'; import { SastSecretRedactionService } from './sast-secret-redaction.service'; import { SastFindingIdentityService } from './sast-finding-identity.service'; +import { + PrismaSastFindingLineageStore +} from './prisma-sast-finding-lineage.store'; +import { + SastFindingLineageStore +} from './sast-finding-lineage.store'; +import { + SastFindingLineageService +} from './sast-finding-lineage.service'; +import { + SastFindingRenameAttestationVerifier, + UnavailableSastFindingRenameAttestationVerifier +} from './sast-finding-rename-attestation.verifier'; +import { + SastFindingLifecycleCoverageGate, + UnavailableSastFindingLifecycleCoverageGate +} from './sast-finding-lifecycle-coverage.gate'; @Module({ imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], @@ -93,6 +110,24 @@ import { SastFindingIdentityService } from './sast-finding-identity.service'; SyftCycloneDxInventoryIngestor, SastSecretRedactionService, SastFindingIdentityService, + SastFindingLineageService, + PrismaSastFindingLineageStore, + { + provide: SastFindingLineageStore, + useExisting: PrismaSastFindingLineageStore + }, + UnavailableSastFindingRenameAttestationVerifier, + { + provide: SastFindingRenameAttestationVerifier, + useExisting: + UnavailableSastFindingRenameAttestationVerifier + }, + UnavailableSastFindingLifecycleCoverageGate, + { + provide: SastFindingLifecycleCoverageGate, + useExisting: + UnavailableSastFindingLifecycleCoverageGate + }, SastArtifactDispositionService, SastArtifactDispositionTask, PrismaSastArtifactDispositionStore, @@ -168,7 +203,7 @@ import { SastFindingIdentityService } from './sast-finding-identity.service'; SandboxRuntimeAttestationService, SastScannerRuntimeService, SyftCycloneDxInventoryIngestor, - SastFindingIdentityService + SastFindingLineageService ] }) export class ScanPlaneModule {} diff --git a/apps/api/test/scan-plane/prisma-sast-finding-lineage.store.e2e-spec.ts b/apps/api/test/scan-plane/prisma-sast-finding-lineage.store.e2e-spec.ts new file mode 100644 index 0000000..538d3bb --- /dev/null +++ b/apps/api/test/scan-plane/prisma-sast-finding-lineage.store.e2e-spec.ts @@ -0,0 +1,1542 @@ +import { createHash } from 'node:crypto'; + +import { Prisma } from '@prisma/client'; +import { + SAST_APPROVED_PROFILE_DIGESTS, + SAST_FORBIDDEN_CAPABILITIES, + SAST_SCAN_PROFILES, + buildFindingFingerprintPreimage, + buildSastFindingLifecycleContextPreimage, + isSastScanPlanValid, + projectRenamedSastFindingFingerprintInput, + type SastScanPlan +} from '@aegisai/shared'; +import type { + SastFindingLineageScanContext, + SastFindingReconciliationScanContext +} from '../../src/scan-plane/sast-finding-lineage.store'; +import { + SastFindingLineageObservationIncompleteError, + SastFindingLineageReplayConflictError, + SastFindingLineageRenameAmbiguousError +} from '../../src/scan-plane/sast-finding-lineage.store'; +import { + PrismaSastFindingLineageStore +} from '../../src/scan-plane/prisma-sast-finding-lineage.store'; +import { PrismaService } from '../../src/prisma/prisma.service'; +import { + LINEAGE_FIXTURE_TIME, + fingerprintedFindingBatch, + fixtureDigest as batchIndependentDigest, + lifecycleCoverageDecision, + lineageContextKey, + lineageObservationContext, + reconciliationContext, + renameAttestation +} from '../support/sast-finding-lineage-fixtures'; + +describe('PrismaSastFindingLineageStore', () => { + it('loads only an accepted scanner run bound to one valid immutable plan and retention ledger', async () => { + const plan = durablePlan(); + expect(isSastScanPlanValid(plan)).toBe(true); + const retentionExpiresAt = new Date( + '2026-08-01T00:00:00.000Z' + ); + const scannerRun = { + findFirst: jest.fn().mockResolvedValue( + durableObservationRow(plan, retentionExpiresAt) + ) + }; + const store = new PrismaSastFindingLineageStore( + { + scannerRun + } as unknown as PrismaService + ); + + const context = await store.loadObservationContext({ + tenantId: plan.tenantId, + repositoryBindingId: + plan.repositoryState.repositoryBindingId, + scanRequestId: plan.scanRequestId, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1' + }); + + expect(context).toMatchObject({ + targetRef: plan.repositoryState.targetRef, + commitSha: plan.repositoryState.fixedCommitSha, + canonicalScanKey: plan.canonicalScanKey, + profileId: plan.profile.id, + profileDigest: plan.profileDigest, + scanner: 'OPENGREP', + source: { + ingestionId: 'ingestion-1', + retentionExpiresAt: + retentionExpiresAt.toISOString() + } + }); + expect(context?.planDigest).toMatch( + /^sha256:[a-f0-9]{64}$/ + ); + expect(scannerRun.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'COMPLETED', + artifactIngestion: { + status: 'ACCEPTED', + dispositionDecision: { + disposition: 'ACCEPTED', + normalizationEligible: true + } + } + }) + }) + ); + }); + + it('rejects scanner provenance that drifts from the immutable plan', async () => { + const plan = durablePlan(); + const retentionExpiresAt = new Date( + '2026-08-01T00:00:00.000Z' + ); + const scannerRun = { + findFirst: jest.fn().mockResolvedValue( + durableObservationRow(plan, retentionExpiresAt, { + scannerImageDigest: + batchIndependentDigest('unapproved-image') + }) + ) + }; + const store = new PrismaSastFindingLineageStore( + { + scannerRun + } as unknown as PrismaService + ); + + await expect( + store.loadObservationContext({ + tenantId: plan.tenantId, + repositoryBindingId: + plan.repositoryState.repositoryBindingId, + scanRequestId: plan.scanRequestId, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1' + }) + ).resolves.toBeNull(); + }); + + it.each([ + 'wrapperDigest', + 'scannerSetDigest', + 'scannerWorkspaceInventoryDigest' + ] as const)( + 'rejects durable %s drift from the immutable plan', + async (field) => { + const plan = durablePlan(); + const scannerRun = { + findFirst: jest.fn().mockResolvedValue( + durableObservationRow( + plan, + new Date('2026-08-01T00:00:00.000Z'), + { + [field]: batchIndependentDigest( + `tampered-${field}` + ) + } + ) + ) + }; + const store = new PrismaSastFindingLineageStore( + { + scannerRun + } as unknown as PrismaService + ); + + await expect( + store.loadObservationContext({ + tenantId: plan.tenantId, + repositoryBindingId: + plan.repositoryState.repositoryBindingId, + scanRequestId: plan.scanRequestId, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1' + }) + ).resolves.toBeNull(); + } + ); + + it('creates one lineage while retaining every repeated occurrence in a serializable transaction', async () => { + const batch = await fingerprintedFindingBatch([ + { + location: { + kind: 'FILE', + normalizedPath: 'src/config.ts', + lineStart: 4, + lineEnd: 4 + } + }, + { + location: { + kind: 'FILE', + normalizedPath: 'src/config.ts', + lineStart: 44, + lineEnd: 44 + }, + identityMaterial: { + ruleSemanticId: 'javascript.hardcoded-secret', + symbolAnchor: '', + sinkKind: '', + structuralHash: + batchIndependentDigest('structure'), + scannerMatchBasedId: 'rules.secret:match-2' + } + } + ]); + const context = lineageObservationContext(batch); + const transaction = observationTransaction(); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockObservationContext(store, context); + + const result = await store.observe({ + observationBatchId: + `finding-observation://${'1'.repeat(64)}`, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context: reorderObservationContext(context), + renameCandidates: [] + }); + + expect(result).toMatchObject({ + findingCount: 2, + occurrenceCount: 2, + distinctFingerprintCount: 1, + createdLineageCount: 1, + exactMatchCount: 0, + renamedMatchCount: 0, + replayed: false + }); + expect(prisma.$transaction).toHaveBeenCalledWith( + expect.any(Function), + { + isolationLevel: 'Serializable', + maxWait: 5_000, + timeout: 120_000 + } + ); + expect( + transaction.sastFindingLineage.createMany + ).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + capability: 'SAST', + fingerprintVersion: 'sast-fingerprint-v1' + }) + ] + }); + expect( + transaction.sastFindingLineage.findMany + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + id: expect.any(Object) + }) + }) + ); + expect( + transaction.normalizedFinding.createMany + ).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ + status: 'OPEN', + sastCapability: 'SAST' + }), + expect.objectContaining({ + status: 'OPEN', + sastCapability: 'SAST' + }) + ]) + }); + expect( + transaction.sastFindingOccurrence.createMany + ).toHaveBeenCalledTimes(1); + const occurrenceRows = + (transaction.sastFindingOccurrence.createMany.mock + .calls[0]?.[0]?.data ?? []) as Array<{ + lineageId: string; + ordinal: number; + }>; + expect(occurrenceRows).toHaveLength(2); + expect( + new Set(occurrenceRows.map((row) => row.lineageId)).size + ).toBe(1); + expect( + occurrenceRows.map((row) => row.ordinal) + ).toEqual([0, 1]); + expect( + transaction.sastFindingObservationBatch.create + ).toHaveBeenCalledWith({ + data: expect.objectContaining({ + capabilities: ['SAST'], + findingCount: 2, + distinctFingerprintCount: 1 + }) + }); + }); + + it.each(['P2034', 'P2002'] as const)( + 'retries a %s serialization or uniqueness race in a fresh transaction', + async (code) => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const transaction = observationTransaction(); + const prisma = { + $transaction: jest + .fn() + .mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError( + 'concurrent lineage write', + { + code, + clientVersion: '5.22.0' + } + ) + ) + .mockImplementation( + async ( + operation: ( + client: typeof transaction + ) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockObservationContext(store, context); + + await expect( + store.observe({ + observationBatchId: + `finding-observation://${'c'.repeat(64)}`, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context, + renameCandidates: [] + }) + ).resolves.toMatchObject({ + findingCount: 1, + occurrenceCount: 1, + replayed: false + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + expect( + transaction.sastFindingObservationBatch.create + ).toHaveBeenCalledTimes(1); + } + ); + + it('rejects a verified rename claim when no durable predecessor alias exists', async () => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const attestation = renameAttestation(context); + const previousPlan = previousPlanForRename( + attestation, + context + ); + const transaction = observationTransaction({ + previousScan: scanRequestRowForPlan(previousPlan) + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockObservationContext(store, context); + const finding = batch.findings[0]; + if (!finding) throw new Error('Missing rename fixture finding.'); + + await expect( + store.observe({ + observationBatchId: + `finding-observation://${'9'.repeat(64)}`, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context, + renameAttestation: attestation, + renameAttestationDigest: + attestation.attestationDigest, + renameCandidates: [ + { + capability: finding.capability, + currentStableFingerprint: + finding.fingerprint.stableFingerprint, + previousStableFingerprint: batchIndependentDigest( + buildFindingFingerprintPreimage( + projectRenamedSastFindingFingerprintInput( + finding.fingerprint, + 'src/old-config.ts' + ) + ) + ), + fromNormalizedPath: 'src/old-config.ts', + toNormalizedPath: 'src/config.ts' + } + ] + }) + ).rejects.toBeInstanceOf( + SastFindingLineageRenameAmbiguousError + ); + expect( + transaction.sastFindingObservationBatch.create + ).not.toHaveBeenCalled(); + }); + + it('records a verified rename-back without duplicating a retained alias', async () => { + const batch = await fingerprintedFindingBatch([ + { + location: { + kind: 'FILE', + normalizedPath: 'src/old-config.ts', + lineStart: 4, + lineEnd: 4 + } + } + ]); + const context = lineageObservationContext(batch); + const attestation = renameAttestation(context, { + entries: [ + { + fromNormalizedPath: 'src/config.ts', + toNormalizedPath: 'src/old-config.ts' + } + ] + }); + const previousPlan = previousPlanForRename( + attestation, + context + ); + const finding = batch.findings[0]; + if (!finding) throw new Error('Missing rename-back fixture.'); + const predecessorFingerprint = batchIndependentDigest( + buildFindingFingerprintPreimage( + projectRenamedSastFindingFingerprintInput( + finding.fingerprint, + 'src/config.ts' + ) + ) + ); + const lineageId = + `finding-lineage://${'5'.repeat(64)}`; + const stateId = `finding-state://${'6'.repeat(64)}`; + const transaction = observationTransaction({ + previousScan: scanRequestRowForPlan(previousPlan), + aliases: [ + { + lineageId, + capability: finding.capability, + stableFingerprint: + finding.fingerprint.stableFingerprint, + normalizedPath: 'src/old-config.ts' + }, + { + lineageId, + capability: finding.capability, + stableFingerprint: predecessorFingerprint, + normalizedPath: 'src/config.ts' + } + ], + lineages: [ + { + id: lineageId, + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + capability: finding.capability, + fingerprintVersion: 'sast-fingerprint-v1' + } + ], + states: [ + { + id: stateId, + lineageId, + status: 'OPEN', + revision: 2, + targetRef: context.targetRef, + lastObservedAt: new Date( + '2026-07-29T23:59:00.000Z' + ) + } + ] + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockObservationContext(store, context); + + await expect( + store.observe({ + observationBatchId: + `finding-observation://${'b'.repeat(64)}`, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context, + renameAttestation: attestation, + renameAttestationDigest: + attestation.attestationDigest, + renameCandidates: [ + { + capability: finding.capability, + currentStableFingerprint: + finding.fingerprint.stableFingerprint, + previousStableFingerprint: + predecessorFingerprint, + fromNormalizedPath: 'src/config.ts', + toNormalizedPath: 'src/old-config.ts' + } + ] + }) + ).resolves.toMatchObject({ + createdLineageCount: 0, + exactMatchCount: 0, + renamedMatchCount: 1 + }); + expect( + transaction.sastFindingIdentityAlias.createMany + ).not.toHaveBeenCalled(); + expect( + transaction.sastFindingLifecycleEvent.createMany + ).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + lifecycleStateId: stateId, + kind: 'RENAMED', + revision: 3, + renameAttestationDigest: + attestation.attestationDigest + }) + ] + }); + }); + + it('replays only an identical complete occurrence ledger', async () => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const observationBatchId = + `finding-observation://${'2'.repeat(64)}`; + const finding = batch.findings[0]; + if (!finding) throw new Error('Missing replay fixture finding.'); + const lineageId = + `finding-lineage://${'7'.repeat(64)}`; + const occurrenceId = deterministicTestId( + 'finding-occurrence', + `${observationBatchId}\0${0}\0${finding.fingerprint.decisionDigest}` + ); + const persistedOccurrence = { + id: occurrenceId, + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + scanRequestId: context.scope.scanRequestId, + attemptId: context.scope.attemptId, + scannerRunId: context.scope.scannerRunId, + observationBatchId, + lineageId, + normalizedFindingId: deterministicTestId( + 'normalized-finding', + occurrenceId + ), + ordinal: 0, + capability: finding.capability, + fingerprintVersion: 'sast-fingerprint-v1', + stableFingerprint: + finding.fingerprint.stableFingerprint, + fingerprintDecisionDigest: + finding.fingerprint.decisionDigest, + sourceFinding: finding, + observedAt: new Date(LINEAGE_FIXTURE_TIME) + }; + const transaction = observationTransaction({ + existingBatch: { + id: observationBatchId, + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + scanRequestId: context.scope.scanRequestId, + attemptId: context.scope.attemptId, + scannerRunId: context.scope.scannerRunId, + lifecycleContextKey: lineageContextKey(context), + targetRef: context.targetRef, + commitSha: context.commitSha, + lane: context.lane, + scanner: context.scanner, + capabilities: ['SAST'], + profileId: context.profileId, + profileDigest: context.profileDigest, + canonicalScanKey: context.canonicalScanKey, + planDigest: context.planDigest, + sourceIdentityBatchDigest: batch.batchDigest, + renameAttestationDigest: null, + findingCount: 1, + distinctFingerprintCount: 1, + createdLineageCount: 1, + exactMatchCount: 0, + renamedMatchCount: 0, + observedAt: new Date(LINEAGE_FIXTURE_TIME) + }, + aliases: [ + { + lineageId, + capability: finding.capability, + stableFingerprint: + finding.fingerprint.stableFingerprint, + normalizedPath: + finding.fingerprint.normalizedPath + } + ], + lineages: [ + { + id: lineageId, + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + capability: finding.capability, + fingerprintVersion: 'sast-fingerprint-v1' + } + ], + occurrences: [persistedOccurrence] + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockObservationContext(store, context); + + await expect( + store.observe({ + observationBatchId, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context, + renameCandidates: [] + }) + ).resolves.toMatchObject({ + replayed: true, + occurrenceCount: 1 + }); + expect( + transaction.sastFindingObservationBatch.create + ).not.toHaveBeenCalled(); + expect( + transaction.normalizedFinding.createMany + ).not.toHaveBeenCalled(); + + transaction.sastFindingOccurrence.findMany.mockResolvedValue([ + { + ...persistedOccurrence, + fingerprintDecisionDigest: + batchIndependentDigest('tampered-decision') + } + ]); + await expect( + store.observe({ + observationBatchId, + lifecycleContextKey: lineageContextKey(context), + observedAt: LINEAGE_FIXTURE_TIME, + batch, + context, + renameCandidates: [] + }) + ).rejects.toBeInstanceOf( + SastFindingLineageReplayConflictError + ); + }); + + it.each([ + { + status: 'OPEN', + observedRows: [], + expectedKind: 'FIXED', + expectedStatus: 'FIXED', + countField: 'fixedCount' + }, + { + status: 'FIXED', + observedRows: [ + { + lineageId: + `finding-lineage://${'1'.repeat(64)}` + } + ], + expectedKind: 'REOPENED', + expectedStatus: 'OPEN', + countField: 'reopenedCount' + } + ] as const)( + 'persists an append-only $expectedKind transition without mutating policy status', + async ({ + status, + observedRows, + expectedKind, + expectedStatus, + countField + }) => { + const decision = lifecycleCoverageDecision(); + const context = reconciliationContext(); + const transaction = reconciliationTransaction({ + decision, + status, + observedRows + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockReconciliationContext(store, context); + + const result = await store.reconcile({ + reconciliationId: + `finding-reconciliation://${'3'.repeat(64)}`, + reconciledAt: LINEAGE_FIXTURE_TIME, + decision, + context: reorderReconciliationContext(context) + }); + + expect(result[countField]).toBe(1); + expect( + transaction.sastFindingLifecycleState.updateMany + ).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: expectedStatus, + revision: { increment: 1 }, + lastReconciliationSequence: 1 + }) + }) + ); + expect( + transaction.sastFindingLifecycleEvent.createMany + ).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + kind: expectedKind, + previousStatus: status, + nextStatus: expectedStatus, + revision: 2 + }) + ] + }); + expect( + transaction.normalizedFinding.updateMany + ).not.toHaveBeenCalled(); + expect( + transaction.normalizedFinding.createMany + ).not.toHaveBeenCalled(); + expect( + transaction.sastFindingLifecycleReconciliation.create + ).toHaveBeenCalledWith({ + data: expect.objectContaining({ + coverageDecisionDigest: + decision.decisionDigest, + coverageDecision: decision + }) + }); + expect( + transaction.sastFindingLifecycleReconciliation.findFirst + ).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + OR: expect.any(Array) + }) + }) + ); + } + ); + + it('catches a newly eligible target state up to the next global reconciliation sequence', async () => { + const context = { + ...reconciliationContext(), + scanRequestId: 'scan-2', + attemptId: 'attempt-2', + commitSha: 'c'.repeat(40), + canonicalScanKey: + batchIndependentDigest('canonical-scan-2'), + planDigest: batchIndependentDigest('plan-2') + }; + const decision = lifecycleCoverageDecision({ + scanRequestId: context.scanRequestId, + attemptId: context.attemptId, + canonicalScanKey: context.canonicalScanKey, + planDigest: context.planDigest, + commitSha: context.commitSha, + sequence: 2, + previousScanRequestId: 'scan-1', + previousCommitSha: 'a'.repeat(40) + }); + const transaction = reconciliationTransaction({ + decision, + context, + status: 'OPEN', + stateSequence: 0, + observedRows: [ + { + lineageId: decision.eligibleLineageIds[0] + } + ], + latest: { + sequence: 1, + scanRequestId: 'scan-1', + reconciledAt: new Date( + '2026-07-29T23:59:30.000Z' + ) + } + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockReconciliationContext(store, context); + + await expect( + store.reconcile({ + reconciliationId: + `finding-reconciliation://${'d'.repeat(64)}`, + reconciledAt: LINEAGE_FIXTURE_TIME, + decision, + context + }) + ).resolves.toMatchObject({ + sequence: 2, + unchangedOpenCount: 1 + }); + expect( + transaction.sastFindingLifecycleState.updateMany + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + lastReconciliationSequence: { lte: 1 } + }), + data: expect.objectContaining({ + lastReconciliationSequence: 2 + }) + }) + ); + }); + + it('rejects partial observation coverage before changing lifecycle state', async () => { + const decision = lifecycleCoverageDecision(); + const context = reconciliationContext(); + const transaction = reconciliationTransaction({ + decision, + status: 'OPEN', + observedRows: [], + observationDigest: batchIndependentDigest('unexpected') + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockReconciliationContext(store, context); + + await expect( + store.reconcile({ + reconciliationId: + `finding-reconciliation://${'4'.repeat(64)}`, + reconciledAt: LINEAGE_FIXTURE_TIME, + decision, + context + }) + ).rejects.toBeInstanceOf( + SastFindingLineageObservationIncompleteError + ); + expect( + transaction.sastFindingLifecycleState.updateMany + ).not.toHaveBeenCalled(); + expect( + transaction.sastFindingLifecycleReconciliation.create + ).not.toHaveBeenCalled(); + }); + + it('rejects an omitted zero-finding observation batch from the exact scan ledger', async () => { + const decision = lifecycleCoverageDecision(); + const context = reconciliationContext(); + const transaction = reconciliationTransaction({ + decision, + status: 'OPEN', + observedRows: [], + additionalBatches: [ + reconciliationBatch( + context, + batchIndependentDigest('zero-finding-batch'), + [] + ) + ] + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockReconciliationContext(store, context); + + await expect( + store.reconcile({ + reconciliationId: + `finding-reconciliation://${'8'.repeat(64)}`, + reconciledAt: LINEAGE_FIXTURE_TIME, + decision, + context + }) + ).rejects.toBeInstanceOf( + SastFindingLineageObservationIncompleteError + ); + expect( + transaction.sastFindingLifecycleState.updateMany + ).not.toHaveBeenCalled(); + }); + + it('rejects a mismatched-context batch even when every current-attempt digest is declared', async () => { + const validDigest = + lifecycleCoverageDecision() + .expectedObservationBatchDigests[0]; + const extraDigest = batchIndependentDigest( + 'mismatched-context-batch' + ); + const decision = lifecycleCoverageDecision({ + expectedObservationBatchDigests: [ + validDigest, + extraDigest + ].sort() + }); + const context = reconciliationContext(); + const transaction = reconciliationTransaction({ + decision, + status: 'OPEN', + observedRows: [], + observationDigest: validDigest, + additionalBatches: [ + { + ...reconciliationBatch( + context, + extraDigest, + [] + ), + lifecycleContextKey: + batchIndependentDigest('other-target-context') + } + ] + }); + const prisma = serializablePrisma(transaction); + const store = new PrismaSastFindingLineageStore( + prisma as unknown as PrismaService + ); + mockReconciliationContext(store, context); + + await expect( + store.reconcile({ + reconciliationId: + `finding-reconciliation://${'e'.repeat(64)}`, + reconciledAt: LINEAGE_FIXTURE_TIME, + decision, + context + }) + ).rejects.toBeInstanceOf( + SastFindingLineageObservationIncompleteError + ); + expect( + transaction.sastFindingObservationBatch.findMany + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + tenantId: decision.tenantId, + repositoryBindingId: + decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId + } + }) + ); + expect( + transaction.sastFindingLifecycleState.updateMany + ).not.toHaveBeenCalled(); + }); +}); + +function observationTransaction(options: { + existingBatch?: Record; + aliases?: readonly Record[]; + lineages?: readonly Record[]; + states?: readonly Record[]; + occurrences?: readonly Record[]; + previousScan?: Record; +} = {}) { + return { + scanRequest: { + findFirst: jest.fn().mockResolvedValue( + options.previousScan ?? null + ) + }, + sastFindingObservationBatch: { + findFirst: jest.fn().mockResolvedValue( + options.existingBatch ?? null + ), + create: jest.fn().mockResolvedValue({ id: 'batch-1' }) + }, + sastFindingIdentityAlias: { + findMany: jest.fn().mockResolvedValue( + options.aliases ?? [] + ), + createMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + sastFindingLineage: { + findMany: jest.fn().mockResolvedValue( + options.lineages ?? [] + ), + createMany: jest.fn().mockResolvedValue({ count: 1 }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + sastFindingLifecycleState: { + findMany: jest.fn().mockResolvedValue( + options.states ?? [] + ), + createMany: jest.fn().mockResolvedValue({ count: 1 }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + sastFindingLifecycleEvent: { + createMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + normalizedFinding: { + createMany: jest.fn().mockResolvedValue({ count: 2 }) + }, + sastFindingOccurrence: { + count: jest.fn().mockResolvedValue( + options.occurrences?.length ?? 0 + ), + findMany: jest.fn().mockResolvedValue( + options.occurrences ?? [] + ), + createMany: jest.fn().mockResolvedValue({ count: 2 }) + }, + auditEvent: { + create: jest.fn().mockResolvedValue({ id: 'audit-1' }) + } + }; +} + +function reconciliationTransaction(input: { + decision: ReturnType; + context?: SastFindingReconciliationScanContext; + status: 'OPEN' | 'FIXED'; + stateSequence?: number; + observedRows: readonly { lineageId: string }[]; + observationDigest?: string; + additionalBatches?: readonly Record[]; + latest?: { + sequence: number; + scanRequestId: string; + reconciledAt: Date; + }; +}) { + const lineageId = input.decision.eligibleLineageIds[0]; + const context = input.context ?? reconciliationContext(); + const previousPlan = previousPlanForDecision( + input.decision, + context + ); + return { + sastFindingLifecycleReconciliation: { + findFirst: jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(input.latest ?? null), + create: jest.fn().mockResolvedValue({ id: 'reconciliation-1' }) + }, + scanRequest: { + findFirst: jest.fn().mockResolvedValue( + scanRequestRowForPlan(previousPlan) + ) + }, + sastFindingObservationBatch: { + findMany: jest.fn().mockResolvedValue([ + reconciliationBatch( + context, + input.observationDigest ?? + input.decision + .expectedObservationBatchDigests[0], + ['SAST'] + ), + ...(input.additionalBatches ?? []) + ]) + }, + sastFindingLineage: { + findMany: jest.fn().mockResolvedValue([ + { + id: lineageId, + capability: + input.decision.completeCapabilities[0], + fingerprintVersion: 'sast-fingerprint-v1' + } + ]) + }, + sastFindingOccurrence: { + findMany: jest.fn().mockResolvedValue( + input.observedRows.map((row) => ({ + ...row, + capability: + input.decision.completeCapabilities[0], + fingerprintVersion: 'sast-fingerprint-v1', + lineage: { + capability: + input.decision.completeCapabilities[0], + fingerprintVersion: 'sast-fingerprint-v1' + } + })) + ) + }, + normalizedFinding: { + updateMany: jest.fn(), + createMany: jest.fn() + }, + sastFindingLifecycleState: { + findMany: jest.fn().mockResolvedValue([ + { + id: `finding-state://${'6'.repeat(64)}`, + lineageId, + status: input.status, + revision: 1, + targetRef: 'refs/heads/main', + lastReconciliationSequence: + input.stateSequence ?? 0 + } + ]), + updateMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + sastFindingLifecycleEvent: { + createMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + auditEvent: { + create: jest.fn().mockResolvedValue({ id: 'audit-1' }) + } + }; +} + +function reconciliationBatch( + context: SastFindingReconciliationScanContext, + sourceIdentityBatchDigest: string, + capabilities: readonly string[] +) { + return { + id: deterministicTestId( + 'finding-observation', + sourceIdentityBatchDigest + ), + sourceIdentityBatchDigest, + lifecycleContextKey: batchIndependentDigest( + buildSastFindingLifecycleContextPreimage({ + tenantId: context.tenantId, + repositoryBindingId: + context.repositoryBindingId, + targetRef: context.targetRef + }) + ), + capabilities, + scanner: 'OPENGREP', + targetRef: context.targetRef, + commitSha: context.commitSha, + lane: context.lane, + profileId: context.profileId, + profileDigest: context.profileDigest, + canonicalScanKey: context.canonicalScanKey, + planDigest: context.planDigest, + observedAt: new Date( + '2026-07-29T23:58:30.000Z' + ) + }; +} + +function previousPlanForRename( + attestation: ReturnType, + context: SastFindingLineageScanContext +): SastScanPlan { + const plan = durablePlan(); + return { + ...plan, + tenantId: attestation.tenantId, + scanRequestId: attestation.fromScanRequestId, + canonicalScanKey: + batchIndependentDigest('rename-previous-canonical'), + profile: SAST_SCAN_PROFILES[attestation.profileId], + profileDigest: attestation.profileDigest, + repositoryState: { + ...plan.repositoryState, + repositoryBindingId: + attestation.repositoryBindingId, + fixedCommitSha: attestation.fromCommitSha, + targetRef: context.targetRef + } + }; +} + +function scanRequestRowForPlan(plan: SastScanPlan) { + return { + lane: plan.profile.lane, + targetRef: plan.repositoryState.targetRef, + commitSha: plan.repositoryState.fixedCommitSha, + canonicalKey: plan.canonicalScanKey, + sastQueueReservation: { + immutablePlan: plan + } + }; +} + +function previousPlanForDecision( + decision: ReturnType, + context: SastFindingReconciliationScanContext +): SastScanPlan { + const plan = durablePlan(); + return { + ...plan, + tenantId: decision.tenantId, + scanRequestId: decision.previousScanRequestId, + canonicalScanKey: + batchIndependentDigest('previous-canonical-scan'), + profile: SAST_SCAN_PROFILES[decision.profileId], + profileDigest: decision.profileDigest, + repositoryState: { + ...plan.repositoryState, + repositoryBindingId: + decision.repositoryBindingId, + fixedCommitSha: decision.previousCommitSha, + targetRef: context.targetRef + } + }; +} + +function serializablePrisma(transaction: T) { + return { + $transaction: jest.fn( + async ( + operation: (client: T) => Promise + ) => operation(transaction) + ) + }; +} + +function mockObservationContext( + store: PrismaSastFindingLineageStore, + context: SastFindingLineageScanContext +): void { + jest + .spyOn( + store as unknown as { + readObservationContext: ( + ...args: unknown[] + ) => Promise; + }, + 'readObservationContext' + ) + .mockResolvedValue(context); +} + +function reorderObservationContext( + context: Readonly +): SastFindingLineageScanContext { + return { + source: { + retentionExpiresAt: + context.source.retentionExpiresAt, + dispositionDecisionDigest: + context.source.dispositionDecisionDigest, + validationResultDigest: + context.source.validationResultDigest, + artifactDigest: context.source.artifactDigest, + envelopeDigest: context.source.envelopeDigest, + artifactSchemaVersion: + context.source.artifactSchemaVersion, + artifactSchema: context.source.artifactSchema, + preflightInventoryDigest: + context.source.preflightInventoryDigest, + preflightAttestationRef: + context.source.preflightAttestationRef, + normalizerBundleDigest: + context.source.normalizerBundleDigest, + schemaBundleDigest: + context.source.schemaBundleDigest, + ...(context.source.vulnerabilityDatabaseDigest + ? { + vulnerabilityDatabaseDigest: + context.source.vulnerabilityDatabaseDigest + } + : {}), + ...(context.source.ruleBundleDigest + ? { + ruleBundleDigest: + context.source.ruleBundleDigest + } + : {}), + scannerImageDigest: + context.source.scannerImageDigest, + scannerVersion: context.source.scannerVersion, + ingestionId: context.source.ingestionId + }, + scanner: context.scanner, + profileDigest: context.profileDigest, + profileId: context.profileId, + planDigest: context.planDigest, + canonicalScanKey: context.canonicalScanKey, + commitSha: context.commitSha, + lane: context.lane, + targetRef: context.targetRef, + scope: { + scannerRunId: context.scope.scannerRunId, + attemptId: context.scope.attemptId, + scanRequestId: context.scope.scanRequestId, + repositoryBindingId: + context.scope.repositoryBindingId, + tenantId: context.scope.tenantId + } + }; +} + +function mockReconciliationContext( + store: PrismaSastFindingLineageStore, + context: SastFindingReconciliationScanContext +): void { + jest + .spyOn( + store as unknown as { + readReconciliationContext: ( + ...args: unknown[] + ) => Promise; + }, + 'readReconciliationContext' + ) + .mockResolvedValue(context); +} + +function reorderReconciliationContext( + context: Readonly +): SastFindingReconciliationScanContext { + return { + profileDigest: context.profileDigest, + profileId: context.profileId, + planDigest: context.planDigest, + canonicalScanKey: context.canonicalScanKey, + commitSha: context.commitSha, + lane: context.lane, + targetRef: context.targetRef, + attemptId: context.attemptId, + scanRequestId: context.scanRequestId, + repositoryBindingId: context.repositoryBindingId, + tenantId: context.tenantId + }; +} + +function deterministicTestId( + prefix: string, + value: string +): string { + return `${prefix}://${createHash('sha256') + .update(value, 'utf8') + .digest('hex')}`; +} + +function durableObservationRow( + plan: SastScanPlan, + retentionExpiresAt: Date, + overrides: Record = {} +) { + return { + scanner: 'OPENGREP', + scannerVersion: + plan.scannerSet.scanners.OPENGREP.version, + wrapperDigest: + plan.scannerSet.scanners.OPENGREP.wrapper.digest, + scannerImageDigest: + plan.scannerSet.scanners.OPENGREP.digest, + scannerSetDigest: + plan.scannerSet.scannerSetDigest, + ruleBundleDigest: + plan.scannerSet.ruleBundles.find( + (bundle) => bundle.scanner === 'OPENGREP' + )?.digest, + databaseDigest: null, + schemaBundleDigest: + plan.scannerSet.schemaBundle.digest, + normalizerBundleDigest: + plan.scannerSet.normalizerBundle.digest, + profileId: plan.profile.id, + profileDigest: plan.profileDigest, + preflightAttestationRef: + plan.repositoryState.attestationRef, + preflightInventoryDigest: + plan.repositoryState.inventoryDigest, + scannerWorkspaceInventoryDigest: + plan.repositoryState.inventoryDigest, + artifactSchema: 'OPENGREP_SARIF', + artifactSchemaVersion: '2.1.0', + scanRequest: { + lane: plan.profile.lane, + targetRef: plan.repositoryState.targetRef, + commitSha: + plan.repositoryState.fixedCommitSha, + canonicalKey: plan.canonicalScanKey, + sastQueueReservation: { + immutablePlan: plan + } + }, + artifactIngestion: { + id: 'ingestion-1', + envelopeDigest: + batchIndependentDigest('envelope'), + observedContentDigest: + batchIndependentDigest('artifact'), + retentionExpiresAt, + dispositionDecision: { + validationResultDigest: + batchIndependentDigest('validation'), + decisionDigest: + batchIndependentDigest('disposition'), + retentionExpiresAt + } + }, + ...overrides + }; +} + +function durablePlan(): SastScanPlan { + const profile = SAST_SCAN_PROFILES.JAVA_FAST_V1; + return { + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + canonicalScanKey: + batchIndependentDigest('canonical-scan'), + profile, + profileDigest: + SAST_APPROVED_PROFILE_DIGESTS[profile.id], + policyVersion: 'policy-v1', + repositoryState: { + repositoryBindingId: 'repository-1', + fixedCommitSha: 'a'.repeat(40), + targetRef: 'refs/heads/main', + inventoryDigest: + batchIndependentDigest('inventory'), + attestationRef: 'preflight://attempt-1', + shallowFetchPreferred: true, + submodulesEnabled: false, + lfsObjectsFetched: false + }, + scannerSet: { + scannerSetVersion: 'scanner-set-v1', + scannerSetDigest: + batchIndependentDigest('scanner-set'), + signatureRef: 'signature://scanner-set-v1', + provenanceRef: 'provenance://scanner-set-v1', + scanners: { + OPENGREP: scannerDescriptor( + 'OPENGREP', + '1.22.0', + 'opengrep' + ), + TRIVY: scannerDescriptor( + 'TRIVY', + '0.66.0', + 'trivy' + ), + SYFT: scannerDescriptor('SYFT', '1.44.0', 'syft') + }, + ruleBundles: [ + ruleBundle('OPENGREP', 'opengrep'), + ruleBundle('TRIVY', 'trivy') + ], + vulnerabilityDatabase: { + databaseVersion: '2026-07-30', + publishedAt: '2026-07-30T00:00:00.000Z', + digest: batchIndependentDigest('trivy-db'), + signatureRef: 'signature://trivy-db', + provenanceRef: 'provenance://trivy-db' + }, + schemaBundle: { + digest: batchIndependentDigest('schema'), + signatureRef: 'signature://schema', + provenanceRef: 'provenance://schema' + }, + normalizerBundle: { + digest: batchIndependentDigest('normalizer'), + signatureRef: 'signature://normalizer', + provenanceRef: 'provenance://normalizer' + }, + sbomSchema: 'CYCLONEDX_JSON', + rollbackRef: 'rollback://scanner-set-v1' + }, + isolationClass: 'HARDENED', + resultIngressRef: 'result-ingress://tenant-1/scan-1', + evidenceOutputRef: 'evidence-output://tenant-1/scan-1', + auditSinkRef: 'audit-sink://tenant-1/scan-1', + forbiddenCapabilities: [...SAST_FORBIDDEN_CAPABILITIES], + createdAt: '2026-07-29T23:00:00.000Z' + }; +} + +function scannerDescriptor( + scanner: 'OPENGREP' | 'TRIVY' | 'SYFT', + version: string, + seed: string +) { + return { + scanner, + version, + digest: batchIndependentDigest(`${seed}-image`), + signatureRef: `signature://${seed}-image`, + provenanceRef: `provenance://${seed}-image`, + sbomRef: `sbom://${seed}-image`, + wrapper: { + digest: batchIndependentDigest(`${seed}-wrapper`), + signatureRef: `signature://${seed}-wrapper`, + provenanceRef: `provenance://${seed}-wrapper` + } + }; +} + +function ruleBundle( + scanner: 'OPENGREP' | 'TRIVY', + seed: string +) { + return { + bundleId: `${seed}-bundle-v1`, + version: '1', + state: 'ACTIVE' as const, + digest: batchIndependentDigest(`${seed}-bundle`), + signatureRef: `signature://${seed}-bundle`, + provenanceRef: `provenance://${seed}-bundle`, + compatibilityRef: `compatibility://${seed}-bundle`, + rolloutPolicyRef: `rollout://${seed}-bundle`, + killSwitchRef: `kill-switch://${seed}-bundle`, + scanner, + source: 'PLATFORM_MANAGED' as const, + immutable: true as const, + customerExecutableConfigAllowed: false as const, + rules: [ + { + ruleId: `${seed}.fixture`, + ruleRevision: '1', + ruleSemanticId: `${seed}.fixture`, + metadataDigest: + batchIndependentDigest(`${seed}-metadata`) + } + ] + }; +} 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 new file mode 100644 index 0000000..9d62a44 --- /dev/null +++ b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts @@ -0,0 +1,183 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('SAST finding lineage persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql' + ); + const onlineSchema = read( + 'scripts/apply-online-sast-runtime-schema.mjs' + ); + const store = read( + 'src/scan-plane/prisma-sast-finding-lineage.store.ts' + ); + const service = read( + 'src/scan-plane/sast-finding-lineage.service.ts' + ); + const coverageGate = read( + 'src/scan-plane/sast-finding-lifecycle-coverage.gate.ts' + ); + const module = read('src/scan-plane/scan-plane.module.ts'); + + it('separates global identity, ordered occurrences, target lifecycle, and append-only events', () => { + for (const model of [ + 'SastFindingLineage', + 'SastFindingIdentityAlias', + 'SastFindingObservationBatch', + 'SastFindingOccurrence', + 'SastFindingLifecycleState', + 'SastFindingLifecycleReconciliation', + 'SastFindingLifecycleEvent' + ]) { + expect(schema).toContain(`model ${model} {`); + expect(migration).toContain(`CREATE TABLE "${model}"`); + } + expect(schema).toContain( + '@@unique([tenantId, repositoryBindingId, capability, fingerprintVersion, stableFingerprint], map: "SastFindingIdentityAlias_fingerprint_key")' + ); + expect(schema).toContain( + 'SastFindingLineage_identity_scope_key' + ); + expect(migration).toContain( + 'SastFindingLineage_identity_scope_key' + ); + expect(schema).toContain( + '@@unique([observationBatchId, ordinal], map: "SastFindingOccurrence_batch_ordinal_key")' + ); + expect(schema).toContain( + '@@unique([tenantId, repositoryBindingId, lifecycleContextKey, lineageId], map: "SastFindingLifecycleState_context_lineage_key")' + ); + expect(schema).toContain( + '@@unique([id, tenantId, repositoryBindingId, lineageId, lifecycleContextKey], map: "SastFindingLifecycleState_scope_key")' + ); + expect(schema).toContain( + '@@unique([id, tenantId, repositoryBindingId, lifecycleContextKey], map: "SastFindingObservationBatch_event_scope_key")' + ); + expect(schema).toContain( + '@@unique([id, tenantId, repositoryBindingId, lifecycleContextKey], map: "SastFindingLifecycleReconciliation_event_scope_key")' + ); + expect(schema).toContain( + '@@unique([lifecycleStateId, revision], map: "SastFindingLifecycleEvent_state_revision_key")' + ); + expect(migration).toContain( + '"kind" = \'FIXED\'' + ); + expect(migration).toContain( + '"kind" = \'REOPENED\'' + ); + for (const sourceConstraint of [ + 'SastFindingLifecycleEvent_observation_scope_fkey', + 'SastFindingLifecycleEvent_reconciliation_scope_fkey' + ]) { + expect(schema).toContain(sourceConstraint); + expect(migration).toContain(sourceConstraint); + } + expect(schema).toMatch(/lastObservedAt\s+DateTime\?/u); + expect(migration).toContain( + '"lastObservedAt" TIMESTAMP(3)' + ); + }); + + it('keeps legacy policy status separate and rolls out nullable metadata online', () => { + const normalizedFinding = + schema.match( + /model NormalizedFinding \{([\s\S]*?)\n\}/ + )?.[1] ?? ''; + expect(normalizedFinding).toContain( + 'status' + ); + expect(normalizedFinding).not.toContain( + 'lifecycleStatus' + ); + expect(migration).toContain( + 'ALTER COLUMN "lineStart" DROP NOT NULL' + ); + expect(migration).toContain( + 'ADD COLUMN "sastLineageId" TEXT' + ); + for (const onlineControl of [ + 'NormalizedFinding_sast_occurrence_scope_key', + 'NormalizedFinding_sastLineageId_idx', + 'NormalizedFinding_sastObservationBatchId_idx', + 'NormalizedFinding_sast_metadata_check', + 'SastFindingOccurrence_normalized_scope_fkey', + 'SastFindingObservationBatch_scanner_scope_fkey' + ]) { + expect(onlineSchema).toContain(onlineControl); + } + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "NormalizedFinding_sast_occurrence_scope_key"' + ); + }); + + it('serializes writes, retains duplicate observations, and rejects incomplete reconciliation', () => { + expect(store).toContain( + 'Prisma.TransactionIsolationLevel.Serializable' + ); + expect(store).toContain( + 'SERIALIZABLE_ATTEMPTS = 3' + ); + expect(store).toContain( + 'SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000' + ); + expect(store).toContain( + 'transaction.sastFindingOccurrence.createMany' + ); + expect(store).toContain( + 'transaction.normalizedFinding.createMany' + ); + expect(store).toContain( + 'SastFindingLineageObservationIncompleteError' + ); + expect(store).toContain( + 'sameStringArray' + ); + expect(store).toContain( + 'canonicalizeSastFingerprintedFinding' + ); + expect(store).toContain( + 'SAST_SCANNER_RESPONSIBILITIES' + ); + expect(service).toContain( + 'isSastFingerprintedFindingBatchShapeValid' + ); + expect(service).toContain( + 'FINDING_LINEAGE_SCAN_STALE' + ); + expect(service).toContain( + 'FINDING_LINEAGE_SCAN_NOT_COMPARABLE' + ); + }); + + it('exports only the T037 gate and leaves rename/coverage authority unavailable by default', () => { + expect(module).toContain( + 'UnavailableSastFindingRenameAttestationVerifier' + ); + expect(module).toContain( + 'UnavailableSastFindingLifecycleCoverageGate' + ); + expect(module).toMatch( + /exports:\s*\[[\s\S]*SastFindingLineageService[\s\S]*\]/ + ); + const exportsBlock = + module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? + ''; + expect(exportsBlock).not.toContain( + 'SastFindingIdentityService' + ); + expect(coverageGate).toContain( + 'T039 owns coverage calculation' + ); + expect(coverageGate).not.toMatch( + /^\s*(?:(?:public|protected|private|static|async|override)\s+)*(?:calculate|publish|override)\s*\([^)]*\)\s*(?::[^{\n]+)?\s*\{|^\s*abstract\s+(?:calculate|publish|override)\s*\([^)]*\)\s*(?::[^;\n]+)?;/mu + ); + }); +}); + +function read(relativePath: string): string { + return readFileSync( + resolve(__dirname, `../../${relativePath}`), + 'utf8' + ); +} diff --git a/apps/api/test/scan-plane/sast-finding-lineage.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-lineage.e2e-spec.ts new file mode 100644 index 0000000..6187680 --- /dev/null +++ b/apps/api/test/scan-plane/sast-finding-lineage.e2e-spec.ts @@ -0,0 +1,683 @@ +import { + isSastFindingLifecycleReconciliationResultShapeValid, + isSastFindingLineageObservationResultShapeValid, + type SastFindingLifecycleCoverageDecision +} from '@aegisai/shared'; + +import { + SastFindingLifecycleCoverageGate +} from '../../src/scan-plane/sast-finding-lifecycle-coverage.gate'; +import { + SastFindingLineageService +} from '../../src/scan-plane/sast-finding-lineage.service'; +import { + SastFindingLineageObservationIncompleteError, + SastFindingLineageStore +} from '../../src/scan-plane/sast-finding-lineage.store'; +import { + SastFindingRenameAttestationVerifier +} from '../../src/scan-plane/sast-finding-rename-attestation.verifier'; +import { + LINEAGE_FIXTURE_TIME, + fingerprintedFindingBatch, + fixtureDigest, + lifecycleCoverageDecision, + lineageContextKey, + lineageFixtureClock, + lineageObservationContext, + reconciliationContext, + renameAttestation +} from '../support/sast-finding-lineage-fixtures'; + +describe('SastFindingLineageService', () => { + it('revalidates the full T036 handoff and persists every repeated occurrence', async () => { + const batch = await fingerprintedFindingBatch([ + { + location: { + kind: 'FILE', + normalizedPath: 'src/config.ts', + lineStart: 4, + lineEnd: 4 + } + }, + { + location: { + kind: 'FILE', + normalizedPath: 'src/config.ts', + lineStart: 40, + lineEnd: 40 + }, + identityMaterial: { + ruleSemanticId: 'javascript.hardcoded-secret', + symbolAnchor: '', + sinkKind: '', + structuralHash: fixtureDigest('structure'), + scannerMatchBasedId: 'rules.secret:match-2' + } + } + ]); + const context = lineageObservationContext(batch); + const store = observationStore(context, { + findingCount: 2, + occurrenceCount: 2, + distinctFingerprintCount: 1, + createdLineageCount: 1, + exactMatchCount: 0, + renamedMatchCount: 0 + }); + const service = serviceWith(store); + const snapshot = structuredClone(batch); + + const result = await service.observe( + { batch }, + lineageFixtureClock + ); + + expect(batch).toEqual(snapshot); + expect(result).toMatchObject({ + outcome: 'OBSERVED', + findingCount: 2, + occurrenceCount: 2, + distinctFingerprintCount: 1, + authority: { + normalizedFindingPersistenceAuthority: true, + occurrenceAuthority: true, + lifecycleAuthority: true, + renameAuthority: true, + correlationAuthority: false, + coverageCalculationAuthority: false, + policyAuthority: false, + publicationAuthority: false, + aiPayloadEligible: false + } + }); + expect( + isSastFindingLineageObservationResultShapeValid( + result, + fixtureDigest + ) + ).toBe(true); + expect(store.observe).toHaveBeenCalledWith( + expect.objectContaining({ + batch, + context, + renameCandidates: [] + }) + ); + }); + + it('fails closed when durable scan scope differs from the signed batch', async () => { + const batch = await fingerprintedFindingBatch(); + const context = { + ...lineageObservationContext(batch), + commitSha: 'b'.repeat(40) + }; + const store = observationStore(context); + const service = serviceWith(store); + + await expect( + service.observe({ batch }, lineageFixtureClock) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ], + sourceBatchDigestStored: false, + sourceFindingStored: false + }); + expect(store.observe).not.toHaveBeenCalled(); + }); + + it('rejects cross-tenant durable scope drift without exposing either tenant', async () => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const store = observationStore({ + ...context, + scope: { + ...context.scope, + tenantId: 'tenant-cross-scope' + } + }); + const service = serviceWith(store); + + const result = await service.observe( + { batch }, + lineageFixtureClock + ); + + expect(result).toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ], + sourceFindingStored: false, + secretValueStored: false + }); + expect(JSON.stringify(result)).not.toContain('tenant-1'); + expect(JSON.stringify(result)).not.toContain( + 'tenant-cross-scope' + ); + expect(store.observe).not.toHaveBeenCalled(); + }); + + it('rejects a non-canonical durable target context before persistence', async () => { + const batch = await fingerprintedFindingBatch(); + const context = { + ...lineageObservationContext(batch), + targetRef: 'refs/heads/cafe\u0301' + }; + const store = observationStore(context); + const service = serviceWith(store); + + await expect( + service.observe({ batch }, lineageFixtureClock) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ] + }); + expect(store.observe).not.toHaveBeenCalled(); + }); + + it('persists a zero-finding observation batch without inventing a lineage', async () => { + const batch = await fingerprintedFindingBatch([]); + const context = lineageObservationContext(batch); + const store = observationStore(context, { + findingCount: 0, + occurrenceCount: 0, + distinctFingerprintCount: 0, + createdLineageCount: 0 + }); + const service = serviceWith(store); + + await expect( + service.observe({ batch }, lineageFixtureClock) + ).resolves.toMatchObject({ + outcome: 'OBSERVED', + findingCount: 0, + occurrenceCount: 0, + distinctFingerprintCount: 0 + }); + }); + + it('accepts only verified fixed-commit rename evidence and projects the previous path fingerprint', async () => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const attestation = renameAttestation(context); + const store = observationStore(context, { + createdLineageCount: 0, + exactMatchCount: 0, + renamedMatchCount: 1 + }); + const renameVerifier = { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingRenameAttestationVerifier; + const service = serviceWith(store, renameVerifier); + + const result = await service.observe( + { batch, renameAttestation: attestation }, + lineageFixtureClock + ); + + expect(result).toMatchObject({ + outcome: 'OBSERVED', + renamedMatchCount: 1 + }); + const persisted = ( + store.observe as jest.Mock + ).mock.calls[0]?.[0]; + expect(persisted).toMatchObject({ + renameAttestationDigest: + attestation.attestationDigest, + renameAttestation: attestation, + renameCandidates: [ + { + capability: 'SAST', + currentStableFingerprint: + batch.findings[0]?.fingerprint.stableFingerprint, + fromNormalizedPath: 'src/old-config.ts', + toNormalizedPath: 'src/config.ts' + } + ] + }); + expect( + persisted.renameCandidates[0].previousStableFingerprint + ).not.toBe( + batch.findings[0]?.fingerprint.stableFingerprint + ); + expect(renameVerifier.verify).toHaveBeenCalledTimes(1); + }); + + it('does not persist rename claims when the verifier is unavailable', async () => { + const batch = await fingerprintedFindingBatch(); + const context = lineageObservationContext(batch); + const store = observationStore(context); + const service = serviceWith(store); + + await expect( + service.observe( + { + batch, + renameAttestation: renameAttestation(context) + }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_RENAME_AUTHORITY_UNAVAILABLE' + ] + }); + expect(store.observe).not.toHaveBeenCalled(); + }); + + it('keeps UNKNOWN locations exact-only and never invents rename continuity', async () => { + const batch = await fingerprintedFindingBatch([ + { + location: { + kind: 'UNKNOWN', + reasonCode: 'SCANNER_LOCATION_OMITTED' + } + } + ]); + const context = lineageObservationContext(batch); + const store = observationStore(context); + const verifier = { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingRenameAttestationVerifier; + const service = serviceWith(store, verifier); + + await expect( + service.observe( + { + batch, + renameAttestation: renameAttestation(context) + }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID' + ] + }); + expect( + batch.findings[0]?.fingerprint.normalizedPath + ).toBe(''); + expect(store.observe).not.toHaveBeenCalled(); + }); + + it('applies only a verified complete, non-stale, comparable coverage decision', async () => { + const context = reconciliationContext(); + const decision = lifecycleCoverageDecision(); + const store = reconciliationStore(context, { + eligibleLineageCount: 1, + observedLineageCount: 0, + fixedCount: 1, + reopenedCount: 0, + unchangedOpenCount: 0, + unchangedFixedCount: 0, + replayed: false, + reconciledAt: LINEAGE_FIXTURE_TIME + }); + const coverageGate = { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingLifecycleCoverageGate; + const service = serviceWith( + store, + undefined, + coverageGate + ); + + const result = await service.reconcile( + { coverageDecision: decision }, + lineageFixtureClock + ); + + expect(result).toMatchObject({ + outcome: 'RECONCILED', + fixedCount: 1, + reopenedCount: 0, + replayed: false + }); + expect( + isSastFindingLifecycleReconciliationResultShapeValid( + result, + fixtureDigest + ) + ).toBe(true); + expect(coverageGate.verify).toHaveBeenCalledWith(decision); + }); + + it('fails closed when lifecycle coverage authority is unavailable', async () => { + const store = reconciliationStore( + reconciliationContext() + ); + const service = serviceWith(store); + + await expect( + service.reconcile( + { + coverageDecision: lifecycleCoverageDecision() + }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_COVERAGE_AUTHORITY_UNAVAILABLE' + ] + }); + expect( + store.loadReconciliationContext + ).not.toHaveBeenCalled(); + expect(store.reconcile).not.toHaveBeenCalled(); + }); + + it('isolates lifecycle reconciliation from a different target context', async () => { + const decision = lifecycleCoverageDecision(); + const context = { + ...reconciliationContext(), + targetRef: 'refs/heads/release' + }; + const store = reconciliationStore(context); + const coverageGate = { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingLifecycleCoverageGate; + const service = serviceWith( + store, + undefined, + coverageGate + ); + + await expect( + service.reconcile( + { coverageDecision: decision }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID' + ] + }); + expect(store.reconcile).not.toHaveBeenCalled(); + }); + + it.each([ + { + patch: { state: 'PARTIAL' }, + reason: 'FINDING_LINEAGE_SCAN_INCOMPLETE' + }, + { + patch: { stale: true }, + reason: 'FINDING_LINEAGE_SCAN_STALE' + }, + { + patch: { comparable: false }, + reason: 'FINDING_LINEAGE_SCAN_NOT_COMPARABLE' + } + ])( + 'rejects lifecycle mutation for $reason', + async ({ patch, reason }) => { + const decision = { + ...lifecycleCoverageDecision(), + ...patch + } as unknown as SastFindingLifecycleCoverageDecision; + const store = reconciliationStore( + reconciliationContext() + ); + const service = serviceWith(store); + + await expect( + service.reconcile( + { coverageDecision: decision }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [reason] + }); + expect(store.reconcile).not.toHaveBeenCalled(); + } + ); + + it('maps an incomplete observation ledger to bounded zero-payload rejection metadata', async () => { + const decision = lifecycleCoverageDecision(); + const store = reconciliationStore( + reconciliationContext(), + new SastFindingLineageObservationIncompleteError() + ); + const gate = { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingLifecycleCoverageGate; + const service = serviceWith(store, undefined, gate); + + const result = await service.reconcile( + { coverageDecision: decision }, + lineageFixtureClock + ); + + expect(result).toMatchObject({ + outcome: 'REJECTED', + reasonCodes: [ + 'FINDING_LINEAGE_OBSERVATION_INCOMPLETE' + ], + eligibleLineageIdsStored: false + }); + expect(JSON.stringify(result)).not.toContain( + decision.eligibleLineageIds[0] + ); + }); + + it('yields cooperatively while retaining a bounded large observation batch', async () => { + const findings = Array.from({ length: 65 }, (_, index) => ({ + location: { + kind: 'FILE' as const, + normalizedPath: `src/config-${index + .toString() + .padStart(2, '0')}.ts`, + lineStart: 1, + lineEnd: 1 + } + })); + const batch = await fingerprintedFindingBatch(findings); + const context = lineageObservationContext(batch); + const store = observationStore(context, { + findingCount: 65, + occurrenceCount: 65, + distinctFingerprintCount: 65, + createdLineageCount: 65 + }); + const service = + new YieldObservingSastFindingLineageService( + store, + { + verify: jest.fn().mockResolvedValue('UNAVAILABLE') + } as unknown as SastFindingRenameAttestationVerifier, + { + verify: jest.fn().mockResolvedValue('UNAVAILABLE') + } as unknown as SastFindingLifecycleCoverageGate + ); + + await expect( + service.observe({ batch }, lineageFixtureClock) + ).resolves.toMatchObject({ outcome: 'OBSERVED' }); + expect(service.yieldCount).toBe(1); + }); + + it('yields at finding boundaries while deriving rename candidates', async () => { + const findings = Array.from({ length: 65 }, (_, index) => { + const suffix = index.toString().padStart(2, '0'); + return { + location: { + kind: 'FILE' as const, + normalizedPath: `src/config-${suffix}.ts`, + lineStart: 1, + lineEnd: 1 + } + }; + }); + const batch = await fingerprintedFindingBatch(findings); + const context = lineageObservationContext(batch); + const store = observationStore(context, { + findingCount: 65, + occurrenceCount: 65, + distinctFingerprintCount: 65, + createdLineageCount: 0, + exactMatchCount: 0, + renamedMatchCount: 65 + }); + const service = + new YieldObservingSastFindingLineageService( + store, + { + verify: jest.fn().mockResolvedValue('VERIFIED') + } as unknown as SastFindingRenameAttestationVerifier, + { + verify: jest.fn().mockResolvedValue('UNAVAILABLE') + } as unknown as SastFindingLifecycleCoverageGate + ); + const attestation = renameAttestation(context, { + entries: findings.map((finding, index) => { + const suffix = index.toString().padStart(2, '0'); + return { + fromNormalizedPath: `legacy/config-${suffix}.ts`, + toNormalizedPath: + finding.location.normalizedPath + }; + }) + }); + + await expect( + service.observe( + { + batch, + renameAttestation: attestation + }, + lineageFixtureClock + ) + ).resolves.toMatchObject({ + outcome: 'OBSERVED', + renamedMatchCount: 65 + }); + expect(service.yieldCount).toBe(1); + expect(store.observe).toHaveBeenCalledWith( + expect.objectContaining({ + renameCandidates: expect.arrayContaining([ + expect.objectContaining({ + fromNormalizedPath: 'legacy/config-64.ts', + toNormalizedPath: 'src/config-64.ts' + }) + ]) + }) + ); + }); +}); + +class YieldObservingSastFindingLineageService + extends SastFindingLineageService { + yieldCount = 0; + + protected override async yieldEventLoop(): Promise { + this.yieldCount += 1; + } +} + +function serviceWith( + store: SastFindingLineageStore, + renameVerifier: SastFindingRenameAttestationVerifier = { + verify: jest.fn().mockResolvedValue('UNAVAILABLE') + } as unknown as SastFindingRenameAttestationVerifier, + coverageGate: SastFindingLifecycleCoverageGate = { + verify: jest.fn().mockResolvedValue('UNAVAILABLE') + } as unknown as SastFindingLifecycleCoverageGate +): SastFindingLineageService { + return new SastFindingLineageService( + store, + renameVerifier, + coverageGate + ); +} + +function observationStore( + context: ReturnType, + countOverrides: Partial<{ + findingCount: number; + occurrenceCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + }> = {} +): SastFindingLineageStore & { + observe: jest.Mock; + reconcile: jest.Mock; +} { + const counts = { + findingCount: 1, + occurrenceCount: 1, + distinctFingerprintCount: 1, + createdLineageCount: 1, + exactMatchCount: 0, + renamedMatchCount: 0, + ...countOverrides + }; + return { + loadObservationContext: jest.fn().mockResolvedValue(context), + loadReconciliationContext: jest.fn(), + observe: jest.fn().mockImplementation((input) => ({ + observationBatchId: input.observationBatchId, + sourceIdentityBatchDigest: input.batch.batchDigest, + lifecycleContextKey: lineageContextKey(context), + ...counts, + replayed: false, + observedAt: LINEAGE_FIXTURE_TIME + })), + reconcile: jest.fn() + } as unknown as SastFindingLineageStore & { + observe: jest.Mock; + reconcile: jest.Mock; + }; +} + +function reconciliationStore( + context: ReturnType, + resultOrError?: unknown +): SastFindingLineageStore & { + observe: jest.Mock; + reconcile: jest.Mock; +} { + const reconcile = + resultOrError instanceof Error + ? jest.fn().mockRejectedValue(resultOrError) + : jest.fn().mockImplementation((input) => ({ + eligibleLineageCount: + input.decision.eligibleLineageIds.length, + observedLineageCount: 1, + fixedCount: 0, + reopenedCount: 0, + unchangedOpenCount: 1, + unchangedFixedCount: 0, + replayed: false, + reconciledAt: LINEAGE_FIXTURE_TIME, + ...(resultOrError ?? {}), + reconciliationId: input.reconciliationId, + coverageDecisionDigest: + input.decision.decisionDigest, + lifecycleContextKey: + input.decision.lifecycleContextKey, + sequence: input.decision.sequence + })); + return { + loadObservationContext: jest.fn(), + loadReconciliationContext: + jest.fn().mockResolvedValue(context), + observe: jest.fn(), + reconcile + } as unknown as SastFindingLineageStore & { + observe: jest.Mock; + reconcile: jest.Mock; + }; +} diff --git a/apps/api/test/support/sast-finding-lineage-fixtures.ts b/apps/api/test/support/sast-finding-lineage-fixtures.ts new file mode 100644 index 0000000..785dda6 --- /dev/null +++ b/apps/api/test/support/sast-finding-lineage-fixtures.ts @@ -0,0 +1,407 @@ +import { createHash } from 'node:crypto'; + +import { + OPENGREP_SARIF_NORMALIZER_VERSION, + SAST_APPROVED_PROFILE_DIGESTS, + SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + SAST_FINDING_RENAME_ATTESTATION_VERSION, + SAST_SECRET_REDACTION_BATCH_INSPECTED_FIELD_COUNT, + SAST_SECRET_REDACTION_TOKEN, + SAST_SECRET_REDACTION_VERSION, + buildSastFindingLifecycleContextPreimage, + canonicalizeSastFindingLifecycleCoverageDecision, + canonicalizeSastFindingRenameAttestation, + canonicalizeSastSecretRedactionBatch, + canonicalizeSastSecretRedactionDecision, + compareSastNormalizedFindingCandidates, + type SastFindingLifecycleCoverageDecision, + type SastFindingLifecycleCoverageDecisionCore, + type SastFindingLocation, + type SastFindingRenameAttestation, + type SastFindingRenameAttestationCore, + type SastFingerprintedFindingBatch, + type SastSecretRedactedFindingCandidate, + type SastSecretRedactionBatch, + type SastSecretRedactionBatchCore +} from '@aegisai/shared'; + +import { + SastFindingIdentityService +} from '../../src/scan-plane/sast-finding-identity.service'; +import type { + SastFindingLineageScanContext, + SastFindingReconciliationScanContext +} from '../../src/scan-plane/sast-finding-lineage.store'; + +export const LINEAGE_FIXTURE_TIME = + '2026-07-30T00:00:00.000Z'; +export const LINEAGE_RETENTION_EXPIRES_AT = + '2026-08-01T00:00:00.000Z'; +export const LINEAGE_DIGEST = fixtureDigest('fixture'); + +type OpenGrepRedactedFinding = Extract< + SastSecretRedactedFindingCandidate, + { capability: 'SAST' } +>; + +export async function fingerprintedFindingBatch( + findingOverrides: readonly Partial[] = [ + {} + ] +): Promise { + const source = redactedBatch( + findingOverrides.map((overrides) => + redactedFinding(overrides) + ) + ); + const result = await new SastFindingIdentityService().construct( + { batch: source }, + lineageFixtureClock + ); + if (result.outcome !== 'FINGERPRINTED') { + throw new Error('The finding-lineage fixture is invalid.'); + } + return result.batch; +} + +export function lineageObservationContext( + batch: Readonly +): SastFindingLineageScanContext { + return { + scope: { ...batch.scope }, + targetRef: 'refs/heads/main', + lane: batch.lane, + commitSha: batch.commitSha, + canonicalScanKey: batch.canonicalScanKey, + planDigest: batch.planDigest, + profileId: 'JAVA_FAST_V1', + profileDigest: + SAST_APPROVED_PROFILE_DIGESTS.JAVA_FAST_V1, + scanner: batch.scanner, + source: { + ingestionId: batch.ingestionId, + scannerVersion: batch.scannerVersion, + scannerImageDigest: batch.scannerImageDigest, + ...(batch.ruleBundleDigest + ? { ruleBundleDigest: batch.ruleBundleDigest } + : {}), + ...(batch.vulnerabilityDatabaseDigest + ? { + vulnerabilityDatabaseDigest: + batch.vulnerabilityDatabaseDigest + } + : {}), + schemaBundleDigest: batch.schemaBundleDigest, + normalizerBundleDigest: batch.normalizerBundleDigest, + preflightAttestationRef: + batch.preflightAttestationRef, + preflightInventoryDigest: + batch.preflightInventoryDigest, + artifactSchema: batch.artifactSchema, + artifactSchemaVersion: batch.artifactSchemaVersion, + envelopeDigest: batch.envelopeDigest, + artifactDigest: batch.artifactDigest, + validationResultDigest: + batch.validationResultDigest, + dispositionDecisionDigest: + batch.dispositionDecisionDigest, + retentionExpiresAt: batch.retentionExpiresAt + } + }; +} + +export function lineageContextKey( + context: Readonly +): `sha256:${string}` { + return fixtureDigest( + buildSastFindingLifecycleContextPreimage({ + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + targetRef: context.targetRef + }) + ); +} + +export function renameAttestation( + context: Readonly, + overrides: Partial = {} +): SastFindingRenameAttestation { + const core: SastFindingRenameAttestationCore = { + version: SAST_FINDING_RENAME_ATTESTATION_VERSION, + tenantId: context.scope.tenantId, + repositoryBindingId: + context.scope.repositoryBindingId, + lifecycleContextKey: lineageContextKey(context), + fromScanRequestId: 'scan-0', + fromCommitSha: 'b'.repeat(40), + toScanRequestId: context.scope.scanRequestId, + toCommitSha: context.commitSha, + profileId: context.profileId, + profileDigest: context.profileDigest, + entries: [ + { + fromNormalizedPath: 'src/old-config.ts', + toNormalizedPath: 'src/config.ts' + } + ], + issuedAt: '2026-07-29T23:59:00.000Z', + attestationRef: 'rename-attestation://scan-0/scan-1', + signatureRef: 'signature://rename-attestation-1', + provenanceRef: 'provenance://rename-attestation-1', + ...overrides + }; + return { + ...core, + attestationDigest: fixtureDigest( + canonicalizeSastFindingRenameAttestation(core) + ) + }; +} + +export function reconciliationContext(): SastFindingReconciliationScanContext { + return { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + targetRef: 'refs/heads/main', + lane: 'FAST', + commitSha: 'a'.repeat(40), + canonicalScanKey: LINEAGE_DIGEST, + planDigest: LINEAGE_DIGEST, + profileId: 'JAVA_FAST_V1', + profileDigest: + SAST_APPROVED_PROFILE_DIGESTS.JAVA_FAST_V1 + }; +} + +export function lifecycleCoverageDecision( + overrides: Partial = {} +): SastFindingLifecycleCoverageDecision { + const context = reconciliationContext(); + const core: SastFindingLifecycleCoverageDecisionCore = { + version: SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + tenantId: context.tenantId, + repositoryBindingId: context.repositoryBindingId, + scanRequestId: context.scanRequestId, + attemptId: context.attemptId, + canonicalScanKey: context.canonicalScanKey, + planDigest: context.planDigest, + commitSha: context.commitSha, + lifecycleContextKey: fixtureDigest( + buildSastFindingLifecycleContextPreimage({ + tenantId: context.tenantId, + repositoryBindingId: context.repositoryBindingId, + targetRef: context.targetRef + }) + ), + profileId: context.profileId, + profileDigest: context.profileDigest, + state: 'COMPLETE', + stale: false, + comparable: true, + sequence: 1, + previousScanRequestId: 'scan-0', + previousCommitSha: 'b'.repeat(40), + completeCapabilities: ['SAST'], + eligibleLineageIds: [ + `finding-lineage://${'1'.repeat(64)}` + ], + expectedObservationBatchDigests: [ + fixtureDigest('identity-batch') + ], + sourceCoverageDecisionDigest: + fixtureDigest('source-coverage'), + sourceCoverageDecisionRef: 'coverage://scan-1', + completedAt: '2026-07-29T23:58:00.000Z', + decidedAt: '2026-07-29T23:59:00.000Z', + ...overrides + }; + return { + ...core, + decisionDigest: fixtureDigest( + canonicalizeSastFindingLifecycleCoverageDecision(core) + ) + }; +} + +export function lineageFixtureClock(): Date { + return new Date(LINEAGE_FIXTURE_TIME); +} + +export function fixtureDigest( + value: string +): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update(value, 'utf8') + .digest('hex')}`; +} + +function redactedFinding( + overrides: Partial +): OpenGrepRedactedFinding { + const baseFinding = { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1', + planDigest: LINEAGE_DIGEST, + canonicalScanKey: LINEAGE_DIGEST, + preflightAttestationRef: 'preflight://attempt-1', + preflightInventoryDigest: LINEAGE_DIGEST, + commitSha: 'a'.repeat(40), + lane: 'FAST', + capability: 'SAST', + title: `${SAST_SECRET_REDACTION_TOKEN} exposure`, + description: 'A credential-like value was removed.', + severity: 'HIGH', + confidence: 'HIGH', + cweIds: ['CWE-798'], + cveIds: [], + location: { + kind: 'FILE', + normalizedPath: 'src/config.ts', + lineStart: 4, + lineEnd: 4 + } satisfies SastFindingLocation, + identityMaterial: { + ruleSemanticId: 'javascript.hardcoded-secret', + symbolAnchor: '', + sinkKind: '', + structuralHash: fixtureDigest('structure'), + scannerMatchBasedId: 'rules.secret:match-1' + }, + provenance: { + scanner: 'OPENGREP', + scannerVersion: '1.22.0', + scannerImageDigest: LINEAGE_DIGEST, + ruleId: 'rules.secret', + ruleRevision: '2026.07.1', + ruleBundleDigest: LINEAGE_DIGEST, + artifactDigest: LINEAGE_DIGEST + }, + notes: [], + redaction: { + version: SAST_SECRET_REDACTION_VERSION, + secretRedactionApplied: true, + replacementToken: SAST_SECRET_REDACTION_TOKEN, + inspectedFieldCount: 10, + redactedFields: ['TITLE'], + replacementCount: 1, + detectorKinds: ['GITHUB_TOKEN'], + secretValueStored: false, + matchedValueDigestStored: false, + rawCandidateStored: false, + decisionDigest: LINEAGE_DIGEST, + decisionRef: + `redaction://${SAST_SECRET_REDACTION_VERSION}/${'a'.repeat(64)}` + }, + durablePersistenceAllowed: false + } satisfies OpenGrepRedactedFinding; + const finding: OpenGrepRedactedFinding = { + ...baseFinding, + ...overrides + }; + const { + decisionDigest: _decisionDigest, + decisionRef: _decisionRef, + ...redactionCore + } = finding.redaction; + void _decisionDigest; + void _decisionRef; + const decisionDigest = fixtureDigest( + canonicalizeSastSecretRedactionDecision({ + ...finding, + redaction: redactionCore + }) + ); + finding.redaction.decisionDigest = decisionDigest; + finding.redaction.decisionRef = + `redaction://${SAST_SECRET_REDACTION_VERSION}/${decisionDigest.slice( + 'sha256:'.length + )}`; + return finding; +} + +function redactedBatch( + sourceFindings: OpenGrepRedactedFinding[] +): SastSecretRedactionBatch { + const findings = [...sourceFindings].sort((left, right) => + compareSastNormalizedFindingCandidates(left, right) + ); + const first = findings[0]; + const batchCore: SastSecretRedactionBatchCore = { + version: SAST_SECRET_REDACTION_VERSION, + outcome: 'REDACTED', + sourceAdapterVersion: OPENGREP_SARIF_NORMALIZER_VERSION, + artifactSchema: 'OPENGREP_SARIF', + artifactSchemaVersion: '2.1.0', + ingestionId: 'ingestion-1', + scope: { + tenantId: first?.tenantId ?? 'tenant-1', + repositoryBindingId: + first?.repositoryBindingId ?? 'repository-1', + scanRequestId: first?.scanRequestId ?? 'scan-1', + attemptId: first?.attemptId ?? 'attempt-1', + scannerRunId: first?.scannerRunId ?? 'scanner-run-1' + }, + scannerRunId: first?.scannerRunId ?? 'scanner-run-1', + scanner: 'OPENGREP', + scannerVersion: + first?.provenance.scannerVersion ?? '1.22.0', + scannerImageDigest: + first?.provenance.scannerImageDigest ?? LINEAGE_DIGEST, + ruleBundleDigest: + first?.provenance.ruleBundleDigest ?? LINEAGE_DIGEST, + planDigest: first?.planDigest ?? LINEAGE_DIGEST, + canonicalScanKey: + first?.canonicalScanKey ?? LINEAGE_DIGEST, + preflightAttestationRef: + first?.preflightAttestationRef ?? + 'preflight://attempt-1', + preflightInventoryDigest: + first?.preflightInventoryDigest ?? LINEAGE_DIGEST, + lane: first?.lane ?? 'FAST', + commitSha: first?.commitSha ?? 'a'.repeat(40), + envelopeDigest: fixtureDigest('envelope'), + artifactDigest: + first?.provenance.artifactDigest ?? LINEAGE_DIGEST, + schemaBundleDigest: fixtureDigest('schema'), + normalizerBundleDigest: fixtureDigest('normalizer'), + validationResultDigest: fixtureDigest('validation'), + dispositionDecisionDigest: fixtureDigest('disposition'), + retentionExpiresAt: LINEAGE_RETENTION_EXPIRES_AT, + findings, + redaction: { + version: SAST_SECRET_REDACTION_VERSION, + secretRedactionApplied: true, + candidateCount: findings.length, + batchInspectedFieldCount: + SAST_SECRET_REDACTION_BATCH_INSPECTED_FIELD_COUNT, + inspectedFieldCount: + findings.reduce( + (sum, finding) => + sum + finding.redaction.inspectedFieldCount, + 0 + ) + SAST_SECRET_REDACTION_BATCH_INSPECTED_FIELD_COUNT, + redactedCandidateCount: findings.length, + redactedFieldCount: findings.length, + replacementCount: findings.length, + detectorKinds: + findings.length === 0 ? [] : ['GITHUB_TOKEN'], + secretValuesStored: false, + matchedValueDigestsStored: false, + rawCandidatesStored: false, + sourceCandidateDigestStored: false + }, + durablePersistenceAllowed: false + }; + return { + ...batchCore, + batchDigest: fixtureDigest( + canonicalizeSastSecretRedactionBatch(batchCore) + ) + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fbf5e43..204f73a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,6 +18,7 @@ export * from './types/sast-trivy-normalization'; export * from './types/sast-sbom-inventory'; export * from './types/sast-secret-redaction'; export * from './types/sast-finding-identity'; +export * from './types/sast-finding-lineage'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/sast-finding-lineage.ts b/packages/shared/src/types/sast-finding-lineage.ts new file mode 100644 index 0000000..eb64d08 --- /dev/null +++ b/packages/shared/src/types/sast-finding-lineage.ts @@ -0,0 +1,1222 @@ +import { + SAST_CAPABILITIES, + SAST_FINDING_FINGERPRINT_VERSION, + SAST_PROFILE_IDS, + buildFindingFingerprintPreimage, + type FindingFingerprintInput, + type SastCapability, + type SastProfileId +} from './sast-runtime'; +import type { + SastFindingFingerprintDecision, + SastFingerprintedFinding +} from './sast-finding-identity'; +import { + hasExactKeys, + isRecord, + isSha256Digest +} from './sast-normalization-validation'; + +export const SAST_FINDING_LINEAGE_VERSION = + 'sast-finding-lineage-v1' as const; +export const SAST_FINDING_LIFECYCLE_CONTEXT_VERSION = + 'sast-finding-lifecycle-context-v1' as const; +export const SAST_FINDING_RENAME_ATTESTATION_VERSION = + 'sast-finding-rename-attestation-v1' as const; +export const SAST_FINDING_LIFECYCLE_COVERAGE_VERSION = + 'sast-finding-lifecycle-coverage-v1' as const; + +export const SAST_FINDING_LINEAGE_LIMITS = Object.freeze({ + maximumFindings: 25_000, + maximumRenameEntries: 25_000, + maximumEligibleLineages: 25_000, + maximumObservationBatches: 16, + maximumReferenceUtf8Bytes: 2_048, + maximumTargetRefUtf8Bytes: 2_048, + maximumNormalizedPathUtf8Bytes: 4_096, + yieldFindingInterval: 64 +}); + +export const SAST_FINDING_LINEAGE_REJECTION_REASON_CODES = [ + 'FINDING_LINEAGE_INPUT_INVALID', + 'FINDING_LINEAGE_RETENTION_INVALID', + 'FINDING_LINEAGE_RETENTION_EXPIRED', + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID', + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID', + 'FINDING_LINEAGE_RENAME_AUTHORITY_UNAVAILABLE', + 'FINDING_LINEAGE_RENAME_AMBIGUOUS', + 'FINDING_LINEAGE_REPLAY_CONFLICT', + 'FINDING_LINEAGE_COVERAGE_AUTHORITY_UNAVAILABLE', + 'FINDING_LINEAGE_COVERAGE_DECISION_INVALID', + 'FINDING_LINEAGE_SCAN_NOT_COMPARABLE', + 'FINDING_LINEAGE_SCAN_STALE', + 'FINDING_LINEAGE_SCAN_INCOMPLETE', + 'FINDING_LINEAGE_RECONCILIATION_OUT_OF_ORDER', + 'FINDING_LINEAGE_OBSERVATION_INCOMPLETE', + 'FINDING_LINEAGE_PERSISTENCE_FAILED' +] as const; +export type SastFindingLineageRejectionReasonCode = + (typeof SAST_FINDING_LINEAGE_REJECTION_REASON_CODES)[number]; + +export const SAST_FINDING_LIFECYCLE_STATUSES = [ + 'OPEN', + 'FIXED' +] as const; +export type SastFindingLifecycleStatus = + (typeof SAST_FINDING_LIFECYCLE_STATUSES)[number]; + +export const SAST_FINDING_LIFECYCLE_EVENT_KINDS = [ + 'CREATED', + 'RENAMED', + 'FIXED', + 'REOPENED' +] as const; +export type SastFindingLifecycleEventKind = + (typeof SAST_FINDING_LIFECYCLE_EVENT_KINDS)[number]; + +export type SastFindingLineageCanonicalDigester = ( + canonicalValue: string +) => `sha256:${string}`; + +export interface SastFindingLifecycleContextInput { + tenantId: string; + repositoryBindingId: string; + targetRef: string; +} + +export interface SastFindingLineageKeyInput { + tenantId: string; + repositoryBindingId: string; + capability: FindingFingerprintInput['capability']; + fingerprintVersion: typeof SAST_FINDING_FINGERPRINT_VERSION; + stableFingerprint: `sha256:${string}`; +} + +export interface SastFindingRenameEntry { + fromNormalizedPath: string; + toNormalizedPath: string; +} + +export interface SastFindingRenameCandidate { + capability: Exclude; + currentStableFingerprint: `sha256:${string}`; + previousStableFingerprint: `sha256:${string}`; + fromNormalizedPath: string; + toNormalizedPath: string; +} + +export interface SastFindingRenameAttestation { + version: typeof SAST_FINDING_RENAME_ATTESTATION_VERSION; + tenantId: string; + repositoryBindingId: string; + lifecycleContextKey: `sha256:${string}`; + fromScanRequestId: string; + fromCommitSha: string; + toScanRequestId: string; + toCommitSha: string; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + entries: SastFindingRenameEntry[]; + issuedAt: string; + attestationRef: string; + signatureRef: string; + provenanceRef: string; + attestationDigest: `sha256:${string}`; +} + +export type SastFindingRenameAttestationCore = Omit< + SastFindingRenameAttestation, + 'attestationDigest' +>; + +export interface SastFindingLifecycleCoverageDecision { + version: typeof SAST_FINDING_LIFECYCLE_COVERAGE_VERSION; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + canonicalScanKey: `sha256:${string}`; + planDigest: `sha256:${string}`; + commitSha: string; + lifecycleContextKey: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + state: 'COMPLETE'; + stale: false; + comparable: true; + sequence: number; + previousScanRequestId: string; + previousCommitSha: string; + completeCapabilities: Exclude[]; + eligibleLineageIds: string[]; + expectedObservationBatchDigests: `sha256:${string}`[]; + sourceCoverageDecisionDigest: `sha256:${string}`; + sourceCoverageDecisionRef: string; + completedAt: string; + decidedAt: string; + decisionDigest: `sha256:${string}`; +} + +export type SastFindingLifecycleCoverageDecisionCore = Omit< + SastFindingLifecycleCoverageDecision, + 'decisionDigest' +>; + +export interface SastFindingLineageAuthority { + normalizedFindingPersistenceAuthority: true; + occurrenceAuthority: true; + lifecycleAuthority: true; + renameAuthority: true; + correlationAuthority: false; + coverageCalculationAuthority: false; + evidenceAuthority: false; + policyAuthority: false; + publicationAuthority: false; + aiPayloadEligible: false; +} + +export interface SastFindingLineageObservationResult { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'OBSERVED'; + operation: 'OBSERVE'; + observationBatchId: string; + sourceIdentityBatchDigest: `sha256:${string}`; + lifecycleContextKey: `sha256:${string}`; + findingCount: number; + occurrenceCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + replayed: boolean; + observedAt: string; + authority: SastFindingLineageAuthority; + resultDigest: `sha256:${string}`; +} + +export type SastFindingLineageObservationResultCore = Omit< + SastFindingLineageObservationResult, + 'resultDigest' +>; + +export interface SastFindingLifecycleReconciliationResult { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'RECONCILED'; + operation: 'RECONCILE'; + reconciliationId: string; + coverageDecisionDigest: `sha256:${string}`; + lifecycleContextKey: `sha256:${string}`; + sequence: number; + eligibleLineageCount: number; + observedLineageCount: number; + fixedCount: number; + reopenedCount: number; + unchangedOpenCount: number; + unchangedFixedCount: number; + replayed: boolean; + reconciledAt: string; + authority: SastFindingLineageAuthority; + resultDigest: `sha256:${string}`; +} + +export type SastFindingLifecycleReconciliationResultCore = Omit< + SastFindingLifecycleReconciliationResult, + 'resultDigest' +>; + +export type SastFindingLineageOperation = + | 'OBSERVE' + | 'RECONCILE'; + +export interface SastFindingLineageRejection { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'REJECTED'; + operation: SastFindingLineageOperation; + reasonCodes: SastFindingLineageRejectionReasonCode[]; + sourceBatchDigestStored: false; + sourceFindingStored: false; + renamePathsStored: false; + eligibleLineageIdsStored: false; + secretValueStored: false; + rejectionDigest: `sha256:${string}`; +} + +export type SastFindingLineageRejectionCore = Omit< + SastFindingLineageRejection, + 'rejectionDigest' +>; + +export type SastFindingLineageObservationOutcome = + | SastFindingLineageObservationResult + | SastFindingLineageRejection; + +export type SastFindingLifecycleReconciliationOutcome = + | SastFindingLifecycleReconciliationResult + | SastFindingLineageRejection; + +export type SastFindingLineageAuditMetadata = + | { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'OBSERVED'; + operation: 'OBSERVE'; + resultDigest: `sha256:${string}`; + observationBatchId: string; + findingCount: number; + occurrenceCount: number; + distinctFingerprintCount: number; + createdLineageCount: number; + exactMatchCount: number; + renamedMatchCount: number; + replayed: boolean; + } + | { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'RECONCILED'; + operation: 'RECONCILE'; + resultDigest: `sha256:${string}`; + reconciliationId: string; + sequence: number; + eligibleLineageCount: number; + observedLineageCount: number; + fixedCount: number; + reopenedCount: number; + replayed: boolean; + } + | { + version: typeof SAST_FINDING_LINEAGE_VERSION; + outcome: 'REJECTED'; + operation: SastFindingLineageOperation; + reasonCodes: SastFindingLineageRejectionReasonCode[]; + rejectionDigest: `sha256:${string}`; + }; + +const FINDING_CAPABILITIES = SAST_CAPABILITIES.filter( + (capability): capability is Exclude => + capability !== 'SBOM' +); +const UTF8_ENCODER = new TextEncoder(); + +const AUTHORITY: Readonly = + Object.freeze({ + normalizedFindingPersistenceAuthority: true, + occurrenceAuthority: true, + lifecycleAuthority: true, + renameAuthority: true, + correlationAuthority: false, + coverageCalculationAuthority: false, + evidenceAuthority: false, + policyAuthority: false, + publicationAuthority: false, + aiPayloadEligible: false + }); + +export function buildSastFindingLifecycleContextPreimage( + input: Readonly +): string { + return `${SAST_FINDING_LIFECYCLE_CONTEXT_VERSION}\0${[ + input.tenantId, + input.repositoryBindingId, + input.targetRef + ] + .map(encodeCanonicalField) + .join('')}`; +} + +export function isSastFindingLifecycleContextInputValid( + value: unknown +): value is SastFindingLifecycleContextInput { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'targetRef' + ]) && + isBoundedReference(value.tenantId) && + isBoundedReference(value.repositoryBindingId) && + isBoundedTargetRef(value.targetRef) + ); +} + +export function buildSastFindingLineageKeyPreimage( + input: Readonly +): string { + return `${SAST_FINDING_LINEAGE_VERSION}\0${[ + input.tenantId, + input.repositoryBindingId, + input.capability, + input.fingerprintVersion, + input.stableFingerprint + ] + .map(encodeCanonicalField) + .join('')}`; +} + +export function projectRenamedSastFindingFingerprintInput( + decision: Readonly, + normalizedPath: string +): FindingFingerprintInput { + return { + repositoryBindingId: + decision.repositoryBindingId.normalize('NFC'), + capability: decision.capability, + ruleSemanticId: decision.ruleSemanticId.normalize('NFC'), + normalizedPath: normalizedPath.normalize('NFC'), + symbolAnchor: decision.symbolAnchor.normalize('NFC'), + sinkKind: decision.sinkKind.normalize('NFC'), + structuralHash: decision.structuralHash.normalize('NFC') + }; +} + +export function buildSastFindingRenameCandidate( + finding: Readonly, + entry: Readonly, + digestFingerprint: SastFindingLineageCanonicalDigester +): SastFindingRenameCandidate | null { + if ( + finding.fingerprint.normalizedPath !== + entry.toNormalizedPath + ) { + return null; + } + return { + capability: finding.capability, + currentStableFingerprint: + finding.fingerprint.stableFingerprint, + previousStableFingerprint: digestFingerprint( + buildFindingFingerprintPreimage( + projectRenamedSastFindingFingerprintInput( + finding.fingerprint, + entry.fromNormalizedPath + ) + ) + ), + fromNormalizedPath: entry.fromNormalizedPath, + toNormalizedPath: entry.toNormalizedPath + }; +} + +export function orderSastFindingRenameCandidates( + candidates: Iterable> +): SastFindingRenameCandidate[] { + const unique = new Map(); + for (const candidate of candidates) { + unique.set(renameCandidateKey(candidate), { + ...candidate + }); + } + return [...unique.values()].sort((left, right) => { + const leftKey = renameCandidateKey(left); + const rightKey = renameCandidateKey(right); + return leftKey < rightKey + ? -1 + : leftKey > rightKey + ? 1 + : 0; + }); +} + +export function canonicalizeSastFindingRenameAttestation( + attestation: Readonly +): string { + return JSON.stringify({ + version: attestation.version, + tenantId: attestation.tenantId, + repositoryBindingId: attestation.repositoryBindingId, + lifecycleContextKey: attestation.lifecycleContextKey, + fromScanRequestId: attestation.fromScanRequestId, + fromCommitSha: attestation.fromCommitSha, + toScanRequestId: attestation.toScanRequestId, + toCommitSha: attestation.toCommitSha, + profileId: attestation.profileId, + profileDigest: attestation.profileDigest, + entries: attestation.entries.map((entry) => ({ + fromNormalizedPath: entry.fromNormalizedPath, + toNormalizedPath: entry.toNormalizedPath + })), + issuedAt: attestation.issuedAt, + attestationRef: attestation.attestationRef, + signatureRef: attestation.signatureRef, + provenanceRef: attestation.provenanceRef + }); +} + +export function canonicalizeSastFindingLifecycleCoverageDecision( + decision: Readonly +): string { + return JSON.stringify({ + version: decision.version, + tenantId: decision.tenantId, + repositoryBindingId: decision.repositoryBindingId, + scanRequestId: decision.scanRequestId, + attemptId: decision.attemptId, + canonicalScanKey: decision.canonicalScanKey, + planDigest: decision.planDigest, + commitSha: decision.commitSha, + lifecycleContextKey: decision.lifecycleContextKey, + profileId: decision.profileId, + profileDigest: decision.profileDigest, + state: 'COMPLETE', + stale: false, + comparable: true, + sequence: decision.sequence, + previousScanRequestId: decision.previousScanRequestId, + previousCommitSha: decision.previousCommitSha, + completeCapabilities: [...decision.completeCapabilities], + eligibleLineageIds: [...decision.eligibleLineageIds], + expectedObservationBatchDigests: [ + ...decision.expectedObservationBatchDigests + ], + sourceCoverageDecisionDigest: + decision.sourceCoverageDecisionDigest, + sourceCoverageDecisionRef: + decision.sourceCoverageDecisionRef, + completedAt: decision.completedAt, + decidedAt: decision.decidedAt + }); +} + +export function canonicalizeSastFindingLineageObservationResult( + result: Readonly +): string { + return JSON.stringify({ + version: result.version, + outcome: 'OBSERVED', + operation: 'OBSERVE', + observationBatchId: result.observationBatchId, + sourceIdentityBatchDigest: + result.sourceIdentityBatchDigest, + lifecycleContextKey: result.lifecycleContextKey, + findingCount: result.findingCount, + occurrenceCount: result.occurrenceCount, + distinctFingerprintCount: + result.distinctFingerprintCount, + createdLineageCount: result.createdLineageCount, + exactMatchCount: result.exactMatchCount, + renamedMatchCount: result.renamedMatchCount, + replayed: result.replayed, + observedAt: result.observedAt, + authority: canonicalAuthority() + }); +} + +export function canonicalizeSastFindingLifecycleReconciliationResult( + result: Readonly +): string { + return JSON.stringify({ + version: result.version, + outcome: 'RECONCILED', + operation: 'RECONCILE', + reconciliationId: result.reconciliationId, + coverageDecisionDigest: result.coverageDecisionDigest, + lifecycleContextKey: result.lifecycleContextKey, + sequence: result.sequence, + eligibleLineageCount: result.eligibleLineageCount, + observedLineageCount: result.observedLineageCount, + fixedCount: result.fixedCount, + reopenedCount: result.reopenedCount, + unchangedOpenCount: result.unchangedOpenCount, + unchangedFixedCount: result.unchangedFixedCount, + replayed: result.replayed, + reconciledAt: result.reconciledAt, + authority: canonicalAuthority() + }); +} + +export function canonicalizeSastFindingLineageRejection( + rejection: Readonly +): string { + return JSON.stringify({ + version: rejection.version, + outcome: 'REJECTED', + operation: rejection.operation, + reasonCodes: [...rejection.reasonCodes], + sourceBatchDigestStored: false, + sourceFindingStored: false, + renamePathsStored: false, + eligibleLineageIdsStored: false, + secretValueStored: false + }); +} + +export function orderSastFindingLineageRejectionReasons( + reasons: Iterable +): SastFindingLineageRejectionReasonCode[] { + const found = new Set(reasons); + return SAST_FINDING_LINEAGE_REJECTION_REASON_CODES.filter( + (reason) => found.has(reason) + ); +} + +export function isSastFindingRenameAttestationShapeValid( + value: unknown, + digestCanonical: SastFindingLineageCanonicalDigester +): value is SastFindingRenameAttestation { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'tenantId', + 'repositoryBindingId', + 'lifecycleContextKey', + 'fromScanRequestId', + 'fromCommitSha', + 'toScanRequestId', + 'toCommitSha', + 'profileId', + 'profileDigest', + 'entries', + 'issuedAt', + 'attestationRef', + 'signatureRef', + 'provenanceRef', + 'attestationDigest' + ]) || + value.version !== + SAST_FINDING_RENAME_ATTESTATION_VERSION || + !isBoundedReference(value.tenantId) || + !isBoundedReference(value.repositoryBindingId) || + !isSha256Digest(value.lifecycleContextKey) || + !isBoundedReference(value.fromScanRequestId) || + !isBoundedReference(value.toScanRequestId) || + value.fromScanRequestId === value.toScanRequestId || + !isCommitSha(value.fromCommitSha) || + !isCommitSha(value.toCommitSha) || + value.fromCommitSha === value.toCommitSha || + !SAST_PROFILE_IDS.includes(value.profileId as SastProfileId) || + !isSha256Digest(value.profileDigest) || + !Array.isArray(value.entries) || + value.entries.length === 0 || + value.entries.length > + SAST_FINDING_LINEAGE_LIMITS.maximumRenameEntries || + !isIsoTimestamp(value.issuedAt) || + !isBoundedReference(value.attestationRef) || + !isBoundedReference(value.signatureRef) || + !isBoundedReference(value.provenanceRef) || + !isSha256Digest(value.attestationDigest) + ) { + return false; + } + + const entries = value.entries as unknown[]; + const fromPaths = new Set(); + const toPaths = new Set(); + let previousKey: string | undefined; + for (const candidate of entries) { + if ( + !isRecord(candidate) || + !hasExactKeys(candidate, [ + 'fromNormalizedPath', + 'toNormalizedPath' + ]) || + !isSafeNormalizedPath(candidate.fromNormalizedPath) || + !isSafeNormalizedPath(candidate.toNormalizedPath) || + candidate.fromNormalizedPath === candidate.toNormalizedPath + ) { + return false; + } + const from = candidate.fromNormalizedPath; + const to = candidate.toNormalizedPath; + const key = JSON.stringify([from, to]); + if ( + fromPaths.has(from) || + toPaths.has(to) || + (previousKey !== undefined && previousKey >= key) + ) { + return false; + } + fromPaths.add(from); + toPaths.add(to); + previousKey = key; + } + if ([...fromPaths].some((path) => toPaths.has(path))) { + return false; + } + + const attestation = + value as unknown as SastFindingRenameAttestation; + const { + attestationDigest, + ...core + } = attestation; + return canonicalDigestMatches( + digestCanonical, + canonicalizeSastFindingRenameAttestation(core), + attestationDigest + ); +} + +export function isSastFindingLifecycleCoverageDecisionShapeValid( + value: unknown, + digestCanonical: SastFindingLineageCanonicalDigester +): value is SastFindingLifecycleCoverageDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'canonicalScanKey', + 'planDigest', + 'commitSha', + 'lifecycleContextKey', + 'profileId', + 'profileDigest', + 'state', + 'stale', + 'comparable', + 'sequence', + 'previousScanRequestId', + 'previousCommitSha', + 'completeCapabilities', + 'eligibleLineageIds', + 'expectedObservationBatchDigests', + 'sourceCoverageDecisionDigest', + 'sourceCoverageDecisionRef', + 'completedAt', + 'decidedAt', + 'decisionDigest' + ]) || + value.version !== + SAST_FINDING_LIFECYCLE_COVERAGE_VERSION || + !isBoundedReference(value.tenantId) || + !isBoundedReference(value.repositoryBindingId) || + !isBoundedReference(value.scanRequestId) || + !isBoundedReference(value.attemptId) || + !isSha256Digest(value.canonicalScanKey) || + !isSha256Digest(value.planDigest) || + !isCommitSha(value.commitSha) || + !isSha256Digest(value.lifecycleContextKey) || + !SAST_PROFILE_IDS.includes(value.profileId as SastProfileId) || + !isSha256Digest(value.profileDigest) || + value.state !== 'COMPLETE' || + value.stale !== false || + value.comparable !== true || + !Number.isSafeInteger(value.sequence) || + (value.sequence as number) <= 0 || + !isBoundedReference(value.previousScanRequestId) || + value.previousScanRequestId === value.scanRequestId || + !isCommitSha(value.previousCommitSha) || + !Array.isArray(value.completeCapabilities) || + value.completeCapabilities.length === 0 || + !isCanonicalCapabilityList(value.completeCapabilities) || + !Array.isArray(value.eligibleLineageIds) || + value.eligibleLineageIds.length > + SAST_FINDING_LINEAGE_LIMITS.maximumEligibleLineages || + !isSortedUniqueStringArray( + value.eligibleLineageIds, + isFindingLineageId + ) || + !Array.isArray(value.expectedObservationBatchDigests) || + value.expectedObservationBatchDigests.length === 0 || + value.expectedObservationBatchDigests.length > + SAST_FINDING_LINEAGE_LIMITS.maximumObservationBatches || + !isSortedUniqueStringArray( + value.expectedObservationBatchDigests, + isSha256Digest + ) || + !isSha256Digest(value.sourceCoverageDecisionDigest) || + !isBoundedReference(value.sourceCoverageDecisionRef) || + !isIsoTimestamp(value.completedAt) || + !isIsoTimestamp(value.decidedAt) || + Date.parse(value.decidedAt as string) < + Date.parse(value.completedAt as string) || + !isSha256Digest(value.decisionDigest) + ) { + return false; + } + + const decision = + value as unknown as SastFindingLifecycleCoverageDecision; + const { + decisionDigest, + ...core + } = decision; + return canonicalDigestMatches( + digestCanonical, + canonicalizeSastFindingLifecycleCoverageDecision(core), + decisionDigest + ); +} + +export function isSastFindingLineageObservationResultShapeValid( + value: unknown, + digestCanonical: SastFindingLineageCanonicalDigester +): value is SastFindingLineageObservationResult { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'outcome', + 'operation', + 'observationBatchId', + 'sourceIdentityBatchDigest', + 'lifecycleContextKey', + 'findingCount', + 'occurrenceCount', + 'distinctFingerprintCount', + 'createdLineageCount', + 'exactMatchCount', + 'renamedMatchCount', + 'replayed', + 'observedAt', + 'authority', + 'resultDigest' + ]) || + value.version !== SAST_FINDING_LINEAGE_VERSION || + value.outcome !== 'OBSERVED' || + value.operation !== 'OBSERVE' || + !isObservationBatchId(value.observationBatchId) || + !isSha256Digest(value.sourceIdentityBatchDigest) || + !isSha256Digest(value.lifecycleContextKey) || + !isNonNegativeSafeInteger(value.findingCount) || + value.findingCount > + SAST_FINDING_LINEAGE_LIMITS.maximumFindings || + value.occurrenceCount !== value.findingCount || + !isNonNegativeSafeInteger(value.distinctFingerprintCount) || + value.distinctFingerprintCount > value.findingCount || + (value.findingCount > 0 && + value.distinctFingerprintCount === 0) || + !isNonNegativeSafeInteger(value.createdLineageCount) || + !isNonNegativeSafeInteger(value.exactMatchCount) || + !isNonNegativeSafeInteger(value.renamedMatchCount) || + value.createdLineageCount + + value.exactMatchCount + + value.renamedMatchCount !== + value.distinctFingerprintCount || + typeof value.replayed !== 'boolean' || + !isIsoTimestamp(value.observedAt) || + !isAuthorityValid(value.authority) || + !isSha256Digest(value.resultDigest) + ) { + return false; + } + + const result = + value as unknown as SastFindingLineageObservationResult; + const { + resultDigest, + ...core + } = result; + return canonicalDigestMatches( + digestCanonical, + canonicalizeSastFindingLineageObservationResult(core), + resultDigest + ); +} + +export function isSastFindingLifecycleReconciliationResultShapeValid( + value: unknown, + digestCanonical: SastFindingLineageCanonicalDigester +): value is SastFindingLifecycleReconciliationResult { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'outcome', + 'operation', + 'reconciliationId', + 'coverageDecisionDigest', + 'lifecycleContextKey', + 'sequence', + 'eligibleLineageCount', + 'observedLineageCount', + 'fixedCount', + 'reopenedCount', + 'unchangedOpenCount', + 'unchangedFixedCount', + 'replayed', + 'reconciledAt', + 'authority', + 'resultDigest' + ]) || + value.version !== SAST_FINDING_LINEAGE_VERSION || + value.outcome !== 'RECONCILED' || + value.operation !== 'RECONCILE' || + !isReconciliationId(value.reconciliationId) || + !isSha256Digest(value.coverageDecisionDigest) || + !isSha256Digest(value.lifecycleContextKey) || + !Number.isSafeInteger(value.sequence) || + (value.sequence as number) <= 0 || + ![ + value.eligibleLineageCount, + value.observedLineageCount, + value.fixedCount, + value.reopenedCount, + value.unchangedOpenCount, + value.unchangedFixedCount + ].every(isNonNegativeSafeInteger) || + (value.eligibleLineageCount as number) > + SAST_FINDING_LINEAGE_LIMITS.maximumEligibleLineages || + (value.observedLineageCount as number) > + (value.eligibleLineageCount as number) || + (value.fixedCount as number) + + (value.reopenedCount as number) + + (value.unchangedOpenCount as number) + + (value.unchangedFixedCount as number) !== + (value.eligibleLineageCount as number) || + (value.reopenedCount as number) + + (value.unchangedOpenCount as number) !== + (value.observedLineageCount as number) || + (value.fixedCount as number) + + (value.unchangedFixedCount as number) !== + (value.eligibleLineageCount as number) - + (value.observedLineageCount as number) || + typeof value.replayed !== 'boolean' || + !isIsoTimestamp(value.reconciledAt) || + !isAuthorityValid(value.authority) || + !isSha256Digest(value.resultDigest) + ) { + return false; + } + + const result = + value as unknown as SastFindingLifecycleReconciliationResult; + const { + resultDigest, + ...core + } = result; + return canonicalDigestMatches( + digestCanonical, + canonicalizeSastFindingLifecycleReconciliationResult(core), + resultDigest + ); +} + +export function isSastFindingLineageRejectionShapeValid( + value: unknown, + digestCanonical: SastFindingLineageCanonicalDigester +): value is SastFindingLineageRejection { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'outcome', + 'operation', + 'reasonCodes', + 'sourceBatchDigestStored', + 'sourceFindingStored', + 'renamePathsStored', + 'eligibleLineageIdsStored', + 'secretValueStored', + 'rejectionDigest' + ]) || + value.version !== SAST_FINDING_LINEAGE_VERSION || + value.outcome !== 'REJECTED' || + !isLineageOperation(value.operation) || + !Array.isArray(value.reasonCodes) || + value.reasonCodes.length === 0 || + value.reasonCodes.length > + SAST_FINDING_LINEAGE_REJECTION_REASON_CODES.length || + value.sourceBatchDigestStored !== false || + value.sourceFindingStored !== false || + value.renamePathsStored !== false || + value.eligibleLineageIdsStored !== false || + value.secretValueStored !== false || + !isSha256Digest(value.rejectionDigest) + ) { + return false; + } + const reasonCodes = + value.reasonCodes as SastFindingLineageRejectionReasonCode[]; + const orderedReasonCodes = + orderSastFindingLineageRejectionReasons(reasonCodes); + if ( + reasonCodes.length !== orderedReasonCodes.length || + reasonCodes.some( + (reason, index) => + reason !== orderedReasonCodes[index] + ) + ) { + return false; + } + + const rejection = + value as unknown as SastFindingLineageRejection; + const { + rejectionDigest, + ...core + } = rejection; + return canonicalDigestMatches( + digestCanonical, + canonicalizeSastFindingLineageRejection(core), + rejectionDigest + ); +} + +export function toSastFindingLineageAuditMetadata( + result: + | Readonly + | Readonly, + digestCanonical: SastFindingLineageCanonicalDigester +): SastFindingLineageAuditMetadata { + if ( + result.outcome === 'REJECTED' && + isSastFindingLineageRejectionShapeValid( + result, + digestCanonical + ) + ) { + return { + version: result.version, + outcome: result.outcome, + operation: result.operation, + reasonCodes: [...result.reasonCodes], + rejectionDigest: result.rejectionDigest + }; + } + if ( + result.outcome === 'OBSERVED' && + isSastFindingLineageObservationResultShapeValid( + result, + digestCanonical + ) + ) { + return { + version: result.version, + outcome: result.outcome, + operation: result.operation, + resultDigest: result.resultDigest, + observationBatchId: result.observationBatchId, + findingCount: result.findingCount, + occurrenceCount: result.occurrenceCount, + distinctFingerprintCount: + result.distinctFingerprintCount, + createdLineageCount: result.createdLineageCount, + exactMatchCount: result.exactMatchCount, + renamedMatchCount: result.renamedMatchCount, + replayed: result.replayed + }; + } + if ( + result.outcome === 'RECONCILED' && + isSastFindingLifecycleReconciliationResultShapeValid( + result, + digestCanonical + ) + ) { + return { + version: result.version, + outcome: result.outcome, + operation: result.operation, + resultDigest: result.resultDigest, + reconciliationId: result.reconciliationId, + sequence: result.sequence, + eligibleLineageCount: result.eligibleLineageCount, + observedLineageCount: result.observedLineageCount, + fixedCount: result.fixedCount, + reopenedCount: result.reopenedCount, + replayed: result.replayed + }; + } + throw new TypeError('SAST finding-lineage result is invalid.'); +} + +export function sastFindingLineageAuthority(): SastFindingLineageAuthority { + return { ...AUTHORITY }; +} + +function canonicalAuthority(): SastFindingLineageAuthority { + return { ...AUTHORITY }; +} + +function isAuthorityValid(value: unknown): boolean { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'normalizedFindingPersistenceAuthority', + 'occurrenceAuthority', + 'lifecycleAuthority', + 'renameAuthority', + 'correlationAuthority', + 'coverageCalculationAuthority', + 'evidenceAuthority', + 'policyAuthority', + 'publicationAuthority', + 'aiPayloadEligible' + ]) && + value.normalizedFindingPersistenceAuthority === true && + value.occurrenceAuthority === true && + value.lifecycleAuthority === true && + value.renameAuthority === true && + value.correlationAuthority === false && + value.coverageCalculationAuthority === false && + value.evidenceAuthority === false && + value.policyAuthority === false && + value.publicationAuthority === false && + value.aiPayloadEligible === false + ); +} + +function isCanonicalCapabilityList(value: unknown[]): boolean { + const candidate = value as string[]; + return ( + candidate.every((capability) => + FINDING_CAPABILITIES.includes( + capability as Exclude + ) + ) && + candidate.every( + (capability, index) => + index === 0 || + FINDING_CAPABILITIES.indexOf( + candidate[index - 1] as Exclude< + SastCapability, + 'SBOM' + > + ) < + FINDING_CAPABILITIES.indexOf( + capability as Exclude + ) + ) + ); +} + +function isSortedUniqueStringArray( + value: unknown[], + predicate: (candidate: unknown) => boolean +): boolean { + return value.every( + (candidate, index) => + predicate(candidate) && + (index === 0 || + (value[index - 1] as string) < (candidate as string)) + ); +} + +function isSafeNormalizedPath(value: unknown): value is string { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.normalize('NFC') || + utf8ByteLength(value) > + SAST_FINDING_LINEAGE_LIMITS.maximumNormalizedPathUtf8Bytes || + hasControlCharacters(value) || + value.includes('\\') || + value.startsWith('/') || + /^[A-Za-z]:/u.test(value) + ) { + return false; + } + const segments = value.split('/'); + return segments.every( + (segment) => + segment.length > 0 && + segment !== '.' && + segment !== '..' + ); +} + +function isBoundedReference(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.normalize('NFC') && + !hasControlCharacters(value) && + utf8ByteLength(value) <= + SAST_FINDING_LINEAGE_LIMITS.maximumReferenceUtf8Bytes + ); +} + +function isBoundedTargetRef(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.normalize('NFC') && + !hasControlCharacters(value) && + utf8ByteLength(value) <= + SAST_FINDING_LINEAGE_LIMITS.maximumTargetRefUtf8Bytes + ); +} + +function isCommitSha(value: unknown): value is string { + return ( + typeof value === 'string' && + /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(value) + ); +} + +function isIsoTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const parsed = Date.parse(value); + return ( + Number.isFinite(parsed) && + new Date(parsed).toISOString() === value + ); +} + +function isFindingLineageId(value: unknown): value is string { + return ( + typeof value === 'string' && + /^finding-lineage:\/\/[a-f0-9]{64}$/u.test(value) + ); +} + +function renameCandidateKey( + candidate: Readonly +): string { + return `${candidate.capability}\0${candidate.currentStableFingerprint}`; +} + +function isObservationBatchId(value: unknown): value is string { + return ( + typeof value === 'string' && + /^finding-observation:\/\/[a-f0-9]{64}$/u.test(value) + ); +} + +function isReconciliationId(value: unknown): value is string { + return ( + typeof value === 'string' && + /^finding-reconciliation:\/\/[a-f0-9]{64}$/u.test(value) + ); +} + +function isLineageOperation( + value: unknown +): value is SastFindingLineageOperation { + return value === 'OBSERVE' || value === 'RECONCILE'; +} + +function isNonNegativeSafeInteger( + value: unknown +): value is number { + return ( + Number.isSafeInteger(value) && + (value as number) >= 0 + ); +} + +function canonicalDigestMatches( + digester: SastFindingLineageCanonicalDigester, + canonicalValue: string, + expected: string +): boolean { + try { + return digester(canonicalValue) === expected; + } catch { + return false; + } +} + +function encodeCanonicalField(value: string): string { + const normalized = value.normalize('NFC'); + return `${utf8ByteLength(normalized)}:${normalized}`; +} + +function utf8ByteLength(value: string): number { + return UTF8_ENCODER.encode(value).byteLength; +} + +function hasControlCharacters(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return ( + codePoint <= 0x1f || + (codePoint >= 0x7f && codePoint <= 0x9f) || + (codePoint >= 0xd800 && codePoint <= 0xdfff) + ); + }); +} diff --git a/packages/shared/test/sast-finding-lineage.test.mjs b/packages/shared/test/sast-finding-lineage.test.mjs new file mode 100644 index 0000000..ab27be8 --- /dev/null +++ b/packages/shared/test/sast-finding-lineage.test.mjs @@ -0,0 +1,756 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + SAST_FINDING_FINGERPRINT_VERSION, + SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + SAST_FINDING_LINEAGE_REJECTION_REASON_CODES, + SAST_FINDING_LINEAGE_VERSION, + SAST_FINDING_RENAME_ATTESTATION_VERSION, + buildFindingFingerprintPreimage, + buildSastFindingLifecycleContextPreimage, + buildSastFindingLineageKeyPreimage, + buildSastFindingRenameCandidate, + canonicalizeSastFindingLifecycleCoverageDecision, + canonicalizeSastFindingLifecycleReconciliationResult, + canonicalizeSastFindingLineageObservationResult, + canonicalizeSastFindingLineageRejection, + canonicalizeSastFindingRenameAttestation, + isSastFindingLifecycleCoverageDecisionShapeValid, + isSastFindingLifecycleContextInputValid, + isSastFindingLifecycleReconciliationResultShapeValid, + isSastFindingLineageObservationResultShapeValid, + isSastFindingLineageRejectionShapeValid, + isSastFindingRenameAttestationShapeValid, + orderSastFindingLineageRejectionReasons, + orderSastFindingRenameCandidates, + projectRenamedSastFindingFingerprintInput, + toSastFindingLineageAuditMetadata +} from '../dist/index.js'; + +const DIGEST = `sha256:${'a'.repeat(64)}`; +const OTHER_DIGEST = `sha256:${'b'.repeat(64)}`; +const CONTEXT_KEY = digest( + buildSastFindingLifecycleContextPreimage({ + tenantId: 'tenant-é', + repositoryBindingId: 'repository-1', + targetRef: 'refs/heads/café' + }) +); + +test('pins target-context and lineage keys with NFC UTF-8 framing', () => { + const context = { + tenantId: 'tenant-é', + repositoryBindingId: 'repository-1', + targetRef: 'refs/heads/café' + }; + const decomposed = { + ...context, + tenantId: 'tenant-e\u0301', + targetRef: 'refs/heads/cafe\u0301' + }; + const expectedContext = + 'sast-finding-lifecycle-context-v1\0' + + '9:tenant-é12:repository-116:refs/heads/café'; + + assert.equal( + buildSastFindingLifecycleContextPreimage(context), + expectedContext + ); + assert.equal( + buildSastFindingLifecycleContextPreimage(decomposed), + expectedContext + ); + assert.equal( + isSastFindingLifecycleContextInputValid(context), + true + ); + assert.equal( + isSastFindingLifecycleContextInputValid(decomposed), + false + ); + assert.equal( + isSastFindingLifecycleContextInputValid({ + ...context, + targetRef: `refs/heads/${'a'.repeat(2049)}` + }), + false + ); + assert.notEqual( + digest(buildSastFindingLifecycleContextPreimage(context)), + digest( + buildSastFindingLifecycleContextPreimage({ + ...context, + targetRef: 'refs/heads/release' + }) + ), + 'target lifecycle contexts must remain isolated' + ); + assert.equal( + buildSastFindingLineageKeyPreimage({ + tenantId: 'tenant-é', + repositoryBindingId: 'repository-1', + capability: 'SAST', + fingerprintVersion: SAST_FINDING_FINGERPRINT_VERSION, + stableFingerprint: DIGEST + }), + 'sast-finding-lineage-v1\0' + + '9:tenant-é12:repository-14:SAST19:sast-fingerprint-v1' + + `71:${DIGEST}` + ); +}); + +test('validates a one-to-one, sorted, fixed-commit rename attestation', () => { + const core = renameAttestationCore(); + const attestation = { + ...core, + attestationDigest: digest( + canonicalizeSastFindingRenameAttestation(core) + ) + }; + + assert.equal( + isSastFindingRenameAttestationShapeValid(attestation, digest), + true + ); + const sha256Core = { + ...core, + fromCommitSha: '0'.repeat(64), + toCommitSha: '1'.repeat(64) + }; + assert.equal( + isSastFindingRenameAttestationShapeValid( + { + ...sha256Core, + attestationDigest: digest( + canonicalizeSastFindingRenameAttestation(sha256Core) + ) + }, + digest + ), + true + ); + for (const invalidCore of [ + { + ...core, + entries: [...core.entries].reverse() + }, + { + ...core, + entries: [ + ...core.entries, + { + fromNormalizedPath: 'src/legacy.java', + toNormalizedPath: 'src/renamed/A.java' + } + ] + } + ]) { + assert.equal( + isSastFindingRenameAttestationShapeValid( + { + ...invalidCore, + attestationDigest: digest( + canonicalizeSastFindingRenameAttestation( + invalidCore + ) + ) + }, + digest + ), + false + ); + } +}); + +test('accepts a later canonical rename-back as a new fixed-commit attestation', () => { + const forwardCore = { + ...renameAttestationCore(), + entries: [ + { + fromNormalizedPath: 'src/A.java', + toNormalizedPath: 'src/renamed/A.java' + } + ] + }; + const forward = { + ...forwardCore, + attestationDigest: digest( + canonicalizeSastFindingRenameAttestation(forwardCore) + ) + }; + const reverseCore = { + ...forwardCore, + fromScanRequestId: forwardCore.toScanRequestId, + fromCommitSha: forwardCore.toCommitSha, + toScanRequestId: 'scan-2', + toCommitSha: '2'.repeat(40), + entries: [ + { + fromNormalizedPath: 'src/renamed/A.java', + toNormalizedPath: 'src/A.java' + } + ], + issuedAt: '2026-07-30T03:00:00.000Z', + attestationRef: 'rename-attestation://scan-1/scan-2', + signatureRef: 'signature://rename/scan-2', + provenanceRef: 'provenance://rename/scan-2' + }; + const reverse = { + ...reverseCore, + attestationDigest: digest( + canonicalizeSastFindingRenameAttestation(reverseCore) + ) + }; + + assert.equal( + isSastFindingRenameAttestationShapeValid(forward, digest), + true + ); + assert.equal( + isSastFindingRenameAttestationShapeValid(reverse, digest), + true + ); +}); + +test('reconstructs only the path component for trusted rename lookup', () => { + const current = { + version: SAST_FINDING_FINGERPRINT_VERSION, + repositoryBindingId: 'repository-1', + capability: 'SAST', + ruleSemanticId: 'java.sql-injection', + normalizedPath: 'src/renamed/A.java', + symbolAnchor: 'com.example.A#run', + sinkKind: 'SQL_EXECUTE', + structuralHash: DIGEST, + stableFingerprint: OTHER_DIGEST, + sourceRedactionDecisionDigest: DIGEST, + unstableCoordinatesIncluded: false, + scannerMatchIdentityAuthoritative: false, + fingerprintPreimageStored: false, + decisionDigest: DIGEST, + decisionRef: `fingerprint://${SAST_FINDING_FINGERPRINT_VERSION}/${'a'.repeat(64)}` + }; + + const predecessor = projectRenamedSastFindingFingerprintInput( + current, + 'src/original/A.java' + ); + assert.deepEqual(predecessor, { + repositoryBindingId: 'repository-1', + capability: 'SAST', + ruleSemanticId: 'java.sql-injection', + normalizedPath: 'src/original/A.java', + symbolAnchor: 'com.example.A#run', + sinkKind: 'SQL_EXECUTE', + structuralHash: DIGEST + }); + assert.notEqual( + buildFindingFingerprintPreimage(predecessor), + buildFindingFingerprintPreimage( + projectRenamedSastFindingFingerprintInput( + current, + current.normalizedPath + ) + ) + ); + + const candidate = buildSastFindingRenameCandidate( + { + capability: 'SAST', + fingerprint: current + }, + { + fromNormalizedPath: 'src/original/A.java', + toNormalizedPath: current.normalizedPath + }, + digest + ); + assert.deepEqual(candidate, { + capability: 'SAST', + currentStableFingerprint: OTHER_DIGEST, + previousStableFingerprint: digest( + buildFindingFingerprintPreimage(predecessor) + ), + fromNormalizedPath: 'src/original/A.java', + toNormalizedPath: 'src/renamed/A.java' + }); + assert.deepEqual( + orderSastFindingRenameCandidates([ + candidate, + { + ...candidate, + currentStableFingerprint: DIGEST + }, + candidate + ]), + [ + { + ...candidate, + currentStableFingerprint: DIGEST + }, + candidate + ] + ); +}); + +test('accepts only complete non-stale comparable lifecycle authority', () => { + const core = coverageDecisionCore(); + const decision = { + ...core, + decisionDigest: digest( + canonicalizeSastFindingLifecycleCoverageDecision(core) + ) + }; + + assert.equal( + isSastFindingLifecycleCoverageDecisionShapeValid(decision, digest), + true + ); + for (const invalidCore of [ + { ...core, state: 'PARTIAL' }, + { ...core, stale: true }, + { ...core, comparable: false }, + { ...core, sequence: 0 }, + { + ...core, + eligibleLineageIds: [ + core.eligibleLineageIds[0], + core.eligibleLineageIds[0] + ] + }, + { + ...core, + expectedObservationBatchDigests: [ + OTHER_DIGEST, + DIGEST + ] + } + ]) { + assert.equal( + isSastFindingLifecycleCoverageDecisionShapeValid( + { + ...invalidCore, + decisionDigest: digest( + canonicalizeSastFindingLifecycleCoverageDecision( + invalidCore + ) + ) + }, + digest + ), + false + ); + } +}); + +test('orders bounded zero-payload lineage rejection metadata', () => { + assert.deepEqual( + orderSastFindingLineageRejectionReasons([ + 'FINDING_LINEAGE_PERSISTENCE_FAILED', + 'FINDING_LINEAGE_INPUT_INVALID', + 'FINDING_LINEAGE_INPUT_INVALID' + ]), + [ + 'FINDING_LINEAGE_INPUT_INVALID', + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ] + ); + assert.deepEqual( + SAST_FINDING_LINEAGE_REJECTION_REASON_CODES, + [ + 'FINDING_LINEAGE_INPUT_INVALID', + 'FINDING_LINEAGE_RETENTION_INVALID', + 'FINDING_LINEAGE_RETENTION_EXPIRED', + 'FINDING_LINEAGE_DURABLE_SCOPE_INVALID', + 'FINDING_LINEAGE_RENAME_ATTESTATION_INVALID', + 'FINDING_LINEAGE_RENAME_AUTHORITY_UNAVAILABLE', + 'FINDING_LINEAGE_RENAME_AMBIGUOUS', + 'FINDING_LINEAGE_REPLAY_CONFLICT', + 'FINDING_LINEAGE_COVERAGE_AUTHORITY_UNAVAILABLE', + 'FINDING_LINEAGE_COVERAGE_DECISION_INVALID', + 'FINDING_LINEAGE_SCAN_NOT_COMPARABLE', + 'FINDING_LINEAGE_SCAN_STALE', + 'FINDING_LINEAGE_SCAN_INCOMPLETE', + 'FINDING_LINEAGE_RECONCILIATION_OUT_OF_ORDER', + 'FINDING_LINEAGE_OBSERVATION_INCOMPLETE', + 'FINDING_LINEAGE_PERSISTENCE_FAILED' + ] + ); + + const core = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'REJECTED', + operation: 'OBSERVE', + reasonCodes: [ + 'FINDING_LINEAGE_INPUT_INVALID' + ], + sourceBatchDigestStored: false, + sourceFindingStored: false, + renamePathsStored: false, + eligibleLineageIdsStored: false, + secretValueStored: false + }; + const rejection = { + ...core, + rejectionDigest: digest( + canonicalizeSastFindingLineageRejection(core) + ) + }; + assert.equal( + isSastFindingLineageRejectionShapeValid(rejection, digest), + true + ); + for (const reasonCodes of [ + [ + 'FINDING_LINEAGE_INPUT_INVALID', + 'FINDING_LINEAGE_INPUT_INVALID' + ], + ['FINDING_LINEAGE_UNKNOWN'], + Array.from( + { + length: + SAST_FINDING_LINEAGE_REJECTION_REASON_CODES.length + + 1 + }, + () => 'FINDING_LINEAGE_INPUT_INVALID' + ) + ]) { + const invalidCore = { + ...core, + reasonCodes + }; + assert.equal( + isSastFindingLineageRejectionShapeValid( + { + ...invalidCore, + rejectionDigest: digest( + canonicalizeSastFindingLineageRejection(invalidCore) + ) + }, + digest + ), + false + ); + } + assert.doesNotMatch( + JSON.stringify(rejection), + /repository|scan-1|src\/|fingerprint|artifact|secret-value/u + ); +}); + +test('validates observation result algebra and projects only audit metadata', () => { + const core = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'OBSERVED', + operation: 'OBSERVE', + observationBatchId: + `finding-observation://${'1'.repeat(64)}`, + sourceIdentityBatchDigest: DIGEST, + lifecycleContextKey: CONTEXT_KEY, + findingCount: 5, + occurrenceCount: 5, + distinctFingerprintCount: 2, + createdLineageCount: 1, + exactMatchCount: 1, + renamedMatchCount: 0, + replayed: false, + observedAt: '2026-07-30T02:07:00.000Z', + authority: lineageAuthority() + }; + const result = { + ...core, + resultDigest: digest( + canonicalizeSastFindingLineageObservationResult(core) + ) + }; + assert.equal( + isSastFindingLineageObservationResultShapeValid( + result, + digest + ), + true + ); + assert.deepEqual( + toSastFindingLineageAuditMetadata(result, digest), + { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'OBSERVED', + operation: 'OBSERVE', + resultDigest: result.resultDigest, + observationBatchId: result.observationBatchId, + findingCount: 5, + occurrenceCount: 5, + distinctFingerprintCount: 2, + createdLineageCount: 1, + exactMatchCount: 1, + renamedMatchCount: 0, + replayed: false + } + ); + for (const invalidCore of [ + { + ...core, + occurrenceCount: 4 + }, + { + ...core, + distinctFingerprintCount: 0, + createdLineageCount: 0, + exactMatchCount: 0 + }, + { + ...core, + createdLineageCount: 0 + }, + { + ...core, + authority: { + ...core.authority, + policyAuthority: true + } + } + ]) { + const invalidResult = { + ...invalidCore, + resultDigest: digest( + canonicalizeSastFindingLineageObservationResult( + invalidCore + ) + ) + }; + assert.equal( + isSastFindingLineageObservationResultShapeValid( + invalidResult, + digest + ), + false + ); + assert.throws( + () => + toSastFindingLineageAuditMetadata( + invalidResult, + digest + ), + TypeError + ); + } +}); + +test('rejects invalid audit outcome dispatch', () => { + const core = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'REJECTED', + operation: 'OBSERVE', + reasonCodes: ['FINDING_LINEAGE_INPUT_INVALID'], + sourceBatchDigestStored: false, + sourceFindingStored: false, + renamePathsStored: false, + eligibleLineageIdsStored: false, + secretValueStored: false + }; + const rejection = { + ...core, + rejectionDigest: digest( + canonicalizeSastFindingLineageRejection(core) + ) + }; + assert.deepEqual( + toSastFindingLineageAuditMetadata(rejection, digest), + { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'REJECTED', + operation: 'OBSERVE', + reasonCodes: ['FINDING_LINEAGE_INPUT_INVALID'], + rejectionDigest: rejection.rejectionDigest + } + ); + assert.equal( + isSastFindingLineageRejectionShapeValid( + { + ...rejection, + outcome: 'INVALID' + }, + digest + ), + false + ); + assert.throws( + () => + toSastFindingLineageAuditMetadata( + { + ...rejection, + outcome: 'INVALID' + }, + digest + ), + TypeError + ); +}); + +test('binds reconciliation transition counts to the observed set', () => { + const core = { + version: SAST_FINDING_LINEAGE_VERSION, + outcome: 'RECONCILED', + operation: 'RECONCILE', + reconciliationId: + `finding-reconciliation://${'3'.repeat(64)}`, + coverageDecisionDigest: DIGEST, + lifecycleContextKey: CONTEXT_KEY, + sequence: 1, + eligibleLineageCount: 4, + observedLineageCount: 2, + fixedCount: 1, + reopenedCount: 1, + unchangedOpenCount: 1, + unchangedFixedCount: 1, + replayed: false, + reconciledAt: '2026-07-30T02:07:00.000Z', + authority: lineageAuthority() + }; + const result = { + ...core, + resultDigest: digest( + canonicalizeSastFindingLifecycleReconciliationResult(core) + ) + }; + assert.equal( + isSastFindingLifecycleReconciliationResultShapeValid( + result, + digest + ), + true + ); + const invalidCore = { + ...core, + observedLineageCount: 1 + }; + assert.equal( + isSastFindingLifecycleReconciliationResultShapeValid( + { + ...invalidCore, + resultDigest: digest( + canonicalizeSastFindingLifecycleReconciliationResult( + invalidCore + ) + ) + }, + digest + ), + false + ); + const overLimitCore = { + ...core, + eligibleLineageCount: 25_001, + observedLineageCount: 0, + fixedCount: 25_001, + reopenedCount: 0, + unchangedOpenCount: 0, + unchangedFixedCount: 0 + }; + assert.equal( + isSastFindingLifecycleReconciliationResultShapeValid( + { + ...overLimitCore, + resultDigest: digest( + canonicalizeSastFindingLifecycleReconciliationResult( + overLimitCore + ) + ) + }, + digest + ), + false + ); +}); + +function renameAttestationCore() { + return { + version: SAST_FINDING_RENAME_ATTESTATION_VERSION, + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + lifecycleContextKey: CONTEXT_KEY, + fromScanRequestId: 'scan-0', + fromCommitSha: '0'.repeat(40), + toScanRequestId: 'scan-1', + toCommitSha: '1'.repeat(40), + profileId: 'JAVA_DEEP_V1', + profileDigest: DIGEST, + entries: [ + { + fromNormalizedPath: 'src/A.java', + toNormalizedPath: 'src/renamed/A.java' + }, + { + fromNormalizedPath: 'src/B.java', + toNormalizedPath: 'src/renamed/B.java' + } + ], + issuedAt: '2026-07-30T02:00:00.000Z', + attestationRef: 'rename-attestation://scan-0/scan-1', + signatureRef: 'signature://rename/scan-1', + provenanceRef: 'provenance://rename/scan-1' + }; +} + +function coverageDecisionCore() { + return { + version: SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + canonicalScanKey: DIGEST, + planDigest: OTHER_DIGEST, + commitSha: '1'.repeat(40), + lifecycleContextKey: CONTEXT_KEY, + profileId: 'JAVA_DEEP_V1', + profileDigest: DIGEST, + state: 'COMPLETE', + stale: false, + comparable: true, + sequence: 2, + previousScanRequestId: 'scan-0', + previousCommitSha: '0'.repeat(40), + completeCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION' + ], + eligibleLineageIds: [ + `finding-lineage://${'1'.repeat(64)}`, + `finding-lineage://${'2'.repeat(64)}` + ], + expectedObservationBatchDigests: [ + DIGEST, + OTHER_DIGEST + ], + sourceCoverageDecisionDigest: DIGEST, + sourceCoverageDecisionRef: 'coverage://scan-1/decision', + completedAt: '2026-07-30T02:05:00.000Z', + decidedAt: '2026-07-30T02:06:00.000Z' + }; +} + +function lineageAuthority() { + return { + normalizedFindingPersistenceAuthority: true, + occurrenceAuthority: true, + lifecycleAuthority: true, + renameAuthority: true, + correlationAuthority: false, + coverageCalculationAuthority: false, + evidenceAuthority: false, + policyAuthority: false, + publicationAuthority: false, + aiPayloadEligible: false + }; +} + +function digest(value) { + return `sha256:${createHash('sha256') + .update(value, 'utf8') + .digest('hex')}`; +} diff --git a/packages/shared/test/shared-contract-exports.test.mjs b/packages/shared/test/shared-contract-exports.test.mjs index ae46423..313533c 100644 --- a/packages/shared/test/shared-contract-exports.test.mjs +++ b/packages/shared/test/shared-contract-exports.test.mjs @@ -33,6 +33,10 @@ const files = { '../src/types/sast-finding-identity.ts', import.meta.url ), + sastFindingLineage: new URL( + '../src/types/sast-finding-lineage.ts', + import.meta.url + ), sastPlanning: new URL('../src/types/sast-planning.ts', import.meta.url), sastFetch: new URL('../src/types/sast-fetch.ts', import.meta.url), sastWrapper: new URL('../src/types/sast-wrapper.ts', import.meta.url), @@ -62,6 +66,7 @@ test('shared contract modules exist and are re-exported from the package root', 'sast-normalization', 'sast-secret-redaction', 'sast-finding-identity', + 'sast-finding-lineage', 'sast-planning', 'sast-fetch', 'sast-wrapper' 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 6d4aad8..ac55984 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -923,9 +923,79 @@ aiPayloadEligible Rejections expose only the version, globally ordered coarse reason codes, four negative storage assertions, and their canonical digest. They omit the T035 batch digest, artifact -digest, candidate, fingerprint, fingerprint preimage, and secret value. `ScanPlaneModule` -exports only `SastFindingIdentityService` to the next internal stage; T035 redaction and both -raw normalizers remain internal providers. +digest, candidate, fingerprint, fingerprint preimage, and secret value. T036 remains an +internal provider once T037 is present; T035 redaction and both raw normalizers also remain +internal. + +### Finding lineage and lifecycle gate v1 + +`sast-finding-lineage-v1` accepts exactly one complete +`SastFingerprintedFindingBatch`. It recomputes the T036 batch and every fingerprint decision, +checks the active retention window before and after asynchronous authority work, and reloads +the accepted artifact, completed scanner run, immutable plan, fixed commit, target ref, +profile, scanner/schema/normalizer/rule/database provenance, and all T030/T031 digests from +durable state. Any mismatch rejects before persistence. + +The gate derives: + +- `sast-finding-lifecycle-context-v1` from NFC/UTF-8-length-framed tenant, repository binding, + and target ref +- a `finding-lineage://` identity from tenant, repository, capability, + `sast-fingerprint-v1`, and stable fingerprint +- a deterministic observation batch, one normalized row, and one occurrence per producer + ordinal; repeated fingerprints never collapse occurrences + +Exact alias lookup creates no new lineage. Observation batches are fenced by source identity +digest and scanner run; replay rechecks every immutable batch field and every ordered +occurrence, while any changed, missing, extra, or malformed row is a conflict. All lineage, +alias, occurrence, lifecycle, reconciliation, event, and audit writes use one serializable +transaction with a 5-second acquisition wait, 120-second transaction deadline, and at most +three serialization/unique-race attempts. + +Path continuity is optional and fail-closed. `sast-finding-rename-attestation-v1` requires a +sorted non-empty one-to-one set of safe canonical `from`/`to` paths, distinct fixed commits +and scan requests, exact target/profile/context binding, issued time, signature, provenance, +and canonical digest. Paths cannot duplicate, chain, or cycle. An injected verifier must +return `VERIFIED`; the default returns `UNAVAILABLE`. T037 changes only the path component of +the verified current fingerprint input to look up the predecessor. It retains both aliases +only when the predecessor resolves unambiguously and the durable predecessor scan exists. +No fuzzy title, coordinate, rule, severity, scanner-local match, or AI similarity can rename +a lineage. A verified rename-back reuses the retained exact alias but is still classified as +`RENAMED` and appends a new event; it never inserts a duplicate alias or rewrites history. + +Lifecycle state is unique per lineage and lifecycle context and is separate from the legacy +normalized-finding policy/triage status. Observation creates `OPEN` and appends `CREATED`; +trusted alias continuity appends `RENAMED`. Observing a previously fixed lineage records the +occurrence but does not reopen it. + +`sast-finding-lifecycle-coverage-v1` is an input owned by T039. T037 has +`coverageCalculationAuthority=false` and accepts the decision only when its injected gate +verifies exact `state=COMPLETE`, `stale=false`, and `comparable=true`. Before applying it, +T037 verifies: + +- the current durable in-flight/terminal scan context plus the previous completed scan, same + target context, fixed commits, plan, profile, and strict monotonic reconciliation sequence +- sorted unique eligible lineage IDs scoped to complete capability families +- exact equality between expected batch digests and every durable T037 observation batch for + the current scan, including zero-finding batches +- every relevant observed lineage is eligible and every eligible lineage already has state + in that target context + +An `OPEN` eligible lineage absent from the verified observation set becomes `FIXED`; a +`FIXED` eligible lineage present becomes `OPEN` with `REOPENED`. Unchanged states only advance +the reconciliation fence. Transitions append immutable events with the next state revision. +Partial, stale, incomparable, missing, extra, out-of-order, unavailable, or malformed +coverage cannot change lifecycle. +The lifecycle-context sequence is globally contiguous. A newly created or newly eligible +state may catch up from an earlier non-future state fence during the current verified +reconciliation, while any state fence ahead of the previous global sequence rejects. + +Successful observation authority is limited to normalized-finding persistence, occurrences, +exact/verified-rename lineage, and lifecycle recording. Correlation, coverage calculation, +evidence, policy, publication, and AI eligibility remain false. Rejections expose only the +operation, ordered coarse codes, five negative storage assertions, and rejection digest; they +never echo source batch/finding data, rename paths, eligible lineage IDs, or secrets. +`ScanPlaneModule` exports only `SastFindingLineageService` to T038. ## Correlation Contract diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index 38bc519..f23d1b1 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -512,36 +512,96 @@ Persistence eligibility grants no downstream authority. `occurrenceAuthority`, `policyAuthority`, `publicationAuthority`, and `aiPayloadEligible` remain false. T037 must turn this batch into stable rows and occurrences before the final entity below gains lifecycle state; later coverage, evidence, policy, publication, and AI gates remain mandatory. -`ScanPlaneModule` exports only the T036 identity service to the next internal stage. +Once T037 is installed, `ScanPlaneModule` exports only the lineage service to T038; T036 +identity construction remains internal. -### NormalizedSastFinding +### SastFindingLineage -- immutable `tenantId`, `repositoryBindingId`, `scanRequestId`, `attemptId`, and `commitSha` - attribution -- lane and capability family -- stable fingerprint -- title, bounded description, severity, and confidence -- CWE/CVE identifiers -- normalized location and symbol anchor -- scanner/rule/artifact provenance -- evidence references -- current lifecycle status +One durable platform identity scoped by tenant, repository binding, capability, and +fingerprint version: -Raw descriptions and snippets are never treated as trusted markup. +- deterministic `finding-lineage://` ID from the framed lineage-key preimage +- first stable fingerprint plus immutable `sast-fingerprint-v1` +- first and last observed timestamps +- one-to-many exact identity aliases, occurrences, target-context states, and events -### FindingOccurrence +The lineage is not a policy decision, correlation group, evidence record, or AI object. +Different capabilities never share a lineage. -One observation of a stable finding in a scan. +### SastFindingIdentityAlias -- `findingId` -- `scanRequestId` -- `scannerRunId` -- commit and target context -- current line/column coordinates -- artifact digest +- tenant/repository/capability/fingerprint-version/stable-fingerprint unique key +- canonical normalized path and owning lineage +- optional verified rename-attestation digest only for a newly added path alias +- old aliases remain immutable so historical exact observations retain continuity + +An exact current alias remains authoritative for lineage ownership. When a verified rename +predecessor resolves to that same lineage, the observation is still classified `RENAMED` and +emits the next event even if the current alias was retained from older history, as in a +rename-back. Conflicting current/predecessor aliases, multiple current identities resolving +to one lineage, path chains/cycles, and unverified/future/non-durable rename claims reject +the complete observation. + +### SastFindingObservationBatch + +- deterministic ID and unique tenant/source T036 batch digest plus tenant/scanner-run fence +- immutable tenant/repository/scan/attempt/scanner scope, target ref, fixed commit, lane, + profile, plan/canonical key, and accepted-artifact provenance +- canonical observed-finding capability set (empty for zero findings), optional verified + rename-attestation digest, exact finding and + distinct-identity counts, and created/exact/renamed classification counts - observed timestamp -Occurrences provide history without changing stable identity. +A replay returns the original ledger only when every binding and every ordered persisted +occurrence agree. A changed, missing, extra, or malformed row on the same scanner run is a +conflict. + +### FindingOccurrence + +Every ordered T036 finding becomes one occurrence, including byte-identical repeated +fingerprints: + +- deterministic occurrence ID and unique `(observationBatchId, ordinal)` +- lineage and normalized-finding IDs +- immutable tenant/repository/scan/attempt/scanner attribution +- capability, fingerprint version/value/decision digest +- the sanitized T036 finding object, current location/coordinates, and observed timestamp + +The companion legacy `NormalizedFinding` row receives nullable T037 identity metadata for a +rolling migration. Its `status` remains policy/triage state and is never used as the lifecycle +authority. Raw descriptions and snippets are never treated as trusted markup. + +### SastFindingLifecycleState + +- deterministic state ID unique by tenant, repository, lifecycle-context key, and lineage +- lifecycle context key derived from tenant, repository binding, and NFC target ref +- independent `OPEN | FIXED`, monotonic revision, last observation binding, and last applied + reconciliation sequence +- fixed/reopened timestamps + +Observing a lineage updates observation metadata but never reopens a fixed state. +The lifecycle-context reconciliation sequence is globally contiguous. A state that is newly +created or newly eligible after an earlier reconciliation may advance from any non-future +state sequence to the current global sequence; a state fence ahead of the previous global +sequence rejects. + +### SastFindingLifecycleReconciliation + +- deterministic ID and unique coverage-decision digest +- strict unique `(tenant, repository, lifecycleContextKey, sequence)` +- complete T039 decision, current scan/attempt/profile binding, exact eligible and observed + counts, and transition counts +- reconciled timestamp + +The expected observation-batch digest list must equal every durable T037 batch for the current +scan, including zero-finding batches. Every observed complete-capability lineage must be +eligible, and every eligible lineage must exist in the same target context. + +### SastFindingLifecycleEvent + +Append-only `CREATED | RENAMED | FIXED | REOPENED` with unique state revision, exact source +observation or reconciliation ID, previous/next lifecycle state, and occurred timestamp. +Only `FIXED` and `REOPENED` change lifecycle status. ### FindingCorrelation @@ -665,13 +725,17 @@ SUSPENDED --rollback--> ROLLED_BACK The two incoming edges are `CANARY -> SUSPENDED` and `ACTIVE -> SUSPENDED`; the single recovery edge is `SUSPENDED -> ROLLED_BACK`. -### Finding +### Finding lifecycle ```text -OPEN -> WAIVED | SUPPRESSED | FIXED -FIXED -> OPEN only when a later complete scan observes the same stable fingerprint +OPEN --verified complete absence--> FIXED +FIXED --verified complete observation--> OPEN ``` +Waived, suppressed, accepted, and rejected remain separate policy/triage states. A lifecycle +transition requires a later complete, non-stale, comparable T039 decision with exact durable +observation coverage; a finding observation alone cannot transition state. + ## Retention - Raw scanner artifacts and quarantine objects: maximum seven days. @@ -689,8 +753,14 @@ FIXED -> OPEN only when a later complete scan observes the same stable fingerpri - Unique attempt by `(scanRequestId, attemptNumber)`. - Unique scanner run by `(attemptId, scanner, wrapperVersion)`. - Unique artifact by `(scannerRunId, contentDigest)`. -- Unique durable finding by `(tenantId, repositoryBindingId, stableFingerprint)`. -- Unique occurrence by `(findingId, scanRequestId, scannerRunId, artifactDigest)`. +- Unique identity alias by + `(tenantId, repositoryBindingId, capability, fingerprintVersion, stableFingerprint)`. +- Unique occurrence by `(observationBatchId, ordinal)` and one normalized-finding extension + per occurrence. +- Unique lifecycle state by + `(tenantId, repositoryBindingId, lifecycleContextKey, lineageId)`. +- Unique reconciliation by coverage-decision digest and by lifecycle-context sequence. +- Unique append-only event by `(lifecycleStateId, revision)`. - Tenant-first indexes on every queryable entity. - Expiry indexes on raw artifacts, evidence, quarantine, and AI payload metadata. - Check constraints require non-negative counts, including zero findings, symlinks, archives, diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index d201c6e..ec15b4c 100644 --- a/specs/006-production-sast-runtime-design/plan.md +++ b/specs/006-production-sast-runtime-design/plan.md @@ -99,10 +99,15 @@ canonical identity fields with NFC and UTF-8 byte-length framing, and computes provenance in a fresh persistence-eligible handoff, allows byte-identical repeated observations, and rejects digest collisions, forged inputs, clock rollback, expiry, and over-limit batches. It exports no occurrence, lifecycle, correlation, coverage, evidence, -policy, publication, or AI authority. T037 next represents occurrences separately from -stable findings and implements exact lineage, rename, fixed, and reopen behavior; subsequent -work correlates only compatible authoritative capabilities and marks fixed/reopened only from -later complete, non-stale comparable scans. +policy, publication, or AI authority. T037 now revalidates that complete handoff against +durable scan state, persists one repository-scoped lineage plus every ordered occurrence, +and keeps lifecycle state separate per target ref. Exact aliases update one lineage; a +path-only alias is added only from a verified fixed-commit, one-to-one rename attestation. +Append-only `CREATED`, `RENAMED`, `FIXED`, and `REOPENED` events are serialized with the +observation/reconciliation ledger. Fixed/reopened transitions consume, but never calculate, +a T039-owned complete, non-stale, comparable coverage decision and verify its exact +observation-batch set. T038 next correlates only compatible authoritative capabilities; +coverage, policy, publication, evidence, and AI authority remain in later gates. ### 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 ebe9e25..c8833a3 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -207,6 +207,32 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl digests, or secrets in identity rejection/audit output. - T036 success alone sets normalized-finding persistence eligibility; occurrence, lifecycle, correlation, coverage, evidence, policy, publication, and AI authority remain false. +- 100% `sast-finding-lineage-v1` revalidation of the complete T036 handoff and durable + tenant/repository/scan/scanner-run/fixed-commit/target/profile/artifact/schema/normalizer/ + rule/database/preflight bindings before persistence. +- 100% one-to-one preservation of producer ordinals as immutable occurrences, including + byte-identical repeated fingerprints; no repeated observation may be silently collapsed. +- 100% source-batch replay equality across the canonical observation batch and complete + ordered occurrence ledger. Missing, extra, changed, reordered, malformed, or cross-scope + rows reject without partial writes. +- 100% rename continuity only for verified canonical fixed-commit/fixed-target one-to-one + attestations with an existing unambiguous predecessor alias. Rename-back retains lineage; + fuzzy, chained, cyclic, ambiguous, missing-predecessor, unavailable, or AI claims grant no + continuity. +- 100% target-context isolation and append-only lifecycle revision order for `CREATED`, + `RENAMED`, `FIXED`, and `REOPENED`; legacy policy/triage status remains unchanged. +- Exactly zero `FIXED` or `REOPENED` mutations unless an injected T039-compatible gate + verifies a strictly newer `COMPLETE`, `stale=false`, `comparable=true` decision and exact + expected-digest equality to every durable current-scan T037 observation batch, including + zero-finding batches. +- 100% fail-closed behavior for partial, pending, failed, stale, incomparable, missing, + extra, out-of-order, tampered, or unavailable coverage decisions, plus serializable + lineage/lifecycle writes with no cross-tenant leakage under concurrency. +- T037 authority assertions remain exact: + `coverageCalculationAuthority=false`, `correlationAuthority=false`, + `policyAuthority=false`, `publicationAuthority=false`, and + `aiPayloadEligible=false`; only `SastFindingLineageService` crosses the Scan Plane module + boundary for T038. - 100% provenance preservation during correlation; no lower-severity result may hide a higher-severity authoritative result. - 100% complete-coverage requirement before external comment/block, AI advisory, or fixed diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index a493f07..01fc421 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -360,18 +360,48 @@ portion of Phase 6: exact distinct/repeated counts, and sets normalized-finding persistence eligibility with `durablePersistenceAllowed=true`. Occurrence, lifecycle, correlation, coverage, evidence, policy, publication, and AI authority all remain false until T037 and later gates. -- `ScanPlaneModule` exports only the T036 identity service to the next internal stage. T035 - redaction and raw OpenGrep/Trivy normalizers remain internal providers. There is still no - user route, artifact reader, database writer, evidence, policy, publication, or AI path. +- `sast-finding-lineage-v1` now revalidates that complete T036 handoff and the active + accepted-artifact, scanner-run, immutable-plan, fixed-commit, profile, digest, and retention + scope before a serializable write. One repository/capability/fingerprint-version lineage is + created or reused, while every producer-ordered finding becomes its own immutable + occurrence and legacy normalized-finding row. Batch/scanner-run replay is idempotent only + when the full ledger agrees; changed or incomplete replay fails closed. +- Lifecycle context is the SHA-256 of tenant, repository binding, and NFC target ref. Its + `OPEN|FIXED` state and monotonic revision are separate from + `NormalizedFinding.status`, which remains policy/triage state. `CREATED`, `RENAMED`, + `FIXED`, and `REOPENED` are append-only events; observing a fixed lineage alone never + reopens it. +- Rename continuity accepts only a canonical, signed/provenance-backed, fixed-commit, + one-to-one attestation verified by an injected authority. T037 reconstructs the predecessor + fingerprint by changing the normalized path component only, retains old and new aliases, + and rejects alias collisions, chains/cycles, ambiguous lineages, future attestations, and + missing durable predecessor scans. The production default verifier is unavailable and + therefore fail-closed. +- Fixed/reopened reconciliation accepts only an injected T039 coverage decision with exact + `COMPLETE`, `stale=false`, and `comparable=true`. It rechecks the current/previous durable + scan contexts, strict sequence, eligible lineage scope, capabilities, and the exact sorted + observation-batch digest set before transitions. Partial, stale, incomparable, omitted, + out-of-order, or unavailable coverage cannot mutate lifecycle state. T037 consumes this + authority but has `coverageCalculationAuthority=false`. +- The Prisma rollout adds nullable T037 metadata to legacy `NormalizedFinding`, creates + tenant-scoped lineage, alias, batch, occurrence, target-state, reconciliation, and event + tables, and installs existing-table indexes/checks plus composite foreign keys through the + mandatory online-schema step. All writes run at serializable isolation with a five-second + acquisition wait, 120-second transaction deadline, and at most three bounded retries. +- `ScanPlaneModule` exports only the T037 lineage service to the next internal stage. T036 + identity construction, T035 redaction, and raw OpenGrep/Trivy normalizers remain internal + providers. There is still no user route, artifact reader, evidence, correlation, coverage + calculation, policy, publication, or AI path. 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 runtime provider exist only to verify the handoff contract. Default production credential issuance and scanner execution both fail closed until live rollout installs provider-backed GitHub App/GitLab scoped minting, microVM, artifact object-store/disposition, -file-coordinate-attestation, and acceptance-gate adapters. T035 secret redaction is complete; -T036 `sast-fingerprint-v1` identity construction is also complete; T037 occurrence and exact -lineage lifecycle construction is therefore the next implementation task. +file-coordinate-attestation, and acceptance-gate adapters. T035 secret redaction, T036 +`sast-fingerprint-v1` identity construction, and T037 occurrence/exact-lineage lifecycle +construction are complete; T038 authority-aware cross-tool correlation 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 45f985a..3fdca5a 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -359,3 +359,41 @@ runtime-native string length, inventing a path for `UNKNOWN`, persisting fingerp preimages, de-duplicating repeated observations inside T036, first-writer collision handling, using scanner `matchBasedId` as platform identity, or allowing T036 output directly into evidence, policy, publication, or AI flows. + +## Decision 19: Separate Exact Lineage, Ordered Occurrences, and Target Lifecycle + +**Decision**: `sast-finding-lineage-v1` independently revalidates the complete T036 handoff +against immutable scan state, then persists one global repository/capability/fingerprint +lineage and one immutable occurrence for every producer ordinal. Repeated identical +fingerprints deliberately remain separate occurrences. Source-batch replay is idempotent +only when the canonical batch and its complete ordered occurrence ledger match exactly. +All lineage, alias, occurrence, lifecycle, reconciliation, event, and audit changes run in +one serializable transaction with bounded acquisition, execution, and retry limits. + +Exact fingerprints are the default continuity mechanism. A path change can retain continuity +only when a canonical, signed, fixed-commit, fixed-target, one-to-one +`sast-finding-rename-attestation-v1` is verified and its predecessor alias already resolves +unambiguously. Both aliases remain durable, which supports a later rename-back without +rewriting history. Missing predecessor aliases, ambiguous mappings, chains, cycles, and +fuzzy or AI similarity provide no rename authority. + +Lifecycle state is separate per canonical tenant/repository/target context and separate from +policy or triage status. T037 consumes but never calculates a T039-owned +`sast-finding-lifecycle-coverage-v1` decision. `FIXED` and `REOPENED` are permitted only for a +strictly newer, complete, non-stale, comparable decision whose expected batch digests equal +the entire durable T037 observation-batch set for the current scan, including explicit +zero-finding batches. The default rename verifier and coverage gate are unavailable and +therefore fail closed. T037 grants no correlation, coverage-calculation, evidence, policy, +publication, or AI authority. + +**Rationale**: Stable identity, observation multiplicity, and target lifecycle answer three +different questions. Collapsing them loses provenance, allows one branch to resolve another, +or turns missing scanner output into a false fix. Exact ledger replay and external coverage +authority make retries deterministic while keeping lifecycle transitions auditable and +forward-compatible with T038 correlation and T039 coverage. + +**Rejected**: De-duplicating repeated fingerprints, deriving lineage from line or scanner +match IDs, repository-global lifecycle status, mutating legacy policy/triage state, accepting +partial or stale coverage, inferring completeness from the batches that happened to arrive, +omitting zero-finding batches, calculating coverage inside T037, last-writer-wins lifecycle +updates, deleting old aliases, or letting fuzzy/AI matching merge or resolve findings. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index 44c1b4c..7ad4cd6 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -213,10 +213,33 @@ incomplete, stale, quarantined, or security-blocked scan. rejected T035 batch digest, artifact digest, or secret value. - **FR-035**: Exact fingerprint matches MUST update one finding lineage rather than create duplicate findings. +- **FR-035a**: `sast-finding-lineage-v1` MUST independently revalidate the complete T036 + handoff and every durable tenant, repository, scan, scanner-run, fixed-commit, target, + profile, artifact, schema, normalizer, rule, database, and preflight binding before one + serializable write. One repository/capability/fingerprint-version/fingerprint tuple MUST + identify one lineage, while every ordered producer observation, including byte-identical + repeated fingerprints, MUST create its own immutable occurrence and provenance row. + Replaying one source batch is idempotent only when the complete observation-batch record + and its entire ordered occurrence ledger are byte-for-byte canonical matches; missing, + extra, changed, malformed, or cross-scope rows MUST reject. +- **FR-035b**: Path continuity MUST be granted only by a verified, canonical, fixed-commit, + fixed-target, one-to-one `sast-finding-rename-attestation-v1`. The predecessor alias MUST + already resolve to exactly one lineage and both old and new aliases MUST remain durable. + Missing, ambiguous, chained, cyclic, fuzzy, coordinate-, title-, severity-, scanner-ID-, + or AI-derived rename claims MUST reject or create no continuity authority. - **FR-036**: Cross-tool correlation MUST preserve every provenance record and MUST NOT collapse distinct capability families into one authoritative finding. - **FR-037**: A finding MAY transition to fixed only after a complete later scan of the relevant profile no longer reports it. +- **FR-037a**: Lifecycle state MUST be unique per lineage and canonical tenant/repository/ + target context, separate from policy and triage status, and backed by append-only + `CREATED`, `RENAMED`, `FIXED`, and `REOPENED` events. T037 MUST NOT calculate coverage. + It MAY apply `FIXED` or `REOPENED` only after an injected T039-compatible gate verifies a + strictly newer `sast-finding-lifecycle-coverage-v1` decision with `COMPLETE`, + `stale=false`, `comparable=true`, and exact equality to every durable T037 observation + batch for the current scan, including zero-finding batches. Missing, partial, pending, + failed, stale, incomparable, out-of-order, or scope-mismatched decisions MUST leave + lifecycle state unchanged. - **FR-038**: Stale scans MUST NOT resolve findings or publish external results. ### Coverage, Failure, and Publication diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 6651680..e79a871 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -56,7 +56,7 @@ ## Phase 7: Finding Identity and Coverage - [x] T036 Implement `sast-fingerprint-v1` with Unicode/path canonicalization -- [ ] T037 Implement occurrences, exact lineage updates, rename handling, and fixed/reopen rules +- [x] T037 Implement occurrences, exact lineage updates, rename handling, and fixed/reopen rules - [ ] T038 Implement authority-aware cross-tool correlation with full provenance preservation - [ ] T039 Persist scanner/capability coverage and apply fail-closed external publication - [ ] T040 Implement stale-scan denial and bounded infrastructure-only retries diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index 8ec01e5..929a376 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -59,6 +59,10 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Stable fingerprint binding forgery | A digest-shaped T035 handoff, fingerprint decision, or T036 batch is accepted without binding the sanitized object | Trusted canonical SHA-256 recomputation at every receiving boundary; exact source-decision and source-batch binding | Tampered source/decision/batch corpus; syntax-only digest rejection | | Stable fingerprint collision | Distinct canonical identities are collapsed under one digest and one finding silently wins | Transient digest-to-preimage map; allow only byte-identical repetition; reject the complete batch on mismatch | Forced-digester collision corpus; zero candidate/preimage rejection leakage | | Unknown-location identity smuggling | Provider reason, coordinates, or an invented fallback path makes unavailable locations drift across runtimes | Fixed empty normalized-path component under length-prefixed framing; reason and coordinates excluded | Both UNKNOWN reasons produce one identity; forged path binding rejected | +| Finding-ledger replay forgery | A retry changes, omits, adds, reorders, or cross-scopes an occurrence while reusing a valid source-batch identity | Revalidate every durable T036/plan/artifact binding; unique source identity; exact canonical batch and complete ordered occurrence-ledger equality | Replay/tamper/cross-tenant corpus; reject the whole transaction with zero source data in output | +| Rename alias poisoning | An untrusted diff, fuzzy match, chain, cycle, missing predecessor, or AI claim joins unrelated findings | Canonical signed fixed-commit/fixed-target one-to-one attestation; exact predecessor alias; immutable old and new aliases; unavailable verifier fails closed | Rename, rename-back, ambiguous, missing-predecessor, chain/cycle, and unverified-attestation fixtures | +| Incomplete-batch false fix | A scanner failure or omitted zero-finding batch is presented as complete absence and resolves an open lineage | T037 never calculates coverage; T039-compatible gate must verify `COMPLETE`, non-stale, comparable, and exact equality to every durable T037 batch including zero-finding batches | Partial/stale/missing/extra/zero-batch tests; lifecycle mutation count remains zero | +| Lifecycle context bleed or race | One target resolves another target, or concurrent/out-of-order reconciliations overwrite a newer state | Canonical tenant/repository/target context key; monotonic reconciliation sequence; append-only events; serializable transaction and bounded retry | Cross-context, reopen, stale-sequence, and serialization-race corpus | | Retention clock rollback | A caller supplies a past payload timestamp to normalize an expired accepted object | Adapter-owned default clock checked before and after streaming; trusted test/task clock seam only; require monotonic time at or after disposition | Expiry, stream-crossing, and pre-decision clock tests | | Stored XSS | Rule message/path/package contains markup | Treat all strings as text; output encoding; sanitized Markdown only | Stored-XSS corpus; presentation CSP | | Secret leakage | Finding or zero-finding binding includes a detected/platform secret | Scanner discard plus T035 display redaction, batch-binding inspection, identity fail-close, and T042 evidence re-redaction | Secret-leak gate must remain zero | @@ -124,6 +128,11 @@ The following must always remain true: 12. CycloneDX inventory cannot smuggle finding, vulnerability, policy, durable-persistence, or AI authority; producer BOM references, properties, source paths, license text, prose, and external-reference payloads never cross the transient inventory boundary. +13. Repeated fingerprint observations remain distinct immutable occurrences, and replay + succeeds only for the exact complete ordered ledger in the same tenant/repository scope. +14. A lineage can change target lifecycle only from a verified newer complete coverage + decision over the exact durable observation-batch set; T037 cannot calculate that + decision or infer absence from missing data. ## Required Security Test Corpus @@ -168,5 +177,13 @@ The following must always remain true: - signed-envelope tenant/scan/commit/digest tampering - cross-tenant object and query access - stale commit, force-push, and duplicate delivery +- repeated fingerprints, exact replay, missing/extra/reordered occurrence rows, cross-tenant + source-identity reuse, concurrent lineage creation, and serialization retry exhaustion +- verified rename and rename-back, missing predecessor aliases, duplicate/ambiguous mappings, + chains/cycles, wrong fixed commit/target/profile/context, invalid signature/provenance, and + unavailable rename verification +- isolated target contexts plus created/fixed/reopened event revision sequences; partial, + pending, failed, stale, incomparable, missing/extra/out-of-order coverage and omitted or + forged zero-finding batches must produce exactly zero lifecycle mutations - prompt-injection strings in reduced evidence - sandbox escape and prohibited egress regression suites diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 01eb859..0c6e442 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -35,6 +35,8 @@ const files = { sharedSastSecretRedactionTest: new URL('../../packages/shared/test/sast-secret-redaction.test.mjs', import.meta.url), sharedSastFindingIdentity: new URL('../../packages/shared/src/types/sast-finding-identity.ts', import.meta.url), sharedSastFindingIdentityTest: new URL('../../packages/shared/test/sast-finding-identity.test.mjs', import.meta.url), + sharedSastFindingLineage: new URL('../../packages/shared/src/types/sast-finding-lineage.ts', import.meta.url), + sharedSastFindingLineageTest: new URL('../../packages/shared/test/sast-finding-lineage.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), @@ -56,6 +58,14 @@ const files = { apiSastSecretRedactionTest: new URL('../../apps/api/test/scan-plane/sast-secret-redaction.e2e-spec.ts', import.meta.url), apiSastFindingIdentity: new URL('../../apps/api/src/scan-plane/sast-finding-identity.service.ts', import.meta.url), apiSastFindingIdentityTest: new URL('../../apps/api/test/scan-plane/sast-finding-identity.e2e-spec.ts', import.meta.url), + apiSastFindingLineage: new URL('../../apps/api/src/scan-plane/sast-finding-lineage.service.ts', import.meta.url), + apiSastFindingLineageStore: new URL('../../apps/api/src/scan-plane/prisma-sast-finding-lineage.store.ts', import.meta.url), + apiSastFindingRenameVerifier: new URL('../../apps/api/src/scan-plane/sast-finding-rename-attestation.verifier.ts', import.meta.url), + apiSastFindingCoverageGate: new URL('../../apps/api/src/scan-plane/sast-finding-lifecycle-coverage.gate.ts', import.meta.url), + apiSastFindingLineageTest: new URL('../../apps/api/test/scan-plane/sast-finding-lineage.e2e-spec.ts', import.meta.url), + apiSastFindingLineagePersistenceTest: new URL('../../apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts', import.meta.url), + apiPrismaSchema: new URL('../../apps/api/prisma/schema.prisma', import.meta.url), + apiSastFindingLineageMigration: new URL('../../apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/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), @@ -92,7 +102,8 @@ const assertScanPlaneExports = (scanPlaneModule) => { exportsBlock, 'Expected to locate the ScanPlaneModule exports array' ); - assert.match(exportsBlock, /SastFindingIdentityService/); + assert.match(exportsBlock, /SastFindingLineageService/); + assert.doesNotMatch(exportsBlock, /SastFindingIdentityService/); assert.doesNotMatch(exportsBlock, /SastSecretRedactionService/); assert.doesNotMatch(exportsBlock, /OpenGrepSarifNormalizer/); assert.doesNotMatch(exportsBlock, /TrivyJsonNormalizer/); @@ -443,7 +454,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 is complete/ + /T035 secret redaction,[\s\S]{0,180}T037 occurrence\/exact-lineage lifecycle[\s\S]{0,80}complete/ ); assert.match(contract, /Syft CycloneDX inventory adapter v1/); assert.match(spec, /FR-031b/); @@ -530,7 +541,10 @@ test('SAST T035 secret redaction is deterministic, fail-closed, and still non-du assertScanPlaneExports(scanPlaneModule); assert.match(tasks, /- \[x\] T035\b/); - assert.match(quickstart, /T035 secret redaction is complete/); + assert.match( + quickstart, + /T035 secret redaction,[\s\S]{0,180}T037 occurrence\/exact-lineage lifecycle[\s\S]{0,80}complete/ + ); assert.match(contract, /Secret redaction gate v1/); assert.match(spec, /FR-031c/); assert.match(plan, /`sast-secret-redaction-v1` gate/); @@ -652,7 +666,7 @@ test('SAST T036 constructs byte-exact stable identity and no downstream authorit assert.match(tasks, /- \[x\] T036\b/); assert.match( quickstart, - /T037 occurrence and exact[\s\S]*next implementation task/ + /T037 occurrence\/exact-lineage lifecycle[\s\S]{0,120}complete; T038[\s\S]{0,120}next[\s\S]{0,40}task/ ); assert.match(contract, /Finding identity construction gate v1/); assert.match(spec, /FR-034a/); @@ -669,6 +683,176 @@ test('SAST T036 constructs byte-exact stable identity and no downstream authorit ); }); +test('SAST T037 persists complete occurrence lineage and fail-closed lifecycle transitions', () => { + const sharedLineage = readNormalizedText( + files.sharedSastFindingLineage + ); + const sharedLineageTest = readNormalizedText( + files.sharedSastFindingLineageTest + ); + const sharedIndex = readNormalizedText(files.sharedIndex); + const service = readNormalizedText(files.apiSastFindingLineage); + const store = readNormalizedText( + files.apiSastFindingLineageStore + ); + const verifier = readNormalizedText( + files.apiSastFindingRenameVerifier + ); + const coverageGate = readNormalizedText( + files.apiSastFindingCoverageGate + ); + const serviceTest = readNormalizedText( + files.apiSastFindingLineageTest + ); + const persistenceTest = readNormalizedText( + files.apiSastFindingLineagePersistenceTest + ); + const schema = readNormalizedText(files.apiPrismaSchema); + const migration = readNormalizedText( + files.apiSastFindingLineageMigration + ); + 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( + sharedLineage, + /SAST_FINDING_LINEAGE_VERSION\s*=[^;]*'sast-finding-lineage-v1'/ + ); + assert.match( + sharedLineage, + /SAST_FINDING_RENAME_ATTESTATION_VERSION\s*=[^;]*'sast-finding-rename-attestation-v1'/ + ); + assert.match( + sharedLineage, + /SAST_FINDING_LIFECYCLE_COVERAGE_VERSION\s*=[^;]*'sast-finding-lifecycle-coverage-v1'/ + ); + assert.match(sharedLineage, /maximumFindings:\s*25_000/); + assert.match( + sharedLineage, + /coverageCalculationAuthority:\s*false/ + ); + assert.match(sharedLineage, /policyAuthority:\s*false/); + assert.match(sharedLineage, /publicationAuthority:\s*false/); + assert.match(sharedLineage, /aiPayloadEligible:\s*false/); + assert.match( + sharedIndex, + /export \* from '.\/types\/sast-finding-lineage';/ + ); + assert.match( + sharedLineageTest, + /one-to-one, sorted, fixed-commit rename attestation/ + ); + assert.match( + sharedLineageTest, + /binds reconciliation transition counts/ + ); + + assert.match(service, /class SastFindingLineageService/); + assert.match( + service, + /isSastFingerprintedFindingBatchShapeValid/ + ); + assert.match( + service, + /isSastFindingLifecycleContextInputValid/ + ); + assert.doesNotMatch(service, /\bLogger\b|\bconsole\./u); + assert.doesNotMatch(service, /@Controller|@(Get|Post|Put|Patch|Delete)\(/u); + assert.match( + verifier, + /UnavailableSastFindingRenameAttestationVerifier/ + ); + assert.match(verifier, /return 'UNAVAILABLE'/); + assert.match( + coverageGate, + /UnavailableSastFindingLifecycleCoverageGate/ + ); + assert.match(coverageGate, /return 'UNAVAILABLE'/); + + assert.match( + store, + /Prisma\.TransactionIsolationLevel\.Serializable/ + ); + assert.match(store, /SERIALIZABLE_ATTEMPTS = 3/); + assert.match( + store, + /SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000/ + ); + assert.match(store, /canonicalizeSastFingerprintedFinding/); + assert.match(store, /SAST_SCANNER_RESPONSIBILITIES/); + assert.match( + serviceTest, + /rejects a non-canonical durable target context/ + ); + assert.match( + persistenceTest, + /separates global identity, ordered occurrences, target lifecycle, and append-only events/ + ); + + for (const model of [ + 'SastFindingLineage', + 'SastFindingIdentityAlias', + 'SastFindingObservationBatch', + 'SastFindingOccurrence', + 'SastFindingLifecycleState', + 'SastFindingLifecycleReconciliation', + 'SastFindingLifecycleEvent' + ]) { + assert.match(schema, new RegExp(`model ${model} \\{`)); + assert.match( + migration, + new RegExp(`CREATE TABLE "${model}"`) + ); + } + assert.match( + migration, + /SastFindingLifecycleEvent_observation_scope_fkey/ + ); + assert.match( + migration, + /SastFindingLifecycleEvent_reconciliation_scope_fkey/ + ); + assertScanPlaneExports(scanPlaneModule); + + assert.match(tasks, /- \[x\] T037\b/); + assert.match( + quickstart, + /T038 authority-aware cross-tool correlation is therefore the next/ + ); + assert.match(contract, /Finding lineage and lifecycle gate v1/); + assert.match(dataModel, /SastFindingLifecycleReconciliation/); + assert.match( + plan, + /T037 now revalidates that complete handoff/ + ); + assert.match(spec, /FR-035a/); + assert.match(spec, /FR-037a/); + assert.match( + research, + /Decision 19: Separate Exact Lineage, Ordered Occurrences, and Target Lifecycle/ + ); + assert.match(threatModel, /Finding-ledger replay forgery/); + assert.match(threatModel, /Incomplete-batch false fix/); + assert.match( + qualityGates, + /100% `sast-finding-lineage-v1` revalidation/ + ); + assert.match( + qualityGates, + /including\s+zero-finding batches/ + ); +}); + test('SAST design completion gate stays synchronized between quickstart and CI', () => { const readme = readNormalizedText(files.readme); const ci = readNormalizedText(files.ci);