diff --git a/apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql b/apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql new file mode 100644 index 0000000..f544cf8 --- /dev/null +++ b/apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql @@ -0,0 +1,302 @@ +-- Numeric bounds in this migration mirror packages/shared +-- SAST_ACCEPTED_EVIDENCE_POLICY and SAST_ACCEPTED_EVIDENCE_LIMITS. +-- The persistence contract test pins both representations together. +-- The occurrence-scope foreign key is installed by the mandatory online-schema +-- step after its pre-existing composite occurrence index is concurrently ready. +CREATE TABLE "SastEvidenceBuildDecision" ( + "id" TEXT NOT NULL, + "freshnessDecisionId" TEXT NOT NULL, + "coverageDecisionId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "observationBatchId" TEXT NOT NULL, + "normalizedFindingId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "findingFingerprint" TEXT NOT NULL, + "fingerprintVersion" TEXT NOT NULL, + "capability" "SastFindingCapability" NOT NULL, + "policyVersion" TEXT NOT NULL, + "candidateSetDigest" TEXT NOT NULL, + "outcome" TEXT NOT NULL, + "reasonCodes" JSONB NOT NULL, + "selectedFragmentCount" INTEGER NOT NULL, + "suppressedFragmentCount" INTEGER NOT NULL, + "reconstructionStatus" TEXT NOT NULL, + "reconstructionDecision" JSONB NOT NULL, + "reconstructionDecisionDigest" TEXT NOT NULL, + "evidencePackId" TEXT, + "evidencePackDigest" TEXT, + "authority" JSONB NOT NULL, + "audit" 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 "SastEvidenceBuildDecision_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastEvidenceBuildDecision_contract_check" CHECK ( + "id" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "freshnessDecisionId" ~ '^sast-freshness://[a-f0-9]{64}$' + AND "coverageDecisionId" ~ '^sast-coverage://[a-f0-9]{64}$' + AND "occurrenceId" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "observationBatchId" ~ '^finding-observation://[a-f0-9]{64}$' + AND "lineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "findingFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "fingerprintVersion" = 'sast-fingerprint-v1' + AND "candidateSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "reconstructionDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "decisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "selectedFragmentCount" BETWEEN 0 AND 5 + AND "suppressedFragmentCount" >= 0 + AND "outcome" IN ('ACCEPTED', 'REJECTED') + AND "reconstructionStatus" IN ('SAFE', 'RISK', 'NOT_CHECKED') + AND jsonb_typeof("reasonCodes") = 'array' + AND jsonb_typeof("reconstructionDecision") = 'object' + AND jsonb_typeof("authority") = 'object' + AND jsonb_typeof("audit") = 'object' + AND jsonb_typeof("decision") = 'object' + AND ("authority"->>'dashboardAccessAllowed')::boolean IS FALSE + AND ("authority"->>'aiPayloadAllowed')::boolean IS FALSE + AND ("authority"->>'policyAuthority')::boolean IS FALSE + AND ("authority"->>'publicationAuthority')::boolean IS FALSE + AND ("authority"->>'lifecycleMutationAuthority')::boolean IS FALSE + AND ("audit"->>'rawSourceStored')::boolean IS FALSE + AND ("audit"->>'secretValuesStored')::boolean IS FALSE + AND ("audit"->>'dashboardPayloadCreated')::boolean IS FALSE + AND ("audit"->>'aiPayloadCreated')::boolean IS FALSE + AND ("audit"->>'publicationAttempted')::boolean IS FALSE + AND ( + ( + "outcome" = 'ACCEPTED' + AND "selectedFragmentCount" > 0 + AND "reconstructionStatus" = 'SAFE' + AND jsonb_array_length("reasonCodes") = 0 + AND "evidencePackId" IS NOT NULL + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "evidencePackDigest" IS NOT NULL + AND "evidencePackDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ("authority"->>'evidenceConstructionAuthority')::boolean IS TRUE + ) + OR + ( + "outcome" = 'REJECTED' + AND "selectedFragmentCount" = 0 + AND jsonb_array_length("reasonCodes") > 0 + AND "evidencePackId" IS NULL + AND "evidencePackDigest" IS NULL + AND ("authority"->>'evidenceConstructionAuthority')::boolean IS FALSE + ) + ) + ) +); + +CREATE TABLE "SastAcceptedEvidencePack" ( + "id" TEXT NOT NULL, + "buildDecisionId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "occurrenceId" TEXT NOT NULL, + "normalizedFindingId" TEXT NOT NULL, + "lineageId" TEXT NOT NULL, + "findingFingerprint" TEXT NOT NULL, + "policyVersion" TEXT NOT NULL, + "candidateSetDigest" TEXT NOT NULL, + "totalBytes" INTEGER NOT NULL, + "fragmentCount" INTEGER NOT NULL, + "truncated" BOOLEAN NOT NULL, + "suppressedFragmentCount" INTEGER NOT NULL, + "reconstructionRiskChecked" BOOLEAN NOT NULL DEFAULT true, + "reconstructionDecisionId" TEXT NOT NULL, + "reconstructionDecisionDigest" TEXT NOT NULL, + "classificationDecisionRef" TEXT, + "deletionScheduleRef" TEXT, + "dashboardSafe" BOOLEAN NOT NULL DEFAULT false, + "aiSafe" BOOLEAN NOT NULL DEFAULT false, + "pack" JSONB NOT NULL, + "packDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastAcceptedEvidencePack_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastAcceptedEvidencePack_contract_check" CHECK ( + "id" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "buildDecisionId" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "occurrenceId" ~ '^finding-occurrence://[a-f0-9]{64}$' + AND "lineageId" ~ '^finding-lineage://[a-f0-9]{64}$' + AND "findingFingerprint" ~ '^sha256:[a-f0-9]{64}$' + AND "candidateSetDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "totalBytes" BETWEEN 1 AND 32768 + AND "fragmentCount" BETWEEN 1 AND 5 + AND "suppressedFragmentCount" >= 0 + AND "truncated" = ("suppressedFragmentCount" > 0) + AND "reconstructionRiskChecked" = true + AND "reconstructionDecisionId" ~ '^sast-evidence-reconstruction://[a-f0-9]{64}$' + AND "reconstructionDecisionDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "classificationDecisionRef" IS NULL + AND "deletionScheduleRef" IS NULL + AND "dashboardSafe" = false + AND "aiSafe" = false + AND "packDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("pack") = 'object' + AND "expiresAt" > "createdAt" + AND "expiresAt" <= "createdAt" + INTERVAL '7 days' + ) +); + +CREATE TABLE "SastAcceptedEvidenceFragment" ( + "id" TEXT NOT NULL, + "evidencePackId" TEXT NOT NULL, + "buildDecisionId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "candidateId" TEXT NOT NULL, + "ordinal" INTEGER NOT NULL, + "role" TEXT NOT NULL, + "normalizedPath" TEXT NOT NULL, + "startLine" INTEGER NOT NULL, + "endLine" INTEGER NOT NULL, + "anchorStartLine" INTEGER NOT NULL, + "anchorEndLine" INTEGER NOT NULL, + "sourceFileLineCount" INTEGER NOT NULL, + "redactedContent" TEXT NOT NULL, + "byteSize" INTEGER NOT NULL, + "sourceContentDigest" TEXT NOT NULL, + "contentDigest" TEXT NOT NULL, + "sourceAttestationRef" TEXT NOT NULL, + "scannerRedactionDecisionRef" TEXT NOT NULL, + "platformRedactionDecisionRef" TEXT NOT NULL, + "secretRedactionApplied" BOOLEAN NOT NULL DEFAULT true, + "rawSourceStored" BOOLEAN NOT NULL DEFAULT false, + "isFullFile" BOOLEAN NOT NULL DEFAULT false, + "fragment" JSONB NOT NULL, + "fragmentDigest" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SastAcceptedEvidenceFragment_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastAcceptedEvidenceFragment_contract_check" CHECK ( + "id" ~ '^sast-evidence-fragment://[a-f0-9]{64}$' + AND "evidencePackId" ~ '^sast-evidence-pack://[a-f0-9]{64}$' + AND "buildDecisionId" ~ '^sast-evidence-build://[a-f0-9]{64}$' + AND "candidateId" ~ '^sast-evidence-candidate://[a-f0-9]{64}$' + AND "ordinal" BETWEEN 0 AND 4 + AND "role" IN ('PRIMARY', 'RELATED') + AND "startLine" > 0 + AND "endLine" >= "startLine" + AND "anchorStartLine" BETWEEN "startLine" AND "endLine" + AND "anchorEndLine" BETWEEN "anchorStartLine" AND "endLine" + AND "anchorStartLine" - "startLine" BETWEEN 0 AND 5 + AND "endLine" - "anchorEndLine" BETWEEN 0 AND 5 + AND "sourceFileLineCount" >= "endLine" + AND NOT ("startLine" = 1 AND "endLine" = "sourceFileLineCount") + AND octet_length("normalizedPath") BETWEEN 1 AND 2048 + AND "normalizedPath" = btrim("normalizedPath") + AND "normalizedPath" = normalize("normalizedPath", NFC) + AND left("normalizedPath", 1) <> '/' + AND right("normalizedPath", 1) <> '/' + AND position(E'\\' in "normalizedPath") = 0 + AND "normalizedPath" NOT LIKE '%//%' + AND "normalizedPath" !~ '(^|/)\.{1,2}(/|$)' + AND "normalizedPath" !~ '[[:cntrl:]]' + AND "byteSize" BETWEEN 1 AND 8192 + AND "sourceContentDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "contentDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "secretRedactionApplied" = true + AND "rawSourceStored" = false + AND "isFullFile" = false + AND "fragmentDigest" ~ '^sha256:[a-f0-9]{64}$' + AND jsonb_typeof("fragment") = 'object' + ) +); + +CREATE UNIQUE INDEX "SastEvidenceBuildDecision_decisionDigest_key" + ON "SastEvidenceBuildDecision"("decisionDigest"); +CREATE UNIQUE INDEX "SastEvidenceBuildDecision_scope_key" + ON "SastEvidenceBuildDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE UNIQUE INDEX "SastEvidenceBuildDecision_replay_key" + ON "SastEvidenceBuildDecision"("tenantId", "occurrenceId", "policyVersion", "candidateSetDigest"); +CREATE INDEX "SastEvidenceBuildDecision_freshnessDecisionId_idx" + ON "SastEvidenceBuildDecision"("freshnessDecisionId"); +CREATE INDEX "SastEvidenceBuildDecision_coverageDecisionId_idx" + ON "SastEvidenceBuildDecision"("coverageDecisionId"); +CREATE INDEX "SastEvidenceBuildDecision_occurrenceId_idx" + ON "SastEvidenceBuildDecision"("occurrenceId"); +CREATE INDEX "SastEvidenceBuildDecision_outcome_idx" + ON "SastEvidenceBuildDecision"("tenantId", "outcome", "decidedAt"); + +CREATE UNIQUE INDEX "SastAcceptedEvidencePack_buildDecisionId_key" + ON "SastAcceptedEvidencePack"("buildDecisionId"); +CREATE UNIQUE INDEX "SastAcceptedEvidencePack_packDigest_key" + ON "SastAcceptedEvidencePack"("packDigest"); +-- Prisma requires this composite key on the defining side of the optional +-- one-to-one relation; buildDecisionId remains the cardinality key. +CREATE UNIQUE INDEX "SastAcceptedEvidencePack_decision_scope_key" + ON "SastAcceptedEvidencePack"("buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE UNIQUE INDEX "SastAcceptedEvidencePack_fragment_scope_key" + ON "SastAcceptedEvidencePack"("id", "buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"); +CREATE INDEX "SastAcceptedEvidencePack_scan_idx" + ON "SastAcceptedEvidencePack"("tenantId", "scanRequestId", "createdAt"); +CREATE INDEX "SastAcceptedEvidencePack_expiresAt_idx" + ON "SastAcceptedEvidencePack"("expiresAt"); + +CREATE UNIQUE INDEX "SastAcceptedEvidenceFragment_fragmentDigest_key" + ON "SastAcceptedEvidenceFragment"("fragmentDigest"); +CREATE UNIQUE INDEX "SastAcceptedEvidenceFragment_pack_ordinal_key" + ON "SastAcceptedEvidenceFragment"("evidencePackId", "ordinal"); +CREATE UNIQUE INDEX "SastAcceptedEvidenceFragment_pack_candidate_key" + ON "SastAcceptedEvidenceFragment"("evidencePackId", "candidateId"); +CREATE INDEX "SastAcceptedEvidenceFragment_path_idx" + ON "SastAcceptedEvidenceFragment"("tenantId", "scanRequestId", "normalizedPath"); +CREATE INDEX "SastAcceptedEvidenceFragment_contentDigest_idx" + ON "SastAcceptedEvidenceFragment"("contentDigest"); + +ALTER TABLE "SastEvidenceBuildDecision" + ADD CONSTRAINT "SastEvidenceBuildDecision_freshness_scope_fkey" + FOREIGN KEY ("freshnessDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastScanFreshnessDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastAcceptedEvidencePack" + ADD CONSTRAINT "SastAcceptedEvidencePack_decision_scope_fkey" + FOREIGN KEY ("buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastEvidenceBuildDecision"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastAcceptedEvidenceFragment" + ADD CONSTRAINT "SastAcceptedEvidenceFragment_pack_scope_fkey" + FOREIGN KEY ("evidencePackId", "buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + REFERENCES "SastAcceptedEvidencePack"("id", "buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE FUNCTION "reject_sast_accepted_evidence_update"() +RETURNS trigger +LANGUAGE plpgsql +SET search_path = pg_catalog +AS $$ +BEGIN + RAISE EXCEPTION 'accepted-finding evidence ledgers are immutable' + USING ERRCODE = '55000'; + RETURN OLD; +END; +$$; + +CREATE TRIGGER "SastEvidenceBuildDecision_immutable_update" + BEFORE UPDATE ON "SastEvidenceBuildDecision" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_accepted_evidence_update"(); + +CREATE TRIGGER "SastAcceptedEvidencePack_immutable_update" + BEFORE UPDATE ON "SastAcceptedEvidencePack" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_accepted_evidence_update"(); + +CREATE TRIGGER "SastAcceptedEvidenceFragment_immutable_update" + BEFORE UPDATE ON "SastAcceptedEvidenceFragment" + FOR EACH ROW + EXECUTE FUNCTION "reject_sast_accepted_evidence_update"(); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a612512..b19deb7 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -969,6 +969,7 @@ model SastFindingOccurrence { sourceCorrelationEdges SastFindingCorrelationEdge[] @relation("SastFindingCorrelationSourceOccurrence") targetCorrelationEdges SastFindingCorrelationEdge[] @relation("SastFindingCorrelationTargetOccurrence") correlationProvenances SastFindingCorrelationProvenance[] + evidenceBuildDecisions SastEvidenceBuildDecision[] @@unique([observationBatchId, ordinal], map: "SastFindingOccurrence_batch_ordinal_key") @@unique([normalizedFindingId, tenantId, scanRequestId, scannerRunId], map: "SastFindingOccurrence_normalized_scope_key") @@ -1351,6 +1352,7 @@ model SastScanFreshnessDecision { 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") + evidenceBuildDecisions SastEvidenceBuildDecision[] @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanFreshnessDecision_scope_key") @@unique([coverageDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastScanFreshnessDecision_coverage_scope_key") @@ -1405,6 +1407,130 @@ model SastScanRetryDecision { @@index([previousFinalAuditEventId], map: "SastScanRetryDecision_previousFinalAuditEventId_idx") } +model SastEvidenceBuildDecision { + id String @id + freshnessDecisionId String + coverageDecisionId String + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + observationBatchId String + normalizedFindingId String + lineageId String + findingFingerprint String + fingerprintVersion String + capability SastFindingCapability + policyVersion String + candidateSetDigest String + outcome String + reasonCodes Json + selectedFragmentCount Int + suppressedFragmentCount Int + reconstructionStatus String + reconstructionDecision Json + reconstructionDecisionDigest String + evidencePackId String? + evidencePackDigest String? + authority Json + audit Json + decision Json + decisionDigest String @unique + decidedAt DateTime + createdAt DateTime @default(now()) + + freshnessDecision SastScanFreshnessDecision @relation(fields: [freshnessDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_freshness_scope_fkey") + findingOccurrence SastFindingOccurrence @relation(fields: [occurrenceId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastEvidenceBuildDecision_occurrence_scope_fkey") + evidencePack SastAcceptedEvidencePack? + + @@unique([id, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastEvidenceBuildDecision_scope_key") + @@unique([tenantId, occurrenceId, policyVersion, candidateSetDigest], map: "SastEvidenceBuildDecision_replay_key") + @@index([freshnessDecisionId], map: "SastEvidenceBuildDecision_freshnessDecisionId_idx") + @@index([coverageDecisionId], map: "SastEvidenceBuildDecision_coverageDecisionId_idx") + @@index([occurrenceId], map: "SastEvidenceBuildDecision_occurrenceId_idx") + @@index([tenantId, outcome, decidedAt], map: "SastEvidenceBuildDecision_outcome_idx") +} + +model SastAcceptedEvidencePack { + id String @id + buildDecisionId String @unique + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + occurrenceId String + normalizedFindingId String + lineageId String + findingFingerprint String + policyVersion String + candidateSetDigest String + totalBytes Int + fragmentCount Int + truncated Boolean + suppressedFragmentCount Int + reconstructionRiskChecked Boolean @default(true) + reconstructionDecisionId String + reconstructionDecisionDigest String + classificationDecisionRef String? + deletionScheduleRef String? + dashboardSafe Boolean @default(false) + aiSafe Boolean @default(false) + pack Json + packDigest String @unique + createdAt DateTime + expiresAt DateTime + + buildDecision SastEvidenceBuildDecision @relation(fields: [buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastAcceptedEvidencePack_decision_scope_fkey") + fragments SastAcceptedEvidenceFragment[] + + // Required by Prisma on the defining side of this optional one-to-one + // composite relation; buildDecisionId remains the database cardinality key. + @@unique([buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastAcceptedEvidencePack_decision_scope_key") + @@unique([id, buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], map: "SastAcceptedEvidencePack_fragment_scope_key") + @@index([tenantId, scanRequestId, createdAt], map: "SastAcceptedEvidencePack_scan_idx") + @@index([expiresAt], map: "SastAcceptedEvidencePack_expiresAt_idx") +} + +model SastAcceptedEvidenceFragment { + id String @id + evidencePackId String + buildDecisionId String + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + candidateId String + ordinal Int + role String + normalizedPath String + startLine Int + endLine Int + anchorStartLine Int + anchorEndLine Int + sourceFileLineCount Int + redactedContent String + byteSize Int + sourceContentDigest String + contentDigest String + sourceAttestationRef String + scannerRedactionDecisionRef String + platformRedactionDecisionRef String + secretRedactionApplied Boolean @default(true) + rawSourceStored Boolean @default(false) + isFullFile Boolean @default(false) + fragment Json + fragmentDigest String @unique + createdAt DateTime @default(now()) + + evidencePack SastAcceptedEvidencePack @relation(fields: [evidencePackId, buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], references: [id, buildDecisionId, tenantId, repositoryBindingId, scanRequestId, attemptId], onDelete: Cascade, map: "SastAcceptedEvidenceFragment_pack_scope_fkey") + + @@unique([evidencePackId, ordinal], map: "SastAcceptedEvidenceFragment_pack_ordinal_key") + @@unique([evidencePackId, candidateId], map: "SastAcceptedEvidenceFragment_pack_candidate_key") + @@index([tenantId, scanRequestId, normalizedPath], map: "SastAcceptedEvidenceFragment_path_idx") + @@index([contentDigest], map: "SastAcceptedEvidenceFragment_contentDigest_idx") +} + model SastFindingCorrelationEdge { id String @id correlationBatchId String diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index 404c489..a96d54d 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -602,6 +602,13 @@ const constraints = [ definition: 'FOREIGN KEY ("normalizedFindingId", "tenantId", "scanRequestId", "scannerRunId") REFERENCES "NormalizedFinding"("id", "tenantId", "scanRequestId", "scannerRunId") ON DELETE CASCADE ON UPDATE CASCADE' }, + { + table: 'SastEvidenceBuildDecision', + name: 'SastEvidenceBuildDecision_occurrence_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("occurrenceId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") REFERENCES "SastFindingOccurrence"("id", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId") ON DELETE CASCADE ON UPDATE CASCADE' + }, { table: 'SastFindingCorrelationEdge', name: 'SastFindingCorrelationEdge_source_occurrence_scope_fkey', diff --git a/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts b/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts new file mode 100644 index 0000000..36a9306 --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts @@ -0,0 +1,814 @@ +import { createHash } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { + SAST_ACCEPTED_EVIDENCE_POLICY, + isSastAcceptedEvidenceBuildResultShapeValid, + isSastFingerprintedFindingShapeValid, + isSastScanCoverageDecisionShapeValid, + isSastScanFreshnessDecisionShapeValid, + type SastAcceptedEvidenceBuildResult, + type SastAcceptedEvidenceScope, + type SastEvidenceBuildDecision, + type SastFingerprintedFinding, + type SastScanCoverageDecision, + type SastScanFreshnessCanonicalDigester, + type SastScanFreshnessDecision +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + SastAcceptedEvidencePersistenceError, + SastAcceptedEvidenceStore, + type PersistedSastAcceptedEvidence, + type SastAcceptedEvidenceContext +} from './sast-accepted-evidence.store'; + +const EVIDENCE_POLICY_VERSION = 'sast-evidence-policy-v1'; +const SERIALIZABLE_ATTEMPTS = 3; +const SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS = 10; +const SERIALIZABLE_MAX_WAIT_MILLISECONDS = 5_000; +const SERIALIZABLE_TIMEOUT_MILLISECONDS = 120_000; + +type EvidenceReader = Pick< + Prisma.TransactionClient, + 'sastScanFreshnessDecision' | 'sastFindingOccurrence' +>; + +@Injectable() +export class PrismaSastAcceptedEvidenceStore + extends SastAcceptedEvidenceStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async loadContext( + freshnessDecisionId: string, + occurrenceId: string + ): Promise { + return this.readContext( + this.prisma, + freshnessDecisionId, + occurrenceId + ); + } + + async persist(input: { + context: Readonly; + result: Readonly; + }): Promise { + validatePersistInput(input); + return this.runSerializable(async (transaction) => { + const decision = input.result.decision; + const scope = decision.scope; + const current = await this.readContext( + transaction, + scope.freshnessDecisionId, + scope.occurrenceId + ); + if ( + !current || + stableJson(current) !== stableJson(input.context) + ) { + throw new SastAcceptedEvidencePersistenceError( + 'CONTEXT_DRIFT' + ); + } + const existing = + await transaction.sastEvidenceBuildDecision.findFirst({ + where: { + tenantId: scope.tenantId, + occurrenceId: scope.occurrenceId, + policyVersion: scope.policyVersion, + candidateSetDigest: decision.candidateSetDigest + }, + include: { + evidencePack: { + include: { + fragments: { orderBy: { ordinal: 'asc' } } + } + } + } + }); + if (existing) { + return replayExisting(existing, input.result); + } + + await transaction.sastEvidenceBuildDecision.create({ + data: { + id: decision.buildDecisionId, + freshnessDecisionId: scope.freshnessDecisionId, + coverageDecisionId: scope.coverageDecisionId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + occurrenceId: scope.occurrenceId, + observationBatchId: scope.observationBatchId, + normalizedFindingId: scope.normalizedFindingId, + lineageId: scope.lineageId, + findingFingerprint: scope.findingFingerprint, + fingerprintVersion: scope.fingerprintVersion, + capability: scope.capability, + policyVersion: scope.policyVersion, + candidateSetDigest: decision.candidateSetDigest, + outcome: decision.outcome, + reasonCodes: json(decision.reasonCodes), + selectedFragmentCount: + decision.selectedFragmentCount, + suppressedFragmentCount: + decision.suppressedFragmentCount, + reconstructionStatus: + decision.reconstruction.status, + reconstructionDecision: json( + decision.reconstruction + ), + reconstructionDecisionDigest: + decision.reconstruction.decisionDigest, + evidencePackId: decision.evidencePackId, + evidencePackDigest: decision.evidencePackDigest, + authority: json(decision.authority), + audit: json(decision.audit), + decision: json(decision), + decisionDigest: decision.decisionDigest, + decidedAt: new Date(decision.decidedAt), + createdAt: new Date(decision.decidedAt) + } + }); + const pack = input.result.pack; + if (pack) { + await transaction.sastAcceptedEvidencePack.create({ + data: { + id: pack.evidencePackId, + buildDecisionId: decision.buildDecisionId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + occurrenceId: scope.occurrenceId, + normalizedFindingId: scope.normalizedFindingId, + lineageId: scope.lineageId, + findingFingerprint: scope.findingFingerprint, + policyVersion: scope.policyVersion, + candidateSetDigest: pack.candidateSetDigest, + totalBytes: pack.totalBytes, + fragmentCount: pack.fragments.length, + truncated: pack.truncated, + suppressedFragmentCount: + pack.suppressedFragmentCount, + reconstructionRiskChecked: true, + reconstructionDecisionId: + pack.reconstructionRiskDecisionRef, + reconstructionDecisionDigest: + pack.reconstructionRiskDecisionDigest, + classificationDecisionRef: null, + deletionScheduleRef: null, + dashboardSafe: false, + aiSafe: false, + pack: json(pack), + packDigest: pack.packDigest, + createdAt: new Date(pack.createdAt), + expiresAt: new Date(pack.expiresAt) + } + }); + await transaction.sastAcceptedEvidenceFragment.createMany({ + data: pack.fragments.map((fragment) => ({ + id: fragment.fragmentId, + evidencePackId: pack.evidencePackId, + buildDecisionId: decision.buildDecisionId, + tenantId: scope.tenantId, + repositoryBindingId: scope.repositoryBindingId, + scanRequestId: scope.scanRequestId, + attemptId: scope.attemptId, + candidateId: fragment.candidateId, + ordinal: fragment.ordinal, + role: fragment.role, + normalizedPath: fragment.normalizedPath, + startLine: fragment.startLine, + endLine: fragment.endLine, + anchorStartLine: fragment.anchorStartLine, + anchorEndLine: fragment.anchorEndLine, + sourceFileLineCount: + fragment.sourceFileLineCount, + redactedContent: fragment.redactedContent, + byteSize: fragment.byteSize, + sourceContentDigest: + fragment.sourceContentDigest, + contentDigest: fragment.contentDigest, + sourceAttestationRef: + fragment.sourceAttestationRef, + scannerRedactionDecisionRef: + fragment.scannerRedactionDecisionRef, + platformRedactionDecisionRef: + fragment.platformRedactionDecisionRef, + secretRedactionApplied: true, + rawSourceStored: false, + isFullFile: false, + fragment: json(fragment), + fragmentDigest: fragment.fragmentDigest, + createdAt: new Date(pack.createdAt) + })) + }); + } + return { + buildDecisionId: decision.buildDecisionId, + decisionDigest: decision.decisionDigest, + outcome: decision.outcome, + evidencePackId: pack?.evidencePackId ?? null, + replayed: false, + result: input.result as SastAcceptedEvidenceBuildResult + }; + }); + } + + private async readContext( + reader: EvidenceReader, + freshnessDecisionId: string, + occurrenceId: string + ): Promise { + const [freshnessRow, occurrence] = await Promise.all([ + reader.sastScanFreshnessDecision.findUnique({ + where: { id: freshnessDecisionId }, + include: { + coverageDecision: { + include: { + correlationBatch: { + select: { + id: true, + sourceSetDigest: true, + sources: { + select: { + observationBatchId: true, + scannerRunId: true + } + } + } + } + } + } + } + }), + reader.sastFindingOccurrence.findUnique({ + where: { id: occurrenceId }, + include: { + observationBatch: true, + normalizedFinding: true, + lineage: true + } + }) + ]); + if (!freshnessRow || !occurrence) return null; + const freshness = freshnessRow.decision as unknown as + SastScanFreshnessDecision; + const coverage = freshnessRow.coverageDecision + .decision as unknown as SastScanCoverageDecision; + const sourceFinding = occurrence.sourceFinding as unknown as + SastFingerprintedFinding; + if ( + !isSastScanFreshnessDecisionShapeValid(freshness, digest) || + !isSastScanCoverageDecisionShapeValid(coverage, digest) || + !isSastFingerprintedFindingShapeValid( + sourceFinding, + digest, + digest + ) || + !freshnessRowMatchesDecision(freshnessRow, freshness) || + !coverageDecisionMatchesFreshness( + freshnessRow, + coverage + ) || + !findingRowsMatch( + freshnessRow, + occurrence, + sourceFinding + ) + ) { + throw new SastAcceptedEvidencePersistenceError( + 'CONTEXT_DRIFT' + ); + } + if ( + freshnessRow.coverageDecision.state !== 'COMPLETE' || + freshness.latestTargetAuthority !== 'VERIFIED' || + freshness.staleStatus !== 'FRESH' || + freshness.comparabilityStatus !== 'COMPARABLE' || + !freshness.externalCommentEligible || + !freshness.blockingStatusEligible || + !freshness.lifecycleMutationAllowed || + freshness.aiAdvisoryAllowed || + freshness.publicationAttempted || + freshness.reasonCodes.length > 0 || + sourceFinding.location.kind !== 'FILE' + ) { + return null; + } + const lineStart = sourceFinding.location.lineStart; + const lineEnd = + sourceFinding.location.lineEnd ?? lineStart; + const scope: SastAcceptedEvidenceScope = { + tenantId: freshnessRow.tenantId, + repositoryBindingId: + freshnessRow.repositoryBindingId, + scanRequestId: freshnessRow.scanRequestId, + attemptId: freshnessRow.attemptId, + targetRef: freshnessRow.targetRef, + commitSha: freshnessRow.commitSha, + canonicalScanKey: freshnessRow.canonicalScanKey, + planDigest: freshnessRow.planDigest, + profileId: + freshnessRow.profileId as SastAcceptedEvidenceScope['profileId'], + profileDigest: freshnessRow.profileDigest, + freshnessDecisionId: freshnessRow.id, + freshnessDecisionDigest: freshnessRow.decisionDigest, + coverageDecisionId: freshnessRow.coverageDecisionId, + coverageDecisionDigest: + freshnessRow.coverageDecisionDigest, + occurrenceId: occurrence.id, + observationBatchId: occurrence.observationBatchId, + normalizedFindingId: occurrence.normalizedFindingId, + lineageId: occurrence.lineageId, + findingFingerprint: occurrence.stableFingerprint, + fingerprintVersion: + occurrence.fingerprintVersion as typeof scopeFingerprintVersion, + capability: + occurrence.capability as SastAcceptedEvidenceScope['capability'], + normalizedPath: sourceFinding.location.normalizedPath, + findingStartLine: lineStart, + findingEndLine: lineEnd, + policyVersion: EVIDENCE_POLICY_VERSION + }; + return { + scope, + freshnessDecidedAt: freshness.decidedAt + }; + } + + 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; + } + await delay( + SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS * attempt + ); + } + } + throw lastError; + } +} + +const scopeFingerprintVersion = 'sast-fingerprint-v1' as const; + +function freshnessRowMatchesDecision( + row: { + id: string; + coverageDecisionId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + targetRef: string; + commitSha: string; + canonicalScanKey: string; + planDigest: string; + profileId: string; + profileDigest: string; + coverageDecisionDigest: string; + decisionDigest: string; + latestTargetAuthority: string; + staleStatus: string; + comparabilityStatus: string; + externalCommentEligible: boolean; + blockingStatusEligible: boolean; + lifecycleMutationAllowed: boolean; + aiAdvisoryAllowed: boolean; + publicationAttempted: boolean; + reasonCodes: Prisma.JsonValue; + }, + decision: Readonly +): boolean { + const scope = decision.scope; + return ( + row.id === decision.freshnessDecisionId && + row.coverageDecisionId === scope.coverageDecisionId && + row.tenantId === scope.tenantId && + row.repositoryBindingId === scope.repositoryBindingId && + row.scanRequestId === scope.scanRequestId && + row.attemptId === scope.attemptId && + row.targetRef === scope.targetRef && + row.commitSha === scope.commitSha && + row.canonicalScanKey === scope.canonicalScanKey && + row.planDigest === scope.planDigest && + row.profileId === scope.profileId && + row.profileDigest === scope.profileDigest && + row.coverageDecisionDigest === + scope.coverageDecisionDigest && + row.decisionDigest === decision.decisionDigest && + row.latestTargetAuthority === + decision.latestTargetAuthority && + row.staleStatus === decision.staleStatus && + row.comparabilityStatus === + decision.comparabilityStatus && + row.externalCommentEligible === + decision.externalCommentEligible && + row.blockingStatusEligible === + decision.blockingStatusEligible && + row.lifecycleMutationAllowed === + decision.lifecycleMutationAllowed && + row.aiAdvisoryAllowed === decision.aiAdvisoryAllowed && + row.publicationAttempted === + decision.publicationAttempted && + stableJson(row.reasonCodes) === + stableJson(decision.reasonCodes) + ); +} + +function findingRowsMatch( + freshness: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + targetRef: string; + commitSha: string; + canonicalScanKey: string; + planDigest: string; + profileId: string; + profileDigest: string; + coverageDecision: { + decisionDigest: string; + correlationBatch: { + sources: Array<{ + observationBatchId: string; + scannerRunId: string; + }>; + }; + }; + }, + occurrence: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + observationBatchId: string; + lineageId: string; + normalizedFindingId: string; + capability: string; + fingerprintVersion: string; + stableFingerprint: string; + fingerprintDecisionDigest: string; + observationBatch: { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + targetRef: string; + commitSha: string; + profileId: string; + profileDigest: string; + canonicalScanKey: string; + planDigest: string; + }; + normalizedFinding: { + id: string; + tenantId: string; + scanRequestId: string; + scannerRunId: string; + filePath: string | null; + lineStart: number | null; + lineEnd: number | null; + sastCapability: string | null; + sastFingerprintVersion: string | null; + sastStableFingerprint: string | null; + sastFingerprintDecisionDigest: string | null; + sastLineageId: string | null; + sastObservationBatchId: string | null; + }; + lineage: { + id: string; + tenantId: string; + repositoryBindingId: string; + capability: string; + fingerprintVersion: string; + }; + }, + finding: Readonly +): boolean { + if (finding.location.kind !== 'FILE') return false; + const observation = occurrence.observationBatch; + const normalized = occurrence.normalizedFinding; + const lineage = occurrence.lineage; + const sourceBound = freshness.coverageDecision + .correlationBatch.sources.some( + (source) => + source.observationBatchId === + occurrence.observationBatchId && + source.scannerRunId === occurrence.scannerRunId + ); + const findingEnd = + finding.location.lineEnd ?? finding.location.lineStart; + return ( + sourceBound && + freshness.coverageDecision.decisionDigest.length > 0 && + occurrence.tenantId === freshness.tenantId && + occurrence.repositoryBindingId === + freshness.repositoryBindingId && + occurrence.scanRequestId === freshness.scanRequestId && + occurrence.attemptId === freshness.attemptId && + observation.tenantId === freshness.tenantId && + observation.repositoryBindingId === + freshness.repositoryBindingId && + observation.scanRequestId === freshness.scanRequestId && + observation.attemptId === freshness.attemptId && + observation.scannerRunId === occurrence.scannerRunId && + observation.targetRef === freshness.targetRef && + observation.commitSha === freshness.commitSha && + observation.profileId === freshness.profileId && + observation.profileDigest === freshness.profileDigest && + observation.canonicalScanKey === + freshness.canonicalScanKey && + observation.planDigest === freshness.planDigest && + occurrence.capability === finding.capability && + occurrence.fingerprintVersion === + scopeFingerprintVersion && + occurrence.stableFingerprint === + finding.fingerprint.stableFingerprint && + occurrence.fingerprintDecisionDigest === + finding.fingerprint.decisionDigest && + lineage.id === occurrence.lineageId && + lineage.tenantId === freshness.tenantId && + lineage.repositoryBindingId === + freshness.repositoryBindingId && + lineage.capability === occurrence.capability && + lineage.fingerprintVersion === + occurrence.fingerprintVersion && + normalized.id === occurrence.normalizedFindingId && + normalized.tenantId === freshness.tenantId && + normalized.scanRequestId === freshness.scanRequestId && + normalized.scannerRunId === occurrence.scannerRunId && + normalized.filePath === finding.location.normalizedPath && + normalized.lineStart === finding.location.lineStart && + (normalized.lineEnd ?? normalized.lineStart) === findingEnd && + normalized.sastCapability === occurrence.capability && + normalized.sastFingerprintVersion === + occurrence.fingerprintVersion && + normalized.sastStableFingerprint === + occurrence.stableFingerprint && + normalized.sastFingerprintDecisionDigest === + occurrence.fingerprintDecisionDigest && + normalized.sastLineageId === occurrence.lineageId && + normalized.sastObservationBatchId === + occurrence.observationBatchId + ); +} + +function coverageDecisionMatchesFreshness( + freshness: { + coverageDecisionId: string; + coverageDecisionDigest: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + targetRef: string; + commitSha: string; + canonicalScanKey: string; + planDigest: string; + profileId: string; + profileDigest: string; + coverageDecision: { + id: string; + correlationBatchId: string; + state: string; + decisionDigest: string; + correlationBatch: { + id: string; + sourceSetDigest: string; + }; + }; + }, + coverage: Readonly +): boolean { + const scope = coverage.scope; + return ( + coverage.coverageDecisionId === freshness.coverageDecisionId && + coverage.decisionDigest === freshness.coverageDecisionDigest && + freshness.coverageDecision.id === coverage.coverageDecisionId && + freshness.coverageDecision.decisionDigest === + coverage.decisionDigest && + freshness.coverageDecision.state === coverage.state && + freshness.coverageDecision.correlationBatchId === + scope.correlationBatchId && + freshness.coverageDecision.correlationBatch.id === + scope.correlationBatchId && + freshness.coverageDecision.correlationBatch.sourceSetDigest === + scope.correlationSourceSetDigest && + scope.tenantId === freshness.tenantId && + scope.repositoryBindingId === freshness.repositoryBindingId && + scope.scanRequestId === freshness.scanRequestId && + scope.attemptId === freshness.attemptId && + scope.targetRef === freshness.targetRef && + scope.commitSha === freshness.commitSha && + scope.canonicalScanKey === freshness.canonicalScanKey && + scope.planDigest === freshness.planDigest && + scope.profileId === freshness.profileId && + scope.profileDigest === freshness.profileDigest + ); +} + +function validatePersistInput(input: { + context: Readonly; + result: Readonly; +}): void { + const decision = input.result.decision; + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + input.result, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) || + stableJson(decision.scope) !== + stableJson(input.context.scope) || + Date.parse(decision.decidedAt) < + Date.parse(input.context.freshnessDecidedAt) + ) { + throw new SastAcceptedEvidencePersistenceError( + 'OUTPUT_INVALID' + ); + } +} + +function replayExisting( + row: { + decision: Prisma.JsonValue; + evidencePack: null | { + pack: Prisma.JsonValue; + fragments: Array<{ fragment: Prisma.JsonValue }>; + }; + }, + result: Readonly +): PersistedSastAcceptedEvidence { + const decision = + row.decision as unknown as SastEvidenceBuildDecision; + const pack = row.evidencePack?.pack as unknown; + const storedResult = { + decision, + pack: row.evidencePack === null ? null : pack + }; + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + storedResult, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) + ) { + throw new SastAcceptedEvidencePersistenceError( + 'REPLAY_CONFLICT' + ); + } + if ( + stableJson(replayProjection(storedResult)) !== + stableJson(replayProjection(result)) || + (storedResult.pack !== null && + (row.evidencePack?.fragments.length !== + storedResult.pack.fragments.length || + row.evidencePack.fragments.some( + (fragment, index) => + stableJson(fragment.fragment) !== + stableJson(storedResult.pack?.fragments[index]) + ))) + ) { + throw new SastAcceptedEvidencePersistenceError( + 'REPLAY_CONFLICT' + ); + } + return { + buildDecisionId: decision.buildDecisionId, + decisionDigest: decision.decisionDigest, + outcome: decision.outcome, + evidencePackId: decision.evidencePackId, + replayed: true, + result: storedResult + }; +} + +function replayProjection( + result: Readonly +): unknown { + return { + decision: { + ...withoutKeys(result.decision, [ + 'decidedAt', + 'decisionDigest', + 'evidencePackDigest' + ] as const), + reconstruction: withoutKeys( + result.decision.reconstruction, + ['checkedAt', 'decisionDigest'] as const + ) + }, + pack: + result.pack === null + ? null + : withoutKeys(result.pack, [ + 'createdAt', + 'expiresAt', + 'packDigest', + 'reconstructionRiskDecisionDigest' + ] as const) + }; +} + +function withoutKeys< + Value extends object, + Key extends keyof Value +>(value: Value, keys: readonly Key[]): Omit { + const omitted = new Set(keys); + return Object.fromEntries( + Object.entries(value).filter(([key]) => !omitted.has(key)) + ) as Omit; +} + +function isRetryableTransactionError(error: unknown): boolean { + if (!(error instanceof Prisma.PrismaClientKnownRequestError)) { + return false; + } + if (error.code === 'P2034') return true; + if (error.code !== 'P2002') return false; + const modelName = error.meta?.modelName; + if (modelName === 'SastEvidenceBuildDecision') return true; + const target = error.meta?.target; + if (typeof target === 'string') { + return target.includes('SastEvidenceBuildDecision'); + } + if (!Array.isArray(target)) return false; + const fields = target.filter( + (value): value is string => typeof value === 'string' + ); + return [ + 'tenantId', + 'occurrenceId', + 'policyVersion', + 'candidateSetDigest' + ].every((field) => fields.includes(field)); +} + +function json(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} + +function digest( + value: string +): ReturnType { + return ( + 'sha256:' + + createHash('sha256').update(value).digest('hex') + ) as ReturnType; +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return '[' + value.map(stableJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + const record = value as Record; + return ( + '{' + + Object.keys(record) + .sort() + .map( + (key) => + JSON.stringify(key) + ':' + stableJson(record[key]) + ) + .join(',') + + '}' + ); + } + return JSON.stringify(value); +} diff --git a/apps/api/src/scan-plane/sast-accepted-evidence-source.authority.ts b/apps/api/src/scan-plane/sast-accepted-evidence-source.authority.ts new file mode 100644 index 0000000..c874d04 --- /dev/null +++ b/apps/api/src/scan-plane/sast-accepted-evidence-source.authority.ts @@ -0,0 +1,41 @@ +import type { + SastAcceptedEvidenceScope, + SastEvidenceDigest, + SastEvidenceFragmentRequest +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +export type SastAcceptedEvidenceSourceResult = + | { + status: 'VERIFIED'; + candidateId: string; + role: SastEvidenceFragmentRequest['role']; + normalizedPath: string; + startLine: number; + endLine: number; + anchorStartLine: number; + anchorEndLine: number; + sourceFileLineCount: number; + scannerRedactedContent: string; + sourceContentDigest: SastEvidenceDigest; + sourceAttestationRef: string; + scannerRedactionApplied: true; + scannerRedactionDecisionRef: string; + platformSecretValues: readonly string[]; + } + | { status: 'UNAVAILABLE' }; + +export abstract class SastAcceptedEvidenceSourceAuthority { + abstract read(input: { + scope: Readonly; + request: Readonly; + }): Promise; +} + +@Injectable() +export class UnavailableSastAcceptedEvidenceSourceAuthority + extends SastAcceptedEvidenceSourceAuthority { + async read(): Promise { + return { status: 'UNAVAILABLE' }; + } +} diff --git a/apps/api/src/scan-plane/sast-accepted-evidence.service.ts b/apps/api/src/scan-plane/sast-accepted-evidence.service.ts new file mode 100644 index 0000000..e8c9aff --- /dev/null +++ b/apps/api/src/scan-plane/sast-accepted-evidence.service.ts @@ -0,0 +1,689 @@ +import { createHash } from 'node:crypto'; +import { setImmediate as yieldToEventLoop } from 'node:timers/promises'; + +import { + SAST_ACCEPTED_EVIDENCE_LIMITS, + SAST_ACCEPTED_EVIDENCE_POLICY, + SAST_SECRET_REDACTION_LIMITS, + buildSastAcceptedEvidence, + buildSastEvidenceEarlyRejection, + canonicalizeSastEvidenceCandidate, + canonicalizeSastEvidenceFragmentRequests, + isSastAcceptedEvidenceBuildResultShapeValid, + isSastEvidenceFragmentRequestValid, + isSastRedactedEvidenceCandidateShapeValid, + type SastAcceptedEvidenceBuildResult, + type SastEvidenceFragmentRequest, + type SastEvidenceReasonCode, + type SastRedactedEvidenceCandidate, + type SastRedactedEvidenceCandidateCore +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { + SastAcceptedEvidenceSourceAuthority, + type SastAcceptedEvidenceSourceResult +} from './sast-accepted-evidence-source.authority'; +import { + SastAcceptedEvidencePersistenceError, + SastAcceptedEvidenceStore, + type SastAcceptedEvidenceContext +} from './sast-accepted-evidence.store'; + +const REDACTION_TOKEN = '[REDACTED]'; +const KNOWN_SECRET_PATTERNS = [ + /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----[\s\S]{0,4096}?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/gu, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu, + /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/gu, + /\b(?:glpat|gloas|gldt|glrt|glrtr|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{8,255}\b/gu, + /\beyJ[A-Za-z0-9_-]{5,511}\.[A-Za-z0-9_-]{8,2048}\.[A-Za-z0-9_-]{8,2048}\b/gu, + /(?|=|:)\s*(?:"[^"\r\n]{1,512}"|'[^'\r\n]{1,512}'|[^\s,;]{4,512})/giu +] as const; + +export type SastAcceptedEvidenceOutcome = + | { + outcome: 'BUILT'; + decision: SastAcceptedEvidenceBuildResult['decision']; + pack: NonNullable; + replayed: boolean; + } + | { + outcome: 'REJECTED'; + reasonCode: SastEvidenceReasonCode; + decision: SastAcceptedEvidenceBuildResult['decision'] | null; + replayed: boolean; + dashboardPayloadCreated: false; + aiPayloadCreated: false; + publicationAttempted: false; + }; + +type EvidenceClock = () => string; + +@Injectable() +export class SastAcceptedEvidenceService { + constructor( + private readonly store: SastAcceptedEvidenceStore, + private readonly source: SastAcceptedEvidenceSourceAuthority + ) {} + + async build( + input: { + freshnessDecisionId: string; + occurrenceId: string; + fragments: readonly SastEvidenceFragmentRequest[]; + }, + clock: EvidenceClock = () => new Date().toISOString() + ): Promise { + if (!isBuildRequestValid(input)) { + return this.reject('EVIDENCE_INPUT_INVALID'); + } + let context: SastAcceptedEvidenceContext | null; + try { + context = await this.store.loadContext( + input.freshnessDecisionId, + input.occurrenceId + ); + } catch { + return this.reject('EVIDENCE_CONTEXT_UNAVAILABLE'); + } + if (!context) { + return this.reject('EVIDENCE_CONTEXT_UNAVAILABLE'); + } + let decidedAt: string; + try { + decidedAt = clock(); + } catch { + return this.reject('EVIDENCE_INPUT_INVALID'); + } + if ( + !isCanonicalTimestamp(decidedAt) || + Date.parse(decidedAt) < + Date.parse(context.freshnessDecidedAt) + ) { + return this.reject('EVIDENCE_INPUT_INVALID'); + } + const requestDigest = digest( + canonicalizeSastEvidenceFragmentRequests(input.fragments) + ); + const candidates: SastRedactedEvidenceCandidate[] = []; + for (let index = 0; index < input.fragments.length; index += 1) { + if ( + index > 0 && + index % + SAST_ACCEPTED_EVIDENCE_LIMITS.yieldCandidateInterval === + 0 + ) { + await yieldToEventLoop(); + } + const request = input.fragments[index]; + if (!request) { + return this.persistEarlyRejection( + context, + requestDigest, + 'EVIDENCE_INPUT_INVALID', + decidedAt + ); + } + let source: unknown; + try { + source = await this.source.read({ + scope: context.scope, + request + }); + } catch { + source = { status: 'UNAVAILABLE' }; + } + if (isUnavailableSourceResult(source)) { + return this.persistEarlyRejection( + context, + requestDigest, + 'EVIDENCE_SOURCE_UNAVAILABLE', + decidedAt + ); + } + if (!isVerifiedSourceResult(source)) { + return this.persistEarlyRejection( + context, + requestDigest, + 'EVIDENCE_SOURCE_INVALID', + decidedAt + ); + } + let redaction: ReturnType; + try { + redaction = redactCandidate( + context, + request, + source + ); + } catch { + return this.persistEarlyRejection( + context, + requestDigest, + 'EVIDENCE_SOURCE_INVALID', + decidedAt + ); + } + if (redaction.status === 'REJECTED') { + return this.persistEarlyRejection( + context, + requestDigest, + redaction.reasonCode, + decidedAt + ); + } + candidates.push(redaction.candidate); + } + + const result = buildSastAcceptedEvidence({ + scope: context.scope, + candidates, + policy: SAST_ACCEPTED_EVIDENCE_POLICY, + decidedAt, + digestCanonical: digest + }); + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + result, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) + ) { + return this.persistEarlyRejection( + context, + requestDigest, + 'EVIDENCE_OUTPUT_INVALID', + decidedAt + ); + } + return this.persistResult(context, result); + } + + private async persistEarlyRejection( + context: Readonly, + requestDigest: string, + reasonCode: SastEvidenceReasonCode, + decidedAt: string + ): Promise { + const result: SastAcceptedEvidenceBuildResult = { + decision: buildSastEvidenceEarlyRejection({ + scope: context.scope, + requestDigest, + reasonCode, + decidedAt, + digestCanonical: digest + }), + pack: null + }; + return this.persistResult(context, result); + } + + private async persistResult( + context: Readonly, + result: Readonly + ): Promise { + try { + const persisted = await this.store.persist({ + context, + result + }); + const canonicalResult = persisted.result; + if ( + !isSastAcceptedEvidenceBuildResultShapeValid( + canonicalResult, + digest, + SAST_ACCEPTED_EVIDENCE_POLICY + ) || + persisted.buildDecisionId !== + canonicalResult.decision.buildDecisionId || + persisted.decisionDigest !== + canonicalResult.decision.decisionDigest || + persisted.outcome !== canonicalResult.decision.outcome || + persisted.evidencePackId !== + (canonicalResult.pack?.evidencePackId ?? null) || + canonicalResult.decision.buildDecisionId !== + result.decision.buildDecisionId || + canonicalResult.decision.candidateSetDigest !== + result.decision.candidateSetDigest + ) { + return this.reject('EVIDENCE_PERSISTENCE_CONFLICT'); + } + if (canonicalResult.pack) { + return { + outcome: 'BUILT', + decision: canonicalResult.decision, + pack: canonicalResult.pack, + replayed: persisted.replayed + }; + } + return { + outcome: 'REJECTED', + reasonCode: + canonicalResult.decision.reasonCodes[0] ?? + 'EVIDENCE_OUTPUT_INVALID', + decision: canonicalResult.decision, + replayed: persisted.replayed, + dashboardPayloadCreated: false, + aiPayloadCreated: false, + publicationAttempted: false + }; + } catch (error) { + if (error instanceof SastAcceptedEvidencePersistenceError) { + return this.reject( + mapPersistenceReason(error.reason) + ); + } + return this.reject('EVIDENCE_PERSISTENCE_CONFLICT'); + } + } + + private reject( + reasonCode: SastEvidenceReasonCode + ): SastAcceptedEvidenceOutcome { + return { + outcome: 'REJECTED', + reasonCode, + decision: null, + replayed: false, + dashboardPayloadCreated: false, + aiPayloadCreated: false, + publicationAttempted: false + }; + } +} + +function mapPersistenceReason( + reason: SastAcceptedEvidencePersistenceError['reason'] +): SastEvidenceReasonCode { + switch (reason) { + case 'CONTEXT_DRIFT': + return 'EVIDENCE_CONTEXT_UNAVAILABLE'; + case 'OUTPUT_INVALID': + return 'EVIDENCE_OUTPUT_INVALID'; + case 'REPLAY_CONFLICT': + return 'EVIDENCE_PERSISTENCE_CONFLICT'; + } +} + +function redactCandidate( + context: Readonly, + request: Readonly, + source: Extract< + SastAcceptedEvidenceSourceResult, + { status: 'VERIFIED' } + > +): + | { + status: 'ACCEPTED'; + candidate: SastRedactedEvidenceCandidate; + } + | { + status: 'REJECTED'; + reasonCode: + | 'EVIDENCE_SOURCE_INVALID' + | 'EVIDENCE_REDACTION_INVALID'; + } { + const platformSecrets = normalizePlatformSecretValues( + source.platformSecretValues + ); + if ( + source.candidateId !== request.candidateId || + source.role !== request.role || + source.normalizedPath !== request.normalizedPath || + source.startLine !== request.startLine || + source.endLine !== request.endLine || + source.scannerRedactionApplied !== true || + source.anchorStartLine < source.startLine || + source.anchorEndLine < source.anchorStartLine || + source.anchorEndLine > source.endLine || + source.sourceFileLineCount < source.endLine || + !isCanonicalEvidenceText(source.scannerRedactedContent) || + utf8Bytes(source.scannerRedactedContent) > + SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentBytes || + digest(source.scannerRedactedContent) !== + source.sourceContentDigest || + !isBoundedReference(source.sourceAttestationRef) || + !isBoundedReference(source.scannerRedactionDecisionRef) || + platformSecrets === null + ) { + return { + status: 'REJECTED', + reasonCode: 'EVIDENCE_SOURCE_INVALID' + }; + } + if ( + source.role === 'PRIMARY' && + (source.normalizedPath !== context.scope.normalizedPath || + source.anchorStartLine !== + context.scope.findingStartLine || + source.anchorEndLine !== context.scope.findingEndLine) + ) { + return { + status: 'REJECTED', + reasonCode: 'EVIDENCE_SOURCE_INVALID' + }; + } + let redactedContent = source.scannerRedactedContent; + let replacementCount = 0; + for (const pattern of KNOWN_SECRET_PATTERNS) { + pattern.lastIndex = 0; + redactedContent = redactedContent.replace( + pattern, + (matched) => { + replacementCount += 1; + return redactionReplacement(matched); + } + ); + } + for (const secret of platformSecrets) { + const parts = redactedContent.split(secret); + if (parts.length > 1) { + replacementCount += parts.length - 1; + redactedContent = parts.join( + redactionReplacement(secret) + ); + } + } + if ( + !isCanonicalEvidenceText(redactedContent) || + countLines(redactedContent) !== + source.endLine - source.startLine + 1 || + utf8Bytes(redactedContent) > + SAST_ACCEPTED_EVIDENCE_POLICY.maxFragmentBytes + ) { + return { + status: 'REJECTED', + reasonCode: 'EVIDENCE_REDACTION_INVALID' + }; + } + const contentDigest = digest(redactedContent); + const platformRedactionDecisionRef = + deterministicId( + 'sast-evidence-redaction', + [ + source.candidateId, + source.sourceContentDigest, + contentDigest, + String(replacementCount) + ].join('\0') + ); + const core: SastRedactedEvidenceCandidateCore = { + candidateId: source.candidateId, + role: source.role, + normalizedPath: source.normalizedPath, + startLine: source.startLine, + endLine: source.endLine, + anchorStartLine: source.anchorStartLine, + anchorEndLine: source.anchorEndLine, + sourceFileLineCount: source.sourceFileLineCount, + redactedContent, + byteSize: utf8Bytes(redactedContent), + sourceContentDigest: source.sourceContentDigest, + contentDigest, + sourceAttestationRef: source.sourceAttestationRef, + scannerRedactionDecisionRef: + source.scannerRedactionDecisionRef, + platformRedactionDecisionRef, + secretRedactionApplied: true, + rawSourceStored: false + }; + const candidate: SastRedactedEvidenceCandidate = { + ...core, + candidateDigest: digest( + canonicalizeSastEvidenceCandidate(core) + ) + }; + return isSastRedactedEvidenceCandidateShapeValid( + candidate, + digest + ) + ? { status: 'ACCEPTED', candidate } + : { + status: 'REJECTED', + reasonCode: 'EVIDENCE_REDACTION_INVALID' + }; +} + +function isBuildRequestValid(value: unknown): value is { + freshnessDecisionId: string; + occurrenceId: string; + fragments: readonly SastEvidenceFragmentRequest[]; +} { + return ( + !!value && + typeof value === 'object' && + Object.keys(value).length === 3 && + Object.keys(value).every((key) => + [ + 'freshnessDecisionId', + 'occurrenceId', + 'fragments' + ].includes(key) + ) && + typeof (value as { freshnessDecisionId?: unknown }) + .freshnessDecisionId === 'string' && + /^sast-freshness:\/\/[a-f0-9]{64}$/u.test( + (value as { freshnessDecisionId: string }) + .freshnessDecisionId + ) && + typeof (value as { occurrenceId?: unknown }).occurrenceId === + 'string' && + /^finding-occurrence:\/\/[a-f0-9]{64}$/u.test( + (value as { occurrenceId: string }).occurrenceId + ) && + Array.isArray( + (value as { fragments?: unknown }).fragments + ) && + (value as { fragments: unknown[] }).fragments.length > 0 && + (value as { fragments: unknown[] }).fragments.length <= + SAST_ACCEPTED_EVIDENCE_LIMITS.maximumSourceCandidates && + (value as { fragments: unknown[] }).fragments.every( + isSastEvidenceFragmentRequestValid + ) && + new Set( + ( + value as { + fragments: SastEvidenceFragmentRequest[]; + } + ).fragments.map((fragment) => fragment.candidateId) + ).size === + (value as { fragments: unknown[] }).fragments.length + ); +} + +function isUnavailableSourceResult( + value: unknown +): value is Extract< + SastAcceptedEvidenceSourceResult, + { status: 'UNAVAILABLE' } +> { + return ( + !!value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + (value as { status?: unknown }).status === 'UNAVAILABLE' + ); +} + +function isVerifiedSourceResult( + value: unknown +): value is Extract< + SastAcceptedEvidenceSourceResult, + { status: 'VERIFIED' } +> { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) + ) { + return false; + } + const record = value as Record; + const keys = [ + 'status', + 'candidateId', + 'role', + 'normalizedPath', + 'startLine', + 'endLine', + 'anchorStartLine', + 'anchorEndLine', + 'sourceFileLineCount', + 'scannerRedactedContent', + 'sourceContentDigest', + 'sourceAttestationRef', + 'scannerRedactionApplied', + 'scannerRedactionDecisionRef', + 'platformSecretValues' + ]; + return ( + Object.keys(record).length === keys.length && + Object.keys(record).every((key) => keys.includes(key)) && + record.status === 'VERIFIED' && + typeof record.candidateId === 'string' && + (record.role === 'PRIMARY' || record.role === 'RELATED') && + typeof record.normalizedPath === 'string' && + Number.isSafeInteger(record.startLine) && + Number.isSafeInteger(record.endLine) && + Number.isSafeInteger(record.anchorStartLine) && + Number.isSafeInteger(record.anchorEndLine) && + Number.isSafeInteger(record.sourceFileLineCount) && + typeof record.scannerRedactedContent === 'string' && + typeof record.sourceContentDigest === 'string' && + typeof record.sourceAttestationRef === 'string' && + record.scannerRedactionApplied === true && + typeof record.scannerRedactionDecisionRef === 'string' && + Array.isArray(record.platformSecretValues) + ); +} + +function normalizePlatformSecretValues( + values: readonly string[] +): string[] | null { + if ( + !Array.isArray(values) || + values.length > + SAST_SECRET_REDACTION_LIMITS.maximumPlatformSecretValues + ) { + return null; + } + const seen = new Set(); + let totalBytes = 0; + for (const value of values as readonly unknown[]) { + if ( + typeof value !== 'string' || + value.length === 0 || + value !== value.normalize('NFC') || + !isCanonicalEvidenceText(value) || + value.includes(REDACTION_TOKEN) || + !/\S/u.test(value) + ) { + return null; + } + const byteSize = utf8Bytes(value); + if ( + byteSize < 8 || + byteSize > + SAST_SECRET_REDACTION_LIMITS + .maximumPlatformSecretValueBytes || + totalBytes > + SAST_SECRET_REDACTION_LIMITS + .maximumPlatformSecretValueTotalBytes - + byteSize || + seen.has(value) + ) { + return null; + } + totalBytes += byteSize; + seen.add(value); + } + return [...seen].sort( + (left, right) => + right.length - left.length || + compareCodeUnits(left, right) + ); +} + +function redactionReplacement(value: string): string { + return REDACTION_TOKEN + '\n'.repeat(countNewlines(value)); +} + +function countNewlines(value: string): number { + let count = 0; + for (const character of value) { + if (character === '\n') count += 1; + } + return count; +} + +function compareCodeUnits(left: string, right: string): number { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const difference = + left.charCodeAt(index) - right.charCodeAt(index); + if (difference !== 0) return difference; + } + return left.length - right.length; +} + +function isCanonicalEvidenceText(value: string): boolean { + return ( + value.length > 0 && + value === value.normalize('NFC') && + !value.includes('\r') && + !value.includes('\u0000') && + ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return ( + (point >= 0xd800 && point <= 0xdfff) || + (point < 0x20 && point !== 0x09 && point !== 0x0a) || + (point >= 0x7f && point <= 0x9f) + ); + }) + ); +} + +function countLines(value: string): number { + return value.split('\n').length; +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +function isBoundedReference(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 2048 && + value === value.normalize('NFC') && + value === value.trim() + ); +} + +function digest(value: string): 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 milliseconds = Date.parse(value); + return ( + Number.isFinite(milliseconds) && + new Date(milliseconds).toISOString() === value + ); +} diff --git a/apps/api/src/scan-plane/sast-accepted-evidence.store.ts b/apps/api/src/scan-plane/sast-accepted-evidence.store.ts new file mode 100644 index 0000000..7049d6d --- /dev/null +++ b/apps/api/src/scan-plane/sast-accepted-evidence.store.ts @@ -0,0 +1,44 @@ +import type { + SastAcceptedEvidenceBuildResult, + SastAcceptedEvidenceScope, + SastEvidenceBuildDecision +} from '@aegisai/shared'; + +export interface SastAcceptedEvidenceContext { + scope: SastAcceptedEvidenceScope; + freshnessDecidedAt: string; +} + +export interface PersistedSastAcceptedEvidence { + buildDecisionId: string; + decisionDigest: string; + outcome: SastEvidenceBuildDecision['outcome']; + evidencePackId: string | null; + replayed: boolean; + result: SastAcceptedEvidenceBuildResult; +} + +export class SastAcceptedEvidencePersistenceError extends Error { + constructor( + readonly reason: + | 'CONTEXT_DRIFT' + | 'REPLAY_CONFLICT' + | 'OUTPUT_INVALID' + ) { + super('The accepted-finding evidence ledger conflicts with durable state.'); + this.name = 'SastAcceptedEvidencePersistenceError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export abstract class SastAcceptedEvidenceStore { + abstract loadContext( + freshnessDecisionId: string, + occurrenceId: string + ): Promise; + + abstract persist(input: { + context: Readonly; + result: Readonly; + }): Promise; +} diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index e537a99..4d610c7 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -126,6 +126,19 @@ import { import { SastFindingLifecycleCoverageGate } from './sast-finding-lifecycle-coverage.gate'; +import { + PrismaSastAcceptedEvidenceStore +} from './prisma-sast-accepted-evidence.store'; +import { + SastAcceptedEvidenceStore +} from './sast-accepted-evidence.store'; +import { + SastAcceptedEvidenceSourceAuthority, + UnavailableSastAcceptedEvidenceSourceAuthority +} from './sast-accepted-evidence-source.authority'; +import { + SastAcceptedEvidenceService +} from './sast-accepted-evidence.service'; @Module({ imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], @@ -149,6 +162,18 @@ import { SastFindingCorrelationService, SastScanCoverageService, SastScanFreshnessService, + SastAcceptedEvidenceService, + PrismaSastAcceptedEvidenceStore, + { + provide: SastAcceptedEvidenceStore, + useExisting: PrismaSastAcceptedEvidenceStore + }, + UnavailableSastAcceptedEvidenceSourceAuthority, + { + provide: SastAcceptedEvidenceSourceAuthority, + useExisting: + UnavailableSastAcceptedEvidenceSourceAuthority + }, PrismaSastFindingLineageStore, { provide: SastFindingLineageStore, @@ -267,7 +292,7 @@ import { RepositoryPreflightService, SandboxRuntimeAttestationService, SastScannerRuntimeService, - SastScanFreshnessService + SastAcceptedEvidenceService ] }) export class ScanPlaneModule {} diff --git a/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts new file mode 100644 index 0000000..2b7ebbe --- /dev/null +++ b/apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts @@ -0,0 +1,292 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + SAST_ACCEPTED_EVIDENCE_LIMITS, + SAST_ACCEPTED_EVIDENCE_POLICY +} from '@aegisai/shared'; + +describe('SAST accepted-finding evidence persistence contract', () => { + const schema = read('prisma/schema.prisma'); + const migration = read( + 'prisma/migrations/20260810043000_sast_accepted_evidence/migration.sql' + ); + const store = read( + 'src/scan-plane/prisma-sast-accepted-evidence.store.ts' + ); + const service = read( + 'src/scan-plane/sast-accepted-evidence.service.ts' + ); + const authority = read( + 'src/scan-plane/sast-accepted-evidence-source.authority.ts' + ); + const module = read('src/scan-plane/scan-plane.module.ts'); + const onlineSchema = read( + 'scripts/apply-online-sast-runtime-schema.mjs' + ); + const shared = readShared( + 'src/types/sast-accepted-evidence.ts' + ); + + it('persists tenant-scoped build, pack, and fragment ledgers', () => { + for (const model of [ + 'SastEvidenceBuildDecision', + 'SastAcceptedEvidencePack', + 'SastAcceptedEvidenceFragment' + ]) { + expect(schema).toContain('model ' + model + ' {'); + expect(migration).toContain( + 'CREATE TABLE "' + model + '"' + ); + } + expect(migration).toContain( + 'SastEvidenceBuildDecision_freshness_scope_fkey' + ); + expect(migration).not.toContain( + 'SastEvidenceBuildDecision_occurrence_scope_fkey' + ); + expect(onlineSchema).toContain( + 'SastFindingOccurrence_correlation_scope_key' + ); + expect(onlineSchema).toContain( + 'SastEvidenceBuildDecision_occurrence_scope_fkey' + ); + expect(migration).toContain( + 'SastAcceptedEvidencePack_decision_scope_fkey' + ); + expect(migration).toContain( + 'SastAcceptedEvidenceFragment_pack_scope_fkey' + ); + expect(migration).toContain( + '"evidencePackId", "buildDecisionId", "tenantId", "repositoryBindingId", "scanRequestId", "attemptId"' + ); + expect(migration).toContain( + 'SastEvidenceBuildDecision_replay_key' + ); + expect(migration).toContain( + 'SastEvidenceBuildDecision_coverageDecisionId_idx' + ); + expect(migration).toContain( + 'SastAcceptedEvidencePack_expiresAt_idx' + ); + expect(migration).not.toContain( + 'SastAcceptedEvidencePack_scope_key' + ); + expect(migration).not.toContain( + 'SastAcceptedEvidencePack_decision_key' + ); + expect(migration).toContain( + 'Prisma requires this composite key' + ); + expect(migration).not.toContain('CONCURRENTLY'); + }); + + it('enforces bounded redacted content and zero downstream authority in SQL', () => { + expect(migration).toContain( + '"totalBytes" BETWEEN 1 AND 32768' + ); + expect(migration).toContain( + '"fragmentCount" BETWEEN 1 AND 5' + ); + expect(migration).toContain( + '"byteSize" BETWEEN 1 AND 8192' + ); + expect(migration).toContain( + '"anchorStartLine" - "startLine" BETWEEN 0 AND 5' + ); + expect(migration).toContain( + '"endLine" - "anchorEndLine" BETWEEN 0 AND 5' + ); + expect(migration).toContain( + '"normalizedPath" = normalize("normalizedPath", NFC)' + ); + expect(migration).toContain( + '"normalizedPath" !~ \'(^|/)\\.{1,2}(/|$)\'' + ); + expect(migration).toContain( + 'NOT ("startLine" = 1 AND "endLine" = "sourceFileLineCount")' + ); + expect(migration).toContain( + '"secretRedactionApplied" = true' + ); + expect(migration).toContain('"rawSourceStored" = false'); + expect(migration).toContain( + '("authority"->>\'dashboardAccessAllowed\')::boolean IS FALSE' + ); + expect(migration).toContain( + '"evidencePackId" IS NOT NULL' + ); + expect(migration).toContain( + '"evidencePackDigest" IS NOT NULL' + ); + expect(migration).toContain('"dashboardSafe" = false'); + expect(migration).toContain('"aiSafe" = false'); + expect(migration).toContain( + '"classificationDecisionRef" IS NULL' + ); + expect(migration).toContain( + '"deletionScheduleRef" IS NULL' + ); + expect(migration).toContain( + '"expiresAt" <= "createdAt" + INTERVAL \'7 days\'' + ); + }); + + it('pins SQL bounds to the shared policy and prevents ledger updates', () => { + expect(migration).toContain( + 'SAST_ACCEPTED_EVIDENCE_POLICY and SAST_ACCEPTED_EVIDENCE_LIMITS' + ); + expect(migration).toContain( + '"selectedFragmentCount" BETWEEN 0 AND 5' + ); + expect(migration).toContain( + '"totalBytes" BETWEEN 1 AND 32768' + ); + expect(migration).toContain( + '"fragmentCount" BETWEEN 1 AND 5' + ); + expect(migration).toContain( + '"ordinal" BETWEEN 0 AND 4' + ); + expect(migration).toContain( + '"byteSize" BETWEEN 1 AND 8192' + ); + expect(migration).toContain("INTERVAL '7 days'"); + expect(SAST_ACCEPTED_EVIDENCE_POLICY).toMatchObject({ + maxTotalBytes: 32768, + maxFragmentCount: 5, + maxFragmentBytes: 8192, + contextLinesBefore: 5, + contextLinesAfter: 5, + maxRetentionSeconds: 604800 + }); + expect( + SAST_ACCEPTED_EVIDENCE_LIMITS.maximumFragmentsPerFile + ).toBe(2); + expect(migration).toContain( + 'CREATE FUNCTION "reject_sast_accepted_evidence_update"()' + ); + for (const table of [ + 'SastEvidenceBuildDecision', + 'SastAcceptedEvidencePack', + 'SastAcceptedEvidenceFragment' + ]) { + expect(migration).toContain( + 'CREATE TRIGGER "' + table + '_immutable_update"' + ); + expect(migration).toContain('BEFORE UPDATE ON "' + table + '"'); + } + }); + + it('rebinds the complete fresh T040 decision and accepted T037 occurrence', () => { + expect(store).toContain( + 'isSastScanFreshnessDecisionShapeValid' + ); + expect(store).toContain( + 'isSastScanCoverageDecisionShapeValid' + ); + expect(store).toContain( + 'isSastFingerprintedFindingShapeValid' + ); + expect(store).toContain( + "freshnessRow.coverageDecision.state !== 'COMPLETE'" + ); + expect(store).toContain( + "freshness.latestTargetAuthority !== 'VERIFIED'" + ); + expect(store).toContain( + "freshness.staleStatus !== 'FRESH'" + ); + expect(store).toContain( + "freshness.comparabilityStatus !== 'COMPARABLE'" + ); + expect(store).toContain('sourceBound'); + expect(store).toContain( + 'correlationBatch.sourceSetDigest ===' + ); + expect(store).toContain( + 'source.observationBatchId ===' + ); + expect(store).toContain( + 'normalized.sastStableFingerprint ===' + ); + expect(store).toContain( + 'Prisma.TransactionIsolationLevel.Serializable' + ); + expect(store).toContain('SERIALIZABLE_ATTEMPTS = 3'); + expect(store).toContain( + 'SERIALIZABLE_RETRY_BASE_DELAY_MILLISECONDS' + ); + expect(store).toContain( + "modelName === 'SastEvidenceBuildDecision'" + ); + expect(store).toContain('replayExisting'); + }); + + it('uses exact UTF-8 byte, context, and reconstruction checks', () => { + expect(shared).toContain('DEFAULT_SAST_EVIDENCE_POLICY'); + expect(shared).toContain('policy.maxTotalBytes'); + expect(shared).toContain('policy.maxFragmentCount'); + expect(shared).toContain('policy.maxFragmentBytes'); + expect(shared).toContain( + 'maximumReconstructedFileCoverageBasisPoints: 2500' + ); + expect(shared).toContain( + 'maximumFragmentsPerFile: 2' + ); + expect(shared).toContain( + "'EVIDENCE_RECONSTRUCTION_OVERLAP'" + ); + expect(shared).toContain( + "'EVIDENCE_RECONSTRUCTION_ADJACENT'" + ); + expect(shared).toContain( + "'EVIDENCE_RECONSTRUCTION_COVERAGE'" + ); + expect(shared).toContain( + 'digestCanonical(value.redactedContent) !== value.contentDigest' + ); + expect(shared).toContain( + 'lineCount(value.redactedContent)' + ); + }); + + it('exports only the T041 sequential handoff with unavailable source by default', () => { + const exportsBlock = + module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? + ''; + expect(exportsBlock).toContain( + 'SastAcceptedEvidenceService' + ); + expect(exportsBlock).not.toContain( + 'SastScanFreshnessService' + ); + expect(exportsBlock).not.toContain( + 'SastScanCoverageService' + ); + expect(module).toMatch( + /provide:\s*SastAcceptedEvidenceSourceAuthority,[\s\S]{0,140}useExisting:\s*UnavailableSastAcceptedEvidenceSourceAuthority/u + ); + expect(authority).toContain( + "return { status: 'UNAVAILABLE' }" + ); + expect(service).not.toMatch( + /@Controller|@(Get|Post|Put|Patch|Delete)\(/u + ); + expect(service).not.toMatch(/\bLogger\b|\bconsole\./u); + expect(service).toContain('dashboardPayloadCreated: false'); + expect(service).toContain('aiPayloadCreated: false'); + expect(service).toContain('publicationAttempted: false'); + }); +}); + +function read(path: string): string { + return readFileSync(resolve(__dirname, '../../' + path), 'utf8'); +} + +function readShared(path: string): string { + return readFileSync( + resolve(__dirname, '../../../../packages/shared/' + path), + 'utf8' + ); +} diff --git a/apps/api/test/scan-plane/sast-accepted-evidence.e2e-spec.ts b/apps/api/test/scan-plane/sast-accepted-evidence.e2e-spec.ts new file mode 100644 index 0000000..d0d4d70 --- /dev/null +++ b/apps/api/test/scan-plane/sast-accepted-evidence.e2e-spec.ts @@ -0,0 +1,679 @@ +import { createHash } from 'node:crypto'; + +import type { + SastAcceptedEvidenceBuildResult, + SastAcceptedEvidenceScope, + SastEvidenceFragmentRequest +} from '@aegisai/shared'; + +import { + SastAcceptedEvidenceSourceAuthority, + type SastAcceptedEvidenceSourceResult +} from '../../src/scan-plane/sast-accepted-evidence-source.authority'; +import { SastAcceptedEvidenceService } from '../../src/scan-plane/sast-accepted-evidence.service'; +import { + SastAcceptedEvidencePersistenceError, + SastAcceptedEvidenceStore, + type SastAcceptedEvidenceContext +} from '../../src/scan-plane/sast-accepted-evidence.store'; + +const DECIDED_AT = '2026-08-10T04:40:00.000Z'; + +describe('SastAcceptedEvidenceService', () => { + it('redacts trusted source and persists a bounded internal-only pack', async () => { + const context = evidenceContext(); + const store = new MemoryEvidenceStore(context); + const source = new MemorySourceAuthority({ + platformSecretValues: ['platform-secret-value'], + contentByCandidate: new Map([ + [ + candidateId('primary'), + [ + 'safe line', + 'password = "platform-secret-value"', + 'return safe' + ].join('\n') + ] + ]) + }); + const service = new SastAcceptedEvidenceService( + store, + source + ); + + const result = await service.build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => DECIDED_AT + ); + + expect(result.outcome).toBe('BUILT'); + if (result.outcome !== 'BUILT') return; + expect(result.pack.fragments).toHaveLength(1); + expect( + result.pack.fragments[0]?.redactedContent + ).toContain('[REDACTED]'); + expect( + result.pack.fragments[0]?.redactedContent + ).not.toContain('platform-secret-value'); + expect(result.pack.dashboardSafe).toBe(false); + expect(result.pack.aiSafe).toBe(false); + expect(result.decision.audit.rawSourceStored).toBe(false); + expect(result.decision.audit.aiPayloadCreated).toBe(false); + expect(store.persisted?.pack).toEqual(result.pack); + }); + + it('redacts multiline known secrets without changing fragment line coordinates', async () => { + const context = evidenceContext(); + const store = new MemoryEvidenceStore(context); + const privateKey = [ + '-----BEGIN PRIVATE KEY-----', + 'SYNTHETIC_PRIVATE_KEY_NEVER_COPY', + '-----END PRIVATE KEY-----' + ].join('\n'); + const source = new MemorySourceAuthority({ + contentByCandidate: new Map([ + [candidateId('primary'), privateKey] + ]) + }); + + const result = await new SastAcceptedEvidenceService( + store, + source + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => DECIDED_AT + ); + + expect(result.outcome).toBe('BUILT'); + if (result.outcome !== 'BUILT') return; + const content = result.pack.fragments[0]?.redactedContent; + expect(content).toContain('[REDACTED]'); + expect(content).not.toContain('PRIVATE KEY'); + expect(content?.split('\n')).toHaveLength(3); + }); + + it('persists an immutable fail-closed decision when source authority is unavailable', async () => { + const context = evidenceContext(); + const store = new MemoryEvidenceStore(context); + const service = new SastAcceptedEvidenceService( + store, + new UnavailableMemorySource() + ); + + const result = await service.build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => DECIDED_AT + ); + + expect(result).toMatchObject({ + outcome: 'REJECTED', + reasonCode: 'EVIDENCE_SOURCE_UNAVAILABLE', + dashboardPayloadCreated: false, + aiPayloadCreated: false, + publicationAttempted: false + }); + expect(store.persisted?.decision.outcome).toBe('REJECTED'); + expect(store.persisted?.pack).toBeNull(); + }); + + it('rejects full-file and overlapping reconstruction before creating a pack', async () => { + const fullContext = evidenceContext({ + findingStartLine: 5, + findingEndLine: 5 + }); + const fullStore = new MemoryEvidenceStore(fullContext); + const fullSource = new MemorySourceAuthority({ + sourceFileLineCount: 10 + }); + const full = await new SastAcceptedEvidenceService( + fullStore, + fullSource + ).build( + { + freshnessDecisionId: + fullContext.scope.freshnessDecisionId, + occurrenceId: fullContext.scope.occurrenceId, + fragments: [ + request({ + seed: 'full', + role: 'PRIMARY', + path: fullContext.scope.normalizedPath, + startLine: 1, + endLine: 10 + }) + ] + }, + () => DECIDED_AT + ); + expect(full.outcome).toBe('REJECTED'); + if (full.outcome === 'REJECTED') { + expect(full.decision?.reasonCodes).toContain( + 'EVIDENCE_FULL_FILE_FORBIDDEN' + ); + } + + const context = evidenceContext(); + const overlapStore = new MemoryEvidenceStore(context); + const overlapSource = new MemorySourceAuthority({ + relatedAnchorStartLine: 12, + relatedAnchorEndLine: 12 + }); + const overlap = await new SastAcceptedEvidenceService( + overlapStore, + overlapSource + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [ + primaryRequest(), + request({ + seed: 'related', + role: 'RELATED', + path: context.scope.normalizedPath, + startLine: 12, + endLine: 14 + }) + ] + }, + () => DECIDED_AT + ); + expect(overlap.outcome).toBe('REJECTED'); + if (overlap.outcome === 'REJECTED') { + expect(overlap.decision?.reasonCodes).toContain( + 'EVIDENCE_RECONSTRUCTION_OVERLAP' + ); + } + }); + + it('rejects adjacent, substantial-coverage, and over-cap per-file reconstruction', async () => { + const context = evidenceContext(); + const adjacent = await new SastAcceptedEvidenceService( + new MemoryEvidenceStore(context), + new MemorySourceAuthority({}) + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [ + primaryRequest(), + request({ + seed: 'adjacent', + role: 'RELATED', + path: context.scope.normalizedPath, + startLine: 13, + endLine: 15 + }) + ] + }, + () => DECIDED_AT + ); + expect(adjacent.outcome).toBe('REJECTED'); + if (adjacent.outcome === 'REJECTED') { + expect(adjacent.decision?.reasonCodes).toContain( + 'EVIDENCE_RECONSTRUCTION_ADJACENT' + ); + expect(adjacent.decision?.reconstruction.status).toBe( + 'RISK' + ); + } + + const substantial = await new SastAcceptedEvidenceService( + new MemoryEvidenceStore(context), + new MemorySourceAuthority({ sourceFileLineCount: 20 }) + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [ + primaryRequest(), + request({ + seed: 'substantial', + role: 'RELATED', + path: context.scope.normalizedPath, + startLine: 1, + endLine: 3 + }) + ] + }, + () => DECIDED_AT + ); + expect(substantial.outcome).toBe('REJECTED'); + if (substantial.outcome === 'REJECTED') { + expect(substantial.decision?.reasonCodes).toContain( + 'EVIDENCE_RECONSTRUCTION_COVERAGE' + ); + expect(substantial.decision?.reconstruction.status).toBe( + 'RISK' + ); + } + + const overCap = await new SastAcceptedEvidenceService( + new MemoryEvidenceStore(context), + new MemorySourceAuthority({}) + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [ + primaryRequest(), + request({ + seed: 'per-file-1', + role: 'RELATED', + path: context.scope.normalizedPath, + startLine: 20, + endLine: 22 + }), + request({ + seed: 'per-file-2', + role: 'RELATED', + path: context.scope.normalizedPath, + startLine: 30, + endLine: 32 + }) + ] + }, + () => DECIDED_AT + ); + expect(overCap.outcome).toBe('REJECTED'); + if (overCap.outcome === 'REJECTED') { + expect(overCap.decision?.reasonCodes).toContain( + 'EVIDENCE_RECONSTRUCTION_FRAGMENT_COUNT' + ); + expect(overCap.decision?.reconstruction.status).toBe( + 'RISK' + ); + } + }); + + it('returns the persisted exact replay and rejects changed input at the store boundary', async () => { + const context = evidenceContext(); + const store = new MemoryEvidenceStore(context); + const source = new MemorySourceAuthority({}); + const service = new SastAcceptedEvidenceService( + store, + source + ); + const input = { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }; + const first = await service.build(input, () => DECIDED_AT); + const second = await service.build( + input, + () => '2026-08-10T04:41:00.000Z' + ); + + expect(first.outcome).toBe('BUILT'); + expect(second).toMatchObject({ + outcome: 'BUILT', + replayed: true + }); + if (first.outcome !== 'BUILT' || second.outcome !== 'BUILT') { + return; + } + expect(second.decision.decisionDigest).toBe( + first.decision.decisionDigest + ); + expect(second.pack.packDigest).toBe(first.pack.packDigest); + expect(second.pack.createdAt).toBe(first.pack.createdAt); + expect(source.calls).toBe(2); + }); + + it('rejects invalid requests and unavailable durable context without reading source', async () => { + const source = new MemorySourceAuthority({}); + const store = new MemoryEvidenceStore(null); + const service = new SastAcceptedEvidenceService( + store, + source + ); + const invalid = await service.build({ + freshnessDecisionId: 'not-a-decision', + occurrenceId: 'not-an-occurrence', + fragments: [] + }); + expect(invalid).toMatchObject({ + outcome: 'REJECTED', + reasonCode: 'EVIDENCE_INPUT_INVALID' + }); + + const context = evidenceContext(); + const unavailable = await service.build({ + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }); + expect(unavailable).toMatchObject({ + outcome: 'REJECTED', + reasonCode: 'EVIDENCE_CONTEXT_UNAVAILABLE' + }); + expect(source.calls).toBe(0); + }); + + it('fails closed for a throwing clock or malformed source provider result', async () => { + const context = evidenceContext(); + const source = new MemorySourceAuthority({}); + const clockFailure = await new SastAcceptedEvidenceService( + new MemoryEvidenceStore(context), + source + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => { + throw new Error('clock unavailable'); + } + ); + expect(clockFailure).toMatchObject({ + outcome: 'REJECTED', + reasonCode: 'EVIDENCE_INPUT_INVALID' + }); + expect(source.calls).toBe(0); + + const malformed = await new SastAcceptedEvidenceService( + new MemoryEvidenceStore(context), + new MalformedMemorySource() + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => DECIDED_AT + ); + expect(malformed).toMatchObject({ + outcome: 'REJECTED', + reasonCode: 'EVIDENCE_SOURCE_INVALID' + }); + }); + + it.each([ + ['CONTEXT_DRIFT', 'EVIDENCE_CONTEXT_UNAVAILABLE'], + ['REPLAY_CONFLICT', 'EVIDENCE_PERSISTENCE_CONFLICT'], + ['OUTPUT_INVALID', 'EVIDENCE_OUTPUT_INVALID'] + ] as const)( + 'maps typed persistence reason %s to %s', + async (persistenceReason, evidenceReason) => { + const context = evidenceContext(); + const result = await new SastAcceptedEvidenceService( + new FailingEvidenceStore(context, persistenceReason), + new MemorySourceAuthority({}) + ).build( + { + freshnessDecisionId: + context.scope.freshnessDecisionId, + occurrenceId: context.scope.occurrenceId, + fragments: [primaryRequest()] + }, + () => DECIDED_AT + ); + + expect(result).toMatchObject({ + outcome: 'REJECTED', + reasonCode: evidenceReason, + decision: null, + replayed: false + }); + } + ); +}); + +class FailingEvidenceStore extends SastAcceptedEvidenceStore { + constructor( + private readonly context: SastAcceptedEvidenceContext, + private readonly reason: + | 'CONTEXT_DRIFT' + | 'REPLAY_CONFLICT' + | 'OUTPUT_INVALID' + ) { + super(); + } + + async loadContext() { + return this.context; + } + + async persist(): Promise { + throw new SastAcceptedEvidencePersistenceError(this.reason); + } +} + +class MemoryEvidenceStore extends SastAcceptedEvidenceStore { + persisted?: SastAcceptedEvidenceBuildResult; + + constructor( + private readonly context: + | SastAcceptedEvidenceContext + | null + ) { + super(); + } + + async loadContext() { + return this.context; + } + + async persist(input: { + result: Readonly; + }) { + const replayed = this.persisted !== undefined; + if ( + this.persisted && + (this.persisted.decision.buildDecisionId !== + input.result.decision.buildDecisionId || + this.persisted.decision.candidateSetDigest !== + input.result.decision.candidateSetDigest) + ) { + throw new Error('changed replay'); + } + this.persisted ??= + input.result as SastAcceptedEvidenceBuildResult; + const canonical = this.persisted; + return { + buildDecisionId: canonical.decision.buildDecisionId, + decisionDigest: canonical.decision.decisionDigest, + outcome: canonical.decision.outcome, + evidencePackId: + canonical.pack?.evidencePackId ?? null, + replayed, + result: canonical + }; + } +} + +class MemorySourceAuthority + extends SastAcceptedEvidenceSourceAuthority { + calls = 0; + + constructor( + private readonly options: { + platformSecretValues?: readonly string[]; + contentByCandidate?: ReadonlyMap; + sourceFileLineCount?: number; + relatedAnchorStartLine?: number; + relatedAnchorEndLine?: number; + } + ) { + super(); + } + + async read(input: { + scope: Readonly; + request: Readonly; + }) { + this.calls += 1; + const lineCount = + input.request.endLine - input.request.startLine + 1; + const content = + this.options.contentByCandidate?.get( + input.request.candidateId + ) ?? + Array.from( + { length: lineCount }, + (_, index) => 'safe line ' + index + ).join('\n'); + const primary = input.request.role === 'PRIMARY'; + const anchorStartLine = primary + ? input.scope.findingStartLine + : (this.options.relatedAnchorStartLine ?? + input.request.startLine + 1); + const anchorEndLine = primary + ? input.scope.findingEndLine + : (this.options.relatedAnchorEndLine ?? + anchorStartLine); + return { + status: 'VERIFIED' as const, + candidateId: input.request.candidateId, + role: input.request.role, + normalizedPath: input.request.normalizedPath, + startLine: input.request.startLine, + endLine: input.request.endLine, + anchorStartLine, + anchorEndLine, + sourceFileLineCount: + this.options.sourceFileLineCount ?? 100, + scannerRedactedContent: content, + sourceContentDigest: digest(content), + sourceAttestationRef: + 'source-attestation://' + + input.request.candidateId, + scannerRedactionApplied: true as const, + scannerRedactionDecisionRef: + 'scanner-redaction://' + + input.request.candidateId, + platformSecretValues: + this.options.platformSecretValues ?? [] + }; + } +} + +class UnavailableMemorySource + extends SastAcceptedEvidenceSourceAuthority { + async read() { + return { status: 'UNAVAILABLE' as const }; + } +} + +class MalformedMemorySource + extends SastAcceptedEvidenceSourceAuthority { + async read(): Promise { + return null as unknown as SastAcceptedEvidenceSourceResult; + } +} + +function evidenceContext( + overrides: Partial = {} +): SastAcceptedEvidenceContext { + return { + scope: { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + targetRef: 'refs/pull/1/head', + commitSha: 'a'.repeat(40), + canonicalScanKey: digest('canonical-scan'), + planDigest: digest('plan'), + profileId: 'JAVA_FAST_V1', + profileDigest: digest('profile'), + freshnessDecisionId: id( + 'sast-freshness', + 'freshness' + ), + freshnessDecisionDigest: digest( + 'freshness-decision' + ), + coverageDecisionId: id('sast-coverage', 'coverage'), + coverageDecisionDigest: digest( + 'coverage-decision' + ), + occurrenceId: id( + 'finding-occurrence', + 'occurrence' + ), + observationBatchId: id( + 'finding-observation', + 'observation' + ), + normalizedFindingId: 'normalized-finding-1', + lineageId: id('finding-lineage', 'lineage'), + findingFingerprint: digest('fingerprint'), + fingerprintVersion: 'sast-fingerprint-v1', + capability: 'SAST', + normalizedPath: 'src/main/java/App.java', + findingStartLine: 11, + findingEndLine: 11, + policyVersion: 'sast-evidence-policy-v1', + ...overrides + }, + freshnessDecidedAt: '2026-08-10T04:39:59.000Z' + }; +} + +function primaryRequest(): SastEvidenceFragmentRequest { + return request({ + seed: 'primary', + role: 'PRIMARY', + path: 'src/main/java/App.java', + startLine: 10, + endLine: 12 + }); +} + +function request(input: { + seed: string; + role: 'PRIMARY' | 'RELATED'; + path: string; + startLine: number; + endLine: number; +}): SastEvidenceFragmentRequest { + return { + candidateId: candidateId(input.seed), + role: input.role, + normalizedPath: input.path, + startLine: input.startLine, + endLine: input.endLine + }; +} + +function candidateId(seed: string): string { + return id('sast-evidence-candidate', seed); +} + +function id(prefix: string, value: string): string { + return prefix + '://' + hex(value); +} + +function digest(value: string): string { + return 'sha256:' + hex(value); +} + +function hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts index 187c4a8..a8f9c56 100644 --- a/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-correlation-persistence.e2e-spec.ts @@ -96,7 +96,7 @@ describe('SAST finding correlation persistence contract', () => { ); }); - it('fences late T037 batches and keeps T038/T039 internal after T040', () => { + it('fences late T037 batches and keeps T038 through T040 internal after T041', () => { 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('SastScanFreshnessService'); + expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).not.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 e3c8f12..cbb77d1 100644 --- a/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-finding-lineage-persistence.e2e-spec.ts @@ -150,7 +150,7 @@ describe('SAST finding lineage persistence contract', () => { ); }); - it('keeps T037 internal after T040 and binds lifecycle consumption to the freshness gate', () => { + it('keeps T037 through T040 internal while preserving the freshness gate', () => { expect(module).toContain( 'UnavailableSastFindingRenameAttestationVerifier' ); @@ -160,7 +160,8 @@ describe('SAST finding lineage persistence contract', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).not.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 62f7950..b516c0c 100644 --- a/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-coverage-persistence.e2e-spec.ts @@ -114,10 +114,11 @@ describe('SAST scan coverage persistence contract', () => { ); }); - it('keeps T039 internal after exposing only the T040 sequential handoff', () => { + it('keeps T039 and T040 internal after exposing only the T041 handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(exportsBlock).not.toContain( 'SastFindingCorrelationService' diff --git a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts index 9ef2a88..16b13c8 100644 --- a/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scan-freshness-persistence.e2e-spec.ts @@ -170,10 +170,11 @@ describe('SAST scan freshness and retry persistence contract', () => { ); }); - it('exports only the T040 sequential handoff and opens no route or SCM writer', () => { + it('keeps T040 internal after exporting the T041 sequential handoff', () => { const exportsBlock = module.match(/exports:\s*\[([\s\S]*?)\]\s*\n\}\)/)?.[1] ?? ''; - expect(exportsBlock).toContain('SastScanFreshnessService'); + expect(exportsBlock).toContain('SastAcceptedEvidenceService'); + expect(exportsBlock).not.toContain('SastScanFreshnessService'); expect(exportsBlock).not.toContain('SastScanCoverageService'); expect(module).toMatch( /provide:\s*SastFindingLifecycleCoverageGate,[\s\S]{0,100}useExisting:\s*SastScanFreshnessService/ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fd29cfb..ee63739 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -22,6 +22,7 @@ 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-accepted-evidence'; export * from './types/sast-planning'; export * from './types/sast-fetch'; export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/sast-accepted-evidence.ts b/packages/shared/src/types/sast-accepted-evidence.ts new file mode 100644 index 0000000..f0e8dd6 --- /dev/null +++ b/packages/shared/src/types/sast-accepted-evidence.ts @@ -0,0 +1,1563 @@ +import { + DEFAULT_SAST_EVIDENCE_POLICY, + SAST_CAPABILITIES, + SAST_FINDING_FINGERPRINT_VERSION, + SAST_PROFILE_IDS, + isSastEvidencePolicySafe, + type SastCapability, + type SastEvidencePolicy, + type SastProfileId +} from './sast-runtime'; +import { + hasExactKeys, + isBoundedReference, + isCommitSha, + isRecord, + isSha256Digest, + utf8Length +} from './sast-normalization-validation'; + +export const SAST_ACCEPTED_EVIDENCE_VERSION = + 'sast-accepted-finding-evidence-v1' as const; +export const SAST_EVIDENCE_BUILD_DECISION_VERSION = + 'sast-evidence-build-decision-v1' as const; +export const SAST_EVIDENCE_RECONSTRUCTION_VERSION = + 'sast-evidence-reconstruction-v1' as const; + +const INVALID_EVIDENCE_DECIDED_AT = + '1970-01-01T00:00:00.000Z' as const; + +const CONTRACT_ID_PATTERNS = new Map([ + ['finding-lineage', /^finding-lineage:\/\/[a-f0-9]{64}$/u], + [ + 'finding-observation', + /^finding-observation:\/\/[a-f0-9]{64}$/u + ], + [ + 'finding-occurrence', + /^finding-occurrence:\/\/[a-f0-9]{64}$/u + ], + ['sast-coverage', /^sast-coverage:\/\/[a-f0-9]{64}$/u], + [ + 'sast-evidence-build', + /^sast-evidence-build:\/\/[a-f0-9]{64}$/u + ], + [ + 'sast-evidence-candidate', + /^sast-evidence-candidate:\/\/[a-f0-9]{64}$/u + ], + [ + 'sast-evidence-fragment', + /^sast-evidence-fragment:\/\/[a-f0-9]{64}$/u + ], + [ + 'sast-evidence-pack', + /^sast-evidence-pack:\/\/[a-f0-9]{64}$/u + ], + [ + 'sast-evidence-reconstruction', + /^sast-evidence-reconstruction:\/\/[a-f0-9]{64}$/u + ], + ['sast-freshness', /^sast-freshness:\/\/[a-f0-9]{64}$/u] +]); + +export const SAST_ACCEPTED_EVIDENCE_POLICY = + DEFAULT_SAST_EVIDENCE_POLICY; + +export const SAST_ACCEPTED_EVIDENCE_LIMITS = Object.freeze({ + maximumSourceCandidates: 64, + maximumFragmentsPerFile: 2, + maximumReconstructedFileCoverageBasisPoints: 2500, + yieldCandidateInterval: 16 +}); + +export const SAST_EVIDENCE_FRAGMENT_ROLES = [ + 'PRIMARY', + 'RELATED' +] as const; +export type SastEvidenceFragmentRole = + (typeof SAST_EVIDENCE_FRAGMENT_ROLES)[number]; + +export const SAST_EVIDENCE_BUILD_OUTCOMES = [ + 'ACCEPTED', + 'REJECTED' +] as const; +export type SastEvidenceBuildOutcome = + (typeof SAST_EVIDENCE_BUILD_OUTCOMES)[number]; + +export const SAST_EVIDENCE_RECONSTRUCTION_STATUSES = [ + 'SAFE', + 'RISK', + 'NOT_CHECKED' +] as const; +export type SastEvidenceReconstructionStatus = + (typeof SAST_EVIDENCE_RECONSTRUCTION_STATUSES)[number]; + +export const SAST_EVIDENCE_REASON_CODES = [ + 'EVIDENCE_INPUT_INVALID', + 'EVIDENCE_CONTEXT_UNAVAILABLE', + 'EVIDENCE_FINDING_NOT_ACCEPTED', + 'EVIDENCE_SOURCE_UNAVAILABLE', + 'EVIDENCE_SOURCE_INVALID', + 'EVIDENCE_REDACTION_INVALID', + 'EVIDENCE_PRIMARY_FRAGMENT_INVALID', + 'EVIDENCE_CONTEXT_EXCEEDED', + 'EVIDENCE_FULL_FILE_FORBIDDEN', + 'EVIDENCE_RECONSTRUCTION_FRAGMENT_COUNT', + 'EVIDENCE_RECONSTRUCTION_OVERLAP', + 'EVIDENCE_RECONSTRUCTION_ADJACENT', + 'EVIDENCE_RECONSTRUCTION_COVERAGE', + 'EVIDENCE_OUTPUT_INVALID', + 'EVIDENCE_PERSISTENCE_CONFLICT' +] as const; +export type SastEvidenceReasonCode = + (typeof SAST_EVIDENCE_REASON_CODES)[number]; + +export type SastEvidenceDigest = string; +export type SastEvidenceCanonicalDigester = ( + canonicalValue: string +) => SastEvidenceDigest; + +export interface SastAcceptedEvidenceScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + targetRef: string; + commitSha: string; + canonicalScanKey: SastEvidenceDigest; + planDigest: SastEvidenceDigest; + profileId: SastProfileId; + profileDigest: SastEvidenceDigest; + freshnessDecisionId: string; + freshnessDecisionDigest: SastEvidenceDigest; + coverageDecisionId: string; + coverageDecisionDigest: SastEvidenceDigest; + occurrenceId: string; + observationBatchId: string; + normalizedFindingId: string; + lineageId: string; + findingFingerprint: SastEvidenceDigest; + fingerprintVersion: typeof SAST_FINDING_FINGERPRINT_VERSION; + capability: Exclude; + normalizedPath: string; + findingStartLine: number; + findingEndLine: number; + policyVersion: string; +} + +export interface SastEvidenceFragmentRequest { + candidateId: string; + role: SastEvidenceFragmentRole; + normalizedPath: string; + startLine: number; + endLine: number; +} + +export interface SastRedactedEvidenceCandidate + extends SastEvidenceFragmentRequest { + anchorStartLine: number; + anchorEndLine: number; + sourceFileLineCount: number; + redactedContent: string; + byteSize: number; + sourceContentDigest: SastEvidenceDigest; + contentDigest: SastEvidenceDigest; + sourceAttestationRef: string; + scannerRedactionDecisionRef: string; + platformRedactionDecisionRef: string; + secretRedactionApplied: true; + rawSourceStored: false; + candidateDigest: SastEvidenceDigest; +} + +export type SastRedactedEvidenceCandidateCore = Omit< + SastRedactedEvidenceCandidate, + 'candidateDigest' +>; + +export interface SastAcceptedEvidenceFragment + extends SastRedactedEvidenceCandidate { + fragmentId: string; + evidencePackId: string; + ordinal: number; + isFullFile: false; + fragmentDigest: SastEvidenceDigest; +} + +export type SastAcceptedEvidenceFragmentCore = Omit< + SastAcceptedEvidenceFragment, + 'fragmentDigest' +>; + +export interface SastEvidenceReconstructionDecision { + version: typeof SAST_EVIDENCE_RECONSTRUCTION_VERSION; + reconstructionDecisionId: string; + candidateSetDigest: SastEvidenceDigest; + status: SastEvidenceReconstructionStatus; + reasonCodes: SastEvidenceReasonCode[]; + intervalSetDigest: SastEvidenceDigest | null; + maximumFileCoverageBasisPoints: number; + checkedAt: string; + decisionDigest: SastEvidenceDigest; +} + +export type SastEvidenceReconstructionDecisionCore = Omit< + SastEvidenceReconstructionDecision, + 'decisionDigest' +>; + +export interface SastAcceptedEvidenceAuthority { + evidenceConstructionAuthority: true; + dashboardAccessAllowed: false; + aiPayloadAllowed: false; + policyAuthority: false; + publicationAuthority: false; + lifecycleMutationAuthority: false; +} + +export interface SastAcceptedEvidencePack { + version: typeof SAST_ACCEPTED_EVIDENCE_VERSION; + evidencePackId: string; + scope: SastAcceptedEvidenceScope; + candidateSetDigest: SastEvidenceDigest; + fragments: SastAcceptedEvidenceFragment[]; + totalBytes: number; + truncated: boolean; + suppressedFragmentCount: number; + reconstructionRiskChecked: true; + reconstructionRiskDecisionRef: string; + reconstructionRiskDecisionDigest: SastEvidenceDigest; + classificationDecisionRef: null; + deletionScheduleRef: null; + dashboardSafe: false; + aiSafe: false; + createdAt: string; + expiresAt: string; + authority: SastAcceptedEvidenceAuthority; + packDigest: SastEvidenceDigest; +} + +export type SastAcceptedEvidencePackCore = Omit< + SastAcceptedEvidencePack, + 'packDigest' +>; + +export interface SastEvidenceAuditProjection { + rawSourceStored: false; + secretValuesStored: false; + dashboardPayloadCreated: false; + aiPayloadCreated: false; + publicationAttempted: false; +} + +export interface SastEvidenceBuildDecision { + version: typeof SAST_EVIDENCE_BUILD_DECISION_VERSION; + buildDecisionId: string; + scope: SastAcceptedEvidenceScope; + candidateSetDigest: SastEvidenceDigest; + outcome: SastEvidenceBuildOutcome; + reasonCodes: SastEvidenceReasonCode[]; + selectedFragmentCount: number; + suppressedFragmentCount: number; + reconstruction: SastEvidenceReconstructionDecision; + evidencePackId: string | null; + evidencePackDigest: SastEvidenceDigest | null; + authority: { + evidenceConstructionAuthority: boolean; + dashboardAccessAllowed: false; + aiPayloadAllowed: false; + policyAuthority: false; + publicationAuthority: false; + lifecycleMutationAuthority: false; + }; + audit: SastEvidenceAuditProjection; + decidedAt: string; + decisionDigest: SastEvidenceDigest; +} + +export type SastEvidenceBuildDecisionCore = Omit< + SastEvidenceBuildDecision, + 'decisionDigest' +>; + +export interface SastAcceptedEvidenceBuildResult { + decision: SastEvidenceBuildDecision; + pack: SastAcceptedEvidencePack | null; +} + +export function canonicalizeSastEvidenceCandidate( + candidate: Readonly +): string { + return stableJson(candidate); +} + +export function canonicalizeSastEvidenceCandidateSet( + candidates: readonly SastRedactedEvidenceCandidate[] +): string { + return stableJson( + [...candidates].sort(compareSastEvidenceCandidates) + ); +} + +export function canonicalizeSastEvidenceReconstructionDecision( + decision: Readonly +): string { + return stableJson(decision); +} + +export function canonicalizeSastAcceptedEvidenceFragment( + fragment: Readonly +): string { + return stableJson(fragment); +} + +export function canonicalizeSastAcceptedEvidencePack( + pack: Readonly +): string { + return stableJson(pack); +} + +export function canonicalizeSastEvidenceBuildDecision( + decision: Readonly +): string { + return stableJson(decision); +} + +export function canonicalizeSastEvidenceFragmentRequests( + requests: readonly SastEvidenceFragmentRequest[] +): string { + return stableJson([...requests].sort(compareSastEvidenceRequests)); +} + +export function compareSastEvidenceRequests( + left: Readonly, + right: Readonly +): number { + return compareStrings(left.role, right.role) || + compareStrings(left.normalizedPath, right.normalizedPath) || + left.startLine - right.startLine || + left.endLine - right.endLine || + compareStrings(left.candidateId, right.candidateId); +} + +export function compareSastEvidenceCandidates( + left: Readonly, + right: Readonly +): number { + return compareSastEvidenceRequests(left, right) || + compareStrings(left.candidateDigest, right.candidateDigest); +} + +export function buildSastAcceptedEvidence(input: { + scope: Readonly; + candidates: readonly SastRedactedEvidenceCandidate[]; + policy?: Readonly; + decidedAt: string; + digestCanonical: SastEvidenceCanonicalDigester; +}): SastAcceptedEvidenceBuildResult { + const policy = input.policy ?? SAST_ACCEPTED_EVIDENCE_POLICY; + const ordered = [...input.candidates].sort( + compareSastEvidenceCandidates + ); + const candidateSetDigest = input.digestCanonical( + canonicalizeSastEvidenceCandidateSet(ordered) + ); + if (!isCanonicalTimestamp(input.decidedAt)) { + return { + decision: buildSastEvidenceEarlyRejection({ + scope: input.scope, + requestDigest: candidateSetDigest, + reasonCode: 'EVIDENCE_INPUT_INVALID', + decidedAt: INVALID_EVIDENCE_DECIDED_AT, + digestCanonical: input.digestCanonical + }), + pack: null + }; + } + const structuralReasons = validateCandidateSet( + input.scope, + ordered, + policy, + input.digestCanonical + ); + const selected: SastRedactedEvidenceCandidate[] = []; + let selectedBytes = 0; + if (structuralReasons.length === 0) { + for (const candidate of ordered) { + if ( + selected.length >= policy.maxFragmentCount || + selectedBytes + candidate.byteSize > policy.maxTotalBytes + ) { + continue; + } + selected.push(candidate); + selectedBytes += candidate.byteSize; + } + } + const suppressedFragmentCount = + structuralReasons.length > 0 + ? ordered.length + : ordered.length - selected.length; + const reconstruction = buildReconstructionDecision({ + candidates: selected, + candidateSetDigest, + checkedAt: input.decidedAt, + skippedReasons: structuralReasons, + digestCanonical: input.digestCanonical + }); + const reasonCodes = orderSastEvidenceReasons([ + ...structuralReasons, + ...reconstruction.reasonCodes + ]); + if (reasonCodes.length > 0) { + return { + decision: buildDecision({ + scope: input.scope, + candidateSetDigest, + reasonCodes, + selectedFragmentCount: 0, + suppressedFragmentCount, + reconstruction, + pack: null, + decidedAt: input.decidedAt, + digestCanonical: input.digestCanonical + }), + pack: null + }; + } + + const evidencePackId = contractId( + 'sast-evidence-pack', + input.digestCanonical( + stableJson({ + scope: input.scope, + candidateSetDigest, + policy: input.scope.policyVersion + }) + ) + ); + const fragments = selected.map((candidate, ordinal) => + buildFragment( + candidate, + evidencePackId, + ordinal, + input.digestCanonical + ) + ); + const createdAtMilliseconds = Date.parse(input.decidedAt); + const expiresAt = new Date( + createdAtMilliseconds + policy.maxRetentionSeconds * 1000 + ).toISOString(); + const authority: SastAcceptedEvidenceAuthority = { + evidenceConstructionAuthority: true, + dashboardAccessAllowed: false, + aiPayloadAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false + }; + const packCore: SastAcceptedEvidencePackCore = { + version: SAST_ACCEPTED_EVIDENCE_VERSION, + evidencePackId, + scope: { ...input.scope }, + candidateSetDigest, + fragments, + totalBytes: selectedBytes, + truncated: suppressedFragmentCount > 0, + suppressedFragmentCount, + reconstructionRiskChecked: true, + reconstructionRiskDecisionRef: + reconstruction.reconstructionDecisionId, + reconstructionRiskDecisionDigest: + reconstruction.decisionDigest, + classificationDecisionRef: null, + deletionScheduleRef: null, + dashboardSafe: false, + aiSafe: false, + createdAt: input.decidedAt, + expiresAt, + authority + }; + const pack: SastAcceptedEvidencePack = { + ...packCore, + packDigest: input.digestCanonical( + canonicalizeSastAcceptedEvidencePack(packCore) + ) + }; + return { + decision: buildDecision({ + scope: input.scope, + candidateSetDigest, + reasonCodes: [], + selectedFragmentCount: fragments.length, + suppressedFragmentCount, + reconstruction, + pack, + decidedAt: input.decidedAt, + digestCanonical: input.digestCanonical + }), + pack + }; +} + +export function buildSastEvidenceEarlyRejection(input: { + scope: Readonly; + requestDigest: SastEvidenceDigest; + reasonCode: SastEvidenceReasonCode; + decidedAt: string; + digestCanonical: SastEvidenceCanonicalDigester; +}): SastEvidenceBuildDecision { + const reconstructionCore: SastEvidenceReconstructionDecisionCore = { + version: SAST_EVIDENCE_RECONSTRUCTION_VERSION, + reconstructionDecisionId: contractId( + 'sast-evidence-reconstruction', + input.digestCanonical( + stableJson({ + scope: input.scope, + candidateSetDigest: input.requestDigest, + status: 'NOT_CHECKED' + }) + ) + ), + candidateSetDigest: input.requestDigest, + status: 'NOT_CHECKED', + reasonCodes: [input.reasonCode], + intervalSetDigest: null, + maximumFileCoverageBasisPoints: 0, + checkedAt: input.decidedAt + }; + const reconstruction: SastEvidenceReconstructionDecision = { + ...reconstructionCore, + decisionDigest: input.digestCanonical( + canonicalizeSastEvidenceReconstructionDecision( + reconstructionCore + ) + ) + }; + return buildDecision({ + scope: input.scope, + candidateSetDigest: input.requestDigest, + reasonCodes: [input.reasonCode], + selectedFragmentCount: 0, + suppressedFragmentCount: 0, + reconstruction, + pack: null, + decidedAt: input.decidedAt, + digestCanonical: input.digestCanonical + }); +} + +export function isSastAcceptedEvidenceScopeValid( + value: unknown +): value is SastAcceptedEvidenceScope { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'targetRef', + 'commitSha', + 'canonicalScanKey', + 'planDigest', + 'profileId', + 'profileDigest', + 'freshnessDecisionId', + 'freshnessDecisionDigest', + 'coverageDecisionId', + 'coverageDecisionDigest', + 'occurrenceId', + 'observationBatchId', + 'normalizedFindingId', + 'lineageId', + 'findingFingerprint', + 'fingerprintVersion', + 'capability', + 'normalizedPath', + 'findingStartLine', + 'findingEndLine', + 'policyVersion' + ]) && + isBoundedReference(value.tenantId) && + isBoundedReference(value.repositoryBindingId) && + isBoundedReference(value.scanRequestId) && + isBoundedReference(value.attemptId) && + isBoundedReference(value.targetRef) && + isCommitSha(value.commitSha) && + isSha256Digest(value.canonicalScanKey) && + isSha256Digest(value.planDigest) && + SAST_PROFILE_IDS.includes(value.profileId as SastProfileId) && + isSha256Digest(value.profileDigest) && + isContractId(value.freshnessDecisionId, 'sast-freshness') && + isSha256Digest(value.freshnessDecisionDigest) && + isContractId(value.coverageDecisionId, 'sast-coverage') && + isSha256Digest(value.coverageDecisionDigest) && + isContractId(value.occurrenceId, 'finding-occurrence') && + isContractId(value.observationBatchId, 'finding-observation') && + isBoundedReference(value.normalizedFindingId) && + isContractId(value.lineageId, 'finding-lineage') && + isSha256Digest(value.findingFingerprint) && + value.fingerprintVersion === SAST_FINDING_FINGERPRINT_VERSION && + SAST_CAPABILITIES.includes(value.capability as SastCapability) && + value.capability !== 'SBOM' && + isSafeNormalizedPath(value.normalizedPath) && + isPositiveInteger(value.findingStartLine) && + isPositiveInteger(value.findingEndLine) && + (value.findingEndLine as number) >= + (value.findingStartLine as number) && + isBoundedReference(value.policyVersion) + ); +} + +export function isSastEvidenceFragmentRequestValid( + value: unknown +): value is SastEvidenceFragmentRequest { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'candidateId', + 'role', + 'normalizedPath', + 'startLine', + 'endLine' + ]) && + isContractId(value.candidateId, 'sast-evidence-candidate') && + SAST_EVIDENCE_FRAGMENT_ROLES.includes( + value.role as SastEvidenceFragmentRole + ) && + isSafeNormalizedPath(value.normalizedPath) && + isPositiveInteger(value.startLine) && + isPositiveInteger(value.endLine) && + (value.endLine as number) >= (value.startLine as number) + ); +} + +export function isSastRedactedEvidenceCandidateShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester +): value is SastRedactedEvidenceCandidate { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'candidateId', + 'role', + 'normalizedPath', + 'startLine', + 'endLine', + 'anchorStartLine', + 'anchorEndLine', + 'sourceFileLineCount', + 'redactedContent', + 'byteSize', + 'sourceContentDigest', + 'contentDigest', + 'sourceAttestationRef', + 'scannerRedactionDecisionRef', + 'platformRedactionDecisionRef', + 'secretRedactionApplied', + 'rawSourceStored', + 'candidateDigest' + ]) || + !isSastEvidenceFragmentRequestValid({ + candidateId: value.candidateId, + role: value.role, + normalizedPath: value.normalizedPath, + startLine: value.startLine, + endLine: value.endLine + }) || + !isPositiveInteger(value.anchorStartLine) || + !isPositiveInteger(value.anchorEndLine) || + !isPositiveInteger(value.sourceFileLineCount) || + (value.anchorEndLine as number) < + (value.anchorStartLine as number) || + (value.startLine as number) > + (value.anchorStartLine as number) || + (value.endLine as number) < (value.anchorEndLine as number) || + (value.sourceFileLineCount as number) < + (value.endLine as number) || + typeof value.redactedContent !== 'string' || + !isCanonicalEvidenceText(value.redactedContent) || + lineCount(value.redactedContent) !== + (value.endLine as number) - + (value.startLine as number) + + 1 || + !isPositiveInteger(value.byteSize) || + value.byteSize !== utf8Length(value.redactedContent) || + !isSha256Digest(value.sourceContentDigest) || + !isSha256Digest(value.contentDigest) || + digestCanonical(value.redactedContent) !== value.contentDigest || + !isBoundedReference(value.sourceAttestationRef) || + !isBoundedReference(value.scannerRedactionDecisionRef) || + !isBoundedReference(value.platformRedactionDecisionRef) || + value.secretRedactionApplied !== true || + value.rawSourceStored !== false || + !isSha256Digest(value.candidateDigest) + ) { + return false; + } + const candidate = + value as unknown as SastRedactedEvidenceCandidate; + const { candidateDigest, ...core } = candidate; + return ( + digestCanonical(canonicalizeSastEvidenceCandidate(core)) === + candidateDigest + ); +} + +export function isSastEvidenceReconstructionDecisionShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester +): value is SastEvidenceReconstructionDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'reconstructionDecisionId', + 'candidateSetDigest', + 'status', + 'reasonCodes', + 'intervalSetDigest', + 'maximumFileCoverageBasisPoints', + 'checkedAt', + 'decisionDigest' + ]) || + value.version !== SAST_EVIDENCE_RECONSTRUCTION_VERSION || + !isContractId( + value.reconstructionDecisionId, + 'sast-evidence-reconstruction' + ) || + !isSha256Digest(value.candidateSetDigest) || + !SAST_EVIDENCE_RECONSTRUCTION_STATUSES.includes( + value.status as SastEvidenceReconstructionStatus + ) || + !isCanonicalReasonArray(value.reasonCodes) || + !( + value.intervalSetDigest === null || + isSha256Digest(value.intervalSetDigest) + ) || + !Number.isSafeInteger(value.maximumFileCoverageBasisPoints) || + (value.maximumFileCoverageBasisPoints as number) < 0 || + (value.maximumFileCoverageBasisPoints as number) > 10000 || + !isCanonicalTimestamp(value.checkedAt) || + !isSha256Digest(value.decisionDigest) + ) { + return false; + } + const decision = + value as unknown as SastEvidenceReconstructionDecision; + if ( + (decision.status === 'SAFE' && + (decision.reasonCodes.length > 0 || + decision.intervalSetDigest === null)) || + (decision.status === 'RISK' && + (decision.reasonCodes.length === 0 || + decision.intervalSetDigest === null)) || + (decision.status === 'NOT_CHECKED' && + (decision.reasonCodes.length === 0 || + decision.intervalSetDigest !== null)) + ) { + return false; + } + const { decisionDigest, ...core } = decision; + return ( + digestCanonical( + canonicalizeSastEvidenceReconstructionDecision(core) + ) === decisionDigest + ); +} + +export function isSastAcceptedEvidenceFragmentShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester +): value is SastAcceptedEvidenceFragment { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'candidateId', + 'role', + 'normalizedPath', + 'startLine', + 'endLine', + 'anchorStartLine', + 'anchorEndLine', + 'sourceFileLineCount', + 'redactedContent', + 'byteSize', + 'sourceContentDigest', + 'contentDigest', + 'sourceAttestationRef', + 'scannerRedactionDecisionRef', + 'platformRedactionDecisionRef', + 'secretRedactionApplied', + 'rawSourceStored', + 'candidateDigest', + 'fragmentId', + 'evidencePackId', + 'ordinal', + 'isFullFile', + 'fragmentDigest' + ]) || + !isSastRedactedEvidenceCandidateShapeValid( + pickCandidate(value), + digestCanonical + ) || + !isContractId(value.fragmentId, 'sast-evidence-fragment') || + !isContractId(value.evidencePackId, 'sast-evidence-pack') || + !Number.isSafeInteger(value.ordinal) || + (value.ordinal as number) < 0 || + value.isFullFile !== false || + !isSha256Digest(value.fragmentDigest) + ) { + return false; + } + const fragment = + value as unknown as SastAcceptedEvidenceFragment; + const { fragmentDigest, ...core } = fragment; + return ( + digestCanonical( + canonicalizeSastAcceptedEvidenceFragment(core) + ) === fragmentDigest + ); +} + +export function isSastAcceptedEvidencePackShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester, + policy: Readonly = + SAST_ACCEPTED_EVIDENCE_POLICY +): value is SastAcceptedEvidencePack { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'evidencePackId', + 'scope', + 'candidateSetDigest', + 'fragments', + 'totalBytes', + 'truncated', + 'suppressedFragmentCount', + 'reconstructionRiskChecked', + 'reconstructionRiskDecisionRef', + 'reconstructionRiskDecisionDigest', + 'classificationDecisionRef', + 'deletionScheduleRef', + 'dashboardSafe', + 'aiSafe', + 'createdAt', + 'expiresAt', + 'authority', + 'packDigest' + ]) || + value.version !== SAST_ACCEPTED_EVIDENCE_VERSION || + !isContractId(value.evidencePackId, 'sast-evidence-pack') || + !isSastAcceptedEvidenceScopeValid(value.scope) || + !isSha256Digest(value.candidateSetDigest) || + !Array.isArray(value.fragments) || + value.fragments.length === 0 || + value.fragments.length > policy.maxFragmentCount || + !value.fragments.every((fragment, index) => + isSastAcceptedEvidenceFragmentShapeValid( + fragment, + digestCanonical + ) && + fragment.evidencePackId === value.evidencePackId && + fragment.ordinal === index + ) || + !Number.isSafeInteger(value.totalBytes) || + typeof value.truncated !== 'boolean' || + !Number.isSafeInteger(value.suppressedFragmentCount) || + (value.suppressedFragmentCount as number) < 0 || + value.truncated !== + ((value.suppressedFragmentCount as number) > 0) || + value.reconstructionRiskChecked !== true || + !isContractId( + value.reconstructionRiskDecisionRef, + 'sast-evidence-reconstruction' + ) || + !isSha256Digest(value.reconstructionRiskDecisionDigest) || + value.classificationDecisionRef !== null || + value.deletionScheduleRef !== null || + value.dashboardSafe !== false || + value.aiSafe !== false || + !isCanonicalTimestamp(value.createdAt) || + !isCanonicalTimestamp(value.expiresAt) || + !isAcceptedAuthority(value.authority) || + !isSha256Digest(value.packDigest) || + !isSastEvidencePolicySafe(policy) + ) { + return false; + } + const pack = value as unknown as SastAcceptedEvidencePack; + const candidates = pack.fragments.map( + (fragment) => + pickCandidate(fragment as unknown as Record) as unknown as + SastRedactedEvidenceCandidate + ); + const reconstruction = buildReconstructionDecision({ + candidates, + candidateSetDigest: pack.candidateSetDigest, + checkedAt: pack.createdAt, + skippedReasons: [], + digestCanonical + }); + if ( + pack.totalBytes !== + pack.fragments.reduce( + (sum, fragment) => sum + fragment.byteSize, + 0 + ) || + pack.totalBytes > policy.maxTotalBytes || + pack.fragments.some( + (fragment) => fragment.byteSize > policy.maxFragmentBytes + ) || + validateCandidateSet( + pack.scope, + candidates, + policy, + digestCanonical + ).length > 0 || + reconstruction.status !== 'SAFE' || + reconstruction.reconstructionDecisionId !== + pack.reconstructionRiskDecisionRef || + reconstruction.decisionDigest !== + pack.reconstructionRiskDecisionDigest || + Date.parse(pack.expiresAt) <= Date.parse(pack.createdAt) || + Date.parse(pack.expiresAt) - Date.parse(pack.createdAt) > + policy.maxRetentionSeconds * 1000 + ) { + return false; + } + const { packDigest, ...core } = pack; + return ( + digestCanonical(canonicalizeSastAcceptedEvidencePack(core)) === + packDigest + ); +} + +export function isSastEvidenceBuildDecisionShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester, + policy: Readonly = + SAST_ACCEPTED_EVIDENCE_POLICY +): value is SastEvidenceBuildDecision { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + 'version', + 'buildDecisionId', + 'scope', + 'candidateSetDigest', + 'outcome', + 'reasonCodes', + 'selectedFragmentCount', + 'suppressedFragmentCount', + 'reconstruction', + 'evidencePackId', + 'evidencePackDigest', + 'authority', + 'audit', + 'decidedAt', + 'decisionDigest' + ]) || + value.version !== SAST_EVIDENCE_BUILD_DECISION_VERSION || + !isContractId(value.buildDecisionId, 'sast-evidence-build') || + !isSastAcceptedEvidenceScopeValid(value.scope) || + !isSha256Digest(value.candidateSetDigest) || + !SAST_EVIDENCE_BUILD_OUTCOMES.includes( + value.outcome as SastEvidenceBuildOutcome + ) || + !isCanonicalReasonArray(value.reasonCodes) || + !Number.isSafeInteger(value.selectedFragmentCount) || + (value.selectedFragmentCount as number) < 0 || + !Number.isSafeInteger(value.suppressedFragmentCount) || + (value.suppressedFragmentCount as number) < 0 || + !isSastEvidenceReconstructionDecisionShapeValid( + value.reconstruction, + digestCanonical + ) || + !isDecisionAuthority(value.authority) || + !isAuditProjection(value.audit) || + !isCanonicalTimestamp(value.decidedAt) || + !isSha256Digest(value.decisionDigest) || + !isSastEvidencePolicySafe(policy) + ) { + return false; + } + const decision = value as unknown as SastEvidenceBuildDecision; + const accepted = + decision.outcome === 'ACCEPTED' && + decision.reasonCodes.length === 0 && + decision.selectedFragmentCount > 0 && + decision.reconstruction.status === 'SAFE' && + decision.evidencePackId !== null && + decision.evidencePackDigest !== null && + decision.authority.evidenceConstructionAuthority; + if ( + accepted !== + (decision.outcome === 'ACCEPTED') || + decision.selectedFragmentCount > + policy.maxFragmentCount || + decision.reconstruction.candidateSetDigest !== + decision.candidateSetDigest || + decision.reconstruction.checkedAt !== decision.decidedAt || + stableJson(decision.reconstruction.reasonCodes) !== + stableJson(decision.reasonCodes) || + (decision.evidencePackId !== null && + !isContractId( + decision.evidencePackId, + 'sast-evidence-pack' + )) || + (decision.evidencePackDigest !== null && + !isSha256Digest(decision.evidencePackDigest)) || + (decision.outcome === 'REJECTED' && + (decision.reasonCodes.length === 0 || + decision.selectedFragmentCount !== 0 || + decision.evidencePackId !== null || + decision.evidencePackDigest !== null || + decision.authority.evidenceConstructionAuthority)) + ) { + return false; + } + const { decisionDigest, ...core } = decision; + return ( + digestCanonical(canonicalizeSastEvidenceBuildDecision(core)) === + decisionDigest + ); +} + +export function isSastAcceptedEvidenceBuildResultShapeValid( + value: unknown, + digestCanonical: SastEvidenceCanonicalDigester, + policy: Readonly = + SAST_ACCEPTED_EVIDENCE_POLICY +): value is SastAcceptedEvidenceBuildResult { + if ( + !isRecord(value) || + !hasExactKeys(value, ['decision', 'pack']) || + !isSastEvidenceBuildDecisionShapeValid( + value.decision, + digestCanonical, + policy + ) + ) { + return false; + } + const decision = value.decision; + if (decision.outcome === 'REJECTED') { + return value.pack === null; + } + if ( + !isSastAcceptedEvidencePackShapeValid( + value.pack, + digestCanonical, + policy + ) + ) { + return false; + } + const pack = value.pack; + return ( + stableJson(pack.scope) === stableJson(decision.scope) && + pack.candidateSetDigest === decision.candidateSetDigest && + pack.evidencePackId === decision.evidencePackId && + pack.packDigest === decision.evidencePackDigest && + pack.fragments.length === decision.selectedFragmentCount && + pack.suppressedFragmentCount === + decision.suppressedFragmentCount && + pack.reconstructionRiskDecisionRef === + decision.reconstruction.reconstructionDecisionId && + pack.reconstructionRiskDecisionDigest === + decision.reconstruction.decisionDigest && + pack.createdAt === decision.decidedAt + ); +} + +export function orderSastEvidenceReasons( + reasons: readonly SastEvidenceReasonCode[] +): SastEvidenceReasonCode[] { + return [...new Set(reasons)].sort( + (left, right) => + SAST_EVIDENCE_REASON_CODES.indexOf(left) - + SAST_EVIDENCE_REASON_CODES.indexOf(right) + ); +} + +function validateCandidateSet( + scope: Readonly, + candidates: readonly SastRedactedEvidenceCandidate[], + policy: Readonly, + digestCanonical: SastEvidenceCanonicalDigester +): SastEvidenceReasonCode[] { + const reasons: SastEvidenceReasonCode[] = []; + if ( + !isSastAcceptedEvidenceScopeValid(scope) || + !isSastEvidencePolicySafe(policy) || + candidates.length === 0 || + candidates.length > + SAST_ACCEPTED_EVIDENCE_LIMITS.maximumSourceCandidates || + !hasUnique(candidates.map((candidate) => candidate.candidateId)) || + !hasUnique(candidates.map((candidate) => candidate.candidateDigest)) || + candidates.some( + (candidate) => + !isSastRedactedEvidenceCandidateShapeValid( + candidate, + digestCanonical + ) || + candidate.byteSize > policy.maxFragmentBytes + ) + ) { + reasons.push('EVIDENCE_SOURCE_INVALID'); + return reasons; + } + const primary = candidates.filter( + (candidate) => candidate.role === 'PRIMARY' + ); + if ( + primary.length !== 1 || + primary[0]?.normalizedPath !== scope.normalizedPath || + primary[0]?.anchorStartLine !== scope.findingStartLine || + primary[0]?.anchorEndLine !== scope.findingEndLine + ) { + reasons.push('EVIDENCE_PRIMARY_FRAGMENT_INVALID'); + } + for (const candidate of candidates) { + if ( + candidate.anchorStartLine - candidate.startLine > + policy.contextLinesBefore || + candidate.endLine - candidate.anchorEndLine > + policy.contextLinesAfter + ) { + reasons.push('EVIDENCE_CONTEXT_EXCEEDED'); + } + if ( + candidate.startLine === 1 && + candidate.endLine === candidate.sourceFileLineCount + ) { + reasons.push('EVIDENCE_FULL_FILE_FORBIDDEN'); + } + } + const lineCounts = new Map(); + for (const candidate of candidates) { + const existing = lineCounts.get(candidate.normalizedPath); + if ( + existing !== undefined && + existing !== candidate.sourceFileLineCount + ) { + reasons.push('EVIDENCE_SOURCE_INVALID'); + } + lineCounts.set( + candidate.normalizedPath, + candidate.sourceFileLineCount + ); + } + return orderSastEvidenceReasons(reasons); +} + +function buildReconstructionDecision(input: { + candidates: readonly SastRedactedEvidenceCandidate[]; + candidateSetDigest: SastEvidenceDigest; + checkedAt: string; + skippedReasons: readonly SastEvidenceReasonCode[]; + digestCanonical: SastEvidenceCanonicalDigester; +}): SastEvidenceReconstructionDecision { + const intervalProjection = input.candidates + .map((candidate) => ({ + normalizedPath: candidate.normalizedPath, + startLine: candidate.startLine, + endLine: candidate.endLine, + sourceFileLineCount: candidate.sourceFileLineCount + })) + .sort( + (left, right) => + compareStrings( + left.normalizedPath, + right.normalizedPath + ) || + left.startLine - right.startLine || + left.endLine - right.endLine + ); + const intervalSetDigest = + input.skippedReasons.length > 0 + ? null + : input.digestCanonical(stableJson(intervalProjection)); + const reasons: SastEvidenceReasonCode[] = []; + let maximumFileCoverageBasisPoints = 0; + if (input.skippedReasons.length === 0) { + const byPath = new Map< + string, + SastRedactedEvidenceCandidate[] + >(); + for (const candidate of input.candidates) { + const values = byPath.get(candidate.normalizedPath) ?? []; + values.push(candidate); + byPath.set(candidate.normalizedPath, values); + } + for (const values of byPath.values()) { + values.sort( + (left, right) => + left.startLine - right.startLine || + left.endLine - right.endLine + ); + if ( + values.length > + SAST_ACCEPTED_EVIDENCE_LIMITS.maximumFragmentsPerFile + ) { + reasons.push('EVIDENCE_RECONSTRUCTION_FRAGMENT_COUNT'); + } + let coveredLines = 0; + let previousEnd = 0; + for (const value of values) { + if (previousEnd > 0) { + if (value.startLine <= previousEnd) { + reasons.push('EVIDENCE_RECONSTRUCTION_OVERLAP'); + } else if (value.startLine === previousEnd + 1) { + reasons.push('EVIDENCE_RECONSTRUCTION_ADJACENT'); + } + } + coveredLines += value.endLine - value.startLine + 1; + previousEnd = Math.max(previousEnd, value.endLine); + } + const sourceFileLineCount = + values[0]?.sourceFileLineCount ?? 1; + const coverageBasisPoints = Math.floor( + (coveredLines * 10000) / sourceFileLineCount + ); + maximumFileCoverageBasisPoints = Math.max( + maximumFileCoverageBasisPoints, + coverageBasisPoints + ); + if ( + coverageBasisPoints >= + SAST_ACCEPTED_EVIDENCE_LIMITS + .maximumReconstructedFileCoverageBasisPoints + ) { + reasons.push('EVIDENCE_RECONSTRUCTION_COVERAGE'); + } + } + } + const orderedReasons = orderSastEvidenceReasons( + input.skippedReasons.length > 0 + ? input.skippedReasons + : reasons + ); + const status: SastEvidenceReconstructionStatus = + input.skippedReasons.length > 0 + ? 'NOT_CHECKED' + : orderedReasons.length > 0 + ? 'RISK' + : 'SAFE'; + const core: SastEvidenceReconstructionDecisionCore = { + version: SAST_EVIDENCE_RECONSTRUCTION_VERSION, + reconstructionDecisionId: contractId( + 'sast-evidence-reconstruction', + input.digestCanonical( + stableJson({ + candidateSetDigest: input.candidateSetDigest, + intervalSetDigest, + status, + reasons: orderedReasons + }) + ) + ), + candidateSetDigest: input.candidateSetDigest, + status, + reasonCodes: orderedReasons, + intervalSetDigest, + maximumFileCoverageBasisPoints, + checkedAt: input.checkedAt + }; + return { + ...core, + decisionDigest: input.digestCanonical( + canonicalizeSastEvidenceReconstructionDecision(core) + ) + }; +} + +function buildFragment( + candidate: Readonly, + evidencePackId: string, + ordinal: number, + digestCanonical: SastEvidenceCanonicalDigester +): SastAcceptedEvidenceFragment { + const fragmentId = contractId( + 'sast-evidence-fragment', + digestCanonical( + stableJson({ + evidencePackId, + ordinal, + candidateDigest: candidate.candidateDigest + }) + ) + ); + const core: SastAcceptedEvidenceFragmentCore = { + ...candidate, + fragmentId, + evidencePackId, + ordinal, + isFullFile: false + }; + return { + ...core, + fragmentDigest: digestCanonical( + canonicalizeSastAcceptedEvidenceFragment(core) + ) + }; +} + +function buildDecision(input: { + scope: Readonly; + candidateSetDigest: SastEvidenceDigest; + reasonCodes: readonly SastEvidenceReasonCode[]; + selectedFragmentCount: number; + suppressedFragmentCount: number; + reconstruction: Readonly; + pack: Readonly | null; + decidedAt: string; + digestCanonical: SastEvidenceCanonicalDigester; +}): SastEvidenceBuildDecision { + const reasonCodes = orderSastEvidenceReasons(input.reasonCodes); + const accepted = input.pack !== null && reasonCodes.length === 0; + const core: SastEvidenceBuildDecisionCore = { + version: SAST_EVIDENCE_BUILD_DECISION_VERSION, + buildDecisionId: contractId( + 'sast-evidence-build', + input.digestCanonical( + stableJson({ + scope: input.scope, + candidateSetDigest: input.candidateSetDigest + }) + ) + ), + scope: { ...input.scope }, + candidateSetDigest: input.candidateSetDigest, + outcome: accepted ? 'ACCEPTED' : 'REJECTED', + reasonCodes, + selectedFragmentCount: accepted + ? input.selectedFragmentCount + : 0, + suppressedFragmentCount: input.suppressedFragmentCount, + reconstruction: { ...input.reconstruction }, + evidencePackId: input.pack?.evidencePackId ?? null, + evidencePackDigest: input.pack?.packDigest ?? null, + authority: { + evidenceConstructionAuthority: accepted, + dashboardAccessAllowed: false, + aiPayloadAllowed: false, + policyAuthority: false, + publicationAuthority: false, + lifecycleMutationAuthority: false + }, + audit: { + rawSourceStored: false, + secretValuesStored: false, + dashboardPayloadCreated: false, + aiPayloadCreated: false, + publicationAttempted: false + }, + decidedAt: input.decidedAt + }; + return { + ...core, + decisionDigest: input.digestCanonical( + canonicalizeSastEvidenceBuildDecision(core) + ) + }; +} + +function pickCandidate( + value: Record +): Record { + const keys = [ + 'candidateId', + 'role', + 'normalizedPath', + 'startLine', + 'endLine', + 'anchorStartLine', + 'anchorEndLine', + 'sourceFileLineCount', + 'redactedContent', + 'byteSize', + 'sourceContentDigest', + 'contentDigest', + 'sourceAttestationRef', + 'scannerRedactionDecisionRef', + 'platformRedactionDecisionRef', + 'secretRedactionApplied', + 'rawSourceStored', + 'candidateDigest' + ]; + return Object.fromEntries(keys.map((key) => [key, value[key]])); +} + +function isAcceptedAuthority( + value: unknown +): value is SastAcceptedEvidenceAuthority { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'evidenceConstructionAuthority', + 'dashboardAccessAllowed', + 'aiPayloadAllowed', + 'policyAuthority', + 'publicationAuthority', + 'lifecycleMutationAuthority' + ]) && + value.evidenceConstructionAuthority === true && + value.dashboardAccessAllowed === false && + value.aiPayloadAllowed === false && + value.policyAuthority === false && + value.publicationAuthority === false && + value.lifecycleMutationAuthority === false + ); +} + +function isDecisionAuthority(value: unknown): boolean { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'evidenceConstructionAuthority', + 'dashboardAccessAllowed', + 'aiPayloadAllowed', + 'policyAuthority', + 'publicationAuthority', + 'lifecycleMutationAuthority' + ]) && + typeof value.evidenceConstructionAuthority === 'boolean' && + value.dashboardAccessAllowed === false && + value.aiPayloadAllowed === false && + value.policyAuthority === false && + value.publicationAuthority === false && + value.lifecycleMutationAuthority === false + ); +} + +function isAuditProjection(value: unknown): boolean { + return ( + isRecord(value) && + hasExactKeys(value, [ + 'rawSourceStored', + 'secretValuesStored', + 'dashboardPayloadCreated', + 'aiPayloadCreated', + 'publicationAttempted' + ]) && + Object.values(value).every((entry) => entry === false) + ); +} + +function isCanonicalReasonArray(value: unknown): boolean { + return ( + Array.isArray(value) && + value.every( + (reason, index) => + SAST_EVIDENCE_REASON_CODES.includes( + reason as SastEvidenceReasonCode + ) && + (index === 0 || + SAST_EVIDENCE_REASON_CODES.indexOf( + value[index - 1] as SastEvidenceReasonCode + ) < + SAST_EVIDENCE_REASON_CODES.indexOf( + reason as SastEvidenceReasonCode + )) + ) + ); +} + +function isCanonicalTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) && + new Date(milliseconds).toISOString() === value; +} + +function isPositiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function isSafeNormalizedPath(value: unknown): value is string { + return ( + isBoundedReference(value) && + value === value.normalize('NFC') && + !value.startsWith('/') && + !value.endsWith('/') && + !value.includes('\\') && + !value.split('/').some( + (segment) => + segment.length === 0 || + segment === '.' || + segment === '..' + ) + ); +} + +function isCanonicalEvidenceText(value: string): boolean { + if ( + value.length === 0 || + value !== value.normalize('NFC') || + value.includes('\r') || + value.includes('\u0000') + ) { + return false; + } + return ![...value].some((character) => { + const point = character.codePointAt(0) ?? 0; + return ( + (point >= 0xd800 && point <= 0xdfff) || + (point < 0x20 && point !== 0x09 && point !== 0x0a) || + (point >= 0x7f && point <= 0x9f) + ); + }); +} + +function lineCount(value: string): number { + return value.split('\n').length; +} + +function hasUnique(values: readonly string[]): boolean { + return new Set(values).size === values.length; +} + +function isContractId(value: unknown, prefix: string): value is string { + return ( + typeof value === 'string' && + CONTRACT_ID_PATTERNS.get(prefix)?.test(value) === true + ); +} + +function contractId( + prefix: string, + digestValue: SastEvidenceDigest +): string { + return prefix + '://' + digestValue.replace(/^sha256:/u, ''); +} + +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/test/sast-accepted-evidence.test.mjs b/packages/shared/test/sast-accepted-evidence.test.mjs new file mode 100644 index 0000000..9436da2 --- /dev/null +++ b/packages/shared/test/sast-accepted-evidence.test.mjs @@ -0,0 +1,554 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + SAST_ACCEPTED_EVIDENCE_POLICY, + buildSastAcceptedEvidence, + canonicalizeSastAcceptedEvidencePack, + canonicalizeSastEvidenceCandidate, + canonicalizeSastEvidenceBuildDecision, + isSastAcceptedEvidenceBuildResultShapeValid, + isSastAcceptedEvidencePackShapeValid, + isSastEvidenceBuildDecisionShapeValid, + isSastRedactedEvidenceCandidateShapeValid +} from '../dist/index.js'; + +test('builds only bounded internal evidence with no dashboard, AI, or publication authority', () => { + const scope = evidenceScope(); + const candidates = [ + candidate({ + seed: 'primary', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 9, + endLine: 13, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }), + candidate({ + seed: 'related', + role: 'RELATED', + normalizedPath: 'src/main/java/Helper.java', + startLine: 20, + endLine: 22, + anchorStartLine: 21, + anchorEndLine: 21, + sourceFileLineCount: 120 + }) + ]; + + const result = buildSastAcceptedEvidence({ + scope, + candidates, + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + + assert.equal(result.decision.outcome, 'ACCEPTED'); + assert.deepEqual(result.decision.reasonCodes, []); + assert.equal( + result.decision.authority.evidenceConstructionAuthority, + true + ); + assert.equal(result.decision.authority.dashboardAccessAllowed, false); + assert.equal(result.decision.authority.aiPayloadAllowed, false); + assert.equal(result.decision.audit.rawSourceStored, false); + assert.equal(result.decision.audit.secretValuesStored, false); + assert.ok(result.pack); + assert.equal(result.pack.fragments.length, 2); + assert.equal(result.pack.dashboardSafe, false); + assert.equal(result.pack.aiSafe, false); + assert.equal(result.pack.classificationDecisionRef, null); + assert.equal(result.pack.deletionScheduleRef, null); + assert.equal( + Date.parse(result.pack.expiresAt) - + Date.parse(result.pack.createdAt), + 7 * 24 * 60 * 60 * 1000 + ); + assert.equal( + isSastEvidenceBuildDecisionShapeValid( + result.decision, + digest + ), + true + ); + assert.equal( + isSastAcceptedEvidencePackShapeValid(result.pack, digest), + true + ); + assert.equal( + isSastAcceptedEvidenceBuildResultShapeValid(result, digest), + true + ); + assert.equal( + isSastEvidenceBuildDecisionShapeValid( + result.decision, + digest, + { + ...SAST_ACCEPTED_EVIDENCE_POLICY, + maxFragmentCount: 1 + } + ), + false + ); +}); + +test('rejects a non-canonical decision timestamp without throwing', () => { + const scope = evidenceScope(); + const result = buildSastAcceptedEvidence({ + scope, + candidates: [ + candidate({ + seed: 'invalid-time-primary', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }) + ], + decidedAt: 'not-a-timestamp', + digestCanonical: digest + }); + + assert.equal(result.pack, null); + assert.equal(result.decision.outcome, 'REJECTED'); + assert.deepEqual(result.decision.reasonCodes, [ + 'EVIDENCE_INPUT_INVALID' + ]); + assert.equal(result.decision.decidedAt, '1970-01-01T00:00:00.000Z'); + assert.equal( + isSastAcceptedEvidenceBuildResultShapeValid(result, digest), + true + ); +}); + +test('canonical ordering produces the same pack for an exact reordered replay', () => { + const scope = evidenceScope(); + const primary = candidate({ + seed: 'primary', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }); + const related = candidate({ + seed: 'related', + role: 'RELATED', + normalizedPath: 'src/main/java/Zed.java', + startLine: 30, + endLine: 32, + anchorStartLine: 31, + anchorEndLine: 31, + sourceFileLineCount: 100 + }); + const input = { + scope, + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }; + const first = buildSastAcceptedEvidence({ + ...input, + candidates: [primary, related] + }); + const replay = buildSastAcceptedEvidence({ + ...input, + candidates: [related, primary] + }); + + assert.equal( + replay.decision.decisionDigest, + first.decision.decisionDigest + ); + assert.equal(replay.pack?.packDigest, first.pack?.packDigest); +}); + +test('rejects full-file, overlapping, adjacent, and substantial reconstruction sets', () => { + const scope = evidenceScope(); + const fullFile = buildSastAcceptedEvidence({ + scope, + candidates: [ + candidate({ + seed: 'full', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 1, + endLine: 20, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 20 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.equal(fullFile.pack, null); + assert.ok( + fullFile.decision.reasonCodes.includes( + 'EVIDENCE_FULL_FILE_FORBIDDEN' + ) + ); + + const primary = candidate({ + seed: 'primary', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }); + const overlapping = buildSastAcceptedEvidence({ + scope, + candidates: [ + primary, + candidate({ + seed: 'overlap', + role: 'RELATED', + normalizedPath: scope.normalizedPath, + startLine: 12, + endLine: 14, + anchorStartLine: 13, + anchorEndLine: 13, + sourceFileLineCount: 100 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.ok( + overlapping.decision.reasonCodes.includes( + 'EVIDENCE_RECONSTRUCTION_OVERLAP' + ) + ); + assert.equal(overlapping.pack, null); + assert.equal(overlapping.decision.reconstruction.status, 'RISK'); + + const adjacent = buildSastAcceptedEvidence({ + scope, + candidates: [ + primary, + candidate({ + seed: 'adjacent', + role: 'RELATED', + normalizedPath: scope.normalizedPath, + startLine: 13, + endLine: 15, + anchorStartLine: 14, + anchorEndLine: 14, + sourceFileLineCount: 100 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.ok( + adjacent.decision.reasonCodes.includes( + 'EVIDENCE_RECONSTRUCTION_ADJACENT' + ) + ); + assert.equal(adjacent.pack, null); + assert.equal(adjacent.decision.reconstruction.status, 'RISK'); + + const substantialScope = { + ...scope, + findingStartLine: 4, + findingEndLine: 4 + }; + const substantial = buildSastAcceptedEvidence({ + scope: substantialScope, + candidates: [ + candidate({ + seed: 'substantial', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 2, + endLine: 6, + anchorStartLine: 4, + anchorEndLine: 4, + sourceFileLineCount: 20 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.ok( + substantial.decision.reasonCodes.includes( + 'EVIDENCE_RECONSTRUCTION_COVERAGE' + ) + ); + assert.equal(substantial.pack, null); + assert.equal(substantial.decision.reconstruction.status, 'RISK'); +}); + +test('rejects more than two fragments per file and an invalid primary fragment', () => { + const scope = evidenceScope(); + const excessivePerFile = buildSastAcceptedEvidence({ + scope, + candidates: [ + candidate({ + seed: 'primary-per-file', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }), + candidate({ + seed: 'related-per-file-1', + role: 'RELATED', + normalizedPath: scope.normalizedPath, + startLine: 20, + endLine: 22, + anchorStartLine: 21, + anchorEndLine: 21, + sourceFileLineCount: 100 + }), + candidate({ + seed: 'related-per-file-2', + role: 'RELATED', + normalizedPath: scope.normalizedPath, + startLine: 30, + endLine: 32, + anchorStartLine: 31, + anchorEndLine: 31, + sourceFileLineCount: 100 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.equal(excessivePerFile.pack, null); + assert.equal( + excessivePerFile.decision.reconstruction.status, + 'RISK' + ); + assert.ok( + excessivePerFile.decision.reasonCodes.includes( + 'EVIDENCE_RECONSTRUCTION_FRAGMENT_COUNT' + ) + ); + + const invalidPrimary = buildSastAcceptedEvidence({ + scope, + candidates: [ + candidate({ + seed: 'misbound-primary', + role: 'PRIMARY', + normalizedPath: 'src/main/java/Other.java', + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }) + ], + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + assert.equal(invalidPrimary.pack, null); + assert.equal( + invalidPrimary.decision.reconstruction.status, + 'NOT_CHECKED' + ); + assert.ok( + invalidPrimary.decision.reasonCodes.includes( + 'EVIDENCE_PRIMARY_FRAGMENT_INVALID' + ) + ); +}); + +test('truncates deterministically at five fragments and rejects content tampering', () => { + const scope = evidenceScope(); + const candidates = [ + candidate({ + seed: 'primary', + role: 'PRIMARY', + normalizedPath: scope.normalizedPath, + startLine: 10, + endLine: 12, + anchorStartLine: 11, + anchorEndLine: 11, + sourceFileLineCount: 100 + }), + ...Array.from({ length: 5 }, (_, index) => + candidate({ + seed: 'related-' + index, + role: 'RELATED', + normalizedPath: + 'src/main/java/Related' + index + '.java', + startLine: 20, + endLine: 22, + anchorStartLine: 21, + anchorEndLine: 21, + sourceFileLineCount: 100 + }) + ) + ]; + const result = buildSastAcceptedEvidence({ + scope, + candidates, + decidedAt: '2026-08-10T04:40:00.000Z', + digestCanonical: digest + }); + + assert.ok(result.pack); + assert.equal(result.pack.fragments.length, 5); + assert.equal(result.pack.truncated, true); + assert.equal(result.pack.suppressedFragmentCount, 1); + + const forged = { + ...candidates[0], + redactedContent: 'tampered\nline\nvalue' + }; + assert.equal( + isSastRedactedEvidenceCandidateShapeValid(forged, digest), + false + ); + + const packCore = omit(result.pack, 'packDigest'); + const forgedPackCore = { + ...packCore, + reconstructionRiskDecisionRef: id( + 'sast-evidence-reconstruction', + 'forged-reconstruction' + ) + }; + const forgedPack = { + ...forgedPackCore, + packDigest: digest( + canonicalizeSastAcceptedEvidencePack(forgedPackCore) + ) + }; + assert.equal( + isSastAcceptedEvidencePackShapeValid(forgedPack, digest), + false + ); + + const decisionCore = omit(result.decision, 'decisionDigest'); + const forgedDecisionCore = { + ...decisionCore, + evidencePackDigest: digest('different-pack') + }; + const forgedDecision = { + ...forgedDecisionCore, + decisionDigest: digest( + canonicalizeSastEvidenceBuildDecision( + forgedDecisionCore + ) + ) + }; + assert.equal( + isSastEvidenceBuildDecisionShapeValid( + forgedDecision, + digest + ), + true + ); + assert.equal( + isSastAcceptedEvidenceBuildResultShapeValid( + { decision: forgedDecision, pack: result.pack }, + digest + ), + false + ); +}); + +function evidenceScope() { + return { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + targetRef: 'refs/pull/1/head', + commitSha: 'a'.repeat(40), + canonicalScanKey: digest('canonical-scan'), + planDigest: digest('plan'), + profileId: 'JAVA_FAST_V1', + profileDigest: digest('profile'), + freshnessDecisionId: id('sast-freshness', 'freshness'), + freshnessDecisionDigest: digest('freshness-decision'), + coverageDecisionId: id('sast-coverage', 'coverage'), + coverageDecisionDigest: digest('coverage-decision'), + occurrenceId: id('finding-occurrence', 'occurrence'), + observationBatchId: id( + 'finding-observation', + 'observation' + ), + normalizedFindingId: 'normalized-finding-1', + lineageId: id('finding-lineage', 'lineage'), + findingFingerprint: digest('fingerprint'), + fingerprintVersion: 'sast-fingerprint-v1', + capability: 'SAST', + normalizedPath: 'src/main/java/App.java', + findingStartLine: 11, + findingEndLine: 11, + policyVersion: 'sast-evidence-policy-v1' + }; +} + +function candidate(input) { + const lineCount = input.endLine - input.startLine + 1; + const redactedContent = Array.from( + { length: lineCount }, + (_, index) => 'safe-' + input.seed + '-' + index + ).join('\n'); + const core = { + candidateId: id( + 'sast-evidence-candidate', + input.seed + ), + role: input.role, + normalizedPath: input.normalizedPath, + startLine: input.startLine, + endLine: input.endLine, + anchorStartLine: input.anchorStartLine, + anchorEndLine: input.anchorEndLine, + sourceFileLineCount: input.sourceFileLineCount, + redactedContent, + byteSize: Buffer.byteLength(redactedContent, 'utf8'), + sourceContentDigest: digest('source-' + input.seed), + contentDigest: digest(redactedContent), + sourceAttestationRef: 'source-attestation://' + input.seed, + scannerRedactionDecisionRef: + 'scanner-redaction://' + input.seed, + platformRedactionDecisionRef: + 'platform-redaction://' + input.seed, + secretRedactionApplied: true, + rawSourceStored: false + }; + return { + ...core, + candidateDigest: digest( + canonicalizeSastEvidenceCandidate(core) + ) + }; +} + +function id(prefix, value) { + return prefix + '://' + hex(value); +} + +function digest(value) { + return 'sha256:' + hex(value); +} + +function hex(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function omit(value, key) { + return Object.fromEntries( + Object.entries(value).filter(([entryKey]) => entryKey !== key) + ); +} 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 ca1bb62..200aa9b 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -1161,6 +1161,34 @@ and its deletion schedule. It records truncation and suppressed fragment counts. archive, broad debug log, raw SARIF/JSON, or sequential fragments that reconstruct substantial source is rejected. +### Accepted-finding evidence gate v1 + +`sast-accepted-finding-evidence-v1` accepts a fragment request only after the persistence +store reloads the exact T040 decision and proves T039 `COMPLETE`, provider authority +`VERIFIED`, `FRESH`, `COMPARABLE`, and an empty reason set. The requested occurrence must +belong to the canonical T038 source set and its T037 observation, normalized-finding metadata, +lineage, capability, fingerprint, target, fixed commit, profile, canonical key, and plan must +all match. An UNKNOWN-location or non-occurrence input has no evidence authority. + +The source authority is internal and defaults to `UNAVAILABLE`. A verified response binds the +candidate ID, role, path/range, attested anchor, source-file line count, scanner-redaction +decision, and source-content digest. Source text is bounded to 8 KiB and exists only in memory. +T041 reapplies known-format and platform-secret redaction, preserves line count, recomputes +exact UTF-8 byte size/content digest, and stores neither raw source nor secret values. + +Candidate order is canonical. The builder selects at most five fragments and 32 KiB, records +truncation and every suppressed fragment, and rejects a full-file span or context beyond five +lines on either side of an attested anchor. Reconstruction uses canonical per-file intervals: +more than two fragments from a file, overlap, adjacency, or at least 25% combined line coverage +is `RISK` and rejects the complete pack. A rejected or accepted result is immutable; the +tenant/occurrence/policy/candidate-set key permits exact replay only inside a bounded +serializable transaction. + +An accepted T041 pack grants only evidence-construction authority. +`dashboardSafe=false`, `aiSafe=false`, classification and deletion references are null, +and policy, publication, lifecycle mutation, user access, and AI payload authority remain +false until T042. `SastAcceptedEvidenceService` exposes no controller or SCM writer. + AI receives finding metadata and reduced evidence references only after a second redaction pass. AI never receives the result-ingress artifact reference. diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index ee23fef..eafceca 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -730,27 +730,50 @@ T040 creates these ledgers in bounded serializable transactions with exact repla the former permanent external-publication constraint name only after the online-schema step validates the replacement invariant, builds populated-table indexes concurrently, and validates their dependent foreign keys. Effective eligibility comes only from the independent freshness row. -`SastScanFreshnessService` is the only sequential Scan Plane handoff to T041. - -### EvidenceFragment - -- evidence pack and finding identifiers plus `normalizedPath` -- bounded `startLine`, `endLine`, `sourceFileLineCount`, and `byteSize` -- `redactedContent` and its SHA-256 `contentDigest` -- invariant `secretRedactionApplied = true` plus `redactionDecisionRef` -- invariant `isFullFile = false`; a fragment spanning the complete source file is invalid - -### SastEvidencePack - -- `evidencePackId`, tenant, repository, scan, and `findingFingerprint` attribution +T041 consumes that row internally and `SastAcceptedEvidenceService` is the only sequential +Scan Plane handoff to T042. + +### SastEvidenceBuildDecision + +- deterministic `sast-evidence-build://` ID bound to the exact T040 freshness + decision, T039 coverage decision, T037 occurrence/observation/lineage, normalized finding, + policy version, and canonical candidate-set digest +- `ACCEPTED | REJECTED`, canonical reason codes, selected/suppressed counts, complete + reconstruction decision and digest, optional pack ID/digest, bounded audit projection, and + decision timestamp/digest +- only accepted decisions have `evidenceConstructionAuthority=true`; dashboard, AI, policy, + publication, and lifecycle mutation authority are always false +- one exact replay key per tenant, occurrence, policy version, and candidate-set digest + +### SastAcceptedEvidenceFragment + +- evidence pack/build decision/candidate identifiers, deterministic ordinal and + `PRIMARY | RELATED` role plus `normalizedPath` +- bounded `startLine`, `endLine`, attested anchor, `sourceFileLineCount`, and exact UTF-8 + `byteSize` +- `redactedContent`, raw-source and redacted-content SHA-256 digests, source attestation, + scanner-redaction decision, platform-redaction decision, and canonical fragment digest +- invariants `secretRedactionApplied=true`, `rawSourceStored=false`, and + `isFullFile=false`; the database stores no pre-redaction source or platform secret value + +### SastAcceptedEvidencePack + +- durable Prisma `id` stores the shared-contract `evidencePackId`; tenant, repository, scan, + and `findingFingerprint` retain the complete attribution - `policyVersion`, fragments, exact `totalBytes`, and per-fragment content digests - `truncated` and non-negative `suppressedFragmentCount` -- invariant `reconstructionRiskChecked = true` plus `reconstructionRiskDecisionRef` -- `classificationDecisionRef`, `deletionScheduleRef`, `dashboardSafe`, and `aiSafe` +- invariant `reconstructionRiskChecked = true`; durable Prisma + `reconstructionDecisionId` stores the shared-contract `reconstructionRiskDecisionRef` +- T041 invariants `classificationDecisionRef=null`, `deletionScheduleRef=null`, + `dashboardSafe=false`, and `aiSafe=false`; T042 creates separate access and deletion + authority rather than mutating this pack - `createdAt` and `expiresAt`; retention cannot exceed the evidence policy -The pack is unavailable to the dashboard when `dashboardSafe = false` and unavailable to the AI -Plane when `aiSafe = false`. These decisions cannot be inferred from a successful scan status. +The reconstruction decision canonicalizes intervals per normalized path. More than two +fragments per file, overlap, adjacency, or combined coverage of at least 2,500 basis points is +`RISK` and rejects the whole build. Full-file or context-invalid input is rejected before that +calculation. The pack remains unavailable to the dashboard and AI Plane until T042; these +decisions cannot be inferred from a successful scan or accepted T041 pack. ### RuleBundlePromotionEvidence diff --git a/specs/006-production-sast-runtime-design/plan.md b/specs/006-production-sast-runtime-design/plan.md index 063e23a..88b5f9b 100644 --- a/specs/006-production-sast-runtime-design/plan.md +++ b/specs/006-production-sast-runtime-design/plan.md @@ -15,7 +15,7 @@ Issue #276 is an explicitly reclassified adjacent bootstrap, not a new productio Its `ontology/` Neo4j and MITRE CWE work remains local dev/demo data tooling with no Scan, AI, policy, finding, evidence, publication, SCM, tenant, or deployment authority. Work on that bootstrap did not advance or satisfy T040; the formal 006 sequence has since completed -T040 independently and now proceeds to T041. +T040 and T041 independently and now proceeds to T042. ## Target Boundaries @@ -122,8 +122,15 @@ in one serializable transaction and persists zero publication authority even whe 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. +with scanner-set and kill-switch revalidation plus new sandbox/workload identity. T041 now +rebinds that exact fresh decision to one durable accepted occurrence and its fingerprinted +source finding before any evidence source read. The internal source authority defaults to +unavailable; verified source is scanner-redacted and platform-redacted in memory, then a +canonical pack is limited to 32 KiB, five fragments, 8 KiB per fragment, and five context +lines. Full-file spans, more than two fragments per file, overlapping/adjacent intervals, or +25% or greater per-file line coverage reject the complete build. Only +`SastAcceptedEvidenceService` crosses the module boundary; T042 classification, expiry +enforcement, and deletion proof are the next gate. ### 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 fec179d..2be3720 100644 --- a/specs/006-production-sast-runtime-design/quality-gates.md +++ b/specs/006-production-sast-runtime-design/quality-gates.md @@ -284,8 +284,21 @@ Raw artifact/evidence expiry is tested at seven days maximum and AI request payl 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. + built concurrently before dependent foreign-key validation. +- 100% T041 accepted-source invariant: every pack rebinds one exact T037 occurrence and + fingerprinted source finding through its T038 source, T039 `COMPLETE` coverage, and T040 + verified/fresh/comparable decision. Missing, rejected, UNKNOWN-location, foreign, + cross-tenant, changed, or late durable state creates zero packs. +- 100% T041 evidence-bound invariant: exact UTF-8 content bytes are at most 8 KiB per + fragment, five fragments and 32 KiB per pack, with no more than five context lines around + each attested anchor. Raw source and platform secret values stored across pack, decision, + audit, logs, dashboard, and AI surfaces equal zero. +- 100% T041 reconstruction invariant: full-file spans, more than two fragments per file, + overlap, adjacency, or combined coverage at or above 2,500 basis points reject the complete + build. Exact replay creates no duplicate decision, pack, or fragment row. +- T041 accepted and rejected decisions have zero dashboard/AI/policy/publication/lifecycle + authority. `SastAcceptedEvidenceService` is the only sequential Scan Plane handoff to T042; + the default source authority remains unavailable and no controller or SCM writer is added. ## 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 f77d2a9..d17729b 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -461,10 +461,30 @@ portion of Phase 6: 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. +- T041 accepts only a durable T037 occurrence that belongs to the exact T038 source set behind + the T039 `COMPLETE` decision and T040 verified, fresh, comparable decision. It reloads the + fingerprinted source finding and normalized row rather than accepting finding authority, + path, coordinates, or freshness from the caller. +- The T041 source authority defaults to unavailable. A verified provider returns only a + bounded scanner-redacted fragment in memory. The service applies known-format and + platform-secret redaction again, stores no raw source or secret value, and checks exact UTF-8 + bytes and line counts before building a canonical fragment. +- The canonical pack permits at most 32 KiB, five fragments, 8 KiB per fragment, and five + context lines on either side of each attested anchor. Selection is deterministic and records + truncation plus suppressed count. A full file, more than two fragments from one file, + overlapping or adjacent intervals, or combined coverage of at least 25% of a source file + rejects the whole pack and persists only the immutable rejection/audit decision. +- `SastEvidenceBuildDecision`, `SastAcceptedEvidencePack`, and + `SastAcceptedEvidenceFragment` are tenant/scan/attempt scoped with exact T040 and occurrence + composite foreign keys. Serializable re-read permits only exact replay. +- Alongside the four established runtime exports (`RepositoryFetchService`, + `RepositoryPreflightService`, `SandboxRuntimeAttestationService`, and + `SastScannerRuntimeService`), `ScanPlaneModule` exports + `SastAcceptedEvidenceService` as the sole sequential T041 handoff to T042. T040 freshness + and all earlier coverage/correlation/lineage/identity/redaction providers remain internal. + T041 adds no controller, evidence access route, AI payload, policy decision, publisher, or + SCM writer; `dashboardSafe` and `aiSafe` remain false and classification/deletion + references remain null until T042. 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 @@ -474,8 +494,10 @@ GitHub App/GitLab scoped minting, microVM, artifact object-store/disposition, file-coordinate-attestation, and acceptance-gate adapters. T035 secret redaction, T036 `sast-fingerprint-v1` identity construction, T037 occurrence/exact-lineage lifecycle, and T038 authority-aware cross-tool correlation, T039 fail-closed scanner/capability coverage, -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. +T040 stale-scan denial and bounded infrastructure-only retry, and T041 bounded +accepted-finding evidence with reconstruction-risk checks are complete; T042 dashboard/AI +classification, second-pass secret redaction, seven-day expiry enforcement, and deletion +proof are therefore the next implementation task. 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 da0becc..629b969 100644 --- a/specs/006-production-sast-runtime-design/research.md +++ b/specs/006-production-sast-runtime-design/research.md @@ -476,3 +476,32 @@ fixed heads, assuming profile names imply compatible capabilities, mutating the 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. + +## Decision 23: Build Evidence from a Rebound Accepted Occurrence and Reject Reconstruction + +**Decision**: `sast-accepted-finding-evidence-v1` reloads the exact T040 +verified/fresh/comparable decision, T039 complete coverage, T038 correlation source, and T037 +occurrence, observation, normalized finding, lineage, and fingerprint before reading source. +The internal source authority defaults unavailable and returns only a bounded +scanner-redacted, attested fragment in memory. T041 reapplies known-format and platform-secret +redaction, verifies UTF-8 bytes and line count, then stores a canonical build decision and, +only when safe, a short-lived pack plus fragments. + +Reconstruction is a deterministic interval decision rather than a caller flag. A file may +contribute at most two fragments; full-file, overlap, adjacency, or at least 25% combined line +coverage rejects the entire build. Candidate order, truncation, suppressed count, IDs, and +digests are canonical. Serializable re-read and the tenant/occurrence/policy/candidate-set key +permit exact replay only. + +**Rationale**: Accepted scanner output alone does not prove that requested source text belongs +to the same fresh finding, and individually small snippets can reconstruct sensitive code when +combined. Durable rebinding closes the authority gap; interval union rules make +reconstruction risk auditable and independent of request order. Keeping all downstream +authority false lets T042 add classification, expiry enforcement, and deletion proof without +retroactively trusting T041 construction. + +**Rejected**: Building from scanner titles/messages or rejected artifacts, accepting caller +paths/coordinates/redaction flags, storing raw source or platform secrets, per-fragment +best-effort acceptance after reconstruction risk, allowing adjacent snippets, using character +counts instead of UTF-8 bytes, mutable last-writer-wins packs, direct dashboard/AI access, or +adding an SCM writer in T041. diff --git a/specs/006-production-sast-runtime-design/spec.md b/specs/006-production-sast-runtime-design/spec.md index e0907ad..22bff18 100644 --- a/specs/006-production-sast-runtime-design/spec.md +++ b/specs/006-production-sast-runtime-design/spec.md @@ -321,6 +321,21 @@ incomplete, stale, quarantined, or security-blocked scan. - **FR-046**: Evidence MUST be built only from accepted normalized findings and bounded source fragments. +- **FR-046a**: T041 MUST reload the exact T040 complete, verified, fresh, comparable decision + and the T037 occurrence, observation, normalized-finding, lineage, and fingerprint rows. + Caller-provided finding, path, coordinate, coverage, freshness, or evidence authority MUST + NOT substitute for durable rebinding. +- **FR-046b**: A source fragment MUST come from an internal authority that defaults to + unavailable, remain memory-only before redaction, preserve its attested line count, and be + redacted for known-format and platform secret values before a pack or audit projection is + persisted. Raw source and secret values MUST NOT be stored. +- **FR-046c**: Reconstruction evaluation MUST reject a full file, more than two fragments per + file, overlapping or adjacent intervals, or combined fragment coverage of at least 25% of a + source file. Rejection MUST create no pack and MUST preserve only a canonical immutable + decision/audit projection. +- **FR-046d**: T041 accepted packs MUST keep dashboard, AI, policy, publication, and lifecycle + authority false. Classification/deletion references MUST remain absent until T042, and T041 + MUST expose no user route, AI payload, or SCM writer. - **FR-047**: The default evidence maximum is 32 KiB total, five fragments, 8 KiB per fragment, and five context lines on either side. - **FR-048**: Evidence MUST redact detected and platform-format secrets before persistence diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 96442ab..2dc9629 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -69,7 +69,7 @@ ## Phase 8: Evidence, Policy, and AI Boundary -- [ ] T041 Build bounded accepted-finding evidence with reconstruction-risk checks +- [x] T041 Build bounded accepted-finding evidence with reconstruction-risk checks - [ ] T042 Enforce dashboard/AI classification, secret redaction, seven-day expiry, and deletion proof - [ ] T043 Send only normalized findings and reduced evidence references to the advisory AI Plane - [ ] T044 Prove AI cannot create, suppress, waive, resolve, or override authoritative findings/policy diff --git a/specs/006-production-sast-runtime-design/threat-model.md b/specs/006-production-sast-runtime-design/threat-model.md index 9a3bb44..de44075 100644 --- a/specs/006-production-sast-runtime-design/threat-model.md +++ b/specs/006-production-sast-runtime-design/threat-model.md @@ -74,7 +74,7 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | 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 | +| Secret leakage | Finding, zero-finding binding, or source fragment includes a detected/platform secret | Scanner discard plus T035 display redaction, T041 bounded memory-only known/platform redaction, and T042 access-time re-redaction/classification | Secret-leak gate must remain zero | | Cross-tenant object access | Object key or query omits tenant | Tenant/scan prefix, encryption context, tenant predicate, purpose-bound reads | Negative tests and access audit | | Cache poisoning | Customer content enters shared cache | Shared cache only for signed public tool/rule/database assets | Cache inventory and digest monitoring | | Rule supply-chain attack | Malicious rule or database promoted | Signed digest, provenance, two-person security approval, corpus gates, canary | Automatic rollback/kill switch | @@ -82,7 +82,8 @@ exfiltrate data, or gain Control/AI/Data-Security authority. | Retry replay | Same attempt/result is processed twice | Canonical scan identity, unique attempt, artifact digest idempotency | Duplicate events ignored and audited | | Stale publication | Old commit result comments on newer PR | Latest-context comparison before policy/comment | Target stale publication count = zero | | Incomplete coverage | Successful tool hides required tool failure | Explicit required coverage state | Comment/block/AI denied | -| Evidence reconstruction | Multiple snippets rebuild source | Total/fragment/context caps and reconstruction-risk check | Evidence build reject and audit | +| Evidence source forgery | A caller supplies a path/range, finding authority, or fragment that is not the durable accepted occurrence | Rebind exact T040/T039/T038/T037 rows and require an internal source attestation that defaults unavailable | Cross-scope, missing occurrence, changed fingerprint, path/range, and unavailable-source fixtures reject | +| Evidence reconstruction | Multiple snippets rebuild source | 32 KiB/five-fragment/8 KiB/five-context caps; per-file maximum two; reject full-file, overlap, adjacency, or at least 25% combined line coverage | Evidence build reject and immutable audit with zero pack | | AI prompt injection | Evidence text instructs model | Evidence is untrusted data, bounded/redacted, no retrieval/tools/SCM | Advisory label and output schema validation | | Sandbox persistence | Compromise survives next scan | No worker/workspace reuse; new microVM per attempt | Destruction evidence and lag alert | | Operator credential leak | Deployment secrets enter repo/config | 005 reference-only credential handoff | Secret scanning and deployment audit | @@ -148,6 +149,9 @@ The following must always remain true: 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. +17. An accepted-finding evidence pack records only the durable accepted occurrence under + verified, fresh, and comparable authority. It contains no raw source or secret value and + grants no dashboard, AI, policy, publication, or lifecycle mutation authority. ## Required Security Test Corpus diff --git a/test/github-actions/active-feature.test.mjs b/test/github-actions/active-feature.test.mjs index 113afc0..d09c13d 100644 --- a/test/github-actions/active-feature.test.mjs +++ b/test/github-actions/active-feature.test.mjs @@ -43,6 +43,8 @@ const files = { 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), + sharedSastAcceptedEvidence: new URL('../../packages/shared/src/types/sast-accepted-evidence.ts', import.meta.url), + sharedSastAcceptedEvidenceTest: new URL('../../packages/shared/test/sast-accepted-evidence.test.mjs', import.meta.url), 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), @@ -83,12 +85,17 @@ const files = { 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), + apiSastAcceptedEvidence: new URL('../../apps/api/src/scan-plane/sast-accepted-evidence.service.ts', import.meta.url), + apiSastAcceptedEvidenceStore: new URL('../../apps/api/src/scan-plane/prisma-sast-accepted-evidence.store.ts', import.meta.url), + apiSastAcceptedEvidenceTest: new URL('../../apps/api/test/scan-plane/sast-accepted-evidence.e2e-spec.ts', import.meta.url), + apiSastAcceptedEvidencePersistenceTest: new URL('../../apps/api/test/scan-plane/sast-accepted-evidence-persistence.e2e-spec.ts', import.meta.url), 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), + apiSastAcceptedEvidenceMigration: new URL('../../apps/api/prisma/migrations/20260810043000_sast_accepted_evidence/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), @@ -125,7 +132,8 @@ const assertScanPlaneExports = (scanPlaneModule) => { exportsBlock, 'Expected to locate the ScanPlaneModule exports array' ); - assert.match(exportsBlock, /SastScanFreshnessService/); + assert.match(exportsBlock, /SastAcceptedEvidenceService/); + assert.doesNotMatch(exportsBlock, /SastScanFreshnessService/); assert.doesNotMatch(exportsBlock, /SastScanCoverageService/); assert.doesNotMatch(exportsBlock, /SastFindingCorrelationService/); assert.doesNotMatch(exportsBlock, /SastFindingLineageService/); @@ -481,7 +489,7 @@ test('SAST T034 Syft CycloneDX ingestion is inventory-only, transient, and fixtu assert.match(tasks, /- \[x\] T034\b/); assert.match( quickstart, - /T035 secret redaction,[\s\S]{0,180}T037 occurrence\/exact-lineage lifecycle[\s\S]{0,200}complete/ + /T035 secret redaction,[\s\S]{0,720}T041 bounded[\s\S]{0,180}are complete/ ); assert.match(contract, /Syft CycloneDX inventory adapter v1/); assert.match(spec, /FR-031b/); @@ -570,7 +578,7 @@ test('SAST T035 secret redaction is deterministic, fail-closed, and still non-du assert.match(tasks, /- \[x\] T035\b/); assert.match( quickstart, - /T035 secret redaction,[\s\S]{0,180}T037 occurrence\/exact-lineage lifecycle[\s\S]{0,200}complete/ + /T035 secret redaction,[\s\S]{0,720}T041 bounded[\s\S]{0,180}are complete/ ); assert.match(contract, /Secret redaction gate v1/); assert.match(spec, /FR-031c/); @@ -693,7 +701,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,260}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ + /T038 authority-aware cross-tool correlation[\s\S]{0,320}T041 bounded[\s\S]{0,180}are complete; T042/ ); assert.match(contract, /Finding identity construction gate v1/); assert.match(spec, /FR-034a/); @@ -854,7 +862,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,260}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ + /T038 authority-aware cross-tool correlation[\s\S]{0,320}T041 bounded[\s\S]{0,180}are complete; T042/ ); assert.match(contract, /Finding lineage and lifecycle gate v1/); assert.match(dataModel, /SastFindingLifecycleReconciliation/); @@ -992,7 +1000,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,160}T040 stale-scan denial[\s\S]{0,160}are complete; T041/ + /T039 fail-closed scanner\/capability coverage[\s\S]{0,220}T041 bounded[\s\S]{0,180}are complete; T042/ ); assert.match(contract, /Finding correlation gate v1/); assert.match(dataModel, /SastFindingCorrelationProvenance/); @@ -1245,7 +1253,7 @@ test('SAST T039 coverage feeds T040 freshness and bounded retry authority', () = assert.match(tasks, /- \[x\] T040\b/); assert.match( quickstart, - /T040 stale-scan denial and bounded infrastructure-only retry[\s\S]{0,160}complete[\s\S]{0,160}T041[\s\S]{0,120}next implementation task/ + /T040 stale-scan denial and bounded infrastructure-only retry[\s\S]{0,160}T041 bounded[\s\S]{0,160}complete; T042[\s\S]{0,220}next implementation task/ ); assert.match(contract, /Scan coverage gate v1/); assert.match(contract, /Freshness and bounded retry gate v1/); @@ -1263,6 +1271,145 @@ test('SAST T039 coverage feeds T040 freshness and bounded retry authority', () = assert.match(qualityGates, /100% T039 zero-publication invariant/); }); +test('SAST T041 builds bounded accepted-finding evidence and rejects reconstruction', () => { + const shared = readNormalizedText( + files.sharedSastAcceptedEvidence + ); + const sharedTest = readNormalizedText( + files.sharedSastAcceptedEvidenceTest + ); + const sharedIndex = readNormalizedText(files.sharedIndex); + const service = readNormalizedText( + files.apiSastAcceptedEvidence + ); + const store = readNormalizedText( + files.apiSastAcceptedEvidenceStore + ); + const serviceTest = readNormalizedText( + files.apiSastAcceptedEvidenceTest + ); + const persistenceTest = readNormalizedText( + files.apiSastAcceptedEvidencePersistenceTest + ); + const schema = readNormalizedText(files.apiPrismaSchema); + const migration = readNormalizedText( + files.apiSastAcceptedEvidenceMigration + ); + const onlineSchema = readNormalizedText( + files.apiOnlineSastRuntimeSchema + ); + const scanPlaneModule = readNormalizedText( + files.apiScanPlaneModule + ); + const tasks = readNormalizedText(files.tasks); + const quickstart = readNormalizedText(files.quickstart); + const contract = readNormalizedText(files.contract); + const dataModel = readNormalizedText(files.dataModel); + const plan = readNormalizedText(files.plan); + const spec = readNormalizedText(files.spec); + const research = readNormalizedText(files.research); + const threatModel = readNormalizedText(files.threatModel); + const qualityGates = readNormalizedText(files.qualityGates); + + assert.match( + shared, + /sast-accepted-finding-evidence-v1/ + ); + assert.match( + shared, + /maximumReconstructedFileCoverageBasisPoints:\s*2500/ + ); + assert.match(shared, /maximumFragmentsPerFile:\s*2/); + assert.match(shared, /EVIDENCE_RECONSTRUCTION_OVERLAP/); + assert.match(shared, /EVIDENCE_RECONSTRUCTION_ADJACENT/); + assert.match(shared, /EVIDENCE_RECONSTRUCTION_COVERAGE/); + assert.match(shared, /dashboardAccessAllowed:\s*false/); + assert.match(sharedIndex, /sast-accepted-evidence/); + assert.match( + sharedTest, + /rejects full-file, overlapping, adjacent, and substantial reconstruction sets/ + ); + + assert.match(service, /KNOWN_SECRET_PATTERNS/); + assert.match(service, /platformSecretValues/); + assert.match(service, /dashboardPayloadCreated:\s*false/); + assert.match(service, /aiPayloadCreated:\s*false/); + assert.match(service, /publicationAttempted:\s*false/); + assert.doesNotMatch( + service, + /@Controller|@(Get|Post|Put|Patch|Delete)\(/u + ); + assert.doesNotMatch(service, /\bLogger\b|\bconsole\./u); + assert.match( + serviceTest, + /redacts trusted source and persists a bounded internal-only pack/ + ); + assert.match( + serviceTest, + /rejects full-file and overlapping reconstruction/ + ); + assert.match( + persistenceTest, + /rebinds the complete fresh T040 decision and accepted T037 occurrence/ + ); + + assert.match( + store, + /isSastScanFreshnessDecisionShapeValid/ + ); + assert.match(store, /isSastFingerprintedFindingShapeValid/); + assert.match( + store, + /Prisma\.TransactionIsolationLevel\.Serializable/ + ); + assert.match(store, /replayExisting/); + for (const model of [ + 'SastEvidenceBuildDecision', + 'SastAcceptedEvidencePack', + 'SastAcceptedEvidenceFragment' + ]) { + assert.match(schema, new RegExp('model ' + model + ' \\{')); + assert.match( + migration, + new RegExp('CREATE TABLE "' + model + '"') + ); + } + assert.match( + migration, + /SastEvidenceBuildDecision_freshness_scope_fkey/ + ); + assert.match( + onlineSchema, + /SastEvidenceBuildDecision_occurrence_scope_fkey/ + ); + assert.match(migration, /"dashboardSafe" = false/); + assert.match(migration, /"aiSafe" = false/); + assertScanPlaneExports(scanPlaneModule); + + assert.match(tasks, /- \[x\] T041\b/); + assert.match( + quickstart, + /T041 bounded[\s\S]{0,180}are complete; T042[\s\S]{0,220}next implementation task/ + ); + assert.match(contract, /Accepted-finding evidence gate v1/); + assert.match(dataModel, /SastEvidenceBuildDecision/); + assert.match(dataModel, /SastAcceptedEvidencePack/); + assert.match( + plan, + /T040 and T041 independently and now proceeds to T042/ + ); + assert.match(spec, /FR-046a/); + assert.match( + research, + /Decision 23: Build Evidence from a Rebound Accepted Occurrence and Reject Reconstruction/ + ); + assert.match(threatModel, /Evidence source forgery/); + assert.match( + qualityGates, + /100% T041 reconstruction invariant/ + ); +}); + test('SAST design completion gate stays synchronized between quickstart and CI', () => { const readme = readNormalizedText(files.readme); const ci = readNormalizedText(files.ci); diff --git a/test/github-actions/ontology.test.mjs b/test/github-actions/ontology.test.mjs index b246329..676787f 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, /did not advance or satisfy T040[\s\S]*proceeds to T041/); + assert.match(plan, /did not advance or satisfy T040[\s\S]*proceeds to T042/); assert.match(tasks, /Approved Adjacent Bootstrap \(Does Not Advance 006\)/); assert.match(tasks, /Keep T040 as the next formal active-milestone task/); });