diff --git a/apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql b/apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql new file mode 100644 index 0000000..d26629c --- /dev/null +++ b/apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql @@ -0,0 +1,324 @@ +-- T039 remains an immutable zero-authority source ledger. Install the +-- replacement without scanning the populated table; prisma:online-schema +-- validates it before dropping the old name, so there is no unguarded window. +ALTER TABLE "SastExternalPublicationDecision" + ADD CONSTRAINT "SastExternalPublicationDecision_t039_source_check" CHECK ( + "externalCommentAllowed" = false + AND "blockingStatusAllowed" = false + AND "aiAdvisoryAllowed" = false + AND "lifecycleMutationAllowed" = false + AND "latestTargetAuthority" = 'UNAVAILABLE' + AND "staleStatus" = 'UNKNOWN' + AND "comparabilityStatus" = 'UNKNOWN' + ) NOT VALID; + +CREATE TABLE "SastLatestTargetObservation" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "provider" "RepoProvider" NOT NULL, + "targetRef" TEXT NOT NULL, + "headCommitSha" TEXT NOT NULL, + "sequence" BIGINT NOT NULL, + "observerRef" TEXT NOT NULL, + "observation" JSONB NOT NULL, + "observationDigest" TEXT NOT NULL, + "observedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastLatestTargetObservation_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastLatestTargetObservation_contract_check" CHECK ( + "id" ~ '^sast-target-observation://[a-f0-9]{64}$' + AND "headCommitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$' + AND "sequence" > 0 + AND "observationDigest" ~ '^sha256:[a-f0-9]{64}$' + ) +); + +CREATE TABLE "SastScanFreshnessDecision" ( + "id" TEXT NOT NULL, + "coverageDecisionId" TEXT NOT NULL, + "previousCoverageDecisionId" TEXT, + "observationId" TEXT, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "provider" "RepoProvider" NOT NULL, + "targetRef" TEXT NOT NULL, + "commitSha" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "attemptNumber" INTEGER NOT NULL, + "coverageDecisionDigest" TEXT NOT NULL, + "lifecycleContextKey" TEXT NOT NULL, + "canonicalScanKey" TEXT NOT NULL, + "planDigest" TEXT NOT NULL, + "profileId" TEXT NOT NULL, + "profileDigest" TEXT NOT NULL, + "profileFamily" TEXT NOT NULL, + "requiredCapabilities" JSONB NOT NULL, + "fingerprintVersion" TEXT NOT NULL, + "lifecycleEligibilityScope" TEXT NOT NULL, + "observationDigest" TEXT, + "observedHeadCommitSha" TEXT, + "observationSequence" BIGINT, + "previousCoverageDecisionDigest" TEXT, + "previousScanRequestId" TEXT, + "previousCommitSha" TEXT, + "latestTargetAuthority" TEXT NOT NULL, + "staleStatus" TEXT NOT NULL, + "comparabilityStatus" TEXT NOT NULL, + "externalCommentEligible" BOOLEAN NOT NULL DEFAULT false, + "blockingStatusEligible" BOOLEAN NOT NULL DEFAULT false, + "lifecycleMutationAllowed" BOOLEAN NOT NULL DEFAULT false, + "aiAdvisoryAllowed" BOOLEAN NOT NULL DEFAULT false, + "publicationAttempted" BOOLEAN NOT NULL DEFAULT false, + "reasonCodes" JSONB NOT NULL, + "decision" JSONB NOT NULL, + "decisionDigest" TEXT NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastScanFreshnessDecision_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastScanFreshnessDecision_contract_check" CHECK ( + "id" ~ '^sast-freshness://[a-f0-9]{64}$' + AND "commitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$' + AND "attemptNumber" BETWEEN 1 AND 2 + AND "coverageDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "lifecycleContextKey" ~ '^sha256:[a-f0-9]{64}$' + AND "canonicalScanKey" ~ '^sha256:[a-f0-9]{64}$' + AND "planDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "profileDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "profileFamily" IN ('JAVA', 'COMMON') + AND "fingerprintVersion" = 'sast-fingerprint-v1' + AND "lifecycleEligibilityScope" ~ '^sha256:[a-f0-9]{64}$' + AND "latestTargetAuthority" IN ('VERIFIED', 'UNAVAILABLE', 'INVALID') + AND "staleStatus" IN ('FRESH', 'STALE', 'UNKNOWN') + AND "comparabilityStatus" IN ('COMPARABLE', 'INCOMPARABLE', 'UNKNOWN') + AND "aiAdvisoryAllowed" = false + AND "publicationAttempted" = false + AND ( + ("observationId" IS NULL + AND "observationDigest" IS NULL + AND "observedHeadCommitSha" IS NULL + AND "observationSequence" IS NULL) + OR + ("observationId" IS NOT NULL + AND "observationDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "observedHeadCommitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$' + AND "observationSequence" > 0) + ) + AND ( + ("previousCoverageDecisionId" IS NULL + AND "previousCoverageDecisionDigest" IS NULL + AND "previousScanRequestId" IS NULL + AND "previousCommitSha" IS NULL) + OR + ("previousCoverageDecisionId" IS NOT NULL + AND "previousCoverageDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "previousScanRequestId" IS NOT NULL + AND "previousCommitSha" ~ '^[a-f0-9]{40}([a-f0-9]{24})?$') + ) + AND ( + ("externalCommentEligible" = true + AND "blockingStatusEligible" = true + AND "lifecycleMutationAllowed" = true + AND "latestTargetAuthority" = 'VERIFIED' + AND "staleStatus" = 'FRESH' + AND "comparabilityStatus" = 'COMPARABLE' + AND "observationId" IS NOT NULL + AND "previousCoverageDecisionId" IS NOT NULL + AND jsonb_array_length("reasonCodes") = 0) + OR + ("externalCommentEligible" = false + AND "blockingStatusEligible" = false + AND "lifecycleMutationAllowed" = false + AND jsonb_array_length("reasonCodes") > 0) + ) + ) +); + +CREATE TABLE "SastScanRetryDecision" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "canonicalScanKey" TEXT NOT NULL, + "planDigest" TEXT NOT NULL, + "originalScannerSetDigest" TEXT NOT NULL, + "previousAttemptId" TEXT NOT NULL, + "previousAttemptNumber" INTEGER NOT NULL, + "previousSandboxId" TEXT NOT NULL, + "previousWorkloadIdentityRef" TEXT NOT NULL, + "requestedAttemptId" TEXT NOT NULL, + "requestedAttemptNumber" INTEGER NOT NULL, + "requestedSandboxId" TEXT NOT NULL, + "requestedWorkloadIdentityRef" TEXT NOT NULL, + "retryAllowed" BOOLEAN NOT NULL, + "previousFailureClass" TEXT, + "previousCompletedAt" TIMESTAMP(3), + "previousFinalAuditEventId" TEXT, + "currentScannerSetDigest" TEXT, + "scannerSetAvailable" BOOLEAN NOT NULL, + "killSwitchStatus" TEXT NOT NULL, + "killSwitchSnapshotDigest" TEXT, + "reasonCodes" JSONB NOT NULL, + "decision" JSONB NOT NULL, + "decisionDigest" TEXT NOT NULL, + "decidedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastScanRetryDecision_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastScanRetryDecision_contract_check" CHECK ( + "id" ~ '^sast-retry://[a-f0-9]{64}$' + AND "canonicalScanKey" ~ '^sha256:[a-f0-9]{64}$' + AND "planDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "originalScannerSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "previousAttemptNumber" > 0 + AND "requestedAttemptNumber" > 0 + AND "requestedAttemptNumber" <= 100 + AND ("currentScannerSetDigest" IS NULL OR "currentScannerSetDigest" ~ '^sha256:[a-f0-9]{64}$') + AND "killSwitchStatus" IN ('CLEAR', 'ACTIVE', 'UNAVAILABLE') + AND ("killSwitchSnapshotDigest" IS NULL OR "killSwitchSnapshotDigest" ~ '^sha256:[a-f0-9]{64}$') + AND ( + ("retryAllowed" = false + AND jsonb_array_length("reasonCodes") > 0) + OR ( + "retryAllowed" = true + AND "previousAttemptNumber" = 1 + AND "requestedAttemptNumber" = 2 + AND "previousFailureClass" = 'RETRYABLE_INFRASTRUCTURE' + AND "previousCompletedAt" IS NOT NULL + AND "previousFinalAuditEventId" IS NOT NULL + AND "scannerSetAvailable" = true + AND "killSwitchStatus" = 'CLEAR' + AND "currentScannerSetDigest" = "originalScannerSetDigest" + AND "killSwitchSnapshotDigest" IS NOT NULL + AND "previousAttemptId" <> "requestedAttemptId" + AND "previousSandboxId" <> "requestedSandboxId" + AND "previousWorkloadIdentityRef" <> "requestedWorkloadIdentityRef" + AND jsonb_array_length("reasonCodes") = 0 + ) + ) + ) +); + +ALTER TABLE "SastScanAttempt" + ADD COLUMN "retryDecisionId" TEXT; + +-- Existing pre-T040 attempt-two rows may not have an authority reference. The +-- NOT VALID form is rolling-safe while enforcing the invariant for every new +-- or updated row after this migration. +ALTER TABLE "SastScanAttempt" + ADD CONSTRAINT "SastScanAttempt_retry_authority_check" CHECK ( + ("attemptNumber" = 1 AND "retryDecisionId" IS NULL) + OR ("attemptNumber" = 2 AND "retryDecisionId" IS NOT NULL) + ) NOT VALID; + +CREATE UNIQUE INDEX "SastLatestTargetObservation_observationDigest_key" + ON "SastLatestTargetObservation"("observationDigest"); +CREATE UNIQUE INDEX "SastLatestTargetObservation_scope_key" + ON "SastLatestTargetObservation"("id", "tenantId", "repositoryBindingId", "provider", "targetRef"); +CREATE UNIQUE INDEX "SastLatestTargetObservation_sequence_key" + ON "SastLatestTargetObservation"("tenantId", "repositoryBindingId", "provider", "targetRef", "sequence"); +CREATE INDEX "SastLatestTargetObservation_latest_idx" + ON "SastLatestTargetObservation"("tenantId", "repositoryBindingId", "provider", "targetRef", "observedAt" DESC); + +CREATE UNIQUE INDEX "SastScanFreshnessDecision_coverageDecisionId_key" + ON "SastScanFreshnessDecision"("coverageDecisionId"); +CREATE UNIQUE INDEX "SastScanFreshnessDecision_decisionDigest_key" + ON "SastScanFreshnessDecision"("decisionDigest"); +CREATE UNIQUE INDEX "SastScanFreshnessDecision_scope_key" + ON "SastScanFreshnessDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE UNIQUE INDEX "SastScanFreshnessDecision_coverage_scope_key" + ON "SastScanFreshnessDecision"("coverageDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE INDEX "SastScanFreshnessDecision_target_idx" + ON "SastScanFreshnessDecision"("tenantId", "repositoryBindingId", "targetRef", "decidedAt" DESC); +CREATE INDEX "SastScanFreshnessDecision_previousCoverageDecisionId_idx" + ON "SastScanFreshnessDecision"("previousCoverageDecisionId"); +CREATE INDEX "SastScanFreshnessDecision_observationId_idx" + ON "SastScanFreshnessDecision"("observationId"); + +CREATE UNIQUE INDEX "SastScanRetryDecision_requestedAttemptId_key" + ON "SastScanRetryDecision"("requestedAttemptId"); +CREATE UNIQUE INDEX "SastScanRetryDecision_requestedSandboxId_key" + ON "SastScanRetryDecision"("requestedSandboxId"); +CREATE UNIQUE INDEX "SastScanRetryDecision_requestedWorkloadIdentityRef_key" + ON "SastScanRetryDecision"("requestedWorkloadIdentityRef"); +CREATE UNIQUE INDEX "SastScanRetryDecision_decisionDigest_key" + ON "SastScanRetryDecision"("decisionDigest"); +CREATE UNIQUE INDEX "SastScanRetryDecision_attempt_number_key" + ON "SastScanRetryDecision"("scanRequestId", "requestedAttemptNumber"); +CREATE UNIQUE INDEX "SastScanRetryDecision_scope_key" + ON "SastScanRetryDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId"); +CREATE INDEX "SastScanRetryDecision_outcome_idx" + ON "SastScanRetryDecision"("tenantId", "retryAllowed", "decidedAt"); +CREATE INDEX "SastScanRetryDecision_previousAttemptId_idx" + ON "SastScanRetryDecision"("previousAttemptId"); +CREATE INDEX "SastScanRetryDecision_previousFinalAuditEventId_idx" + ON "SastScanRetryDecision"("previousFinalAuditEventId"); + +-- Unique indexes added to the populated coverage and attempt tables are built +-- concurrently by the mandatory prisma:online-schema deployment step. + +ALTER TABLE "SastLatestTargetObservation" + ADD CONSTRAINT "SastLatestTargetObservation_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastLatestTargetObservation" + ADD CONSTRAINT "SastLatestTargetObservation_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastScanFreshnessDecision" + ADD CONSTRAINT "SastScanFreshnessDecision_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanFreshnessDecision" + ADD CONSTRAINT "SastScanFreshnessDecision_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanFreshnessDecision" + ADD CONSTRAINT "SastScanFreshnessDecision_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanFreshnessDecision" + ADD CONSTRAINT "SastScanFreshnessDecision_coverage_scope_fkey" + FOREIGN KEY ("coverageDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastScanCoverageDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanFreshnessDecision" + ADD CONSTRAINT "SastScanFreshnessDecision_observation_scope_fkey" + FOREIGN KEY ("observationId", "tenantId", "repositoryBindingId", "provider", "targetRef") + REFERENCES "SastLatestTargetObservation"("id", "tenantId", "repositoryBindingId", "provider", "targetRef") + ON DELETE RESTRICT ON UPDATE CASCADE; + +ALTER TABLE "SastScanRetryDecision" + ADD CONSTRAINT "SastScanRetryDecision_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanRetryDecision" + ADD CONSTRAINT "SastScanRetryDecision_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanRetryDecision" + ADD CONSTRAINT "SastScanRetryDecision_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanRetryDecision" + ADD CONSTRAINT "SastScanRetryDecision_previous_attempt_fkey" + FOREIGN KEY ("previousAttemptId", "tenantId", "repositoryBindingId", "scanRequestId") + REFERENCES "SastScanAttempt"("id", "tenantId", "repositoryBindingId", "scanRequestId") + ON DELETE RESTRICT ON UPDATE CASCADE; +-- The previous-coverage and final-audit scope FKs depend on concurrently +-- installed referenced indexes and are added and validated by the mandatory +-- prisma:online-schema deployment step. + +ALTER TABLE "SastScanAttempt" + ADD CONSTRAINT "SastScanAttempt_retryDecisionId_fkey" + FOREIGN KEY ("retryDecisionId") REFERENCES "SastScanRetryDecision"("id") + ON DELETE NO ACTION ON UPDATE NO ACTION NOT VALID; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 30610f8..a612512 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -376,6 +376,9 @@ model Tenant { sastFindingCorrelations SastFindingCorrelationBatch[] sastScanCoverageDecisions SastScanCoverageDecision[] sastScannerCoverageRecords SastScannerCoverageRecord[] + sastTargetObservations SastLatestTargetObservation[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] users User[] } @@ -428,6 +431,9 @@ model RepositoryBinding { sastFindingCorrelations SastFindingCorrelationBatch[] sastScanCoverageDecisions SastScanCoverageDecision[] sastScannerCoverageRecords SastScannerCoverageRecord[] + sastTargetObservations SastLatestTargetObservation[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -472,6 +478,8 @@ model ScanRequest { sastFindingCorrelations SastFindingCorrelationBatch[] sastScanCoverageDecisions SastScanCoverageDecision[] sastScannerCoverageRecords SastScannerCoverageRecord[] + sastFreshnessDecisions SastScanFreshnessDecision[] + sastRetryDecisions SastScanRetryDecision[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -538,6 +546,8 @@ model SastScanAttempt { cleanupEvidence Json? cleanupEvidenceDigest String? finalAuditEventId String? @unique + // The unique index is installed concurrently by prisma:online-schema. + retryDecisionId String? @unique startedAt DateTime attemptDeadlineAt DateTime completedAt DateTime? @@ -554,6 +564,8 @@ model SastScanAttempt { sastFindingCorrelations SastFindingCorrelationBatch[] sastScanCoverageDecisions SastScanCoverageDecision[] sastScannerCoverageRecords SastScannerCoverageRecord[] + previousRetryDecisions SastScanRetryDecision[] @relation("SastScanRetryPreviousAttempt") + retryAdmissionDecision SastScanRetryDecision? @relation("SastScanRetryAdmittedAttempt", fields: [retryDecisionId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_retryDecisionId_fkey") auditEvents AuditEvent[] @relation("SastScanAttemptAuditEvents") finalAuditEvent AuditEvent? @relation("SastScanAttemptFinalAuditEvent", fields: [finalAuditEventId, id, tenantId], references: [id, attemptId, tenantId], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_finalAuditEventId_fkey") @@ -1170,8 +1182,12 @@ model SastScanCoverageDecision { correlationBatch SastFindingCorrelationBatch @relation(fields: [correlationBatchId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastScanCoverageDecision_correlation_scope_fkey") scannerRecords SastScannerCoverageRecord[] publicationDecision SastExternalPublicationDecision? + freshnessDecision SastScanFreshnessDecision? @relation("SastScanFreshnessCurrentCoverage") + comparisonFor SastScanFreshnessDecision[] @relation("SastScanFreshnessPreviousCoverage") @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanCoverageDecision_scope_key") + // Installed concurrently by the mandatory prisma:online-schema step. + @@unique([id, tenantId, repositoryBindingId, scanRequestId], map: "SastScanCoverageDecision_comparison_scope_key") @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId, correlationBatchId], map: "SastScanCoverageDecision_record_scope_key") @@unique([correlationBatchId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanCoverageDecision_correlation_scope_key") @@unique([tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanCoverageDecision_attempt_key") @@ -1263,6 +1279,132 @@ model SastExternalPublicationDecision { @@index([tenantId, repositoryBindingId, decidedAt], map: "SastExternalPublicationDecision_scope_decided_idx") } +model SastLatestTargetObservation { + id String @id + tenantId String + repositoryBindingId String + provider RepoProvider + targetRef String + headCommitSha String + sequence BigInt + observerRef String + observation Json + observationDigest String @unique + 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: "SastLatestTargetObservation_repository_scope_fkey") + freshnessDecisions SastScanFreshnessDecision[] + + @@unique([id, tenantId, repositoryBindingId, provider, targetRef], map: "SastLatestTargetObservation_scope_key") + @@unique([tenantId, repositoryBindingId, provider, targetRef, sequence], map: "SastLatestTargetObservation_sequence_key") + @@index([tenantId, repositoryBindingId, provider, targetRef, observedAt(sort: Desc)], map: "SastLatestTargetObservation_latest_idx") +} + +model SastScanFreshnessDecision { + id String @id + coverageDecisionId String @unique + previousCoverageDecisionId String? + observationId String? + tenantId String + repositoryBindingId String + provider RepoProvider + targetRef String + commitSha String + scanRequestId String + attemptId String + attemptNumber Int + coverageDecisionDigest String + lifecycleContextKey String + canonicalScanKey String + planDigest String + profileId String + profileDigest String + profileFamily String + requiredCapabilities Json + fingerprintVersion String + lifecycleEligibilityScope String + observationDigest String? + observedHeadCommitSha String? + observationSequence BigInt? + previousCoverageDecisionDigest String? + previousScanRequestId String? + previousCommitSha String? + latestTargetAuthority String + staleStatus String + comparabilityStatus String + externalCommentEligible Boolean @default(false) + blockingStatusEligible Boolean @default(false) + lifecycleMutationAllowed Boolean @default(false) + aiAdvisoryAllowed Boolean @default(false) + publicationAttempted Boolean @default(false) + reasonCodes Json + decision Json + decisionDigest String @unique + decidedAt DateTime + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanFreshnessDecision_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanFreshnessDecision_scan_scope_fkey") + coverageDecision SastScanCoverageDecision @relation("SastScanFreshnessCurrentCoverage", fields: [coverageDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastScanFreshnessDecision_coverage_scope_fkey") + previousCoverageDecision SastScanCoverageDecision? @relation("SastScanFreshnessPreviousCoverage", fields: [previousCoverageDecisionId, tenantId, repositoryBindingId, previousScanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Restrict, map: "SastScanFreshnessDecision_previous_coverage_fkey") + observation SastLatestTargetObservation? @relation(fields: [observationId, tenantId, repositoryBindingId, provider, targetRef], references: [id, tenantId, repositoryBindingId, provider, targetRef], onDelete: Restrict, map: "SastScanFreshnessDecision_observation_scope_fkey") + + @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanFreshnessDecision_scope_key") + @@unique([coverageDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanFreshnessDecision_coverage_scope_key") + @@index([tenantId, repositoryBindingId, targetRef, decidedAt(sort: Desc)], map: "SastScanFreshnessDecision_target_idx") + @@index([previousCoverageDecisionId], map: "SastScanFreshnessDecision_previousCoverageDecisionId_idx") + @@index([observationId], map: "SastScanFreshnessDecision_observationId_idx") +} + +model SastScanRetryDecision { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + canonicalScanKey String + planDigest String + originalScannerSetDigest String + previousAttemptId String + previousAttemptNumber Int + previousSandboxId String + previousWorkloadIdentityRef String + requestedAttemptId String @unique + requestedAttemptNumber Int + requestedSandboxId String @unique + requestedWorkloadIdentityRef String @unique + retryAllowed Boolean + previousFailureClass String? + previousCompletedAt DateTime? + previousFinalAuditEventId String? + currentScannerSetDigest String? + scannerSetAvailable Boolean + killSwitchStatus String + killSwitchSnapshotDigest String? + reasonCodes Json + decision Json + decisionDigest String @unique + decidedAt DateTime + createdAt DateTime @default(now()) + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanRetryDecision_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanRetryDecision_scan_scope_fkey") + previousAttempt SastScanAttempt @relation("SastScanRetryPreviousAttempt", fields: [previousAttemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Restrict, map: "SastScanRetryDecision_previous_attempt_fkey") + // Installed and validated by prisma:online-schema after its referenced + // AuditEvent composite scope index is concurrently ready. + previousFinalAuditEvent AuditEvent? @relation("SastScanRetryFinalAudit", fields: [previousFinalAuditEventId, previousAttemptId, tenantId], references: [id, attemptId, tenantId], onDelete: Restrict, map: "SastScanRetryDecision_final_audit_fkey") + admittedAttempt SastScanAttempt? @relation("SastScanRetryAdmittedAttempt") + + @@unique([scanRequestId, requestedAttemptNumber], map: "SastScanRetryDecision_attempt_number_key") + @@unique([id, tenantId, repositoryBindingId, scanRequestId], map: "SastScanRetryDecision_scope_key") + @@index([tenantId, retryAllowed, decidedAt], map: "SastScanRetryDecision_outcome_idx") + @@index([previousAttemptId], map: "SastScanRetryDecision_previousAttemptId_idx") + @@index([previousFinalAuditEventId], map: "SastScanRetryDecision_previousFinalAuditEventId_idx") +} + model SastFindingCorrelationEdge { id String @id correlationBatchId String @@ -1459,6 +1601,7 @@ model AuditEvent { scanRequest ScanRequest? @relation(fields: [scanRequestId], references: [id], onDelete: SetNull) attempt SastScanAttempt? @relation("SastScanAttemptAuditEvents", fields: [attemptId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "AuditEvent_attempt_scope_fkey") finalForAttempt SastScanAttempt? @relation("SastScanAttemptFinalAuditEvent") + sastRetryDecisions SastScanRetryDecision[] @relation("SastScanRetryFinalAudit") artifactDispositionDecision SastArtifactDispositionDecision? @@unique([id, attemptId, tenantId], map: "AuditEvent_final_attempt_scope_key") diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index dff4a7b..404c489 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -33,6 +33,12 @@ const indexes = [ create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AuditEvent_artifact_disposition_scope_key" ON "AuditEvent"("id", "attemptId", "tenantId", "scanRequestId")' }, + { + name: 'SastScanAttempt_retryDecisionId_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanAttempt_retryDecisionId_key" ON "SastScanAttempt"("retryDecisionId")' + }, { name: 'ScannerRun_ingress_scope_key', unique: true, @@ -104,10 +110,30 @@ const indexes = [ unique: true, create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastFindingCorrelationSource_coverage_scope_key" ON "SastFindingCorrelationSource"("id", "correlationBatchId")' + }, + { + name: 'SastScanCoverageDecision_comparison_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanCoverageDecision_comparison_scope_key" ON "SastScanCoverageDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId")' } ]; const constraints = [ + { + table: 'SastExternalPublicationDecision', + name: 'SastExternalPublicationDecision_t039_source_check', + type: 'c', + definition: `CHECK ( + "externalCommentAllowed" = false + AND "blockingStatusAllowed" = false + AND "aiAdvisoryAllowed" = false + AND "lifecycleMutationAllowed" = false + AND "latestTargetAuthority" = 'UNAVAILABLE' + AND "staleStatus" = 'UNKNOWN' + AND "comparabilityStatus" = 'UNKNOWN' + )` + }, { table: 'ScannerRun', name: 'ScannerRun_attempt_scope_fkey', @@ -245,6 +271,27 @@ const constraints = [ definition: 'FOREIGN KEY ("finalAuditEventId", "id", "tenantId") REFERENCES "AuditEvent"("id", "attemptId", "tenantId") ON DELETE NO ACTION ON UPDATE NO ACTION' }, + { + table: 'SastScanAttempt', + name: 'SastScanAttempt_retryDecisionId_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("retryDecisionId") REFERENCES "SastScanRetryDecision"("id") ON DELETE NO ACTION ON UPDATE NO ACTION' + }, + { + table: 'SastScanFreshnessDecision', + name: 'SastScanFreshnessDecision_previous_coverage_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("previousCoverageDecisionId", "tenantId", "repositoryBindingId", "previousScanRequestId") REFERENCES "SastScanCoverageDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId") ON DELETE RESTRICT ON UPDATE CASCADE' + }, + { + table: 'SastScanRetryDecision', + name: 'SastScanRetryDecision_final_audit_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("previousFinalAuditEventId", "previousAttemptId", "tenantId") REFERENCES "AuditEvent"("id", "attemptId", "tenantId") ON DELETE RESTRICT ON UPDATE CASCADE' + }, { table: 'SastArtifactIngestion', name: 'SastArtifactIngestion_scanner_run_scope_fkey', @@ -632,6 +679,11 @@ const constraints = [ ]; const supersededConstraints = [ + { + table: 'SastExternalPublicationDecision', + name: 'SastExternalPublicationDecision_contract_check', + replacement: 'SastExternalPublicationDecision_t039_source_check' + }, { table: 'ScannerRun', name: 'ScannerRun_runtime_metadata_check', diff --git a/apps/api/src/scan-plane/prisma-sast-scan-freshness.store.ts b/apps/api/src/scan-plane/prisma-sast-scan-freshness.store.ts new file mode 100644 index 0000000..e611f32 --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-scan-freshness.store.ts @@ -0,0 +1,1144 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_CAPABILITIES, + SAST_FINDING_FINGERPRINT_VERSION, + SAST_SCAN_PROFILES, + SAST_SCANNER_KINDS, + buildSastScanCoverageRecordsPreimage, + buildSastScanFreshnessDecision, + buildSastScanPlanDigestPreimage, + evaluateSastScanRetry, + isSastExternalPublicationDecisionShapeValid, + isSastLatestTargetObservationShapeValid, + isSastScanCoverageDecisionShapeValid, + isSastScanFreshnessDecisionShapeValid, + isSastScanPlanValid, + isSastScanRetryDecisionShapeValid, + isSastScannerCoverageRecordShapeValid, + sastProfileFamily, + type SastCapability, + type SastExternalPublicationDecision, + type SastFindingLifecycleCoverageDecision, + type SastLatestTargetObservation, + type SastProfileId, + type SastScanComparisonSource, + type SastScanCoverageDecision, + type SastScanFreshnessDecision, + type SastScanFreshnessScope, + type SastScanPlan, + type SastScanRetryDecision, + type SastScannerCoverageRecord, + type SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + SastScanFreshnessPersistenceError, + SastScanFreshnessStore, + type PersistedSastScanFreshness, + type SastScanFreshnessContext, + type SastScanRetryDurableContext +} from './sast-scan-freshness.store'; + +const SERIALIZABLE_ATTEMPTS = 3; +const SERIALIZABLE_MAX_WAIT_MILLISECONDS = 5_000; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000; + +type FreshnessReader = Pick< + Prisma.TransactionClient, + | 'sastScanCoverageDecision' + | 'sastLatestTargetObservation' + | 'sastScanFreshnessDecision' +>; + +@Injectable() +export class PrismaSastScanFreshnessStore + extends SastScanFreshnessStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async loadContext( + coverageDecisionId: string + ): Promise { + return this.readContext(this.prisma, coverageDecisionId); + } + + async persistFreshness(input: { + context: Readonly; + observation: Readonly | null; + decision: Readonly; + }): Promise { + this.validateFreshnessInput(input); + return this.runSerializable(async (transaction) => { + const current = await this.readContext( + transaction, + input.context.scope.coverageDecisionId + ); + if (!current || !sameFreshnessContext(current, input.context)) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + if (current.existingDecision) { + return replayFreshness(current.existingDecision, input.decision); + } + if (input.observation) { + await transaction.sastLatestTargetObservation.create({ + data: { + id: input.observation.observationId, + tenantId: input.observation.tenantId, + repositoryBindingId: + input.observation.repositoryBindingId, + provider: input.observation.provider, + targetRef: input.observation.targetRef, + headCommitSha: input.observation.headCommitSha, + sequence: BigInt(input.observation.sequence), + observerRef: input.observation.observerRef, + observation: json(input.observation), + observationDigest: + input.observation.observationDigest, + observedAt: new Date(input.observation.observedAt), + createdAt: new Date(input.decision.decidedAt) + } + }); + } + const decision = input.decision; + const scope = decision.scope; + await transaction.sastScanFreshnessDecision.create({ + data: { + id: decision.freshnessDecisionId, + coverageDecisionId: scope.coverageDecisionId, + previousCoverageDecisionId: + decision.previousCoverageDecisionId, + observationId: decision.observationId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + provider: scope.provider, + targetRef: scope.targetRef, + commitSha: scope.commitSha, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + attemptNumber: scope.attemptNumber, + coverageDecisionDigest: scope.coverageDecisionDigest, + lifecycleContextKey: scope.lifecycleContextKey, + canonicalScanKey: scope.canonicalScanKey, + planDigest: scope.planDigest, + profileId: scope.profileId, + profileDigest: scope.profileDigest, + profileFamily: scope.profileFamily, + requiredCapabilities: json(scope.requiredCapabilities), + fingerprintVersion: scope.fingerprintVersion, + lifecycleEligibilityScope: + scope.lifecycleEligibilityScope, + observationDigest: decision.observationDigest, + observedHeadCommitSha: decision.observedHeadCommitSha, + observationSequence: + decision.observationSequence === null + ? null + : BigInt(decision.observationSequence), + previousCoverageDecisionDigest: + decision.previousCoverageDecisionDigest, + previousScanRequestId: decision.previousScanRequestId, + previousCommitSha: decision.previousCommitSha, + latestTargetAuthority: decision.latestTargetAuthority, + staleStatus: decision.staleStatus, + comparabilityStatus: decision.comparabilityStatus, + externalCommentEligible: + decision.externalCommentEligible, + blockingStatusEligible: decision.blockingStatusEligible, + lifecycleMutationAllowed: + decision.lifecycleMutationAllowed, + aiAdvisoryAllowed: false, + publicationAttempted: false, + reasonCodes: json(decision.reasonCodes), + decision: json(decision), + decisionDigest: decision.decisionDigest, + decidedAt: new Date(decision.decidedAt), + createdAt: new Date(decision.decidedAt) + } + }); + return { + freshnessDecisionId: decision.freshnessDecisionId, + decisionDigest: decision.decisionDigest, + replayed: false + }; + }); + } + + async verifyLifecycleSource( + input: Readonly + ): Promise<'MATCHED' | 'REJECTED'> { + const row = await this.prisma.sastScanFreshnessDecision.findFirst({ + where: { + coverageDecisionId: input.sourceCoverageDecisionRef, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + coverageDecisionDigest: + input.sourceCoverageDecisionDigest, + latestTargetAuthority: 'VERIFIED', + staleStatus: 'FRESH', + comparabilityStatus: 'COMPARABLE', + lifecycleMutationAllowed: true, + aiAdvisoryAllowed: false, + publicationAttempted: false + }, + include: { + scanRequest: { select: { completedAt: true } }, + coverageDecision: { select: { state: true } } + } + }); + if (!row || row.coverageDecision.state !== 'COMPLETE') { + return 'REJECTED'; + } + const decision = row.decision as unknown as SastScanFreshnessDecision; + if ( + !isSastScanFreshnessDecisionShapeValid(decision, digest) || + decision.decisionDigest !== row.decisionDigest || + !decision.lifecycleMutationAllowed || + !row.scanRequest.completedAt || + input.canonicalScanKey !== decision.scope.canonicalScanKey || + input.planDigest !== decision.scope.planDigest || + input.commitSha !== decision.scope.commitSha || + input.lifecycleContextKey !== + decision.scope.lifecycleContextKey || + input.profileId !== decision.scope.profileId || + input.profileDigest !== decision.scope.profileDigest || + input.previousScanRequestId !== + decision.previousScanRequestId || + input.previousCommitSha !== decision.previousCommitSha || + input.completedAt !== row.scanRequest.completedAt.toISOString() || + input.decidedAt !== decision.decidedAt || + stableJson(input.completeCapabilities) !== + stableJson( + decision.scope.requiredCapabilities.filter( + (capability) => capability !== 'SBOM' + ) + ) + ) { + return 'REJECTED'; + } + return 'MATCHED'; + } + + async loadRetryContext( + request: Readonly + ): Promise { + const scan = await this.prisma.scanRequest.findFirst({ + where: { + id: request.plan.scanRequestId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId + }, + include: { + sastQueueReservation: { + select: { canonicalScanKey: true, immutablePlan: true } + }, + sastScanAttempts: { + orderBy: { attemptNumber: 'desc' }, + take: 1, + include: { + finalAuditEvent: { + select: { + id: true, + tenantId: true, + scanRequestId: true, + attemptId: true, + eventType: true, + occurredAt: true + } + } + } + }, + sastRetryDecisions: { + where: { requestedAttemptId: request.attemptId }, + take: 1, + select: { decision: true } + } + } + }); + const previous = scan?.sastScanAttempts[0]; + const reservation = scan?.sastQueueReservation; + if (!scan || !previous || !reservation) return null; + const durablePlan = reservation.immutablePlan as unknown as SastScanPlan; + if (!isSastScanPlanValid(durablePlan)) return null; + const durablePlanDigest = digest( + buildSastScanPlanDigestPreimage(durablePlan) + ); + const finalAudit = previous.finalAuditEvent; + const previousCompletedAt = previous.completedAt?.toISOString() ?? null; + const previousFinalAuditValid = Boolean( + finalAudit && + finalAudit.id === previous.finalAuditEventId && + finalAudit.tenantId === previous.tenantId && + finalAudit.scanRequestId === previous.scanRequestId && + finalAudit.attemptId === previous.id && + finalAudit.eventType === 'sandbox.terminated' && + previousCompletedAt !== null && + finalAudit.occurredAt.toISOString() === previousCompletedAt + ); + const existing = scan.sastRetryDecisions[0]?.decision as unknown as + | SastScanRetryDecision + | undefined; + return { + evaluation: { + scope: { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + canonicalScanKey: request.plan.canonicalScanKey, + planDigest: digest( + buildSastScanPlanDigestPreimage(request.plan) + ), + originalScannerSetDigest: + durablePlan.scannerSet.scannerSetDigest, + previousAttemptId: previous.id, + previousAttemptNumber: previous.attemptNumber, + previousSandboxId: previous.sandboxId, + previousWorkloadIdentityRef: + previous.workloadIdentityRef, + requestedAttemptId: request.attemptId, + requestedAttemptNumber: request.attemptNumber, + requestedSandboxId: request.sandboxId, + requestedWorkloadIdentityRef: + request.workloadIdentityRef + }, + previousStage: previous.stage, + previousFailureClass: previous.failureClass, + previousRetryEligible: previous.retryEligible, + previousCompletedAt, + previousFinalAuditEventId: previous.finalAuditEventId, + previousFinalAuditValid, + durableCanonicalScanKey: reservation.canonicalScanKey as + `sha256:${string}`, + durablePlanDigest + }, + existingDecision: + existing && isSastScanRetryDecisionShapeValid(existing, digest) + ? existing + : null + }; + } + + async persistRetryDecision(input: { + context: Readonly; + decision: Readonly; + }): Promise<{ + retryDecisionId: string; + decisionDigest: `sha256:${string}`; + retryAllowed: boolean; + replayed: boolean; + }> { + const retryEvaluation = { + ...input.context.evaluation, + currentScannerSetDigest: + input.decision.currentScannerSetDigest, + scannerSetAvailable: input.decision.scannerSetAvailable, + killSwitchStatus: input.decision.killSwitchStatus, + killSwitchSnapshotDigest: + input.decision.killSwitchSnapshotDigest + }; + if ( + !isSastScanRetryDecisionShapeValid(input.decision, digest) || + stableJson(input.decision.scope) !== + stableJson(input.context.evaluation.scope) || + input.decision.previousFailureClass !== + input.context.evaluation.previousFailureClass || + input.decision.previousCompletedAt !== + input.context.evaluation.previousCompletedAt || + input.decision.previousFinalAuditEventId !== + input.context.evaluation.previousFinalAuditEventId || + stableJson(input.decision.reasonCodes) !== + stableJson(evaluateSastScanRetry(retryEvaluation)) + ) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + return this.runSerializable(async (transaction) => { + const scope = input.decision.scope; + const previous = await transaction.sastScanAttempt.findFirst({ + where: { + id: scope.previousAttemptId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId + }, + include: { + finalAuditEvent: { + select: { + id: true, + tenantId: true, + scanRequestId: true, + attemptId: true, + eventType: true, + occurredAt: true + } + }, + scanRequest: { + include: { + sastQueueReservation: { + select: { + canonicalScanKey: true, + immutablePlan: true + } + } + } + } + } + }); + if (!previous || !sameRetryPredecessor(previous, input.context)) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + const existing = await transaction.sastScanRetryDecision.findFirst({ + where: { + OR: [ + { id: input.decision.retryDecisionId }, + { requestedAttemptId: scope.requestedAttemptId }, + { + scanRequestId: scope.scanRequestId, + requestedAttemptNumber: scope.requestedAttemptNumber + } + ] + }, + select: { decision: true } + }); + if (existing) { + const stored = existing.decision as unknown as SastScanRetryDecision; + if ( + !isSastScanRetryDecisionShapeValid(stored, digest) || + stableJson(stored) !== stableJson(input.decision) + ) { + throw new SastScanFreshnessPersistenceError('REPLAY_CONFLICT'); + } + return { + retryDecisionId: stored.retryDecisionId, + decisionDigest: stored.decisionDigest, + retryAllowed: stored.retryAllowed, + replayed: true + }; + } + const decision = input.decision; + // Denials are immutable audit evidence and intentionally consume this + // scan/attempt slot. Recovery requires a new scan request; a later + // mutable-authority snapshot must never rewrite an earlier denial. + await transaction.sastScanRetryDecision.create({ + data: { + id: decision.retryDecisionId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + canonicalScanKey: scope.canonicalScanKey, + planDigest: scope.planDigest, + originalScannerSetDigest: scope.originalScannerSetDigest, + previousAttemptId: scope.previousAttemptId, + previousAttemptNumber: scope.previousAttemptNumber, + previousSandboxId: scope.previousSandboxId, + previousWorkloadIdentityRef: + scope.previousWorkloadIdentityRef, + requestedAttemptId: scope.requestedAttemptId, + requestedAttemptNumber: scope.requestedAttemptNumber, + requestedSandboxId: scope.requestedSandboxId, + requestedWorkloadIdentityRef: + scope.requestedWorkloadIdentityRef, + retryAllowed: decision.retryAllowed, + previousFailureClass: decision.previousFailureClass, + previousCompletedAt: decision.previousCompletedAt + ? new Date(decision.previousCompletedAt) + : null, + previousFinalAuditEventId: + decision.previousFinalAuditEventId, + currentScannerSetDigest: decision.currentScannerSetDigest, + scannerSetAvailable: decision.scannerSetAvailable, + killSwitchStatus: decision.killSwitchStatus, + killSwitchSnapshotDigest: + decision.killSwitchSnapshotDigest, + reasonCodes: json(decision.reasonCodes), + decision: json(decision), + decisionDigest: decision.decisionDigest, + decidedAt: new Date(decision.decidedAt), + createdAt: new Date(decision.decidedAt) + } + }); + return { + retryDecisionId: decision.retryDecisionId, + decisionDigest: decision.decisionDigest, + retryAllowed: decision.retryAllowed, + replayed: false + }; + }); + } + + private async readContext( + reader: FreshnessReader, + coverageDecisionId: string + ): Promise { + const row = await reader.sastScanCoverageDecision.findUnique({ + where: { id: coverageDecisionId }, + include: { + attempt: { select: { attemptNumber: true } }, + repositoryBinding: { + include: { integration: { select: { provider: true } } } + }, + scanRequest: { + select: { status: true, completedAt: true } + }, + freshnessDecision: { select: { decision: true } }, + scannerRecords: { orderBy: { scanner: 'asc' } }, + publicationDecision: true + } + }); + if (!row) return null; + const coverage = row.decision as unknown as SastScanCoverageDecision; + if ( + !isSastScanCoverageDecisionShapeValid(coverage, digest) || + !coverageRowMatchesDecision(row, coverage) || + !coverageLedgerMatchesDecision(row, coverage) + ) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + const profile = SAST_SCAN_PROFILES[coverage.scope.profileId]; + const requiredCapabilities = SAST_CAPABILITIES.filter((capability) => + profile.requiredCapabilities.includes(capability) + ); + const profileFamily = sastProfileFamily( + row.profileId as SastProfileId + ); + const compatibleProfileIds = ( + Object.keys(SAST_SCAN_PROFILES) as SastProfileId[] + ).filter((profileId) => { + const candidate = SAST_SCAN_PROFILES[profileId]; + const candidateCapabilities = SAST_CAPABILITIES.filter( + (capability) => + candidate.requiredCapabilities.includes(capability) + ); + return ( + sastProfileFamily(profileId) === profileFamily && + sameCapabilities(candidateCapabilities, requiredCapabilities) + ); + }); + const scope: SastScanFreshnessScope = { + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + provider: row.repositoryBinding.integration.provider, + targetRef: row.targetRef, + commitSha: row.commitSha, + scanRequestId: row.scanRequestId, + attemptId: row.attemptId, + attemptNumber: row.attempt.attemptNumber as 1 | 2, + coverageDecisionId: row.id, + coverageDecisionDigest: + row.decisionDigest as `sha256:${string}`, + lifecycleContextKey: + row.lifecycleContextKey as `sha256:${string}`, + canonicalScanKey: row.canonicalScanKey as `sha256:${string}`, + planDigest: row.planDigest as `sha256:${string}`, + profileId: row.profileId as SastProfileId, + profileDigest: row.profileDigest as `sha256:${string}`, + profileFamily, + requiredCapabilities, + fingerprintVersion: SAST_FINDING_FINGERPRINT_VERSION, + lifecycleEligibilityScope: lifecycleEligibilityScope({ + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + targetRef: row.targetRef, + profileFamily, + requiredCapabilities + }) + }; + const previousRow = await reader.sastScanCoverageDecision.findFirst({ + where: { + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + targetRef: row.targetRef, + state: 'COMPLETE', + id: { not: row.id }, + scanRequestId: { not: row.scanRequestId }, + profileId: { in: compatibleProfileIds }, + decidedAt: { lt: row.decidedAt }, + scanRequest: { + completedAt: { not: null, lt: row.decidedAt } + } + }, + orderBy: { decidedAt: 'desc' }, + include: { + scanRequest: { select: { completedAt: true } }, + scannerRecords: { orderBy: { scanner: 'asc' } }, + publicationDecision: true + } + }); + const comparison = previousRow + ? buildComparisonSource(previousRow) + : null; + const latestObservation = + await reader.sastLatestTargetObservation.findFirst({ + where: { + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + provider: scope.provider, + targetRef: scope.targetRef + }, + orderBy: { sequence: 'desc' }, + select: { id: true, sequence: true, observedAt: true } + }); + const latestObservationSequence = latestObservation + ? Number(latestObservation.sequence) + : null; + if ( + latestObservationSequence !== null && + (!Number.isSafeInteger(latestObservationSequence) || + latestObservationSequence <= 0) + ) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + const existing = row.freshnessDecision?.decision as unknown as + | SastScanFreshnessDecision + | undefined; + return { + coverageComplete: + row.state === 'COMPLETE' && + coverage.state === 'COMPLETE' && + row.scanRequest.status === 'COMPLETED' && + row.scanRequest.completedAt !== null, + coverageCompletedAt: + row.scanRequest.completedAt + ? new Date( + Math.max( + row.scanRequest.completedAt.getTime(), + row.decidedAt.getTime() + ) + ).toISOString() + : null, + scope, + comparison, + latestObservation: latestObservation + ? { + observationId: latestObservation.id, + sequence: latestObservationSequence as number, + observedAt: latestObservation.observedAt.toISOString() + } + : null, + existingDecision: + existing && isSastScanFreshnessDecisionShapeValid(existing, digest) + ? existing + : null + }; + } + + private validateFreshnessInput(input: { + context: Readonly; + observation: Readonly | null; + decision: Readonly; + }): void { + const previous = input.context.latestObservation; + const observationMonotonic = Boolean( + input.observation && + (!previous || + (input.observation.sequence > previous.sequence && + Date.parse(input.observation.observedAt) >= + Date.parse(previous.observedAt))) + ); + const expected = buildSastScanFreshnessDecision({ + freshnessDecisionId: input.decision.freshnessDecisionId, + coverageComplete: input.context.coverageComplete, + scope: input.context.scope, + observation: input.observation, + observationAuthority: input.observation + ? 'VERIFIED' + : input.decision.latestTargetAuthority === 'UNAVAILABLE' + ? 'UNAVAILABLE' + : 'INVALID', + observationMonotonic, + comparison: input.context.comparison, + decidedAt: input.decision.decidedAt, + digestCanonical: digest + }); + if ( + !isSastScanFreshnessDecisionShapeValid(input.decision, digest) || + (input.observation !== null && + !isSastLatestTargetObservationShapeValid( + input.observation, + digest + )) || + stableJson(input.decision.scope) !== + stableJson(input.context.scope) || + !input.context.coverageCompletedAt || + Date.parse(input.decision.decidedAt) < + Date.parse(input.context.coverageCompletedAt) || + (input.observation !== null && + (input.observation.tenantId !== input.context.scope.tenantId || + input.observation.repositoryBindingId !== + input.context.scope.repositoryBindingId || + input.observation.provider !== input.context.scope.provider || + input.observation.targetRef !== + input.context.scope.targetRef || + Date.parse(input.observation.observedAt) < + Date.parse(input.context.coverageCompletedAt) || + Date.parse(input.observation.observedAt) > + Date.parse(input.decision.decidedAt) + 5_000)) || + (!input.context.coverageComplete && + (input.decision.externalCommentEligible || + input.decision.blockingStatusEligible || + input.decision.lifecycleMutationAllowed)) || + input.decision.observationId !== + (input.observation?.observationId ?? null) || + stableJson(input.decision) !== stableJson(expected) + ) { + throw new SastScanFreshnessPersistenceError('CONTEXT_DRIFT'); + } + } + + private async runSerializable( + operation: (transaction: Prisma.TransactionClient) => Promise + ): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= SERIALIZABLE_ATTEMPTS; attempt += 1) { + try { + return await this.prisma.$transaction(operation, { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: SERIALIZABLE_MAX_WAIT_MILLISECONDS, + timeout: SERIALIZABLE_TIMEOUT_MILLISECONDS + }); + } catch (error) { + lastError = error; + if (!isRetryableTransactionError(error) || + attempt === SERIALIZABLE_ATTEMPTS) { + throw error; + } + } + } + throw lastError; + } +} + +type CoverageLedgerProjection = { + scannerRecords: Array<{ + id: string; + scanner: string; + scannerRunId: string | null; + artifactIngestionId: string | null; + dispositionDecisionId: string | null; + correlationSourceId: string | null; + record: Prisma.JsonValue; + recordDigest: string; + }>; + publicationDecision: { + id: string; + coverageDecisionId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + coverageState: string; + externalCommentAllowed: boolean; + blockingStatusAllowed: boolean; + aiAdvisoryAllowed: boolean; + lifecycleMutationAllowed: boolean; + latestTargetAuthority: string; + staleStatus: string; + comparabilityStatus: string; + reasonCodes: Prisma.JsonValue; + decision: Prisma.JsonValue; + decisionDigest: string; + decidedAt: Date; + } | null; +}; + +function buildComparisonSource( + row: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + correlationBatchId: string; + lifecycleContextKey: string; + targetRef: string; + commitSha: string; + lane: string; + profileId: string; + profileDigest: string; + canonicalScanKey: string; + planDigest: string; + scannerSetDigest: string; + correlationSourceSetDigest: string; + state: string; + requiredScanners: Prisma.JsonValue; + optionalScanners: Prisma.JsonValue; + missingRequiredScanners: Prisma.JsonValue; + pendingRequiredScanners: Prisma.JsonValue; + failedRequiredScanners: Prisma.JsonValue; + achievedRequiredCapabilities: Prisma.JsonValue; + missingRequiredCapabilities: Prisma.JsonValue; + duplicateScanners: Prisma.JsonValue; + optionalIncompleteScanners: Prisma.JsonValue; + reasonCodes: Prisma.JsonValue; + recordsDigest: string; + authority: Prisma.JsonValue; + decisionDigest: string; + decision: Prisma.JsonValue; + decidedAt: Date; + scanRequest: { completedAt: Date | null }; + } & CoverageLedgerProjection +): SastScanComparisonSource | null { + const coverage = row.decision as unknown as SastScanCoverageDecision; + const profileId = row.profileId as SastProfileId; + const profile = SAST_SCAN_PROFILES[profileId]; + if ( + !profile || + !row.scanRequest.completedAt || + !isSastScanCoverageDecisionShapeValid(coverage, digest) || + coverage.state !== 'COMPLETE' || + !coverageRowMatchesDecision(row, coverage) || + !coverageLedgerMatchesDecision(row, coverage) + ) { + return null; + } + const requiredCapabilities = SAST_CAPABILITIES.filter((capability) => + profile.requiredCapabilities.includes(capability) + ); + const profileFamily = sastProfileFamily(profileId); + return { + coverageDecisionId: row.id, + coverageDecisionDigest: row.decisionDigest as `sha256:${string}`, + scanRequestId: row.scanRequestId, + commitSha: row.commitSha, + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + targetRef: row.targetRef, + profileId, + profileDigest: row.profileDigest as `sha256:${string}`, + profileFamily, + requiredCapabilities, + fingerprintVersion: SAST_FINDING_FINGERPRINT_VERSION, + lifecycleEligibilityScope: lifecycleEligibilityScope({ + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + targetRef: row.targetRef, + profileFamily, + requiredCapabilities + }), + completedAt: row.scanRequest.completedAt.toISOString() + }; +} + +function coverageRowMatchesDecision( + row: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + correlationBatchId: string; + lifecycleContextKey: string; + targetRef: string; + commitSha: string; + lane: string; + profileId: string; + profileDigest: string; + canonicalScanKey: string; + planDigest: string; + scannerSetDigest: string; + correlationSourceSetDigest: string; + state: string; + requiredScanners: Prisma.JsonValue; + optionalScanners: Prisma.JsonValue; + missingRequiredScanners: Prisma.JsonValue; + pendingRequiredScanners: Prisma.JsonValue; + failedRequiredScanners: Prisma.JsonValue; + achievedRequiredCapabilities: Prisma.JsonValue; + missingRequiredCapabilities: Prisma.JsonValue; + duplicateScanners: Prisma.JsonValue; + optionalIncompleteScanners: Prisma.JsonValue; + reasonCodes: Prisma.JsonValue; + recordsDigest: string; + authority: Prisma.JsonValue; + decisionDigest: string; + decidedAt: Date; + } & CoverageLedgerProjection, + decision: Readonly +): boolean { + const scope = decision.scope; + return ( + decision.coverageDecisionId === row.id && + decision.decisionDigest === row.decisionDigest && + scope.tenantId === row.tenantId && + scope.repositoryBindingId === row.repositoryBindingId && + scope.scanRequestId === row.scanRequestId && + scope.attemptId === row.attemptId && + scope.correlationBatchId === row.correlationBatchId && + scope.lifecycleContextKey === row.lifecycleContextKey && + scope.targetRef === row.targetRef && + scope.commitSha === row.commitSha && + scope.lane === row.lane && + scope.profileId === row.profileId && + scope.profileDigest === row.profileDigest && + scope.canonicalScanKey === row.canonicalScanKey && + scope.planDigest === row.planDigest && + scope.scannerSetDigest === row.scannerSetDigest && + scope.correlationSourceSetDigest === + row.correlationSourceSetDigest && + decision.state === row.state && + stableJson(decision.requiredScanners) === + stableJson(row.requiredScanners) && + stableJson(decision.optionalScanners) === + stableJson(row.optionalScanners) && + stableJson(decision.missingRequiredScanners) === + stableJson(row.missingRequiredScanners) && + stableJson(decision.pendingRequiredScanners) === + stableJson(row.pendingRequiredScanners) && + stableJson(decision.failedRequiredScanners) === + stableJson(row.failedRequiredScanners) && + stableJson(decision.achievedRequiredCapabilities) === + stableJson(row.achievedRequiredCapabilities) && + stableJson(decision.missingRequiredCapabilities) === + stableJson(row.missingRequiredCapabilities) && + stableJson(decision.duplicateScanners) === + stableJson(row.duplicateScanners) && + stableJson(decision.optionalIncompleteScanners) === + stableJson(row.optionalIncompleteScanners) && + stableJson(decision.reasonCodes) === stableJson(row.reasonCodes) && + decision.recordsDigest === row.recordsDigest && + stableJson(decision.authority) === stableJson(row.authority) && + decision.decidedAt === row.decidedAt.toISOString() + ); +} + +function coverageLedgerMatchesDecision( + row: CoverageLedgerProjection, + decision: Readonly +): boolean { + const records = row.scannerRecords + .map((stored) => stored.record as unknown as SastScannerCoverageRecord) + .sort( + (left, right) => + SAST_SCANNER_KINDS.indexOf(left.scanner) - + SAST_SCANNER_KINDS.indexOf(right.scanner) + ); + if ( + records.length !== SAST_SCANNER_KINDS.length || + new Set(records.map((record) => record.scannerCoverageId)).size !== + records.length || + records.some( + (record, index) => + record.scanner !== SAST_SCANNER_KINDS[index] || + !isSastScannerCoverageRecordShapeValid(record, digest) + ) || + digest(buildSastScanCoverageRecordsPreimage(records)) !== + decision.recordsDigest + ) { + return false; + } + const rowsById = new Map( + row.scannerRecords.map((stored) => [stored.id, stored]) + ); + if (rowsById.size !== records.length) return false; + for (const record of records) { + const stored = rowsById.get(record.scannerCoverageId); + if ( + !stored || + stored.scanner !== record.scanner || + stored.scannerRunId !== record.scannerRunId || + stored.artifactIngestionId !== record.artifactIngestionId || + stored.dispositionDecisionId !== record.dispositionDecisionId || + stored.correlationSourceId !== record.correlationSourceId || + stored.recordDigest !== record.recordDigest || + stableJson(stored.record) !== stableJson(record) + ) { + return false; + } + } + + const publicationRow = row.publicationDecision; + const publication = publicationRow?.decision as unknown as + | SastExternalPublicationDecision + | undefined; + return Boolean( + publicationRow && + publication && + isSastExternalPublicationDecisionShapeValid(publication, digest) && + publication.publicationDecisionId === publicationRow.id && + publication.coverageDecisionId === decision.coverageDecisionId && + publication.coverageDecisionId === + publicationRow.coverageDecisionId && + publication.coverageDecisionDigest === decision.decisionDigest && + publication.coverageState === decision.state && + publication.decidedAt === decision.decidedAt && + publication.decisionDigest === publicationRow.decisionDigest && + publicationRow.tenantId === decision.scope.tenantId && + publicationRow.repositoryBindingId === + decision.scope.repositoryBindingId && + publicationRow.scanRequestId === decision.scope.scanRequestId && + publicationRow.attemptId === decision.scope.attemptId && + publicationRow.coverageState === decision.state && + publicationRow.externalCommentAllowed === false && + publicationRow.blockingStatusAllowed === false && + publicationRow.aiAdvisoryAllowed === false && + publicationRow.lifecycleMutationAllowed === false && + publicationRow.latestTargetAuthority === 'UNAVAILABLE' && + publicationRow.staleStatus === 'UNKNOWN' && + publicationRow.comparabilityStatus === 'UNKNOWN' && + stableJson(publicationRow.reasonCodes) === + stableJson(publication.reasonCodes) && + publicationRow.decidedAt.toISOString() === publication.decidedAt && + stableJson(publicationRow.decision) === stableJson(publication) + ); +} + +function lifecycleEligibilityScope(input: { + tenantId: string; + repositoryBindingId: string; + targetRef: string; + profileFamily: string; + requiredCapabilities: readonly SastCapability[]; +}): `sha256:${string}` { + return digest( + [ + 'sast-lifecycle-eligibility-scope-v1', + input.tenantId, + input.repositoryBindingId, + input.targetRef, + input.profileFamily, + SAST_FINDING_FINGERPRINT_VERSION, + ...input.requiredCapabilities + ].join('\0') + ); +} + +function sameCapabilities( + left: readonly SastCapability[], + right: readonly SastCapability[] +): boolean { + return ( + left.length === right.length && + left.every((capability, index) => capability === right[index]) + ); +} + +function sameFreshnessContext( + left: Readonly, + right: Readonly +): boolean { + return left.coverageComplete === right.coverageComplete && + left.coverageCompletedAt === right.coverageCompletedAt && + stableJson(left.scope) === stableJson(right.scope) && + stableJson(left.comparison) === stableJson(right.comparison) && + stableJson(left.latestObservation) === + stableJson(right.latestObservation); +} + +function replayFreshness( + stored: Readonly, + requested: Readonly +): PersistedSastScanFreshness { + if ( + !isSastScanFreshnessDecisionShapeValid(stored, digest) || + stableJson(stored) !== stableJson(requested) + ) { + throw new SastScanFreshnessPersistenceError('REPLAY_CONFLICT'); + } + return { + freshnessDecisionId: stored.freshnessDecisionId, + decisionDigest: stored.decisionDigest, + replayed: true + }; +} + +function sameRetryPredecessor( + previous: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptNumber: number; + sandboxId: string; + workloadIdentityRef: string; + stage: string; + failureClass: string | null; + retryEligible: boolean; + completedAt: Date | null; + finalAuditEventId: string | null; + finalAuditEvent: { + id: string; + tenantId: string; + scanRequestId: string | null; + attemptId: string | null; + eventType: string; + occurredAt: Date; + } | null; + scanRequest: { + sastQueueReservation: { + canonicalScanKey: string; + immutablePlan: Prisma.JsonValue; + } | null; + }; + }, + context: Readonly +): boolean { + const expected = context.evaluation; + const plan = previous.scanRequest.sastQueueReservation + ?.immutablePlan as unknown as SastScanPlan | undefined; + const completedAt = previous.completedAt?.toISOString() ?? null; + const finalAuditValid = Boolean( + previous.finalAuditEvent && + previous.finalAuditEvent.id === previous.finalAuditEventId && + previous.finalAuditEvent.tenantId === previous.tenantId && + previous.finalAuditEvent.scanRequestId === previous.scanRequestId && + previous.finalAuditEvent.attemptId === previous.id && + previous.finalAuditEvent.eventType === 'sandbox.terminated' && + completedAt !== null && + previous.finalAuditEvent.occurredAt.toISOString() === completedAt + ); + return ( + previous.id === expected.scope.previousAttemptId && + previous.tenantId === expected.scope.tenantId && + previous.repositoryBindingId === + expected.scope.repositoryBindingId && + previous.scanRequestId === expected.scope.scanRequestId && + previous.attemptNumber === expected.scope.previousAttemptNumber && + previous.sandboxId === expected.scope.previousSandboxId && + previous.workloadIdentityRef === + expected.scope.previousWorkloadIdentityRef && + previous.stage === expected.previousStage && + previous.failureClass === expected.previousFailureClass && + previous.retryEligible === expected.previousRetryEligible && + completedAt === expected.previousCompletedAt && + previous.finalAuditEventId === + expected.previousFinalAuditEventId && + finalAuditValid === expected.previousFinalAuditValid && + plan !== undefined && + isSastScanPlanValid(plan) && + previous.scanRequest.sastQueueReservation?.canonicalScanKey === + expected.durableCanonicalScanKey && + digest(buildSastScanPlanDigestPreimage(plan)) === + expected.durablePlanDigest + ); +} + +function isRetryableTransactionError(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && + (error.code === 'P2034' || error.code === 'P2002'); +} + +function json(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} diff --git a/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts b/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts index 2caf73b..11b74e0 100644 --- a/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts +++ b/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts @@ -4,11 +4,13 @@ import { SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS, buildSastScanPlanDigestPreimage, isSastScanPlanValid, + isSastScanRetryDecisionShapeValid, type SastScannerExecutionRecord, type SastScannerInvocation, type SastScannerRuntimeAuditSignal, type SastScannerWrapperExecutionRequest, - type SastScanPlan + type SastScanPlan, + type SastScanRetryDecision } from '@aegisai/shared'; import { Injectable } from '@nestjs/common'; import { @@ -138,7 +140,10 @@ export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { attemptNumber: 'desc' }, select: { + id: true, attemptNumber: true, + sandboxId: true, + workloadIdentityRef: true, stage: true, failureClass: true, retryEligible: true, @@ -159,6 +164,59 @@ export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { ); } + const retryDecision = request.attemptNumber === 2 + ? await transaction.sastScanRetryDecision.findUnique({ + where: { requestedAttemptId: request.attemptId }, + select: { id: true, decision: true } + }) + : null; + const canonicalRetryDecision = retryDecision?.decision as unknown as + | SastScanRetryDecision + | undefined; + if ( + request.attemptNumber === 2 && + (!retryDecision || + !canonicalRetryDecision || + !isSastScanRetryDecisionShapeValid( + canonicalRetryDecision, + (value) => this.digest(value) + ) || + !canonicalRetryDecision.retryAllowed || + canonicalRetryDecision.decidedAt !== startedAt || + canonicalRetryDecision.scope.tenantId !== + request.plan.tenantId || + canonicalRetryDecision.scope.repositoryBindingId !== + request.plan.repositoryState.repositoryBindingId || + canonicalRetryDecision.scope.scanRequestId !== + request.plan.scanRequestId || + canonicalRetryDecision.scope.requestedAttemptId !== + request.attemptId || + canonicalRetryDecision.scope.requestedAttemptNumber !== 2 || + canonicalRetryDecision.scope.requestedSandboxId !== + request.sandboxId || + canonicalRetryDecision.scope.requestedWorkloadIdentityRef !== + request.workloadIdentityRef || + canonicalRetryDecision.scope.previousAttemptId !== + latestAttempt?.id || + canonicalRetryDecision.scope.previousAttemptNumber !== + latestAttempt?.attemptNumber || + canonicalRetryDecision.scope.previousSandboxId !== + latestAttempt?.sandboxId || + canonicalRetryDecision.scope.previousWorkloadIdentityRef !== + latestAttempt?.workloadIdentityRef || + canonicalRetryDecision.scope.canonicalScanKey !== + request.plan.canonicalScanKey || + canonicalRetryDecision.scope.planDigest !== + this.planDigest(request.plan) || + canonicalRetryDecision.scope.originalScannerSetDigest !== + request.plan.scannerSet.scannerSetDigest) + ) { + throw securityViolation( + 'SCAN_ATTEMPT_RETRY_DECISION_INVALID', + 'Attempt two is not bound to an exact durable T040 retry decision.' + ); + } + await transaction.sastScanAttempt.create({ data: { id: request.attemptId, @@ -171,6 +229,7 @@ export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { workloadIdentityRef: request.workloadIdentityRef, stage: 'VALIDATING', retryEligible: false, + retryDecisionId: retryDecision?.id, startedAt: new Date(startedAt), attemptDeadlineAt: new Date( request.sandboxAttestation.claims.attemptDeadlineAt diff --git a/apps/api/src/scan-plane/repository-preflight-attestation.service.ts b/apps/api/src/scan-plane/repository-preflight-attestation.service.ts index 27fbda0..0d5af0a 100644 --- a/apps/api/src/scan-plane/repository-preflight-attestation.service.ts +++ b/apps/api/src/scan-plane/repository-preflight-attestation.service.ts @@ -5,6 +5,9 @@ import type { SastPreflightDecision } from '@aegisai/shared'; import { ConfigService } from '../config/config.service'; +const MAX_PREFLIGHT_ATTESTATION_CLOCK_SKEW_MS = 5_000; +const MAX_PREFLIGHT_ATTESTATION_AGE_MS = 60_000; + export interface RepositoryPreflightAttestationClaims { attemptId: string; fixedCommitSha: string; @@ -32,7 +35,8 @@ export class RepositoryPreflightAttestationService { verify( attestationRef: string, - expected: Omit + expected: Omit, + now = new Date() ): boolean { const prefix = 'attestation://sast-preflight/v1/'; if ( @@ -59,9 +63,13 @@ export class RepositoryPreflightAttestationService { const claims = JSON.parse( Buffer.from(payload, 'base64url').toString('utf8') ) as RepositoryPreflightAttestationClaims; + const issuedAt = Date.parse(claims.issuedAt); return ( this.canonical(claims) === Buffer.from(payload, 'base64url').toString('utf8') && - Number.isFinite(Date.parse(claims.issuedAt)) && + Number.isFinite(issuedAt) && + issuedAt <= + now.getTime() + MAX_PREFLIGHT_ATTESTATION_CLOCK_SKEW_MS && + issuedAt >= now.getTime() - MAX_PREFLIGHT_ATTESTATION_AGE_MS && claims.attemptId === expected.attemptId && claims.fixedCommitSha === expected.fixedCommitSha && claims.pathPolicyVersion === expected.pathPolicyVersion && diff --git a/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts b/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts index 3515f56..733dc83 100644 --- a/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts +++ b/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts @@ -18,6 +18,7 @@ import { type SastSandboxRuntimeAttestationClaims, type SastSandboxRuntimePolicy, type SastScanPlan, + type SastScannerPreflightBinding, type SastSignedSandboxCleanupObservation } from '@aegisai/shared'; import { Injectable } from '@nestjs/common'; @@ -33,6 +34,7 @@ export interface SandboxRuntimeAttestationBinding { sandboxId: string; workloadIdentityRef: string; policy: Readonly; + preflight?: Readonly; } @Injectable() @@ -56,6 +58,10 @@ export class SandboxRuntimeAttestationService { ) { throw new Error('Sandbox runtime attestation binding is invalid.'); } + const preflight = this.effectivePreflight(binding); + if (!preflight) { + throw new Error('Sandbox runtime attestation binding is invalid.'); + } const issuedAt = now.toISOString(); const attemptDeadlineAt = new Date( @@ -79,8 +85,8 @@ export class SandboxRuntimeAttestationService { profileId: binding.plan.profile.id, profileDigest: binding.plan.profileDigest, scannerSetDigest: binding.plan.scannerSet.scannerSetDigest, - preflightAttestationRef: binding.plan.repositoryState.attestationRef, - preflightInventoryDigest: binding.plan.repositoryState.inventoryDigest, + preflightAttestationRef: preflight.attestationRef, + preflightInventoryDigest: preflight.inventoryDigest, policy: binding.policy, nonce: randomBytes(16).toString('hex'), issuedAt, @@ -101,8 +107,10 @@ export class SandboxRuntimeAttestationService { ): boolean { try { const claims = attestation?.claims; + const preflight = this.effectivePreflight(expected); if ( !claims || + !preflight || !isSastScanPlanValid(expected.plan) || !this.hasOnlyKeys(attestation, ['claims', 'signature']) || !this.hasOnlyKeys(claims, [ @@ -172,9 +180,9 @@ export class SandboxRuntimeAttestationService { claims.scannerSetDigest === expected.plan.scannerSet.scannerSetDigest && claims.preflightAttestationRef === - expected.plan.repositoryState.attestationRef && + preflight.attestationRef && claims.preflightInventoryDigest === - expected.plan.repositoryState.inventoryDigest && + preflight.inventoryDigest && this.canonicalPolicy(claims.policy) === this.canonicalPolicy(expected.policy) && /^[a-f0-9]{32}$/u.test(claims.nonce) && @@ -396,6 +404,33 @@ export class SandboxRuntimeAttestationService { }); } + private effectivePreflight( + binding: SandboxRuntimeAttestationBinding + ): Readonly | null { + const preflight = binding.preflight ?? { + attestationRef: binding.plan.repositoryState.attestationRef, + decision: 'ACCEPT' as const, + inventoryDigest: binding.plan.repositoryState.inventoryDigest, + pathPolicyVersion: 'plan-bound-path-policy' + }; + return ( + this.isBoundedIdentifier(preflight.attestationRef, 8192) && + preflight.inventoryDigest === + binding.plan.repositoryState.inventoryDigest && + (preflight.decision === 'ACCEPT' || + (preflight.decision === 'RESTRICTED_ESCALATION' && + binding.plan.isolationClass === 'RESTRICTED')) && + this.isBoundedIdentifier(preflight.pathPolicyVersion, 255) && + (binding.attemptNumber === 1 + ? preflight.attestationRef === + binding.plan.repositoryState.attestationRef + : preflight.attestationRef !== + binding.plan.repositoryState.attestationRef) + ) + ? preflight + : null; + } + private canonicalCleanup( observation: SastSandboxCleanupObservation ): string { diff --git a/apps/api/src/scan-plane/sast-latest-target-authority.ts b/apps/api/src/scan-plane/sast-latest-target-authority.ts new file mode 100644 index 0000000..c5b20c5 --- /dev/null +++ b/apps/api/src/scan-plane/sast-latest-target-authority.ts @@ -0,0 +1,30 @@ +import type { + SastScanFreshnessScope +} from '@aegisai/shared'; + +export type SastLatestTargetObservationResult = + | { + status: 'VERIFIED'; + headCommitSha: string; + sequence: number; + observerRef: string; + observedAt: string; + } + | { status: 'UNAVAILABLE' }; + +/** + * Reads a provider-authoritative target head. The default stays unavailable + * until a read-only GitHub App or GitLab integration adapter is installed. + */ +export abstract class SastLatestTargetAuthority { + abstract observe( + scope: Readonly + ): Promise; +} + +export class UnavailableSastLatestTargetAuthority + extends SastLatestTargetAuthority { + async observe(): Promise { + return { status: 'UNAVAILABLE' }; + } +} diff --git a/apps/api/src/scan-plane/sast-retry-admission.gate.ts b/apps/api/src/scan-plane/sast-retry-admission.gate.ts new file mode 100644 index 0000000..acb6720 --- /dev/null +++ b/apps/api/src/scan-plane/sast-retry-admission.gate.ts @@ -0,0 +1,26 @@ +import type { + SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; + +export type SastRetryAdmissionVerification = + | { + outcome: 'AUTHORIZED'; + startedAt: string; + } + | { + outcome: 'REJECTED'; + }; + +export abstract class SastRetryAdmissionGate { + abstract authorize( + request: Readonly, + decidedAt: string + ): Promise; +} + +export class UnavailableSastRetryAdmissionGate + extends SastRetryAdmissionGate { + async authorize(): Promise { + return { outcome: 'REJECTED' }; + } +} diff --git a/apps/api/src/scan-plane/sast-retry-runtime-authority.ts b/apps/api/src/scan-plane/sast-retry-runtime-authority.ts new file mode 100644 index 0000000..b297c2a --- /dev/null +++ b/apps/api/src/scan-plane/sast-retry-runtime-authority.ts @@ -0,0 +1,32 @@ +import type { + SastScanRetryScope +} from '@aegisai/shared'; + +export interface SastRetryRuntimeAuthorityDecision { + currentScannerSetDigest: `sha256:${string}` | null; + scannerSetAvailable: boolean; + killSwitchStatus: 'CLEAR' | 'ACTIVE' | 'UNAVAILABLE'; + killSwitchSnapshotDigest: `sha256:${string}` | null; +} + +/** + * T040 rechecks mutable runtime safety state without changing immutable scan + * intent. T049 will install the live kill-switch authority. + */ +export abstract class SastRetryRuntimeAuthority { + abstract verify( + scope: Readonly + ): Promise; +} + +export class UnavailableSastRetryRuntimeAuthority + extends SastRetryRuntimeAuthority { + async verify(): Promise { + return { + currentScannerSetDigest: null, + scannerSetAvailable: false, + killSwitchStatus: 'UNAVAILABLE', + killSwitchSnapshotDigest: null + }; + } +} diff --git a/apps/api/src/scan-plane/sast-scan-freshness.service.ts b/apps/api/src/scan-plane/sast-scan-freshness.service.ts new file mode 100644 index 0000000..a2478e9 --- /dev/null +++ b/apps/api/src/scan-plane/sast-scan-freshness.service.ts @@ -0,0 +1,395 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_LATEST_TARGET_OBSERVATION_VERSION, + buildSastScanFreshnessDecision, + buildSastScanRetryDecision, + canonicalizeSastLatestTargetObservation, + isSastFindingLifecycleCoverageDecisionShapeValid, + isSastLatestTargetObservationShapeValid, + isSastScanFreshnessDecisionShapeValid, + isSastScanRetryDecisionShapeValid, + isSastScannerWrapperExecutionRequestValid, + type SastFindingLifecycleCoverageDecision, + type SastLatestTargetAuthority as SastLatestTargetAuthorityStatus, + type SastLatestTargetObservation, + type SastScanFreshnessDecision, + type SastScanRetryEvaluation, + type SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import type { + SastFindingLifecycleCoverageVerification +} from './sast-finding-lifecycle-coverage.gate'; +import { SastFindingLifecycleCoverageGate } from './sast-finding-lifecycle-coverage.gate'; +import { SastLatestTargetAuthority } from './sast-latest-target-authority'; +import { + SastRetryAdmissionGate, + type SastRetryAdmissionVerification +} from './sast-retry-admission.gate'; +import { SastRetryRuntimeAuthority } from './sast-retry-runtime-authority'; +import { + SastScanFreshnessStore, + type SastScanFreshnessContext +} from './sast-scan-freshness.store'; + +export type SastScanFreshnessOutcome = + | { + outcome: 'EVALUATED'; + decision: SastScanFreshnessDecision; + replayed: boolean; + } + | { + outcome: 'REJECTED'; + reasonCode: + | 'FRESHNESS_INPUT_INVALID' + | 'FRESHNESS_CONTEXT_UNAVAILABLE' + | 'FRESHNESS_PERSISTENCE_FAILED'; + publicationAttempted: false; + lifecycleMutationAttempted: false; + aiPayloadCreated: false; + }; + +type FreshnessClock = () => string; + +@Injectable() +export class SastScanFreshnessService + extends SastFindingLifecycleCoverageGate + implements SastRetryAdmissionGate { + constructor( + private readonly store: SastScanFreshnessStore, + private readonly latestTarget: SastLatestTargetAuthority, + private readonly retryRuntime: SastRetryRuntimeAuthority + ) { + super(); + } + + async evaluate( + coverageDecisionId: string, + clock: FreshnessClock = () => new Date().toISOString() + ): Promise { + if (!/^sast-coverage:\/\/[a-f0-9]{64}$/u.test(coverageDecisionId)) { + return this.reject('FRESHNESS_INPUT_INVALID'); + } + try { + const context = await this.store.loadContext(coverageDecisionId); + if (!context) return this.reject('FRESHNESS_CONTEXT_UNAVAILABLE'); + if ( + context.existingDecision && + isSastScanFreshnessDecisionShapeValid( + context.existingDecision, + digest + ) + ) { + return { + outcome: 'EVALUATED', + decision: context.existingDecision, + replayed: true + }; + } + + const decidedAt = clock(); + if ( + !isCanonicalTimestamp(decidedAt) || + !context.coverageCompletedAt || + Date.parse(decidedAt) < Date.parse(context.coverageCompletedAt) + ) { + return this.reject('FRESHNESS_INPUT_INVALID'); + } + const observed = await this.readTargetObservation( + context, + decidedAt + ); + const decision = buildSastScanFreshnessDecision({ + freshnessDecisionId: deterministicId( + 'sast-freshness', + `${context.scope.coverageDecisionId}\0${context.scope.coverageDecisionDigest}` + ), + coverageComplete: context.coverageComplete, + scope: context.scope, + observation: observed.observation, + observationAuthority: observed.authority, + observationMonotonic: observed.monotonic, + comparison: context.comparison, + decidedAt, + digestCanonical: digest + }); + if (!isSastScanFreshnessDecisionShapeValid(decision, digest)) { + return this.reject('FRESHNESS_INPUT_INVALID'); + } + const persisted = await this.store.persistFreshness({ + context, + observation: observed.observation, + decision + }); + if ( + persisted.freshnessDecisionId !== + decision.freshnessDecisionId || + persisted.decisionDigest !== decision.decisionDigest + ) { + return this.reject('FRESHNESS_PERSISTENCE_FAILED'); + } + return { + outcome: 'EVALUATED', + decision, + replayed: persisted.replayed + }; + } catch { + return this.reject('FRESHNESS_PERSISTENCE_FAILED'); + } + } + + async verify( + decision: Readonly + ): Promise { + if ( + !isSastFindingLifecycleCoverageDecisionShapeValid( + decision, + digest + ) + ) { + return 'REJECTED'; + } + try { + const context = await this.store.loadContext( + decision.sourceCoverageDecisionRef + ); + if ( + !context || + !(await this.targetStillCurrent( + context, + decision.commitSha, + decision.decidedAt + )) + ) { + return 'REJECTED'; + } + return (await this.store.verifyLifecycleSource(decision)) === + 'MATCHED' + ? 'VERIFIED' + : 'REJECTED'; + } catch { + return 'REJECTED'; + } + } + + async authorize( + request: Readonly, + proposedStartedAt: string + ): Promise { + if ( + request.attemptNumber !== 2 || + !isCanonicalTimestamp(proposedStartedAt) || + !isSastScannerWrapperExecutionRequestValid(request) + ) { + return { outcome: 'REJECTED' }; + } + try { + const context = await this.store.loadRetryContext(request); + if (!context) return { outcome: 'REJECTED' }; + if (context.existingDecision) { + return isSastScanRetryDecisionShapeValid( + context.existingDecision, + digest + ) && + context.existingDecision.retryAllowed && + Date.parse(context.existingDecision.decidedAt) <= + Date.parse(proposedStartedAt) + ? { + outcome: 'AUTHORIZED', + startedAt: context.existingDecision.decidedAt + } + : { outcome: 'REJECTED' }; + } + const authority = await this.retryRuntime + .verify(context.evaluation.scope) + .catch(() => ({ + currentScannerSetDigest: null, + scannerSetAvailable: false, + killSwitchStatus: 'UNAVAILABLE' as const, + killSwitchSnapshotDigest: null + })); + const evaluation: SastScanRetryEvaluation = { + ...context.evaluation, + ...authority + }; + const decision = buildSastScanRetryDecision({ + retryDecisionId: deterministicId( + 'sast-retry', + `${evaluation.scope.scanRequestId}\0${evaluation.scope.requestedAttemptId}` + ), + evaluation, + decidedAt: proposedStartedAt, + digestCanonical: digest + }); + if (!isSastScanRetryDecisionShapeValid(decision, digest)) { + return { outcome: 'REJECTED' }; + } + const persisted = await this.store.persistRetryDecision({ + context, + decision + }); + return persisted.retryDecisionId === decision.retryDecisionId && + persisted.decisionDigest === decision.decisionDigest && + persisted.retryAllowed && + decision.retryAllowed + ? { outcome: 'AUTHORIZED', startedAt: decision.decidedAt } + : { outcome: 'REJECTED' }; + } catch { + return { outcome: 'REJECTED' }; + } + } + + private async readTargetObservation( + context: Readonly, + decidedAt: string + ): Promise<{ + authority: SastLatestTargetAuthorityStatus; + observation: SastLatestTargetObservation | null; + monotonic: boolean; + }> { + let result: Awaited>; + try { + result = await this.latestTarget.observe(context.scope); + } catch { + return { + authority: 'UNAVAILABLE', + observation: null, + monotonic: false + }; + } + if (result.status === 'UNAVAILABLE') { + return { + authority: 'UNAVAILABLE', + observation: null, + monotonic: false + }; + } + const core = { + version: SAST_LATEST_TARGET_OBSERVATION_VERSION, + observationId: deterministicId( + 'sast-target-observation', + [ + context.scope.tenantId, + context.scope.repositoryBindingId, + context.scope.provider, + context.scope.targetRef, + result.headCommitSha, + String(result.sequence), + result.observerRef, + result.observedAt + ].join('\0') + ), + tenantId: context.scope.tenantId, + repositoryBindingId: context.scope.repositoryBindingId, + provider: context.scope.provider, + targetRef: context.scope.targetRef, + headCommitSha: result.headCommitSha, + sequence: result.sequence, + observerRef: result.observerRef, + observedAt: result.observedAt + }; + const observation: SastLatestTargetObservation = { + ...core, + observationDigest: digest( + canonicalizeSastLatestTargetObservation(core) + ) + }; + if ( + !isSastLatestTargetObservationShapeValid(observation, digest) || + !context.coverageCompletedAt || + Date.parse(observation.observedAt) < + Date.parse(context.coverageCompletedAt) || + Date.parse(observation.observedAt) > Date.parse(decidedAt) + 5_000 + ) { + return { + authority: 'INVALID', + observation: null, + monotonic: false + }; + } + const previous = context.latestObservation; + const monotonic = !previous || + (observation.sequence > previous.sequence && + Date.parse(observation.observedAt) >= + Date.parse(previous.observedAt)); + if (!monotonic) { + return { + authority: 'INVALID', + observation: null, + monotonic: false + }; + } + return { + authority: 'VERIFIED', + observation, + monotonic + }; + } + + private async targetStillCurrent( + context: Readonly, + commitSha: string, + freshnessDecidedAt: string + ): Promise { + try { + const result = await this.latestTarget.observe(context.scope); + if ( + result.status !== 'VERIFIED' || + !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test( + result.headCommitSha + ) || + result.headCommitSha !== commitSha || + !Number.isSafeInteger(result.sequence) || + result.sequence <= 0 || + typeof result.observerRef !== 'string' || + result.observerRef.length === 0 || + result.observerRef.length > 2048 || + !isCanonicalTimestamp(result.observedAt) || + Date.parse(result.observedAt) < Date.parse(freshnessDecidedAt) || + Date.parse(result.observedAt) > Date.now() + 5_000 + ) { + return false; + } + const previous = context.latestObservation; + return !previous || + (result.sequence > previous.sequence && + Date.parse(result.observedAt) >= + Date.parse(previous.observedAt)); + } catch { + return false; + } + } + + private reject( + reasonCode: Extract< + SastScanFreshnessOutcome, + { outcome: 'REJECTED' } + >['reasonCode'] + ): SastScanFreshnessOutcome { + return { + outcome: 'REJECTED', + reasonCode, + publicationAttempted: false, + lifecycleMutationAttempted: false, + aiPayloadCreated: false + }; + } +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function deterministicId(prefix: string, preimage: string): string { + return `${prefix}://${createHash('sha256') + .update(preimage) + .digest('hex')}`; +} + +function isCanonicalTimestamp(value: string): boolean { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString() === value; +} diff --git a/apps/api/src/scan-plane/sast-scan-freshness.store.ts b/apps/api/src/scan-plane/sast-scan-freshness.store.ts new file mode 100644 index 0000000..7edd17b --- /dev/null +++ b/apps/api/src/scan-plane/sast-scan-freshness.store.ts @@ -0,0 +1,77 @@ +import type { + SastFindingLifecycleCoverageDecision, + SastLatestTargetObservation, + SastScanComparisonSource, + SastScanFreshnessDecision, + SastScanFreshnessScope, + SastScanRetryDecision, + SastScanRetryEvaluation, + SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; + +export interface SastScanFreshnessContext { + coverageComplete: boolean; + coverageCompletedAt: string | null; + scope: SastScanFreshnessScope; + comparison: SastScanComparisonSource | null; + latestObservation: { + observationId: string; + sequence: number; + observedAt: string; + } | null; + existingDecision: SastScanFreshnessDecision | null; +} + +export interface PersistedSastScanFreshness { + freshnessDecisionId: string; + decisionDigest: `sha256:${string}`; + replayed: boolean; +} + +export interface SastScanRetryDurableContext { + evaluation: Omit< + SastScanRetryEvaluation, + | 'currentScannerSetDigest' + | 'scannerSetAvailable' + | 'killSwitchStatus' + | 'killSwitchSnapshotDigest' + >; + existingDecision: SastScanRetryDecision | null; +} + +export class SastScanFreshnessPersistenceError extends Error { + constructor(readonly reason: 'CONTEXT_DRIFT' | 'REPLAY_CONFLICT') { + super('The SAST freshness or retry ledger conflicts with durable state.'); + this.name = 'SastScanFreshnessPersistenceError'; + } +} + +export abstract class SastScanFreshnessStore { + abstract loadContext( + coverageDecisionId: string + ): Promise; + + abstract persistFreshness(input: { + context: Readonly; + observation: Readonly | null; + decision: Readonly; + }): Promise; + + abstract verifyLifecycleSource( + decision: Readonly + ): Promise<'MATCHED' | 'REJECTED'>; + + abstract loadRetryContext( + request: Readonly + ): Promise; + + abstract persistRetryDecision(input: { + context: Readonly; + decision: Readonly; + }): Promise<{ + retryDecisionId: string; + decisionDigest: `sha256:${string}`; + retryAllowed: boolean; + replayed: boolean; + }>; +} diff --git a/apps/api/src/scan-plane/sast-scanner-runtime.service.ts b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts index ee63fc9..85cb9c2 100644 --- a/apps/api/src/scan-plane/sast-scanner-runtime.service.ts +++ b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts @@ -27,6 +27,10 @@ import { ScannerSandboxAdapterService } from './scanner-sandbox-adapter.service' import { ScannerSandboxRuntimeProvider } from './scanner-sandbox-runtime.provider'; import { ScannerWorkspaceManifestService } from './scanner-workspace-manifest.service'; import { SastScannerRuntimeStore } from './sast-scanner-runtime.store'; +import { + SastRetryAdmissionGate, + UnavailableSastRetryAdmissionGate +} from './sast-retry-admission.gate'; @Injectable() export class SastScannerRuntimeService { @@ -36,7 +40,9 @@ export class SastScannerRuntimeService { private readonly attestation: SandboxRuntimeAttestationService, private readonly manifestVerifier: ScannerWorkspaceManifestService, private readonly provider: ScannerSandboxRuntimeProvider, - private readonly store: SastScannerRuntimeStore + private readonly store: SastScannerRuntimeStore, + private readonly retryAdmission: SastRetryAdmissionGate = + new UnavailableSastRetryAdmissionGate() ) {} async execute( @@ -65,6 +71,7 @@ export class SastScannerRuntimeService { } await this.assertControlPlaneScope(request); + this.manifestVerifier.verifyPreflight(request); const policy = this.adapter.buildPolicy(request.plan); if ( !this.attestation.verify(request.sandboxAttestation, { @@ -73,7 +80,8 @@ export class SastScannerRuntimeService { attemptNumber: request.attemptNumber, sandboxId: request.sandboxId, workloadIdentityRef: request.workloadIdentityRef, - policy + policy, + preflight: request.preflight }) ) { throw securityViolation( @@ -82,9 +90,22 @@ export class SastScannerRuntimeService { ); } - const startedAt = new Date().toISOString(); + let startedAt = new Date().toISOString(); const attemptDeadlineAt = request.sandboxAttestation.claims.attemptDeadlineAt; + if (request.attemptNumber === 2) { + const admission = await this.retryAdmission.authorize( + request, + startedAt + ); + if (admission.outcome !== 'AUTHORIZED') { + throw securityViolation( + 'SCAN_ATTEMPT_RETRY_NOT_ELIGIBLE', + 'Attempt two requires a durable T040 infrastructure-only retry decision.' + ); + } + startedAt = admission.startedAt; + } await this.store.beginAttempt(request, startedAt); const auditSignals: SastScannerRuntimeAuditSignal[] = []; diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index 104a942..e537a99 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -101,6 +101,24 @@ import { import { SastScanCoverageService } from './sast-scan-coverage.service'; +import { + PrismaSastScanFreshnessStore +} from './prisma-sast-scan-freshness.store'; +import { + SastScanFreshnessStore +} from './sast-scan-freshness.store'; +import { + SastScanFreshnessService +} from './sast-scan-freshness.service'; +import { + SastLatestTargetAuthority, + UnavailableSastLatestTargetAuthority +} from './sast-latest-target-authority'; +import { + SastRetryRuntimeAuthority, + UnavailableSastRetryRuntimeAuthority +} from './sast-retry-runtime-authority'; +import { SastRetryAdmissionGate } from './sast-retry-admission.gate'; import { SastFindingRenameAttestationVerifier, UnavailableSastFindingRenameAttestationVerifier @@ -130,6 +148,7 @@ import { SastFindingLineageService, SastFindingCorrelationService, SastScanCoverageService, + SastScanFreshnessService, PrismaSastFindingLineageStore, { provide: SastFindingLineageStore, @@ -145,6 +164,21 @@ import { provide: SastScanCoverageStore, useExisting: PrismaSastScanCoverageStore }, + PrismaSastScanFreshnessStore, + { + provide: SastScanFreshnessStore, + useExisting: PrismaSastScanFreshnessStore + }, + UnavailableSastLatestTargetAuthority, + { + provide: SastLatestTargetAuthority, + useExisting: UnavailableSastLatestTargetAuthority + }, + UnavailableSastRetryRuntimeAuthority, + { + provide: SastRetryRuntimeAuthority, + useExisting: UnavailableSastRetryRuntimeAuthority + }, UnavailableSastFindingRenameAttestationVerifier, { provide: SastFindingRenameAttestationVerifier, @@ -153,7 +187,11 @@ import { }, { provide: SastFindingLifecycleCoverageGate, - useExisting: SastScanCoverageService + useExisting: SastScanFreshnessService + }, + { + provide: SastRetryAdmissionGate, + useExisting: SastScanFreshnessService }, SastArtifactDispositionService, SastArtifactDispositionTask, @@ -229,7 +267,7 @@ import { RepositoryPreflightService, SandboxRuntimeAttestationService, SastScannerRuntimeService, - SastScanCoverageService + SastScanFreshnessService ] }) export class ScanPlaneModule {} diff --git a/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts b/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts index 117c6c1..0ead707 100644 --- a/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts +++ b/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts @@ -27,27 +27,7 @@ export class ScannerWorkspaceManifestService { manifest: SastScannerRepositoryManifest, now = new Date() ): SastRepositoryPreflightResult { - if (!isSastScannerWrapperExecutionRequestValid(request)) { - throw securityViolation( - 'SCANNER_PLAN_BINDING_INVALID', - 'Scanner request is not bound to an immutable plan.' - ); - } - - if ( - !this.preflightAttestation.verify(request.preflight.attestationRef, { - attemptId: request.attemptId, - fixedCommitSha: request.plan.repositoryState.fixedCommitSha, - pathPolicyVersion: request.preflight.pathPolicyVersion, - inventoryDigest: request.preflight.inventoryDigest, - decision: request.preflight.decision - }) - ) { - throw securityViolation( - 'PREFLIGHT_ATTESTATION_INVALID', - 'Preflight attestation is missing, invalid, or stale for this attempt.' - ); - } + this.verifyPreflight(request); const observedAt = typeof manifest?.observedAt === 'string' @@ -141,6 +121,30 @@ export class ScannerWorkspaceManifestService { return evaluated; } + verifyPreflight(request: SastScannerWrapperExecutionRequest): void { + if (!isSastScannerWrapperExecutionRequestValid(request)) { + throw securityViolation( + 'SCANNER_PLAN_BINDING_INVALID', + 'Scanner request is not bound to an immutable plan.' + ); + } + + if ( + !this.preflightAttestation.verify(request.preflight.attestationRef, { + attemptId: request.attemptId, + fixedCommitSha: request.plan.repositoryState.fixedCommitSha, + pathPolicyVersion: request.preflight.pathPolicyVersion, + inventoryDigest: request.preflight.inventoryDigest, + decision: request.preflight.decision + }) + ) { + throw securityViolation( + 'PREFLIGHT_ATTESTATION_INVALID', + 'Preflight attestation is missing, invalid, or stale for this attempt.' + ); + } + } + private hasOnlyKeys(value: unknown, allowedKeys: readonly string[]): boolean { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; diff --git a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts index a13a2fa..187c4a8 100644 --- a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts @@ -96,7 +96,7 @@ describe('SAST finding correlation persistence contract', () => { ); }); - it('fences late T037 batches and keeps the T038 gate internal after T039', () => { + it('fences late T037 batches and keeps T038/T039 internal after T040', () => { expect(lineageStore).toContain( 'transaction.sastFindingCorrelationBatch' ); @@ -105,7 +105,8 @@ describe('SAST finding correlation persistence contract', () => { ); const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanCoverageService'); + expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( 'SastFindingCorrelationService' ); diff --git a/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts index 7fc50bc..e3c8f12 100644 --- a/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts @@ -150,17 +150,18 @@ describe('SAST finding lineage persistence contract', () => { ); }); - it('keeps T037 internal after T039 and binds coverage consumption to the fail-closed gate', () => { + it('keeps T037 internal after T040 and binds lifecycle consumption to the freshness gate', () => { expect(module).toContain( 'UnavailableSastFindingRenameAttestationVerifier' ); expect(module).toMatch( - /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanCoverageService/ + /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanFreshnessService/ ); const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanCoverageService'); + expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( 'SastFindingIdentityService' ); diff --git a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts index 8eef67f..62f7950 100644 --- a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts @@ -114,10 +114,11 @@ describe('SAST scan coverage persistence contract', () => { ); }); - it('exposes only the T039 handoff and opens no route or SCM writer', () => { + it('keeps T039 internal after exposing only the T040 sequential handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanCoverageService'); + expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( 'SastFindingCorrelationService' ); @@ -128,7 +129,7 @@ describe('SAST scan coverage persistence contract', () => { expect(exportsBlock).not.toContain('TrivyJsonNormalizer'); expect(exportsBlock).not.toContain('SyftCycloneDxInventoryIngestor'); expect(module).toMatch( - /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanCoverageService/ + /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanFreshnessService/ ); expect(service).not.toMatch(/\bLogger\b|\bconsole\./u); expect(service).not.toMatch( diff --git a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts new file mode 100644 index 0000000..9ef2a88 --- /dev/null +++ b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts @@ -0,0 +1,194 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('SAST scan freshness and retry persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql' + ); + const onlineSchema = read( + 'scripts/apply-online-sast-runtime-schema.mjs' + ); + const store = read( + 'src/scan-plane/prisma-sast-scan-freshness.store.ts' + ); + const service = read('src/scan-plane/sast-scan-freshness.service.ts'); + const runtime = read('src/scan-plane/sast-scanner-runtime.service.ts'); + const attestation = read( + 'src/scan-plane/sandbox-runtime-attestation.service.ts' + ); + const runtimeStore = read( + 'src/scan-plane/prisma-sast-scanner-runtime.store.ts' + ); + const module = read('src/scan-plane/scan-plane.module.ts'); + + it('persists independently scoped target observations, freshness, and retry decisions', () => { + for (const model of [ + 'SastLatestTargetObservation', + 'SastScanFreshnessDecision', + 'SastScanRetryDecision' + ]) { + expect(schema).toContain(`model ${model} {`); + expect(migration).toContain(`CREATE TABLE "${model}"`); + } + expect(migration).toContain( + 'SastLatestTargetObservation_sequence_key' + ); + expect(migration).toContain( + 'SastScanFreshnessDecision_coverage_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastScanCoverageDecision_comparison_scope_key' + ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanCoverageDecision_comparison_scope_key"' + ); + expect(migration).not.toContain( + 'CREATE UNIQUE INDEX "SastScanCoverageDecision_comparison_scope_key"' + ); + expect(onlineSchema).toMatch( + /FOREIGN KEY \("previousCoverageDecisionId", "tenantId", "repositoryBindingId", "previousScanRequestId"\)/u + ); + expect(migration).toContain( + 'SastScanRetryDecision_previous_attempt_fkey' + ); + expect(onlineSchema).toContain( + 'SastScanRetryDecision_final_audit_fkey' + ); + expect(onlineSchema).toContain( + 'FOREIGN KEY ("previousFinalAuditEventId", "previousAttemptId", "tenantId") REFERENCES "AuditEvent"("id", "attemptId", "tenantId") ON DELETE RESTRICT ON UPDATE CASCADE' + ); + expect(migration).not.toContain( + 'SastScanRetryDecision_final_audit_fkey' + ); + expect(migration).toContain( + 'SastScanAttempt_retryDecisionId_fkey' + ); + expect(migration).toMatch( + /SastScanAttempt_retryDecisionId_fkey[\s\S]{0,220}NOT VALID/u + ); + expect(onlineSchema).toContain( + 'SastScanAttempt_retryDecisionId_key' + ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanAttempt_retryDecisionId_key"' + ); + expect(onlineSchema).toContain( + 'SastScanAttempt_retryDecisionId_fkey' + ); + expect(migration).toMatch( + /SastScanAttempt_retry_authority_check[\s\S]{0,260}NOT VALID/u + ); + }); + + it('replaces the T039 permanent check while preserving its immutable source row', () => { + expect(migration).toContain( + 'SastExternalPublicationDecision_t039_source_check' + ); + expect(migration).toMatch( + /SastExternalPublicationDecision_t039_source_check[\s\S]{0,500}NOT VALID/u + ); + expect(migration).not.toContain( + 'VALIDATE CONSTRAINT "SastExternalPublicationDecision_t039_source_check"' + ); + expect(migration).not.toContain( + 'DROP CONSTRAINT "SastExternalPublicationDecision_contract_check"' + ); + expect(onlineSchema).toContain( + "replacement: 'SastExternalPublicationDecision_t039_source_check'" + ); + expect(onlineSchema).toContain( + "name: 'SastExternalPublicationDecision_contract_check'" + ); + expect(migration).toContain( + 'SastScanFreshnessDecision_contract_check' + ); + expect(migration).toMatch( + /"latestTargetAuthority" = 'VERIFIED'[\s\S]{0,200}"staleStatus" = 'FRESH'[\s\S]{0,200}"comparabilityStatus" = 'COMPARABLE'/ + ); + expect(migration).toContain('"aiAdvisoryAllowed" = false'); + expect(migration).toContain('"publicationAttempted" = false'); + }); + + it('uses bounded serializable replay and re-verifies T037 lifecycle input', () => { + expect(store).toContain( + 'Prisma.TransactionIsolationLevel.Serializable' + ); + expect(store).toContain('SERIALIZABLE_ATTEMPTS = 3'); + expect(store).toContain( + 'SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000' + ); + expect(store).toContain('sameFreshnessContext'); + expect(store).toContain('replayFreshness'); + expect(store).toContain('sameRetryPredecessor'); + expect(store).toContain('coverageRowMatchesDecision'); + expect(store).toContain('coverageLedgerMatchesDecision'); + expect(store).toContain('buildSastScanCoverageRecordsPreimage'); + expect(store).toContain('verifyLifecycleSource'); + expect(store).toContain( + 'scanRequestId: { not: row.scanRequestId }' + ); + expect(store).toContain( + 'profileId: { in: compatibleProfileIds }' + ); + expect(store).toContain('Denials are immutable audit evidence'); + expect(store).toContain("state: 'COMPLETE'"); + expect(store).toContain("staleStatus: 'FRESH'"); + expect(store).toContain("comparabilityStatus: 'COMPARABLE'"); + }); + + it('requires a durable allowed retry row before attempt two starts', () => { + expect(runtime).toContain('SastRetryAdmissionGate'); + expect(runtime).toContain("request.attemptNumber === 2"); + expect(runtime).toContain("'AUTHORIZED'"); + expect(runtimeStore).toContain( + 'isSastScanRetryDecisionShapeValid' + ); + expect(runtimeStore).toContain( + 'sastScanRetryDecision.findUnique' + ); + expect(runtimeStore).toContain('retryDecisionId: retryDecision?.id'); + expect(runtime).toContain('preflight: request.preflight'); + expect(runtime).toContain('verifyPreflight(request)'); + expect(runtime).toContain('startedAt = admission.startedAt'); + expect(service).toContain( + 'startedAt: context.existingDecision.decidedAt' + ); + expect(attestation).toContain('effectivePreflight'); + expect(attestation).toContain('binding.attemptNumber === 1'); + expect(attestation).toMatch( + /preflight\.attestationRef\s*!==\s*binding\.plan\.repositoryState\.attestationRef/u + ); + expect(migration).toContain( + '"previousFailureClass" = \'RETRYABLE_INFRASTRUCTURE\'' + ); + expect(migration).toMatch( + /"retryAllowed" = true\s+AND "previousAttemptNumber" = 1/u + ); + expect(migration).toContain( + '"previousSandboxId" <> "requestedSandboxId"' + ); + }); + + it('exports only the T040 sequential handoff and opens no route or SCM writer', () => { + const exportsBlock = + module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; + expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).not.toContain('SastScanCoverageService'); + expect(module).toMatch( + /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanFreshnessService/ + ); + expect(module).toMatch( + /provide:\s*SastRetryAdmissionGate,[\s\S]{0,100}useExisting:\s*SastScanFreshnessService/ + ); + expect(service).not.toMatch(/@Controller|@(Get|Post|Put|Patch|Delete)\(/u); + expect(service).not.toMatch(/SCM_WRITE|commentWrite|statusWrite/u); + expect(service).not.toMatch(/\bLogger\b|\bconsole\./u); + expect(service).toContain('publicationAttempted: false'); + expect(service).toContain('aiPayloadCreated: false'); + }); +}); + +function read(path: string): string { + return readFileSync(resolve(__dirname, `../../${path}`), 'utf8'); +} diff --git a/apps/api/test/scan-plane/sast-scan-freshness.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-freshness.e2e-spec.ts new file mode 100644 index 0000000..5e9a70a --- /dev/null +++ b/apps/api/test/scan-plane/sast-scan-freshness.e2e-spec.ts @@ -0,0 +1,374 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + canonicalizeSastFindingLifecycleCoverageDecision, + type SastFindingLifecycleCoverageDecision, + type SastScanFreshnessDecision, + type SastScanRetryDecision +} from '@aegisai/shared'; + +import { + SastLatestTargetAuthority, + UnavailableSastLatestTargetAuthority +} from '../../src/scan-plane/sast-latest-target-authority'; +import { + UnavailableSastRetryRuntimeAuthority +} from '../../src/scan-plane/sast-retry-runtime-authority'; +import { SastScanFreshnessService } from '../../src/scan-plane/sast-scan-freshness.service'; +import { + SastScanFreshnessStore, + type SastScanFreshnessContext, + type SastScanRetryDurableContext +} from '../../src/scan-plane/sast-scan-freshness.store'; + +const DECIDED_AT = '2026-08-10T03:00:01.000Z'; + +describe('SastScanFreshnessService', () => { + it('persists fresh comparable eligibility without publishing or creating AI payloads', async () => { + const context = freshnessContext(); + const store = new MemoryFreshnessStore(context); + const service = new SastScanFreshnessService( + store, + new VerifiedTargetAuthority(context.scope.commitSha), + new UnavailableSastRetryRuntimeAuthority() + ); + + const result = await service.evaluate( + context.scope.coverageDecisionId, + () => DECIDED_AT + ); + + expect(result.outcome).toBe('EVALUATED'); + if (result.outcome !== 'EVALUATED') return; + expect(result.decision).toMatchObject({ + latestTargetAuthority: 'VERIFIED', + staleStatus: 'FRESH', + comparabilityStatus: 'COMPARABLE', + externalCommentEligible: true, + blockingStatusEligible: true, + lifecycleMutationAllowed: true, + aiAdvisoryAllowed: false, + publicationAttempted: false, + reasonCodes: [] + }); + expect(store.persistedFreshness?.observation).not.toBeNull(); + expect(store.persistedFreshness?.decision).toEqual(result.decision); + }); + + it('denies a stale target head even with complete comparable coverage', async () => { + const context = freshnessContext(); + const service = new SastScanFreshnessService( + new MemoryFreshnessStore(context), + new VerifiedTargetAuthority('c'.repeat(40)), + new UnavailableSastRetryRuntimeAuthority() + ); + const result = await service.evaluate( + context.scope.coverageDecisionId, + () => DECIDED_AT + ); + + expect(result.outcome).toBe('EVALUATED'); + if (result.outcome !== 'EVALUATED') return; + expect(result.decision.staleStatus).toBe('STALE'); + expect(result.decision.externalCommentEligible).toBe(false); + expect(result.decision.lifecycleMutationAllowed).toBe(false); + expect(result.decision.reasonCodes).toEqual([ + 'TARGET_HEAD_MISMATCH', + 'PUBLICATION_FAIL_CLOSED' + ]); + }); + + it('fails closed when the latest-target authority is unavailable', async () => { + const context = freshnessContext(); + const result = await new SastScanFreshnessService( + new MemoryFreshnessStore(context), + new UnavailableSastLatestTargetAuthority(), + new UnavailableSastRetryRuntimeAuthority() + ).evaluate(context.scope.coverageDecisionId, () => DECIDED_AT); + + expect(result.outcome).toBe('EVALUATED'); + if (result.outcome !== 'EVALUATED') return; + expect(result.decision.latestTargetAuthority).toBe('UNAVAILABLE'); + expect(result.decision.staleStatus).toBe('UNKNOWN'); + expect(result.decision.externalCommentEligible).toBe(false); + expect(result.decision.aiAdvisoryAllowed).toBe(false); + }); + + it('rejects an observation captured before terminal coverage', async () => { + const context = freshnessContext(); + const store = new MemoryFreshnessStore(context); + const result = await new SastScanFreshnessService( + store, + new VerifiedTargetAuthority( + context.scope.commitSha, + 2, + '2026-08-10T02:59:59.000Z' + ), + new UnavailableSastRetryRuntimeAuthority() + ).evaluate(context.scope.coverageDecisionId, () => DECIDED_AT); + + expect(result.outcome).toBe('EVALUATED'); + if (result.outcome !== 'EVALUATED') return; + expect(result.decision.latestTargetAuthority).toBe('INVALID'); + expect(result.decision.externalCommentEligible).toBe(false); + expect(store.persistedFreshness?.observation).toBeNull(); + }); + + it('replays the exact canonical decision without observing the provider again', async () => { + const context = freshnessContext(); + const target = new VerifiedTargetAuthority(context.scope.commitSha); + const store = new MemoryFreshnessStore(context); + const first = await new SastScanFreshnessService( + store, + target, + new UnavailableSastRetryRuntimeAuthority() + ).evaluate(context.scope.coverageDecisionId, () => DECIDED_AT); + expect(first.outcome).toBe('EVALUATED'); + if (first.outcome !== 'EVALUATED') return; + store.context.existingDecision = first.decision; + + const second = await new SastScanFreshnessService( + store, + target, + new UnavailableSastRetryRuntimeAuthority() + ).evaluate(context.scope.coverageDecisionId, () => DECIDED_AT); + + expect(second).toMatchObject({ outcome: 'EVALUATED', replayed: true }); + expect(target.calls).toBe(1); + }); + + it('lets T037 consume only a canonical decision reverified by the T040 store', async () => { + const store = new MemoryFreshnessStore(freshnessContext()); + store.lifecycleVerification = 'MATCHED'; + const decision = lifecycleDecision(); + const service = new SastScanFreshnessService( + store, + new VerifiedTargetAuthority(freshnessContext().scope.commitSha), + new UnavailableSastRetryRuntimeAuthority() + ); + + await expect(service.verify(decision)).resolves.toBe('VERIFIED'); + expect(store.lifecycleDecision).toEqual(decision); + await expect( + service.verify({ ...decision, stale: true } as never) + ).resolves.toBe('REJECTED'); + }); + + it('rechecks the provider head at lifecycle consumption time', async () => { + const store = new MemoryFreshnessStore(freshnessContext()); + store.lifecycleVerification = 'MATCHED'; + const decision = lifecycleDecision(); + const service = new SastScanFreshnessService( + store, + new VerifiedTargetAuthority('d'.repeat(40)), + new UnavailableSastRetryRuntimeAuthority() + ); + + await expect(service.verify(decision)).resolves.toBe('REJECTED'); + expect(store.lifecycleDecision).toBeUndefined(); + }); + + it('rejects a rolled-back lifecycle observation sequence', async () => { + const store = new MemoryFreshnessStore(freshnessContext()); + store.lifecycleVerification = 'MATCHED'; + const decision = lifecycleDecision(); + const service = new SastScanFreshnessService( + store, + new VerifiedTargetAuthority( + freshnessContext().scope.commitSha, + 1 + ), + new UnavailableSastRetryRuntimeAuthority() + ); + + await expect(service.verify(decision)).resolves.toBe('REJECTED'); + expect(store.lifecycleDecision).toBeUndefined(); + }); +}); + +class VerifiedTargetAuthority extends SastLatestTargetAuthority { + calls = 0; + + constructor( + private readonly headCommitSha: string, + private readonly sequence = 2, + private readonly observedAt = DECIDED_AT + ) { + super(); + } + + async observe() { + this.calls += 1; + return { + status: 'VERIFIED' as const, + headCommitSha: this.headCommitSha, + sequence: this.sequence, + observerRef: 'scm-head-authority://test', + observedAt: this.observedAt + }; + } +} + +class MemoryFreshnessStore extends SastScanFreshnessStore { + persistedFreshness?: { + observation: unknown; + decision: Readonly; + }; + lifecycleVerification: 'MATCHED' | 'REJECTED' = 'REJECTED'; + lifecycleDecision?: Readonly; + + constructor(readonly context: SastScanFreshnessContext) { + super(); + } + + async loadContext() { + return this.context; + } + + async persistFreshness(input: { + observation: unknown; + decision: Readonly; + }) { + this.persistedFreshness = input; + return { + freshnessDecisionId: input.decision.freshnessDecisionId, + decisionDigest: input.decision.decisionDigest, + replayed: false + }; + } + + async verifyLifecycleSource( + decision: Readonly + ) { + this.lifecycleDecision = decision; + return this.lifecycleVerification; + } + + async loadRetryContext(): Promise { + return null; + } + + async persistRetryDecision(input: { + decision: Readonly; + }) { + return { + retryDecisionId: input.decision.retryDecisionId, + decisionDigest: input.decision.decisionDigest, + retryAllowed: input.decision.retryAllowed, + replayed: false + }; + } +} + +function freshnessContext(): SastScanFreshnessContext { + return { + coverageComplete: true, + coverageCompletedAt: '2026-08-10T03:00:00.000Z', + scope: { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + provider: 'GITHUB', + targetRef: 'refs/heads/main', + commitSha: 'b'.repeat(40), + scanRequestId: 'scan-current', + attemptId: 'attempt-current', + attemptNumber: 1, + coverageDecisionId: id('sast-coverage', 'current'), + coverageDecisionDigest: digest('coverage-current'), + lifecycleContextKey: digest('lifecycle'), + canonicalScanKey: digest('canonical'), + planDigest: digest('plan'), + profileId: 'JAVA_DEEP_V1', + profileDigest: digest('profile'), + profileFamily: 'JAVA', + requiredCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION', + 'SBOM' + ], + fingerprintVersion: 'sast-fingerprint-v1', + lifecycleEligibilityScope: digest('eligibility') + }, + comparison: { + coverageDecisionId: id('sast-coverage', 'previous'), + coverageDecisionDigest: digest('coverage-previous'), + scanRequestId: 'scan-previous', + commitSha: 'a'.repeat(40), + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + targetRef: 'refs/heads/main', + profileId: 'JAVA_DEEP_V1', + profileDigest: digest('profile-previous'), + profileFamily: 'JAVA', + requiredCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION', + 'SBOM' + ], + fingerprintVersion: 'sast-fingerprint-v1', + lifecycleEligibilityScope: digest('eligibility'), + completedAt: '2026-08-09T03:00:00.000Z' + }, + latestObservation: { + observationId: id('sast-target-observation', 'previous'), + sequence: 1, + observedAt: '2026-08-09T03:00:00.000Z' + }, + existingDecision: null + }; +} + +function lifecycleDecision(): SastFindingLifecycleCoverageDecision { + const context = freshnessContext(); + const core = { + version: SAST_FINDING_LIFECYCLE_COVERAGE_VERSION, + tenantId: context.scope.tenantId, + repositoryBindingId: context.scope.repositoryBindingId, + scanRequestId: context.scope.scanRequestId, + attemptId: context.scope.attemptId, + canonicalScanKey: context.scope.canonicalScanKey, + planDigest: context.scope.planDigest, + commitSha: context.scope.commitSha, + lifecycleContextKey: context.scope.lifecycleContextKey, + profileId: context.scope.profileId, + profileDigest: context.scope.profileDigest, + state: 'COMPLETE' as const, + stale: false as const, + comparable: true as const, + sequence: 2, + previousScanRequestId: context.comparison!.scanRequestId, + previousCommitSha: context.comparison!.commitSha, + completeCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION' + ] as SastFindingLifecycleCoverageDecision['completeCapabilities'], + eligibleLineageIds: [], + expectedObservationBatchDigests: [digest('observation-batch')], + sourceCoverageDecisionDigest: + context.scope.coverageDecisionDigest, + sourceCoverageDecisionRef: context.scope.coverageDecisionId, + completedAt: DECIDED_AT, + decidedAt: DECIDED_AT + }; + return { + ...core, + decisionDigest: digest( + canonicalizeSastFindingLifecycleCoverageDecision(core) + ) + }; +} + +function digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function id(prefix: string, value: string): string { + return `${prefix}://${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts b/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts index 2ba6587..e75fbb2 100644 --- a/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts @@ -1,7 +1,10 @@ +import { createHash } from 'node:crypto'; + import { SAST_APPROVED_PROFILE_DIGESTS, SAST_FORBIDDEN_CAPABILITIES, SAST_SCAN_PROFILES, + buildSastScanPlanDigestPreimage, isSastScanPlanValid, type SastRepositoryPreflightSelection, type SastScannerExecutionRecord, @@ -11,6 +14,7 @@ import { type SastScannerRuntimeAuditSignal, type SastScannerWrapperExecutionRequest, type SastScanPlan, + type SastScanRetryDecision, type SastSignedSandboxCleanupObservation } from '@aegisai/shared'; @@ -20,6 +24,15 @@ import { SandboxRuntimeAttestationService } from '../../src/scan-plane/sandbox-r import { SastScannerRuntimeService } from '../../src/scan-plane/sast-scanner-runtime.service'; import type { FinishSastAttemptInput } from '../../src/scan-plane/sast-scanner-runtime.store'; import { SastScannerRuntimeStore } from '../../src/scan-plane/sast-scanner-runtime.store'; +import { SastRetryAdmissionGate } from '../../src/scan-plane/sast-retry-admission.gate'; +import { UnavailableSastLatestTargetAuthority } from '../../src/scan-plane/sast-latest-target-authority'; +import { SastRetryRuntimeAuthority } from '../../src/scan-plane/sast-retry-runtime-authority'; +import { SastScanFreshnessService } from '../../src/scan-plane/sast-scan-freshness.service'; +import { + SastScanFreshnessStore, + type SastScanFreshnessContext, + type SastScanRetryDurableContext +} from '../../src/scan-plane/sast-scan-freshness.store'; import { ScannerSandboxAdapterService } from '../../src/scan-plane/scanner-sandbox-adapter.service'; import type { ScannerSandboxCleanupOperation, @@ -57,6 +70,7 @@ const repositoryEntries = [ class InMemoryRuntimeStore extends SastScannerRuntimeStore { began = false; + beganAt?: string; stages: string[] = []; scannerRuns: SastScannerExecutionRecord[] = []; begunScannerRunIds: string[] = []; @@ -65,8 +79,12 @@ class InMemoryRuntimeStore extends SastScannerRuntimeStore { finished?: FinishSastAttemptInput; credentialCleanupDurable = true; - beginAttempt(): Promise { + beginAttempt( + _request: SastScannerWrapperExecutionRequest, + startedAt: string + ): Promise { this.began = true; + this.beganAt = startedAt; return Promise.resolve(); } @@ -124,6 +142,7 @@ class InMemoryRuntimeStore extends SastScannerRuntimeStore { interface RuntimeHarness { adapter: ScannerSandboxAdapterService; attestation: SandboxRuntimeAttestationService; + preflightAttestation: RepositoryPreflightAttestationService; provider: { readRepositoryManifest: jest.Mock< Promise, @@ -616,6 +635,207 @@ describe('Pinned scanner wrapper and sandbox lifecycle', () => { expect(harness.provider.readRepositoryManifest).not.toHaveBeenCalled(); }); + it('denies attempt two before persistence when no T040 retry authority exists', async () => { + const harness = buildHarness(); + const request = retryRequest(harness); + + await expect(harness.runtime.execute(request)).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SCAN_ATTEMPT_RETRY_NOT_ELIGIBLE' + }); + expect(harness.store.began).toBe(false); + expect(harness.provider.readRepositoryManifest).not.toHaveBeenCalled(); + }); + + it('uses a fresh attempt-scoped preflight and sandbox only after T040 authorization', async () => { + const persistedStartedAt = '2026-08-10T03:05:00.000Z'; + const authorize = jest.fn().mockResolvedValue({ + outcome: 'AUTHORIZED', + startedAt: persistedStartedAt + }); + const harness = buildHarness({ + retryAdmission: { authorize } as SastRetryAdmissionGate + }); + const request = retryRequest(harness); + + await expect(harness.runtime.execute(request)).resolves.toMatchObject({ + attemptId: 'attempt-runtime-2', + attemptNumber: 2, + sandboxId: 'sandbox-runtime-2', + stage: 'COMPLETED' + }); + expect(authorize).toHaveBeenCalledWith(request, expect.any(String)); + expect(request.plan.repositoryState.attestationRef).not.toBe( + request.preflight.attestationRef + ); + expect(request.plan.canonicalScanKey).toBe( + harness.request.plan.canonicalScanKey + ); + expect(harness.store.began).toBe(true); + expect(harness.store.beganAt).toBe(persistedStartedAt); + }); + + it('rejects attempt two when it reuses the original preflight attestation', async () => { + const harness = buildHarness({ + retryAdmission: { + authorize: jest.fn().mockResolvedValue({ + outcome: 'AUTHORIZED', + startedAt: '2026-08-10T03:05:00.000Z' + }) + } as SastRetryAdmissionGate + }); + const request = retryRequest(harness); + request.preflight = harness.request.preflight; + + await expect(harness.runtime.execute(request)).rejects.toMatchObject({ + reasonCode: 'SCANNER_EXECUTION_REQUEST_INVALID' + }); + expect(harness.store.began).toBe(false); + }); + + it('verifies the fresh attempt-two preflight before retry admission', async () => { + const authorize = jest.fn().mockResolvedValue({ + outcome: 'AUTHORIZED', + startedAt: '2026-08-10T03:05:00.000Z' + }); + const harness = buildHarness({ + retryAdmission: { authorize } as SastRetryAdmissionGate + }); + const request = retryRequest(harness); + const lastCharacter = request.preflight.attestationRef.at(-1); + request.preflight = { + ...request.preflight, + attestationRef: `${request.preflight.attestationRef.slice(0, -1)}${lastCharacter === 'x' ? 'y' : 'x'}` + }; + request.sandboxAttestation = harness.attestation.issue({ + plan: request.plan, + attemptId: request.attemptId, + attemptNumber: request.attemptNumber, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + policy: harness.adapter.buildPolicy(request.plan), + preflight: request.preflight + }); + + await expect(harness.runtime.execute(request)).rejects.toMatchObject({ + reasonCode: 'PREFLIGHT_ATTESTATION_INVALID' + }); + expect(authorize).not.toHaveBeenCalled(); + expect(harness.store.began).toBe(false); + expect(harness.provider.readRepositoryManifest).not.toHaveBeenCalled(); + }); + + it('rejects a stale signed attempt-two preflight before retry admission', async () => { + const authorize = jest.fn().mockResolvedValue({ + outcome: 'AUTHORIZED', + startedAt: '2026-08-10T03:05:00.000Z' + }); + const harness = buildHarness({ + retryAdmission: { authorize } as SastRetryAdmissionGate + }); + const request = retryRequest(harness); + request.preflight = { + ...request.preflight, + attestationRef: harness.preflightAttestation.issue( + { + attemptId: request.attemptId, + fixedCommitSha: request.plan.repositoryState.fixedCommitSha, + pathPolicyVersion: request.preflight.pathPolicyVersion, + inventoryDigest: request.preflight.inventoryDigest, + decision: request.preflight.decision + }, + new Date(Date.now() - 65_000) + ) + }; + request.sandboxAttestation = harness.attestation.issue({ + plan: request.plan, + attemptId: request.attemptId, + attemptNumber: request.attemptNumber, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + policy: harness.adapter.buildPolicy(request.plan), + preflight: request.preflight + }); + + await expect(harness.runtime.execute(request)).rejects.toMatchObject({ + reasonCode: 'PREFLIGHT_ATTESTATION_INVALID' + }); + expect(authorize).not.toHaveBeenCalled(); + expect(harness.store.began).toBe(false); + }); + + it('reports the canonical binding error before deriving preflight from an invalid plan', () => { + const harness = buildHarness(); + + expect(() => + harness.attestation.issue({ + plan: undefined as never, + attemptId: harness.request.attemptId, + attemptNumber: harness.request.attemptNumber, + sandboxId: harness.request.sandboxId, + workloadIdentityRef: harness.request.workloadIdentityRef, + policy: harness.adapter.buildPolicy(harness.request.plan) + }) + ).toThrow('Sandbox runtime attestation binding is invalid.'); + }); + + it('persists the canonical infrastructure-only T040 decision before attempt two', async () => { + const retryStore = new MemoryRetryFreshnessStore(); + const gate = new SastScanFreshnessService( + retryStore, + new UnavailableSastLatestTargetAuthority(), + new ClearRetryRuntimeAuthority() + ); + const harness = buildHarness({ retryAdmission: gate }); + const request = retryRequest(harness); + + await expect(harness.runtime.execute(request)).resolves.toMatchObject({ + attemptNumber: 2, + stage: 'COMPLETED' + }); + expect(retryStore.persistedDecision).toMatchObject({ + retryAllowed: true, + reasonCodes: [], + previousFailureClass: 'RETRYABLE_INFRASTRUCTURE' + }); + expect(retryStore.persistedDecision?.scope).toMatchObject({ + previousAttemptId: ATTEMPT_ID, + requestedAttemptId: 'attempt-runtime-2', + previousSandboxId: 'sandbox-runtime-1', + requestedSandboxId: 'sandbox-runtime-2', + canonicalScanKey: request.plan.canonicalScanKey + }); + }); + + it('reuses the persisted retry timestamp when attempt creation is retried', async () => { + const retryStore = new MemoryRetryFreshnessStore(); + const gate = new SastScanFreshnessService( + retryStore, + new UnavailableSastLatestTargetAuthority(), + new ClearRetryRuntimeAuthority() + ); + const harness = buildHarness({ retryAdmission: gate }); + const request = retryRequest(harness); + const beginAttempt = jest.spyOn(harness.store, 'beginAttempt'); + beginAttempt.mockRejectedValueOnce( + new Error('simulated attempt persistence interruption') + ); + + await expect(harness.runtime.execute(request)).rejects.toThrow( + 'simulated attempt persistence interruption' + ); + await new Promise((resolve) => setTimeout(resolve, 5)); + await expect(harness.runtime.execute(request)).resolves.toMatchObject({ + attemptNumber: 2, + stage: 'COMPLETED' + }); + + expect(retryStore.persistedDecision?.retryAllowed).toBe(true); + expect(harness.store.beganAt).toBe( + retryStore.persistedDecision?.decidedAt + ); + }); + it('rejects caller command fields and any egress-enabled sandbox policy', async () => { const commandInjection = { ...buildHarness().request, @@ -680,6 +900,7 @@ describe('Pinned scanner wrapper and sandbox lifecycle', () => { interface BuildHarnessOptions { profile?: SastScanPlan['profile']; selection?: Readonly; + retryAdmission?: SastRetryAdmissionGate; } function buildHarness(options: BuildHarnessOptions = {}): RuntimeHarness { @@ -806,12 +1027,14 @@ function buildHarness(options: BuildHarnessOptions = {}): RuntimeHarness { attestation, manifestVerifier, provider as unknown as ScannerSandboxRuntimeProvider, - store + store, + options.retryAdmission ); return { adapter, attestation, + preflightAttestation, provider, request, runtime, @@ -819,6 +1042,128 @@ function buildHarness(options: BuildHarnessOptions = {}): RuntimeHarness { }; } +function retryRequest( + harness: RuntimeHarness +): SastScannerWrapperExecutionRequest { + const attemptId = 'attempt-runtime-2'; + const sandboxId = 'sandbox-runtime-2'; + const workloadIdentityRef = + 'spiffe://aegis/scan/attempt-runtime-2'; + const preflight = { + ...harness.request.preflight, + attestationRef: harness.preflightAttestation.issue({ + attemptId, + fixedCommitSha: + harness.request.plan.repositoryState.fixedCommitSha, + pathPolicyVersion: + harness.request.preflight.pathPolicyVersion, + inventoryDigest: harness.request.preflight.inventoryDigest, + decision: harness.request.preflight.decision + }) + }; + return { + ...harness.request, + attemptId, + attemptNumber: 2, + sandboxId, + workloadIdentityRef, + preflight, + sandboxAttestation: harness.attestation.issue({ + plan: harness.request.plan, + attemptId, + attemptNumber: 2, + sandboxId, + workloadIdentityRef, + policy: harness.adapter.buildPolicy(harness.request.plan), + preflight + }) + }; +} + +class ClearRetryRuntimeAuthority extends SastRetryRuntimeAuthority { + async verify(scope: { originalScannerSetDigest: `sha256:${string}` }) { + return { + currentScannerSetDigest: scope.originalScannerSetDigest, + scannerSetAvailable: true, + killSwitchStatus: 'CLEAR' as const, + killSwitchSnapshotDigest: shaDigest('clear-kill-switch') + }; + } +} + +class MemoryRetryFreshnessStore extends SastScanFreshnessStore { + persistedDecision?: Readonly; + + async loadContext(): Promise { + return null; + } + + async persistFreshness(): Promise { + throw new Error('Freshness persistence is not used in this fixture.'); + } + + async verifyLifecycleSource(): Promise<'REJECTED'> { + return 'REJECTED'; + } + + async loadRetryContext( + request: Readonly + ): Promise { + const planDigest = shaDigest( + buildSastScanPlanDigestPreimage(request.plan) + ); + return { + evaluation: { + scope: { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + canonicalScanKey: request.plan.canonicalScanKey, + planDigest, + originalScannerSetDigest: + request.plan.scannerSet.scannerSetDigest, + previousAttemptId: ATTEMPT_ID, + previousAttemptNumber: 1, + previousSandboxId: 'sandbox-runtime-1', + previousWorkloadIdentityRef: + 'spiffe://aegis/scan/attempt-runtime-1', + requestedAttemptId: request.attemptId, + requestedAttemptNumber: request.attemptNumber, + requestedSandboxId: request.sandboxId, + requestedWorkloadIdentityRef: + request.workloadIdentityRef + }, + previousStage: 'FAILED', + previousFailureClass: 'RETRYABLE_INFRASTRUCTURE', + previousRetryEligible: true, + previousCompletedAt: '2026-08-10T02:59:00.000Z', + previousFinalAuditEventId: 'audit-attempt-runtime-1', + previousFinalAuditValid: true, + durableCanonicalScanKey: request.plan.canonicalScanKey, + durablePlanDigest: planDigest + }, + existingDecision: this.persistedDecision ?? null + }; + } + + async persistRetryDecision(input: { + decision: Readonly; + }) { + this.persistedDecision = input.decision; + return { + retryDecisionId: input.decision.retryDecisionId, + decisionDigest: input.decision.decisionDigest, + retryAllowed: input.decision.retryAllowed, + replayed: false + }; + } +} + +function shaDigest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + function scanPlan( inventoryDigest: `sha256:${string}`, attestationRef: string, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3fc69a9..fd29cfb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,6 +21,7 @@ export * from './types/sast-finding-identity'; export * from './types/sast-finding-lineage'; export * from './types/sast-finding-correlation'; export * from './types/sast-scan-coverage'; +export * from './types/sast-scan-freshness'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/sast-scan-freshness.ts b/packages/shared/src/types/sast-scan-freshness.ts new file mode 100644 index 0000000..5672fa6 --- /dev/null +++ b/packages/shared/src/types/sast-scan-freshness.ts @@ -0,0 +1,947 @@ +import { + SAST_CAPABILITIES, + SAST_FINDING_FINGERPRINT_VERSION, + SAST_PROFILE_IDS, + type SastCapability, + type SastProfileId +} from './sast-runtime'; +import { + hasExactKeys, + isBoundedReference, + isCommitSha, + isRecord, + isSha256Digest +} from './sast-normalization-validation'; + +export const SAST_SCAN_FRESHNESS_VERSION = + 'sast-scan-freshness-v1' as const; +export const SAST_LATEST_TARGET_OBSERVATION_VERSION = + 'sast-latest-target-observation-v1' as const; +export const SAST_SCAN_RETRY_DECISION_VERSION = + 'sast-scan-retry-decision-v1' as const; + +export const SAST_SCM_PROVIDERS = ['GITHUB', 'GITLAB'] as const; +export type SastScmProvider = (typeof SAST_SCM_PROVIDERS)[number]; + +export const SAST_PROFILE_FAMILIES = ['JAVA', 'COMMON'] as const; +export type SastProfileFamily = + (typeof SAST_PROFILE_FAMILIES)[number]; + +export const SAST_LATEST_TARGET_AUTHORITIES = [ + 'VERIFIED', + 'UNAVAILABLE', + 'INVALID' +] as const; +export type SastLatestTargetAuthority = + (typeof SAST_LATEST_TARGET_AUTHORITIES)[number]; + +export const SAST_STALE_STATUSES = [ + 'FRESH', + 'STALE', + 'UNKNOWN' +] as const; +export type SastStaleStatus = (typeof SAST_STALE_STATUSES)[number]; + +export const SAST_COMPARABILITY_STATUSES = [ + 'COMPARABLE', + 'INCOMPARABLE', + 'UNKNOWN' +] as const; +export type SastComparabilityStatus = + (typeof SAST_COMPARABILITY_STATUSES)[number]; + +export const SAST_SCAN_FRESHNESS_REASON_CODES = [ + 'COVERAGE_NOT_COMPLETE', + 'LATEST_TARGET_AUTHORITY_UNAVAILABLE', + 'LATEST_TARGET_OBSERVATION_INVALID', + 'LATEST_TARGET_OBSERVATION_NON_MONOTONIC', + 'TARGET_HEAD_MISMATCH', + 'COMPARISON_SOURCE_UNAVAILABLE', + 'COMPARISON_SCOPE_MISMATCH', + 'PROFILE_FAMILY_INCOMPATIBLE', + 'REQUIRED_CAPABILITY_SET_INCOMPATIBLE', + 'FINGERPRINT_VERSION_INCOMPATIBLE', + 'LIFECYCLE_ELIGIBILITY_SCOPE_INCOMPATIBLE', + 'PUBLICATION_FAIL_CLOSED' +] as const; +export type SastScanFreshnessReasonCode = + (typeof SAST_SCAN_FRESHNESS_REASON_CODES)[number]; + +export const SAST_SCAN_RETRY_REASON_CODES = [ + 'RETRY_ATTEMPT_LIMIT_EXCEEDED', + 'PREVIOUS_ATTEMPT_NOT_IMMEDIATE', + 'PREVIOUS_ATTEMPT_NOT_FAILED', + 'FAILURE_NOT_RETRYABLE_INFRASTRUCTURE', + 'PREVIOUS_ATTEMPT_NOT_RETRY_ELIGIBLE', + 'FINAL_AUDIT_BINDING_MISSING', + 'PREVIOUS_COMPLETION_MISSING', + 'IMMUTABLE_SCAN_INTENT_CHANGED', + 'SCANNER_SET_UNAVAILABLE', + 'SCANNER_SET_CHANGED', + 'KILL_SWITCH_AUTHORITY_UNAVAILABLE', + 'KILL_SWITCH_ACTIVE', + 'SANDBOX_IDENTITY_REUSED', + 'RETRY_PERSISTENCE_CONFLICT' +] as const; +export type SastScanRetryReasonCode = + (typeof SAST_SCAN_RETRY_REASON_CODES)[number]; + +export type SastScanFreshnessCanonicalDigester = ( + canonicalValue: string +) => `sha256:${string}`; + +export interface SastScanFreshnessScope { + tenantId: string; + repositoryBindingId: string; + provider: SastScmProvider; + targetRef: string; + commitSha: string; + scanRequestId: string; + attemptId: string; + attemptNumber: 1 | 2; + coverageDecisionId: string; + coverageDecisionDigest: `sha256:${string}`; + lifecycleContextKey: `sha256:${string}`; + canonicalScanKey: `sha256:${string}`; + planDigest: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + profileFamily: SastProfileFamily; + requiredCapabilities: SastCapability[]; + fingerprintVersion: typeof SAST_FINDING_FINGERPRINT_VERSION; + lifecycleEligibilityScope: `sha256:${string}`; +} + +export interface SastLatestTargetObservation { + version: typeof SAST_LATEST_TARGET_OBSERVATION_VERSION; + observationId: string; + tenantId: string; + repositoryBindingId: string; + provider: SastScmProvider; + targetRef: string; + headCommitSha: string; + sequence: number; + observerRef: string; + observedAt: string; + observationDigest: `sha256:${string}`; +} + +export type SastLatestTargetObservationCore = Omit< + SastLatestTargetObservation, + 'observationDigest' +>; + +export interface SastScanComparisonSource { + coverageDecisionId: string; + coverageDecisionDigest: `sha256:${string}`; + scanRequestId: string; + commitSha: string; + tenantId: string; + repositoryBindingId: string; + targetRef: string; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + profileFamily: SastProfileFamily; + requiredCapabilities: SastCapability[]; + fingerprintVersion: typeof SAST_FINDING_FINGERPRINT_VERSION; + lifecycleEligibilityScope: `sha256:${string}`; + completedAt: string; +} + +export interface SastScanFreshnessDecision { + version: typeof SAST_SCAN_FRESHNESS_VERSION; + freshnessDecisionId: string; + scope: SastScanFreshnessScope; + observationId: string | null; + observationDigest: `sha256:${string}` | null; + observedHeadCommitSha: string | null; + observationSequence: number | null; + previousCoverageDecisionId: string | null; + previousCoverageDecisionDigest: `sha256:${string}` | null; + previousScanRequestId: string | null; + previousCommitSha: string | null; + latestTargetAuthority: SastLatestTargetAuthority; + staleStatus: SastStaleStatus; + comparabilityStatus: SastComparabilityStatus; + externalCommentEligible: boolean; + blockingStatusEligible: boolean; + lifecycleMutationAllowed: boolean; + aiAdvisoryAllowed: false; + publicationAttempted: false; + reasonCodes: SastScanFreshnessReasonCode[]; + decidedAt: string; + decisionDigest: `sha256:${string}`; +} + +export type SastScanFreshnessDecisionCore = Omit< + SastScanFreshnessDecision, + 'decisionDigest' +>; + +export interface SastScanRetryScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + canonicalScanKey: `sha256:${string}`; + planDigest: `sha256:${string}`; + originalScannerSetDigest: `sha256:${string}`; + previousAttemptId: string; + previousAttemptNumber: number; + previousSandboxId: string; + previousWorkloadIdentityRef: string; + requestedAttemptId: string; + requestedAttemptNumber: number; + requestedSandboxId: string; + requestedWorkloadIdentityRef: string; +} + +export interface SastScanRetryEvaluation { + scope: SastScanRetryScope; + previousStage: string; + previousFailureClass: string | null; + previousRetryEligible: boolean; + previousCompletedAt: string | null; + previousFinalAuditEventId: string | null; + previousFinalAuditValid: boolean; + durableCanonicalScanKey: `sha256:${string}`; + durablePlanDigest: `sha256:${string}`; + currentScannerSetDigest: `sha256:${string}` | null; + scannerSetAvailable: boolean; + killSwitchStatus: 'CLEAR' | 'ACTIVE' | 'UNAVAILABLE'; + killSwitchSnapshotDigest: `sha256:${string}` | null; +} + +export interface SastScanRetryDecision { + version: typeof SAST_SCAN_RETRY_DECISION_VERSION; + retryDecisionId: string; + scope: SastScanRetryScope; + retryAllowed: boolean; + previousFailureClass: string | null; + previousCompletedAt: string | null; + previousFinalAuditEventId: string | null; + currentScannerSetDigest: `sha256:${string}` | null; + scannerSetAvailable: boolean; + killSwitchStatus: 'CLEAR' | 'ACTIVE' | 'UNAVAILABLE'; + killSwitchSnapshotDigest: `sha256:${string}` | null; + reasonCodes: SastScanRetryReasonCode[]; + decidedAt: string; + decisionDigest: `sha256:${string}`; +} + +export type SastScanRetryDecisionCore = Omit< + SastScanRetryDecision, + 'decisionDigest' +>; + +export function sastProfileFamily( + profileId: SastProfileId +): SastProfileFamily { + return profileId.startsWith('JAVA_') ? 'JAVA' : 'COMMON'; +} + +export function canonicalizeSastLatestTargetObservation( + observation: Readonly +): string { + return stableJson(observation); +} + +export function canonicalizeSastScanFreshnessDecision( + decision: Readonly +): string { + return stableJson(decision); +} + +export function canonicalizeSastScanRetryDecision( + decision: Readonly +): string { + return stableJson(decision); +} + +export function orderSastScanFreshnessReasons( + reasons: readonly SastScanFreshnessReasonCode[] +): SastScanFreshnessReasonCode[] { + return orderByCanonical( + reasons, + SAST_SCAN_FRESHNESS_REASON_CODES + ); +} + +export function orderSastScanRetryReasons( + reasons: readonly SastScanRetryReasonCode[] +): SastScanRetryReasonCode[] { + return orderByCanonical(reasons, SAST_SCAN_RETRY_REASON_CODES); +} + +export function evaluateSastScanFreshness(input: { + coverageComplete: boolean; + scope: Readonly; + observation: Readonly | null; + observationAuthority: SastLatestTargetAuthority; + observationMonotonic: boolean; + comparison: Readonly | null; +}): { + latestTargetAuthority: SastLatestTargetAuthority; + staleStatus: SastStaleStatus; + comparabilityStatus: SastComparabilityStatus; + reasonCodes: SastScanFreshnessReasonCode[]; + authorityEligible: boolean; +} { + const reasons: SastScanFreshnessReasonCode[] = []; + if (!input.coverageComplete) reasons.push('COVERAGE_NOT_COMPLETE'); + + let latestTargetAuthority = input.observationAuthority; + let staleStatus: SastStaleStatus = 'UNKNOWN'; + if (latestTargetAuthority === 'UNAVAILABLE') { + reasons.push('LATEST_TARGET_AUTHORITY_UNAVAILABLE'); + } else if ( + latestTargetAuthority !== 'VERIFIED' || + !input.observation + ) { + latestTargetAuthority = 'INVALID'; + reasons.push('LATEST_TARGET_OBSERVATION_INVALID'); + } else if (!input.observationMonotonic) { + latestTargetAuthority = 'INVALID'; + reasons.push('LATEST_TARGET_OBSERVATION_NON_MONOTONIC'); + } else if ( + input.observation.headCommitSha !== input.scope.commitSha + ) { + staleStatus = 'STALE'; + reasons.push('TARGET_HEAD_MISMATCH'); + } else { + staleStatus = 'FRESH'; + } + + let comparabilityStatus: SastComparabilityStatus = 'UNKNOWN'; + if (!input.comparison) { + reasons.push('COMPARISON_SOURCE_UNAVAILABLE'); + } else { + comparabilityStatus = 'COMPARABLE'; + const previous = input.comparison; + if ( + previous.tenantId !== input.scope.tenantId || + previous.repositoryBindingId !== input.scope.repositoryBindingId || + previous.targetRef !== input.scope.targetRef || + previous.scanRequestId === input.scope.scanRequestId + ) { + reasons.push('COMPARISON_SCOPE_MISMATCH'); + comparabilityStatus = 'INCOMPARABLE'; + } + if (previous.profileFamily !== input.scope.profileFamily) { + reasons.push('PROFILE_FAMILY_INCOMPATIBLE'); + comparabilityStatus = 'INCOMPARABLE'; + } + if ( + !sameStrings( + previous.requiredCapabilities, + input.scope.requiredCapabilities + ) + ) { + reasons.push('REQUIRED_CAPABILITY_SET_INCOMPATIBLE'); + comparabilityStatus = 'INCOMPARABLE'; + } + if (previous.fingerprintVersion !== input.scope.fingerprintVersion) { + reasons.push('FINGERPRINT_VERSION_INCOMPATIBLE'); + comparabilityStatus = 'INCOMPARABLE'; + } + if ( + previous.lifecycleEligibilityScope !== + input.scope.lifecycleEligibilityScope + ) { + reasons.push('LIFECYCLE_ELIGIBILITY_SCOPE_INCOMPATIBLE'); + comparabilityStatus = 'INCOMPARABLE'; + } + } + + const authorityEligible = + input.coverageComplete && + latestTargetAuthority === 'VERIFIED' && + staleStatus === 'FRESH' && + comparabilityStatus === 'COMPARABLE'; + if (!authorityEligible) reasons.push('PUBLICATION_FAIL_CLOSED'); + return { + latestTargetAuthority, + staleStatus, + comparabilityStatus, + reasonCodes: orderSastScanFreshnessReasons(reasons), + authorityEligible + }; +} + +export function buildSastScanFreshnessDecision(input: { + freshnessDecisionId: string; + coverageComplete: boolean; + scope: Readonly; + observation: Readonly | null; + observationAuthority: SastLatestTargetAuthority; + observationMonotonic: boolean; + comparison: Readonly | null; + decidedAt: string; + digestCanonical: SastScanFreshnessCanonicalDigester; +}): SastScanFreshnessDecision { + const evaluation = evaluateSastScanFreshness(input); + const core: SastScanFreshnessDecisionCore = { + version: SAST_SCAN_FRESHNESS_VERSION, + freshnessDecisionId: input.freshnessDecisionId, + scope: { ...input.scope, requiredCapabilities: [ + ...input.scope.requiredCapabilities + ] }, + observationId: input.observation?.observationId ?? null, + observationDigest: input.observation?.observationDigest ?? null, + observedHeadCommitSha: input.observation?.headCommitSha ?? null, + observationSequence: input.observation?.sequence ?? null, + previousCoverageDecisionId: + input.comparison?.coverageDecisionId ?? null, + previousCoverageDecisionDigest: + input.comparison?.coverageDecisionDigest ?? null, + previousScanRequestId: input.comparison?.scanRequestId ?? null, + previousCommitSha: input.comparison?.commitSha ?? null, + latestTargetAuthority: evaluation.latestTargetAuthority, + staleStatus: evaluation.staleStatus, + comparabilityStatus: evaluation.comparabilityStatus, + externalCommentEligible: evaluation.authorityEligible, + blockingStatusEligible: evaluation.authorityEligible, + lifecycleMutationAllowed: evaluation.authorityEligible, + aiAdvisoryAllowed: false, + publicationAttempted: false, + reasonCodes: evaluation.reasonCodes, + decidedAt: input.decidedAt + }; + return { + ...core, + decisionDigest: input.digestCanonical( + canonicalizeSastScanFreshnessDecision(core) + ) + }; +} + +export function evaluateSastScanRetry( + input: Readonly +): SastScanRetryReasonCode[] { + const reasons: SastScanRetryReasonCode[] = []; + if ( + input.scope.requestedAttemptNumber !== 2 || + input.scope.previousAttemptNumber !== 1 + ) { + reasons.push('RETRY_ATTEMPT_LIMIT_EXCEEDED'); + } + if ( + input.scope.requestedAttemptNumber !== + input.scope.previousAttemptNumber + 1 + ) { + reasons.push('PREVIOUS_ATTEMPT_NOT_IMMEDIATE'); + } + if (input.previousStage !== 'FAILED') { + reasons.push('PREVIOUS_ATTEMPT_NOT_FAILED'); + } + if ( + input.previousFailureClass !== 'RETRYABLE_INFRASTRUCTURE' + ) { + reasons.push('FAILURE_NOT_RETRYABLE_INFRASTRUCTURE'); + } + if (!input.previousRetryEligible) { + reasons.push('PREVIOUS_ATTEMPT_NOT_RETRY_ELIGIBLE'); + } + if (!input.previousFinalAuditEventId || !input.previousFinalAuditValid) { + reasons.push('FINAL_AUDIT_BINDING_MISSING'); + } + if (!input.previousCompletedAt) { + reasons.push('PREVIOUS_COMPLETION_MISSING'); + } + if ( + input.scope.canonicalScanKey !== input.durableCanonicalScanKey || + input.scope.planDigest !== input.durablePlanDigest + ) { + reasons.push('IMMUTABLE_SCAN_INTENT_CHANGED'); + } + if (!input.scannerSetAvailable) { + reasons.push('SCANNER_SET_UNAVAILABLE'); + } + if ( + input.currentScannerSetDigest !== + input.scope.originalScannerSetDigest + ) { + reasons.push('SCANNER_SET_CHANGED'); + } + if (input.killSwitchStatus === 'UNAVAILABLE') { + reasons.push('KILL_SWITCH_AUTHORITY_UNAVAILABLE'); + } else if (input.killSwitchStatus === 'ACTIVE') { + reasons.push('KILL_SWITCH_ACTIVE'); + } else if (!input.killSwitchSnapshotDigest) { + reasons.push('KILL_SWITCH_AUTHORITY_UNAVAILABLE'); + } + if ( + input.scope.requestedAttemptId === input.scope.previousAttemptId || + input.scope.requestedSandboxId === input.scope.previousSandboxId || + input.scope.requestedWorkloadIdentityRef === + input.scope.previousWorkloadIdentityRef + ) { + reasons.push('SANDBOX_IDENTITY_REUSED'); + } + return orderSastScanRetryReasons(reasons); +} + +export function buildSastScanRetryDecision(input: { + retryDecisionId: string; + evaluation: Readonly; + decidedAt: string; + digestCanonical: SastScanFreshnessCanonicalDigester; +}): SastScanRetryDecision { + const reasonCodes = evaluateSastScanRetry(input.evaluation); + const core: SastScanRetryDecisionCore = { + version: SAST_SCAN_RETRY_DECISION_VERSION, + retryDecisionId: input.retryDecisionId, + scope: { ...input.evaluation.scope }, + retryAllowed: reasonCodes.length === 0, + previousFailureClass: input.evaluation.previousFailureClass, + previousCompletedAt: input.evaluation.previousCompletedAt, + previousFinalAuditEventId: + input.evaluation.previousFinalAuditEventId, + currentScannerSetDigest: + input.evaluation.currentScannerSetDigest, + scannerSetAvailable: input.evaluation.scannerSetAvailable, + killSwitchStatus: input.evaluation.killSwitchStatus, + killSwitchSnapshotDigest: + input.evaluation.killSwitchSnapshotDigest, + reasonCodes, + decidedAt: input.decidedAt + }; + return { + ...core, + decisionDigest: input.digestCanonical( + canonicalizeSastScanRetryDecision(core) + ) + }; +} + +export function isSastLatestTargetObservationShapeValid( + value: unknown, + digestCanonical: SastScanFreshnessCanonicalDigester +): value is SastLatestTargetObservation { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'observationId', + 'tenantId', + 'repositoryBindingId', + 'provider', + 'targetRef', + 'headCommitSha', + 'sequence', + 'observerRef', + 'observedAt', + 'observationDigest' + ]) || + value.version !== SAST_LATEST_TARGET_OBSERVATION_VERSION || + !isContractId(value.observationId, 'sast-target-observation') || + !isBoundedReference(value.tenantId) || + !isBoundedReference(value.repositoryBindingId) || + !SAST_SCM_PROVIDERS.includes(value.provider as SastScmProvider) || + !isBoundedReference(value.targetRef) || + !isCommitSha(value.headCommitSha) || + !Number.isSafeInteger(value.sequence) || + (value.sequence as number) <= 0 || + !isBoundedReference(value.observerRef) || + !isCanonicalIsoTimestamp(value.observedAt) || + !isSha256Digest(value.observationDigest) + ) { + return false; + } + const observation = value as unknown as SastLatestTargetObservation; + const { observationDigest, ...core } = observation; + return ( + digestCanonical(canonicalizeSastLatestTargetObservation(core)) === + observationDigest + ); +} + +export function isSastScanFreshnessDecisionShapeValid( + value: unknown, + digestCanonical: SastScanFreshnessCanonicalDigester +): value is SastScanFreshnessDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'freshnessDecisionId', + 'scope', + 'observationId', + 'observationDigest', + 'observedHeadCommitSha', + 'observationSequence', + 'previousCoverageDecisionId', + 'previousCoverageDecisionDigest', + 'previousScanRequestId', + 'previousCommitSha', + 'latestTargetAuthority', + 'staleStatus', + 'comparabilityStatus', + 'externalCommentEligible', + 'blockingStatusEligible', + 'lifecycleMutationAllowed', + 'aiAdvisoryAllowed', + 'publicationAttempted', + 'reasonCodes', + 'decidedAt', + 'decisionDigest' + ]) || + value.version !== SAST_SCAN_FRESHNESS_VERSION || + !isContractId(value.freshnessDecisionId, 'sast-freshness') || + !isSastScanFreshnessScopeValid(value.scope) || + !isNullableContractId(value.observationId, 'sast-target-observation') || + !isNullableDigest(value.observationDigest) || + !isNullableCommit(value.observedHeadCommitSha) || + !isNullablePositiveInteger(value.observationSequence) || + !isNullableContractId(value.previousCoverageDecisionId, 'sast-coverage') || + !isNullableDigest(value.previousCoverageDecisionDigest) || + !isNullableReference(value.previousScanRequestId) || + !isNullableCommit(value.previousCommitSha) || + !SAST_LATEST_TARGET_AUTHORITIES.includes( + value.latestTargetAuthority as SastLatestTargetAuthority + ) || + !SAST_STALE_STATUSES.includes(value.staleStatus as SastStaleStatus) || + !SAST_COMPARABILITY_STATUSES.includes( + value.comparabilityStatus as SastComparabilityStatus + ) || + typeof value.externalCommentEligible !== 'boolean' || + typeof value.blockingStatusEligible !== 'boolean' || + typeof value.lifecycleMutationAllowed !== 'boolean' || + value.aiAdvisoryAllowed !== false || + value.publicationAttempted !== false || + !isCanonicalReasonArray( + value.reasonCodes, + SAST_SCAN_FRESHNESS_REASON_CODES + ) || + !isCanonicalIsoTimestamp(value.decidedAt) || + !isSha256Digest(value.decisionDigest) + ) { + return false; + } + const decision = value as unknown as SastScanFreshnessDecision; + const eligible = + decision.latestTargetAuthority === 'VERIFIED' && + decision.staleStatus === 'FRESH' && + decision.comparabilityStatus === 'COMPARABLE' && + decision.observationId !== null && + decision.previousCoverageDecisionId !== null && + decision.reasonCodes.length === 0; + if ( + decision.externalCommentEligible !== eligible || + decision.blockingStatusEligible !== eligible || + decision.lifecycleMutationAllowed !== eligible || + (!eligible && decision.reasonCodes.length === 0) || + !sameNullableObservationTuple(decision) || + !sameNullableComparisonTuple(decision) + ) { + return false; + } + const { decisionDigest, ...core } = decision; + return ( + digestCanonical(canonicalizeSastScanFreshnessDecision(core)) === + decisionDigest + ); +} + +export function isSastScanRetryDecisionShapeValid( + value: unknown, + digestCanonical: SastScanFreshnessCanonicalDigester +): value is SastScanRetryDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'retryDecisionId', + 'scope', + 'retryAllowed', + 'previousFailureClass', + 'previousCompletedAt', + 'previousFinalAuditEventId', + 'currentScannerSetDigest', + 'scannerSetAvailable', + 'killSwitchStatus', + 'killSwitchSnapshotDigest', + 'reasonCodes', + 'decidedAt', + 'decisionDigest' + ]) || + value.version !== SAST_SCAN_RETRY_DECISION_VERSION || + !isContractId(value.retryDecisionId, 'sast-retry') || + !isSastScanRetryScopeValid(value.scope) || + typeof value.retryAllowed !== 'boolean' || + !isNullableReference(value.previousFailureClass) || + !isNullableTimestamp(value.previousCompletedAt) || + !isNullableReference(value.previousFinalAuditEventId) || + !isNullableDigest(value.currentScannerSetDigest) || + typeof value.scannerSetAvailable !== 'boolean' || + !['CLEAR', 'ACTIVE', 'UNAVAILABLE'].includes( + value.killSwitchStatus as string + ) || + !isNullableDigest(value.killSwitchSnapshotDigest) || + !isCanonicalReasonArray( + value.reasonCodes, + SAST_SCAN_RETRY_REASON_CODES + ) || + value.retryAllowed !== + ((value.reasonCodes as unknown[]).length === 0) || + !isCanonicalIsoTimestamp(value.decidedAt) || + !isSha256Digest(value.decisionDigest) + ) { + return false; + } + const decision = value as unknown as SastScanRetryDecision; + if ( + decision.retryAllowed && + (decision.scope.previousAttemptNumber !== 1 || + decision.scope.requestedAttemptNumber !== 2 || + decision.scope.requestedAttemptNumber !== + decision.scope.previousAttemptNumber + 1 || + decision.previousFailureClass !== + 'RETRYABLE_INFRASTRUCTURE' || + !decision.previousCompletedAt || + !decision.previousFinalAuditEventId || + !decision.scannerSetAvailable || + decision.currentScannerSetDigest !== + decision.scope.originalScannerSetDigest || + decision.killSwitchStatus !== 'CLEAR' || + !decision.killSwitchSnapshotDigest || + decision.scope.previousAttemptId === + decision.scope.requestedAttemptId || + decision.scope.previousSandboxId === + decision.scope.requestedSandboxId || + decision.scope.previousWorkloadIdentityRef === + decision.scope.requestedWorkloadIdentityRef) + ) { + return false; + } + const { decisionDigest, ...core } = decision; + return ( + digestCanonical(canonicalizeSastScanRetryDecision(core)) === + decisionDigest + ); +} + +export function isSastScanFreshnessScopeValid( + value: unknown +): value is SastScanFreshnessScope { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'provider', + 'targetRef', + 'commitSha', + 'scanRequestId', + 'attemptId', + 'attemptNumber', + 'coverageDecisionId', + 'coverageDecisionDigest', + 'lifecycleContextKey', + 'canonicalScanKey', + 'planDigest', + 'profileId', + 'profileDigest', + 'profileFamily', + 'requiredCapabilities', + 'fingerprintVersion', + 'lifecycleEligibilityScope' + ]) && + isBoundedReference(value.tenantId) && + isBoundedReference(value.repositoryBindingId) && + SAST_SCM_PROVIDERS.includes(value.provider as SastScmProvider) && + isBoundedReference(value.targetRef) && + isCommitSha(value.commitSha) && + isBoundedReference(value.scanRequestId) && + isBoundedReference(value.attemptId) && + (value.attemptNumber === 1 || value.attemptNumber === 2) && + isContractId(value.coverageDecisionId, 'sast-coverage') && + isSha256Digest(value.coverageDecisionDigest) && + isSha256Digest(value.lifecycleContextKey) && + isSha256Digest(value.canonicalScanKey) && + isSha256Digest(value.planDigest) && + SAST_PROFILE_IDS.includes(value.profileId as SastProfileId) && + isSha256Digest(value.profileDigest) && + SAST_PROFILE_FAMILIES.includes( + value.profileFamily as SastProfileFamily + ) && + value.profileFamily === sastProfileFamily(value.profileId as SastProfileId) && + isCanonicalCapabilityArray(value.requiredCapabilities) && + value.fingerprintVersion === SAST_FINDING_FINGERPRINT_VERSION && + isSha256Digest(value.lifecycleEligibilityScope) + ); +} + +export function isSastScanRetryScopeValid( + value: unknown +): value is SastScanRetryScope { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'canonicalScanKey', + 'planDigest', + 'originalScannerSetDigest', + 'previousAttemptId', + 'previousAttemptNumber', + 'previousSandboxId', + 'previousWorkloadIdentityRef', + 'requestedAttemptId', + 'requestedAttemptNumber', + 'requestedSandboxId', + 'requestedWorkloadIdentityRef' + ]) && + isBoundedReference(value.tenantId) && + isBoundedReference(value.repositoryBindingId) && + isBoundedReference(value.scanRequestId) && + isSha256Digest(value.canonicalScanKey) && + isSha256Digest(value.planDigest) && + isSha256Digest(value.originalScannerSetDigest) && + isBoundedReference(value.previousAttemptId) && + Number.isSafeInteger(value.previousAttemptNumber) && + (value.previousAttemptNumber as number) > 0 && + isBoundedReference(value.previousSandboxId) && + isBoundedReference(value.previousWorkloadIdentityRef) && + isBoundedReference(value.requestedAttemptId) && + Number.isSafeInteger(value.requestedAttemptNumber) && + (value.requestedAttemptNumber as number) > 0 && + (value.requestedAttemptNumber as number) <= 100 && + isBoundedReference(value.requestedSandboxId) && + isBoundedReference(value.requestedWorkloadIdentityRef) + ); +} + +function sameNullableObservationTuple( + decision: Readonly +): boolean { + const values = [ + decision.observationId, + decision.observationDigest, + decision.observedHeadCommitSha, + decision.observationSequence + ]; + return values.every((value) => value === null) || + values.every((value) => value !== null); +} + +function sameNullableComparisonTuple( + decision: Readonly +): boolean { + const values = [ + decision.previousCoverageDecisionId, + decision.previousCoverageDecisionDigest, + decision.previousScanRequestId, + decision.previousCommitSha + ]; + return values.every((value) => value === null) || + values.every((value) => value !== null); +} + +function isCanonicalCapabilityArray( + value: unknown +): value is SastCapability[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every( + (capability, index) => + SAST_CAPABILITIES.includes(capability as SastCapability) && + (index === 0 || + SAST_CAPABILITIES.indexOf( + value[index - 1] as SastCapability + ) < SAST_CAPABILITIES.indexOf(capability as SastCapability)) + ) + ); +} + +function isCanonicalReasonArray( + value: unknown, + allowed: readonly string[] +): boolean { + return ( + Array.isArray(value) && + value.every( + (reason, index) => + typeof reason === 'string' && + allowed.includes(reason) && + (index === 0 || + allowed.indexOf(value[index - 1] as string) < + allowed.indexOf(reason)) + ) + ); +} + +function orderByCanonical( + values: readonly T[], + canonical: readonly T[] +): T[] { + return [...new Set(values)].sort( + (left, right) => canonical.indexOf(left) - canonical.indexOf(right) + ); +} + +function isContractId(value: unknown, prefix: string): value is string { + return typeof value === 'string' && + new RegExp(`^${prefix}:\\/\\/[a-f0-9]{64}$`, 'u').test(value); +} + +function isNullableContractId(value: unknown, prefix: string): boolean { + return value === null || isContractId(value, prefix); +} + +function isNullableReference(value: unknown): value is string | null { + return value === null || isBoundedReference(value); +} + +function isNullableDigest( + value: unknown +): value is `sha256:${string}` | null { + return value === null || isSha256Digest(value); +} + +function isNullableCommit(value: unknown): value is string | null { + return value === null || isCommitSha(value); +} + +function isNullablePositiveInteger(value: unknown): boolean { + return value === null || + (Number.isSafeInteger(value) && (value as number) > 0); +} + +function isNullableTimestamp(value: unknown): value is string | null { + return value === null || isCanonicalIsoTimestamp(value); +} + +function isCanonicalIsoTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && + new Date(timestamp).toISOString() === value; +} + +function sameStrings( + left: readonly string[], + right: readonly string[] +): boolean { + return left.length === right.length && + left.every((value, index) => value === right[index]); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]`; + } + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort(compareStrings) + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/shared/src/types/sast-wrapper.ts b/packages/shared/src/types/sast-wrapper.ts index 5df190c..4db9f5a 100644 --- a/packages/shared/src/types/sast-wrapper.ts +++ b/packages/shared/src/types/sast-wrapper.ts @@ -443,8 +443,15 @@ export function isSastScannerWrapperExecutionRequestValid( request.preflight.attestationRef && request.sandboxAttestation.claims.preflightInventoryDigest === request.preflight.inventoryDigest && - request.preflight.attestationRef === - request.plan.repositoryState.attestationRef && + isBoundedIdentifier(request.preflight.attestationRef, 8192) && + // This is the pure shape/binding check. The runtime separately verifies + // the signed, attempt-bound, time-bounded repository preflight before + // retry admission and before any attempt row is created. + (request.attemptNumber === 1 + ? request.preflight.attestationRef === + request.plan.repositoryState.attestationRef + : request.preflight.attestationRef !== + request.plan.repositoryState.attestationRef) && request.preflight.inventoryDigest === request.plan.repositoryState.inventoryDigest && (request.preflight.decision === 'ACCEPT' || diff --git a/packages/shared/test/sast-scan-freshness.test.mjs b/packages/shared/test/sast-scan-freshness.test.mjs new file mode 100644 index 0000000..aea25d6 --- /dev/null +++ b/packages/shared/test/sast-scan-freshness.test.mjs @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + SAST_LATEST_TARGET_OBSERVATION_VERSION, + SAST_SCAN_FRESHNESS_VERSION, + buildSastScanFreshnessDecision, + buildSastScanRetryDecision, + canonicalizeSastLatestTargetObservation, + canonicalizeSastScanFreshnessDecision, + evaluateSastScanFreshness, + evaluateSastScanRetry, + isSastLatestTargetObservationShapeValid, + isSastScanFreshnessDecisionShapeValid, + isSastScanRetryDecisionShapeValid +} from '../dist/index.js'; + +test('grants only eligibility for independently verified fresh comparable coverage', () => { + const scope = freshnessScope(); + const observation = targetObservation(scope.commitSha); + const comparison = comparisonSource(); + const decision = buildSastScanFreshnessDecision({ + freshnessDecisionId: id('sast-freshness', 'decision'), + coverageComplete: true, + scope, + observation, + observationAuthority: 'VERIFIED', + observationMonotonic: true, + comparison, + decidedAt: '2026-08-10T03:00:01.000Z', + digestCanonical: digest + }); + + assert.equal(decision.version, SAST_SCAN_FRESHNESS_VERSION); + assert.equal(decision.staleStatus, 'FRESH'); + assert.equal(decision.comparabilityStatus, 'COMPARABLE'); + assert.equal(decision.externalCommentEligible, true); + assert.equal(decision.blockingStatusEligible, true); + assert.equal(decision.lifecycleMutationAllowed, true); + assert.equal(decision.aiAdvisoryAllowed, false); + assert.equal(decision.publicationAttempted, false); + assert.deepEqual(decision.reasonCodes, []); + assert.equal( + isSastScanFreshnessDecisionShapeValid(decision, digest), + true + ); + + const core = { ...decision }; + delete core.decisionDigest; + const forgedCore = { + ...core, + observationId: null, + observationDigest: null, + observedHeadCommitSha: null, + observationSequence: null + }; + assert.equal( + isSastScanFreshnessDecisionShapeValid( + { + ...forgedCore, + decisionDigest: digest( + canonicalizeSastScanFreshnessDecision(forgedCore) + ) + }, + digest + ), + false + ); +}); + +test('fails closed for stale, unavailable, non-monotonic, or incomparable scans', () => { + const scope = freshnessScope(); + const stale = evaluateSastScanFreshness({ + coverageComplete: true, + scope, + observation: targetObservation('c'.repeat(40)), + observationAuthority: 'VERIFIED', + observationMonotonic: true, + comparison: comparisonSource() + }); + assert.equal(stale.staleStatus, 'STALE'); + assert.equal(stale.authorityEligible, false); + assert.deepEqual(stale.reasonCodes, [ + 'TARGET_HEAD_MISMATCH', + 'PUBLICATION_FAIL_CLOSED' + ]); + + const unavailable = evaluateSastScanFreshness({ + coverageComplete: true, + scope, + observation: null, + observationAuthority: 'UNAVAILABLE', + observationMonotonic: false, + comparison: null + }); + assert.equal(unavailable.staleStatus, 'UNKNOWN'); + assert.equal(unavailable.comparabilityStatus, 'UNKNOWN'); + assert.equal(unavailable.authorityEligible, false); + assert.deepEqual(unavailable.reasonCodes, [ + 'LATEST_TARGET_AUTHORITY_UNAVAILABLE', + 'COMPARISON_SOURCE_UNAVAILABLE', + 'PUBLICATION_FAIL_CLOSED' + ]); + + const incomparable = evaluateSastScanFreshness({ + coverageComplete: true, + scope, + observation: targetObservation(scope.commitSha), + observationAuthority: 'VERIFIED', + observationMonotonic: false, + comparison: { + ...comparisonSource(), + requiredCapabilities: ['SAST'] + } + }); + assert.equal(incomparable.latestTargetAuthority, 'INVALID'); + assert.equal(incomparable.comparabilityStatus, 'INCOMPARABLE'); + assert.deepEqual(incomparable.reasonCodes, [ + 'LATEST_TARGET_OBSERVATION_NON_MONOTONIC', + 'REQUIRED_CAPABILITY_SET_INCOMPATIBLE', + 'PUBLICATION_FAIL_CLOSED' + ]); +}); + +test('accepts exactly one infrastructure-only retry with a fresh sandbox', () => { + const evaluation = retryEvaluation(); + assert.deepEqual(evaluateSastScanRetry(evaluation), []); + const decision = buildSastScanRetryDecision({ + retryDecisionId: id('sast-retry', 'allowed'), + evaluation, + decidedAt: '2026-08-10T03:05:00.000Z', + digestCanonical: digest + }); + assert.equal(decision.retryAllowed, true); + assert.equal( + isSastScanRetryDecisionShapeValid(decision, digest), + true + ); +}); + +test('rejects attempt three, non-infrastructure failure, missing audit, kill switch, and sandbox reuse', () => { + const base = retryEvaluation(); + const reasons = evaluateSastScanRetry({ + ...base, + scope: { + ...base.scope, + requestedAttemptId: base.scope.previousAttemptId, + requestedAttemptNumber: 3, + requestedSandboxId: base.scope.previousSandboxId + }, + previousFailureClass: 'SCANNER_DEFECT', + previousFinalAuditValid: false, + killSwitchStatus: 'ACTIVE' + }); + assert.deepEqual(reasons, [ + 'RETRY_ATTEMPT_LIMIT_EXCEEDED', + 'PREVIOUS_ATTEMPT_NOT_IMMEDIATE', + 'FAILURE_NOT_RETRYABLE_INFRASTRUCTURE', + 'FINAL_AUDIT_BINDING_MISSING', + 'KILL_SWITCH_ACTIVE', + 'SANDBOX_IDENTITY_REUSED' + ]); + + assert.deepEqual( + evaluateSastScanRetry({ + ...base, + killSwitchSnapshotDigest: null + }), + ['KILL_SWITCH_AUTHORITY_UNAVAILABLE'] + ); +}); + +test('recomputes observation and decision digests instead of trusting shaped values', () => { + const observation = targetObservation('b'.repeat(40)); + assert.equal( + isSastLatestTargetObservationShapeValid(observation, digest), + true + ); + assert.equal( + isSastLatestTargetObservationShapeValid( + { ...observation, headCommitSha: 'c'.repeat(40) }, + digest + ), + false + ); +}); + +function freshnessScope() { + return { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + provider: 'GITHUB', + targetRef: 'refs/heads/main', + commitSha: 'b'.repeat(40), + scanRequestId: 'scan-current', + attemptId: 'attempt-current', + attemptNumber: 1, + coverageDecisionId: id('sast-coverage', 'current'), + coverageDecisionDigest: digest('current-coverage'), + lifecycleContextKey: digest('lifecycle'), + canonicalScanKey: digest('canonical'), + planDigest: digest('plan'), + profileId: 'JAVA_DEEP_V1', + profileDigest: digest('profile'), + profileFamily: 'JAVA', + requiredCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION', + 'SBOM' + ], + fingerprintVersion: 'sast-fingerprint-v1', + lifecycleEligibilityScope: digest('eligibility') + }; +} + +function comparisonSource() { + return { + coverageDecisionId: id('sast-coverage', 'previous'), + coverageDecisionDigest: digest('previous-coverage'), + scanRequestId: 'scan-previous', + commitSha: 'a'.repeat(40), + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + targetRef: 'refs/heads/main', + profileId: 'JAVA_DEEP_V1', + profileDigest: digest('previous-profile'), + profileFamily: 'JAVA', + requiredCapabilities: [ + 'SAST', + 'DEPENDENCY_VULNERABILITY', + 'SECRET_DETECTION', + 'IAC_MISCONFIGURATION', + 'SBOM' + ], + fingerprintVersion: 'sast-fingerprint-v1', + lifecycleEligibilityScope: digest('eligibility'), + completedAt: '2026-08-09T03:00:00.000Z' + }; +} + +function targetObservation(headCommitSha) { + const core = { + version: SAST_LATEST_TARGET_OBSERVATION_VERSION, + observationId: id('sast-target-observation', headCommitSha), + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + provider: 'GITHUB', + targetRef: 'refs/heads/main', + headCommitSha, + sequence: 2, + observerRef: 'scm-head-authority://github-app', + observedAt: '2026-08-10T03:00:00.000Z' + }; + return { + ...core, + observationDigest: digest( + canonicalizeSastLatestTargetObservation(core) + ) + }; +} + +function retryEvaluation() { + return { + scope: { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + canonicalScanKey: digest('canonical'), + planDigest: digest('plan'), + originalScannerSetDigest: digest('scanner-set'), + previousAttemptId: 'attempt-1', + previousAttemptNumber: 1, + previousSandboxId: 'sandbox-1', + previousWorkloadIdentityRef: 'spiffe://aegis/attempt-1', + requestedAttemptId: 'attempt-2', + requestedAttemptNumber: 2, + requestedSandboxId: 'sandbox-2', + requestedWorkloadIdentityRef: 'spiffe://aegis/attempt-2' + }, + previousStage: 'FAILED', + previousFailureClass: 'RETRYABLE_INFRASTRUCTURE', + previousRetryEligible: true, + previousCompletedAt: '2026-08-10T03:04:00.000Z', + previousFinalAuditEventId: 'audit-attempt-1', + previousFinalAuditValid: true, + durableCanonicalScanKey: digest('canonical'), + durablePlanDigest: digest('plan'), + currentScannerSetDigest: digest('scanner-set'), + scannerSetAvailable: true, + killSwitchStatus: 'CLEAR', + killSwitchSnapshotDigest: digest('kill-switch-snapshot') + }; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function id(prefix, value) { + return `${prefix}://${createHash('sha256').update(value).digest('hex')}`; +} diff --git a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md index 85d9d92..ca1bb62 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1081,6 +1081,53 @@ row is a database-enforced zero-authority decision with latest-target authority `UNAVAILABLE`, stale/comparability `UNKNOWN`, and comment/block/AI/lifecycle booleans false. T040 must replace that missing authority before any external or lifecycle action can proceed. +### Freshness and bounded retry gate v1 + +`sast-scan-freshness-v1` accepts only the durable terminal T039 decision reference and reloads +its canonical coverage object. It independently binds tenant, repository, provider, target, +fixed commit, scan/attempt number, profile/digest/family, canonical scan key, plan digest, +required capability set, `sast-fingerprint-v1`, and lifecycle-eligibility scope. The caller +cannot supply `stale=false`, `comparable=true`, target head, or publication authority. + +A `sast-latest-target-observation-v1` row is valid only when a provider-authoritative read-only +adapter returns a canonical commit, strictly increasing target-scoped sequence, nondecreasing +observation time, and bounded observer reference. Freshness is `FRESH` only when that head is +byte-exactly the scan's fixed commit. Missing authority is `UNAVAILABLE`/`UNKNOWN`; malformed, +future, rolled-back, or cross-scope observation is invalid; a different head is `STALE`. +The default adapter is unavailable and therefore cannot authorize publication or lifecycle. + +Comparability selects the newest completed `COMPLETE` T039 source from a different scan +request in the same tenant, repository, and target whose supported profile family and required +capability set are compatible; an attempt-one row from the current scan and an incompatible +profile cannot hide an older valid predecessor. Fingerprint version and lifecycle-eligibility +scope must also match exactly. Only complete coverage with +`VERIFIED`, `FRESH`, and `COMPARABLE` state creates comment/block eligibility and permits the +T037 lifecycle gate to reverify its exact source. The decision keeps `aiAdvisoryAllowed=false` +and `publicationAttempted=false`; T040 installs neither an SCM writer nor a publisher route. +The lifecycle consumer performs another authoritative head read and rejects when the target +has advanced since the stored decision. +The original T039 external-publication object remains immutable for exact replay. The migration +adds the named T039-source constraint `NOT VALID`; the mandatory online-schema step validates +it and only then removes `SastExternalPublicationDecision_contract_check`. Existing-table +comparison and retry indexes are built concurrently, and their dependent foreign keys are +installed or validated afterward. + +`sast-scan-retry-decision-v1` is durable before attempt-two admission. Allow requires the +immediately preceding attempt one to be terminal `FAILED`, classified +`RETRYABLE_INFRASTRUCTURE`, explicitly retry eligible, completed, and bound to its exact +`sandbox.terminated` audit event. It revalidates current scanner-set availability and +kill-switch state while preserving the original canonical scan key, immutable plan digest, +and scanner-set digest. Attempt, sandbox, and workload identities must all be new. Attempt +two also carries signed, attempt-bound preflight evidence issued within 60 seconds and a fresh +sandbox attestation over the unchanged fixed commit and inventory digest; the original +canonical plan remains immutable. An allowed decision replay reuses its original `decidedAt` +as the attempt start timestamp so an interruption between decision persistence and attempt +creation cannot strand attempt two. Denied rows are permanent audit evidence for that +scan/attempt slot; reevaluation requires a new scan request. Attempt +three, cleanup failure, capacity/input/scanner/security failure, missing audit, changed or +unavailable scanner set, active/unavailable kill-switch authority, and identity reuse deny. +All target, freshness, and retry ledgers use bounded serializable writes and exact replay. + ## Failure Contract | Failure class | Examples | Automatic retry | Isolation/action | diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index 59a8ec3..ee23fef 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -680,7 +680,7 @@ values and never becomes lifecycle, coverage, evidence, policy, publication, or - deterministic `sast-publication://` ID and one-to-one composite coverage binding - T039 invariant false external comment, blocking status, AI advisory, and lifecycle mutation - latest-target authority `UNAVAILABLE`, stale status `UNKNOWN`, and comparability `UNKNOWN` - until T040 installs its independent comparison gate + as the immutable T039 source projection consumed by T040 - ordered denial reasons, canonical decision object, timestamp, and unique digest `PENDING` is returned as a canonical non-durable evaluation and is never inserted into these @@ -691,6 +691,47 @@ replay returns the existing ledger; changed, missing, extra, reordered, cross-sc durable state rejects without partial writes. T039 cannot synthesize a lifecycle-compatible `stale=false`/`comparable=true` projection from complete coverage alone. +### SastLatestTargetObservation + +- deterministic `sast-target-observation://` ID and unique + tenant/repository/provider/target/sequence binding +- provider-authoritative fixed head commit, strictly positive monotonic sequence, observer + reference, observed time, canonical object, and digest +- composite repository scope foreign key and latest-target index; no credential, repository + content, SCM write principal, comment, status, or AI payload + +### SastScanFreshnessDecision + +- deterministic `sast-freshness://` ID and one-to-one T039 coverage binding +- exact tenant/repository/provider/target/fixed-commit/scan/attempt/profile/plan/canonical-key + rebinding plus ordered required capabilities, `sast-fingerprint-v1`, and lifecycle scope +- optional composite target observation and previous completed coverage references whose + identifier/digest/commit tuples are all-null or all-present +- `VERIFIED | UNAVAILABLE | INVALID`, `FRESH | STALE | UNKNOWN`, and + `COMPARABLE | INCOMPARABLE | UNKNOWN` states with ordered fail-closed reasons +- comment/block eligibility and lifecycle mutation can be true only together for complete, + verified, fresh, comparable state with zero reasons; AI and publication-attempt flags are + database-enforced false + +### SastScanRetryDecision + +- deterministic `sast-retry://` ID and unique requested attempt/sandbox/workload + identities plus one decision per scan/attempt number +- exact original canonical scan key, plan and scanner-set digest, immediately preceding + attempt scope, failure/completion/final-audit binding, and current safety snapshot digests +- allow only from attempt one to attempt two for `RETRYABLE_INFRASTRUCTURE`, with a new + attempt/sandbox/workload identity and clear, available mutable runtime safety authority +- attempt two stores the decision foreign key; denied decisions remain durable audit evidence + and permanently consume that scan/attempt slot, so they can never admit a sandbox +- an exact allowed replay carries forward the persisted decision timestamp into attempt + creation rather than generating a conflicting second timestamp + +T040 creates these ledgers in bounded serializable transactions with exact replay. It drops +the former permanent external-publication constraint name only after the online-schema step +validates the replacement invariant, builds populated-table indexes concurrently, and validates +their dependent foreign keys. Effective eligibility comes only from the independent freshness row. +`SastScanFreshnessService` is the only sequential Scan Plane handoff to T041. + ### EvidenceFragment - evidence pack and finding identifiers plus `normalizedPath` diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index 39997c0..063e23a 100644 --- a/specs/006-production-sast-runtime-design/plan.md +++ b/specs/006-production-sast-runtime-design/plan.md @@ -14,11 +14,12 @@ microVM platform is live. Provider-specific deployment execution remains governe Issue #276 is an explicitly reclassified adjacent bootstrap, not a new production slice. Its `ontology/` Neo4j and MITRE CWE work remains local dev/demo data tooling with no Scan, AI, policy, finding, evidence, publication, SCM, tenant, or deployment authority. Work on -that bootstrap does not change the next formal 006 task: T040. +that bootstrap did not advance or satisfy T040; the formal 006 sequence has since completed +T040 independently and now proceeds to T041. ## Target Boundaries -- `packages/shared`: scanner/profile/plan/artifact/finding/coverage/evidence/rule contracts +- `packages/shared`: scanner/profile/plan/artifact/finding/coverage/freshness/retry/evidence/rule contracts - `apps/api`: planning, repository binding, policy, canonical identity, and user-facing state - `services/scan-orchestrator`: lane queues, attempt state, isolation requests, retries, coverage, correlation, evidence, and cleanup coordination @@ -118,8 +119,11 @@ severity, lifecycle, coverage, policy, publication, evidence, or AI authority. T rebinds that T038 batch to immutable plan, scanner-run, artifact-ingestion/disposition, and scanner-responsibility state. It stores canonical scanner records plus a coverage decision in one serializable transaction and persists zero publication authority even when coverage -is complete. Only `SastScanCoverageService` crosses the module boundary; T040 freshness, -comparability, and retry policy is the next gate. +is complete. T040 now independently rebinds that immutable coverage source to a monotonic, +provider-authoritative latest-target observation and an exact prior-scan comparison. It also +persists a bounded attempt-two infrastructure-only retry decision before sandbox admission, +with scanner-set and kill-switch revalidation plus new sandbox/workload identity. Only +`SastScanFreshnessService` crosses the module boundary; T041 bounded evidence is the next gate. ### Slice 6 - Coverage, Failure, Policy, and Evidence diff --git a/specs/006-production-sast-runtime-design/quality-gates.md b/specs/006-production-sast-runtime-design/quality-gates.md index ec3c30d..fec179d 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -267,10 +267,25 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl zero coverage-ledger writes, and can be reevaluated after a required scanner becomes terminal. - 100% T039 zero-publication invariant: even complete coverage has false comment/block/AI/ lifecycle flags while latest-target authority is unavailable and stale/comparability are - unknown. Only `SastScanCoverageService` crosses the Scan Plane boundary to T040. + unknown. The T039 row remains immutable after T040 installation. - The T039 caller records a structured rejection counter keyed by the coarse reason code; `SCAN_COVERAGE_PERSISTENCE_FAILED` is alerted separately from expected durable-scope, scanner-set, source-set, input, and replay rejections without logging sensitive context. +- 100% T040 stale-publication invariant: comment/block eligibility and T037 lifecycle + verification require exact durable T039 `COMPLETE`, a provider-authoritative monotonic head + equal to the fixed commit, and an exact prior-scan tenant/repository/target/profile-family/ + capability/fingerprint/lifecycle-scope comparison. Every unavailable, invalid, stale, or + incomparable fixture produces zero authority and zero publication attempts. Current-scan + attempt rows and incompatible profiles cannot mask the newest older compatible source. +- 100% T040 retry-fence invariant: only attempt one `FAILED` with + `RETRYABLE_INFRASTRUCTURE`, retry eligibility, completion, exact final audit, unchanged and + available scanner set, clear kill-switch authority, and fresh attempt/sandbox/workload + identities plus a signed, attempt-bound preflight no more than 60 seconds old may admit + attempt two. Exact allowed replay reuses the persisted authorization timestamp after an + interrupted attempt insert; denied decisions remain permanent audit evidence. Attempt three + and every other failure class produce zero sandbox admissions. Populated-table indexes are + built concurrently before dependent foreign-key validation. `SastScanFreshnessService` is + the only sequential Scan Plane handoff to T041. ## Canary and Continuous Production Gates diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index 1033fc2..f77d2a9 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -429,14 +429,42 @@ portion of Phase 6: concurrently, then installs and validates scanner-run, ingestion, disposition, and source scope foreign keys without putting an online-index dependency in the transactional Prisma migration. -- Even `COMPLETE` coverage stores external comment, blocking status, AI advisory, and - lifecycle mutation as false. T040 has not yet established latest-target freshness or - comparability, so authority remains `UNAVAILABLE`/`UNKNOWN` and the T037 consumer rejects - fixed/reopened transitions. -- `ScanPlaneModule` now exports only the T039 coverage service to T040. T038 correlation, - T037 lineage, T036 identity construction, T035 redaction, and raw OpenGrep/Trivy/Syft - normalization remain internal providers. There is still no user route, artifact reader, - SCM writer, evidence, policy, publication, or AI path. +- The immutable T039 source row still stores external comment, blocking status, AI advisory, + and lifecycle mutation as false. T040 adds its replacement constraint `NOT VALID`; the + mandatory online-schema step validates it before dropping the old permanent constraint, + builds the populated coverage-comparison and attempt retry-decision indexes concurrently, + and then installs or validates their dependent foreign keys. Independent + `sast-scan-freshness-v1` rows never rewrite or weaken exact T039 replay. +- T040 rebinds tenant, repository, provider, target, fixed commit, scan, attempt, approved + profile/plan, canonical scan key, required capability set, `sast-fingerprint-v1`, and a + canonical lifecycle-eligibility scope. A provider-authoritative, monotonic target-head + observation must match the fixed commit exactly. The default observer is unavailable, so + deployments without a read-only provider adapter remain fail closed. +- Comparability selects the newest completed `COMPLETE` coverage source from a different scan + request in the same tenant/repository/target with a compatible profile family and exact + required capability set; a current-scan attempt or incompatible profile cannot mask an older + valid predecessor. Fingerprint version and lifecycle-eligibility scope also match. Only + `VERIFIED` + `FRESH` + + `COMPARABLE` marks comment/block eligibility and lets the T037 gate verify lifecycle input. + The T037 consumer performs another provider-head read and rejects if the target advanced + after the stored decision. This is eligibility only: T040 creates no SCM write, publisher + route, or AI payload. +- T040 stores every attempt-two retry decision before admission. It permits only the + immediately preceding durable attempt-one `FAILED` row with + `RETRYABLE_INFRASTRUCTURE`, `retryEligible=true`, completion time, and exact terminal audit + binding. It rechecks scanner-set availability and kill-switch authority, preserves the + canonical scan identity and plan digest, and requires a new attempt, sandbox, and workload + identity. Attempt three, cleanup failure, capacity/input/scanner/security failure, missing + audit, unavailable safety authority, or changed scanner set is denied. + Attempt two refreshes its signed, at-most-60-second-old attempt-scoped preflight and sandbox + attestations while retaining the original fixed commit, inventory digest, canonical key, + and immutable plan. Exact allowed replay reuses the persisted decision time for attempt + creation; denied evidence permanently consumes that scan/attempt slot and recovery starts a + new scan request. +- `ScanPlaneModule` now exports only `SastScanFreshnessService` as the sequential T040 handoff + to T041. T039 coverage, T038 correlation, T037 lineage, T036 identity construction, T035 + redaction, and raw OpenGrep/Trivy/Syft normalization remain internal providers. There is + still no user route, artifact reader, SCM writer, evidence, 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 @@ -445,9 +473,9 @@ issuance and scanner execution both fail closed until live rollout installs prov GitHub App/GitLab scoped minting, microVM, artifact object-store/disposition, file-coordinate-attestation, and acceptance-gate adapters. T035 secret redaction, T036 `sast-fingerprint-v1` identity construction, T037 occurrence/exact-lineage lifecycle, and -T038 authority-aware cross-tool correlation and T039 fail-closed scanner/capability coverage -are complete; T040 stale-scan denial and bounded infrastructure-only retry is therefore the -next implementation task. +T038 authority-aware cross-tool correlation, T039 fail-closed scanner/capability coverage, +and T040 stale-scan denial and bounded infrastructure-only retry are complete; T041 bounded +accepted-finding evidence with reconstruction-risk checks 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 4c150fc..da0becc 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -448,3 +448,31 @@ cannot publish, invoke AI, or mutate finding lifecycle before T040. artifact acceptance, allowing optional scanners to replace required owners, synthesizing a Syft finding source, last-writer-wins replay, per-replica coverage cache, publishing directly from T039, or inferring latest-target freshness from the scanned commit alone. + +## Decision 22: Separate Latest-Target Authority from Coverage and Fence Retry Admission + +**Decision**: `sast-scan-freshness-v1` keeps T039 immutable and persists an independent, +provider-scoped latest-target observation plus a one-to-one freshness/comparability decision. +The fixed commit must equal a monotonic authoritative target head, and the prior complete scan +must match tenant, repository, target, supported profile family, required capabilities, +fingerprint version, and lifecycle scope. Only that exact conjunction creates external-action +eligibility or lets the T037 gate verify lifecycle input. It still creates no SCM write and no +AI payload. + +`sast-scan-retry-decision-v1` is written before attempt two. It rechecks the immediately +preceding durable attempt-one failure/audit tuple plus mutable scanner-set and kill-switch +authority, while preserving immutable scan intent and requiring new attempt, sandbox, and +workload identities. The default target and mutable-runtime authorities are unavailable, so +the repository remains fail closed until live read-only provider and T049 governance adapters +are installed. + +**Rationale**: Scanner completeness cannot prove that a provider target has not advanced, +and an old retry-eligible bit cannot prove that current runtime assets remain safe. Separate +canonical ledgers retain exact T039 replay, make every authority boundary auditable, and let +future publisher/evidence stages consume a narrow verified handoff. + +**Rejected**: Caller-supplied head/fresh/comparable flags, comparing branch names instead of +fixed heads, assuming profile names imply compatible capabilities, mutating the T039 decision, +retrying attempt three, retrying cleanup/input/capacity/scanner/security failure, reusing a +sandbox identity, trusting a missing final audit event, or treating unavailable kill-switch +authority as clear. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index 1ec9c86..e0907ad 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -290,6 +290,15 @@ incomplete, stale, quarantined, or security-blocked scan. - **FR-039c**: Until T040 proves the latest target, freshness, and comparability, every T039 publication decision MUST set external comment, blocking status, AI advisory, and lifecycle mutation authority to false even when scanner/capability coverage is `COMPLETE`. +- **FR-039d**: T040 MUST independently reload the exact terminal T039 decision and bind a + provider-authoritative, target-scoped, monotonic head observation. Freshness MUST be true + only when that head equals the fixed scanned commit. Comparability MUST require the same + tenant/repository/target, supported profile family, exact required capability set, + `sast-fingerprint-v1`, and lifecycle-eligibility scope as the previous completed complete + scan. Missing or invalid authority MUST fail closed. +- **FR-039e**: T040 MAY expose comment/block eligibility and lifecycle verification only for + complete, verified, fresh, comparable state. It MUST keep AI advisory and publication + execution false and MUST NOT expose an SCM-write route. - **FR-040**: Required scanner failure, timeout, absence, invalid output, or quarantine MUST prevent complete coverage. - **FR-041**: Partial, stale, failed, quarantined, or security-blocked scans MUST NOT publish @@ -300,6 +309,11 @@ incomplete, stale, quarantined, or security-blocked scan. automatically under identical conditions. - **FR-044**: Retry MUST reuse the canonical scan identity but create a new attempt and sandbox identity. +- **FR-044a**: Attempt two MUST have a durable canonical retry decision that re-verifies the + immediately preceding attempt-one infrastructure failure, retry-eligible flag, completion + time, final audit event, current scanner-set availability, and kill-switch state. Attempt + three, cleanup/input/capacity/scanner/security failure, unavailable safety authority, or + reused attempt/sandbox/workload identity MUST deny. - **FR-045**: Every partial or failed scan MUST remain visible in the dashboard with reason, achieved coverage, and retry eligibility. diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index f49aae8..96442ab 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -59,7 +59,7 @@ - [x] T037 Implement occurrences, exact lineage updates, rename handling, and fixed/reopen rules - [x] T038 Implement authority-aware cross-tool correlation with full provenance preservation - [x] T039 Persist scanner/capability coverage and apply fail-closed external publication -- [ ] T040 Implement stale-scan denial and bounded infrastructure-only retries; drop and replace `SastExternalPublicationDecision_contract_check` before accepting independently validated freshness and comparability authority rows +- [x] T040 Implement stale-scan denial and bounded infrastructure-only retries; drop and replace `SastExternalPublicationDecision_contract_check` before accepting independently validated freshness and comparability authority rows ## Approved Adjacent Bootstrap (Does Not Advance 006) diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index c49ece0..9a3bb44 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -70,7 +70,8 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Correlation replay forgery | T037 replay flag or one changed edge/provenance row changes retry identity or silently mutates the ledger | Replay-independent durable source binding plus exact source/edge/two-sided-provenance equality and deterministic digests | T037 replay-flag parity and tampered edge/provenance fixtures | | Coverage authority injection | A caller supplies a required-scanner/capability list or successful status that differs from the immutable profile and durable scanner run | Derive requirements from the approved profile/responsibility matrix; rebind scanner image, wrapper, rule/DB, schema, normalizer, artifact and disposition | Fast/Deep/Common, foreign/duplicate scanner, and provenance-tamper fixtures; zero caller-owned authority | | Coverage replay drift | A late scanner/artifact/source change reuses an earlier attempt decision or concurrent writers create divergent coverage | Attempt-unique decision, composite foreign keys, serializable re-read, canonical three-record digest, exact replay only, bounded P2034/P2002 retry | Changed/missing/extra/reordered/cross-scope/concurrent replay corpus; no partial or duplicate rows | -| Premature complete publication | Complete scanner coverage is treated as proof that the result still matches the latest PR target | Persist `UNAVAILABLE` latest-target and `UNKNOWN` stale/comparability with database-enforced false comment/block/AI/lifecycle flags until T040 | Complete-coverage fixture still has zero publication and zero lifecycle mutations | +| Premature complete publication | Complete scanner coverage is treated as proof that the result still matches the latest PR target | Preserve the T039 `UNAVAILABLE`/`UNKNOWN` zero-authority source; T040 independently requires a monotonic provider head equal to the fixed commit plus exact prior-scan comparability | Complete-only, unavailable, stale, non-monotonic, and incomparable fixtures have zero publication and lifecycle mutations | +| Retry escalation or sandbox reuse | A non-infrastructure failure, missing audit, attempt three, or reused sandbox is admitted as a retry | Durable T040 decision rechecks immediate attempt-one failure/audit, scanner set, kill switches, immutable intent, and new attempt/sandbox/workload identity before attempt-two insertion | Every disallowed failure/safety state and identity-reuse fixture has zero sandbox admissions | | 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 | @@ -141,6 +142,12 @@ The following must always remain true: 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. +15. A T039-complete scan gains no external or lifecycle eligibility unless a monotonic, + provider-authoritative target observation proves the exact fixed commit and a durable + prior complete scan proves exact comparability. +16. Attempt two cannot start without a durable infrastructure-only retry decision bound to + attempt-one failure/completion/final-audit state, current scanner-set and kill-switch + authority, and a new attempt/sandbox/workload identity; attempt three is impossible. ## Required Security Test Corpus @@ -185,6 +192,12 @@ 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 +- unavailable/malformed/future/non-monotonic target observations, same-sequence replay, + force-pushed head mismatch, cross-provider/repository/target observation reuse, missing + previous complete scan, profile-family/capability/fingerprint/lifecycle-scope drift +- retry attempt three, non-infrastructure/cleanup/capacity/input/scanner/security failure, + false retry-eligible flag, missing/mismatched final audit, changed/unavailable scanner set, + active/unavailable kill-switch authority, and reused attempt/sandbox/workload identity - 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, diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 3114274..113afc0 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -41,6 +41,8 @@ const files = { sharedSastFindingCorrelationTest: new URL('../../packages/shared/test/sast-finding-correlation.test.mjs', import.meta.url), sharedSastScanCoverage: new URL('../../packages/shared/src/types/sast-scan-coverage.ts', import.meta.url), sharedSastScanCoverageTest: new URL('../../packages/shared/test/sast-scan-coverage.test.mjs', import.meta.url), + sharedSastScanFreshness: new URL('../../packages/shared/src/types/sast-scan-freshness.ts', import.meta.url), + sharedSastScanFreshnessTest: new URL('../../packages/shared/test/sast-scan-freshness.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), @@ -77,11 +79,16 @@ const files = { apiSastScanCoverageTest: new URL('../../apps/api/test/scan-plane/sast-scan-coverage.e2e-spec.ts', import.meta.url), apiSastScanCoveragePrismaTest: new URL('../../apps/api/test/scan-plane/prisma-sast-scan-coverage.store.e2e-spec.ts', import.meta.url), apiSastScanCoveragePersistenceTest: new URL('../../apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts', import.meta.url), + apiSastScanFreshness: new URL('../../apps/api/src/scan-plane/sast-scan-freshness.service.ts', import.meta.url), + apiSastScanFreshnessStore: new URL('../../apps/api/src/scan-plane/prisma-sast-scan-freshness.store.ts', import.meta.url), + apiSastScanFreshnessTest: new URL('../../apps/api/test/scan-plane/sast-scan-freshness.e2e-spec.ts', import.meta.url), + apiSastScanFreshnessPersistenceTest: new URL('../../apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts', import.meta.url), apiPrismaSchema: new URL('../../apps/api/prisma/schema.prisma', import.meta.url), apiOnlineSastRuntimeSchema: new URL('../../apps/api/scripts/apply-online-sast-runtime-schema.mjs', import.meta.url), apiSastFindingLineageMigration: new URL('../../apps/api/prisma/migrations/20260730160000_sast_finding_lineage_lifecycle/migration.sql', import.meta.url), apiSastFindingCorrelationMigration: new URL('../../apps/api/prisma/migrations/20260802120000_sast_finding_correlation/migration.sql', import.meta.url), apiSastScanCoverageMigration: new URL('../../apps/api/prisma/migrations/20260802150000_sast_scan_coverage/migration.sql', import.meta.url), + apiSastScanFreshnessMigration: new URL('../../apps/api/prisma/migrations/20260810030000_sast_scan_freshness_retry/migration.sql', import.meta.url), 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), @@ -118,7 +125,8 @@ const assertScanPlaneExports = (scanPlaneModule) => { exportsBlock, 'Expected to locate the ScanPlaneModule exports array' ); - assert.match(exportsBlock, /SastScanCoverageService/); + assert.match(exportsBlock, /SastScanFreshnessService/); + assert.doesNotMatch(exportsBlock, /SastScanCoverageService/); assert.doesNotMatch(exportsBlock, /SastFindingCorrelationService/); assert.doesNotMatch(exportsBlock, /SastFindingLineageService/); assert.doesNotMatch(exportsBlock, /SastFindingIdentityService/); @@ -685,7 +693,7 @@ test('SAST T036 constructs byte-exact stable identity and no downstream authorit assert.match(tasks, /- \[x\] T036\b/); assert.match( quickstart, - /T038 authority-aware cross-tool correlation[\s\S]{0,180}are complete; T040/ + /T038 authority-aware cross-tool correlation[\s\S]{0,260}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ ); assert.match(contract, /Finding identity construction gate v1/); assert.match(spec, /FR-034a/); @@ -846,7 +854,7 @@ test('SAST T037 persists complete occurrence lineage and fail-closed lifecycle t assert.match(tasks, /- \[x\] T037\b/); assert.match( quickstart, - /T038 authority-aware cross-tool correlation[\s\S]{0,180}are complete; T040/ + /T038 authority-aware cross-tool correlation[\s\S]{0,260}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ ); assert.match(contract, /Finding lineage and lifecycle gate v1/); assert.match(dataModel, /SastFindingLifecycleReconciliation/); @@ -984,7 +992,7 @@ test('SAST T038 correlates by scanner authority while preserving every provenanc assert.match(tasks, /- \[x\] T038\b/); assert.match( quickstart, - /T039 fail-closed scanner\/capability coverage[\s\S]{0,80}are complete; T040[\s\S]{0,120}next implementation task/ + /T039 fail-closed scanner\/capability coverage[\s\S]{0,160}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ ); assert.match(contract, /Finding correlation gate v1/); assert.match(dataModel, /SastFindingCorrelationProvenance/); @@ -1001,13 +1009,19 @@ test('SAST T038 correlates by scanner authority while preserving every provenanc ); }); -test('SAST T039 persists durable coverage and fail-closes every publication authority', () => { +test('SAST T039 coverage feeds T040 freshness and bounded retry authority', () => { const sharedCoverage = readNormalizedText( files.sharedSastScanCoverage ); const sharedCoverageTest = readNormalizedText( files.sharedSastScanCoverageTest ); + const sharedFreshness = readNormalizedText( + files.sharedSastScanFreshness + ); + const sharedFreshnessTest = readNormalizedText( + files.sharedSastScanFreshnessTest + ); const sharedIndex = readNormalizedText(files.sharedIndex); const service = readNormalizedText(files.apiSastScanCoverage); const store = readNormalizedText(files.apiSastScanCoverageStore); @@ -1020,10 +1034,25 @@ test('SAST T039 persists durable coverage and fail-closes every publication auth const persistenceTest = readNormalizedText( files.apiSastScanCoveragePersistenceTest ); + const freshnessService = readNormalizedText( + files.apiSastScanFreshness + ); + const freshnessStore = readNormalizedText( + files.apiSastScanFreshnessStore + ); + const freshnessTest = readNormalizedText( + files.apiSastScanFreshnessTest + ); + const freshnessPersistenceTest = readNormalizedText( + files.apiSastScanFreshnessPersistenceTest + ); const schema = readNormalizedText(files.apiPrismaSchema); const migration = readNormalizedText( files.apiSastScanCoverageMigration ); + const freshnessMigration = readNormalizedText( + files.apiSastScanFreshnessMigration + ); const onlineSchema = readNormalizedText( files.apiOnlineSastRuntimeSchema ); @@ -1073,6 +1102,28 @@ test('SAST T039 persists durable coverage and fail-closes every publication auth sharedCoverageTest, /fail-closing every external publication authority/ ); + assert.match( + sharedFreshness, + /SAST_SCAN_FRESHNESS_VERSION\s*=[^;]*'sast-scan-freshness-v1'/ + ); + assert.match( + sharedFreshness, + /SAST_SCAN_RETRY_DECISION_VERSION\s*=[^;]*'sast-scan-retry-decision-v1'/ + ); + assert.match(sharedFreshness, /evaluateSastScanFreshness/); + assert.match(sharedFreshness, /evaluateSastScanRetry/); + assert.match( + sharedIndex, + /export \* from '.\/types\/sast-scan-freshness';/ + ); + assert.match( + sharedFreshnessTest, + /independently verified fresh comparable coverage/ + ); + assert.match( + sharedFreshnessTest, + /exactly one infrastructure-only retry with a fresh sandbox/ + ); assert.match(service, /class SastScanCoverageService/); assert.match(service, /isSastFindingCorrelationResultShapeValid/); @@ -1121,6 +1172,16 @@ test('SAST T039 persists durable coverage and fail-closes every publication auth persistenceTest, /zero external publication a database invariant/ ); + assert.match(freshnessService, /class SastScanFreshnessService/); + assert.match(freshnessService, /SastLatestTargetAuthority/); + assert.match(freshnessService, /SastRetryRuntimeAuthority/); + assert.match(freshnessStore, /SERIALIZABLE_ATTEMPTS = 3/); + assert.match(freshnessStore, /verifyLifecycleSource/); + assert.match(freshnessTest, /denies a stale target head/); + assert.match( + freshnessPersistenceTest, + /durable allowed retry row before attempt two starts/ + ); for (const model of [ 'SastScanCoverageDecision', @@ -1130,6 +1191,29 @@ test('SAST T039 persists durable coverage and fail-closes every publication auth assert.match(schema, new RegExp(`model ${model} \\{`)); assert.match(migration, new RegExp(`CREATE TABLE "${model}"`)); } + for (const model of [ + 'SastLatestTargetObservation', + 'SastScanFreshnessDecision', + 'SastScanRetryDecision' + ]) { + assert.match(schema, new RegExp(`model ${model} \\{`)); + assert.match( + freshnessMigration, + new RegExp(`CREATE TABLE "${model}"`) + ); + } + assert.match( + onlineSchema, + /SastExternalPublicationDecision_contract_check[\s\S]{0,160}SastExternalPublicationDecision_t039_source_check/ + ); + assert.match( + onlineSchema, + /CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanCoverageDecision_comparison_scope_key"/ + ); + assert.match( + onlineSchema, + /CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "SastScanAttempt_retryDecisionId_key"/ + ); for (const constraint of [ 'scanner_scope_fkey', 'ingestion_scope_fkey', @@ -1158,12 +1242,16 @@ test('SAST T039 persists durable coverage and fail-closes every publication auth assertScanPlaneExports(scanPlaneModule); assert.match(tasks, /- \[x\] T039\b/); + assert.match(tasks, /- \[x\] T040\b/); assert.match( quickstart, - /T039 fail-closed scanner\/capability coverage[\s\S]{0,80}are complete; T040[\s\S]{0,120}next implementation task/ + /T040 stale-scan denial and bounded infrastructure-only retry[\s\S]{0,160}complete[\s\S]{0,160}T041[\s\S]{0,120}next implementation task/ ); assert.match(contract, /Scan coverage gate v1/); + assert.match(contract, /Freshness and bounded retry gate v1/); assert.match(dataModel, /SastExternalPublicationDecision/); + assert.match(dataModel, /SastScanFreshnessDecision/); + assert.match(dataModel, /SastScanRetryDecision/); assert.match(plan, /T039 now[\s\S]{0,80}immutable plan/); assert.match(spec, /FR-039a/); assert.match( diff --git a/test/github-actions/ontology.test.mjs b/test/github-actions/ontology.test.mjs index 460626a..b246329 100644 --- a/test/github-actions/ontology.test.mjs +++ b/test/github-actions/ontology.test.mjs @@ -80,7 +80,7 @@ test('active 006 spec explicitly reclassifies only the bounded issue 276 bootstr assert.match(spec, /MUST NOT receive Scan Plane, AI Plane, policy/); assert.match(spec, /does not[\s\S]*advance or satisfy T040/); assert.match(plan, /Issue #276 is an explicitly reclassified adjacent bootstrap/); - assert.match(plan, /does not change the next formal 006 task: T040/); + assert.match(plan, /did not advance or satisfy T040[\s\S]*proceeds to T041/); assert.match(tasks, /Approved Adjacent Bootstrap \(Does Not Advance 006\)/); assert.match(tasks, /Keep T040 as the next formal active-milestone task/); });