diff --git a/apps/api/prisma/migrations/20260724180000_sast_artifact_ingress/migration.sql b/apps/api/prisma/migrations/20260724180000_sast_artifact_ingress/migration.sql new file mode 100644 index 0000000..04633cf --- /dev/null +++ b/apps/api/prisma/migrations/20260724180000_sast_artifact_ingress/migration.sql @@ -0,0 +1,143 @@ +CREATE TYPE "SastArtifactIngestionStatus" AS ENUM ( + 'RECEIVING', + 'PENDING_VALIDATION', + 'ACCEPTED', + 'REJECTED', + 'QUARANTINED' +); + +-- Deployment contract: prisma:migrate:deploy synchronously runs +-- scripts/apply-online-sast-runtime-schema.mjs and MUST finish before new-version +-- traffic is admitted. The v1 ScannerRun runtime constraint stays active +-- throughout this transaction; the online step validates v2 first and only then +-- removes v1, so a failed rollout cannot leave a constraint gap. + +CREATE TABLE "SastArtifactIngestion" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "scannerRunId" TEXT NOT NULL, + "workloadIdentityRef" TEXT NOT NULL, + "idempotencyKey" TEXT NOT NULL, + "envelopeDigest" TEXT NOT NULL, + "declaredContentDigest" TEXT NOT NULL, + "observedContentDigest" TEXT, + "declaredByteSize" INTEGER NOT NULL, + "observedByteSize" INTEGER, + "objectKey" TEXT, + "identityValidated" BOOLEAN NOT NULL DEFAULT false, + "status" "SastArtifactIngestionStatus" NOT NULL DEFAULT 'RECEIVING', + "validationMetadata" JSONB, + "rejectionReason" TEXT, + "receivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastArtifactIngestion_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastArtifactIngestion_identity_check" + CHECK ( + "identityValidated" = true + AND char_length("workloadIdentityRef") BETWEEN 1 AND 512 + ), + CONSTRAINT "SastArtifactIngestion_digest_check" + CHECK ( + "envelopeDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "declaredContentDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ( + "observedContentDigest" IS NULL + OR "observedContentDigest" ~ '^sha256:[a-f0-9]{64}$' + ) + ), + CONSTRAINT "SastArtifactIngestion_size_check" + CHECK ( + "declaredByteSize" BETWEEN 1 AND 268435456 + AND ( + "observedByteSize" IS NULL + OR "observedByteSize" BETWEEN 0 AND 268435457 + ) + ), + CONSTRAINT "SastArtifactIngestion_key_check" + CHECK ( + char_length("idempotencyKey") BETWEEN 1 AND 1024 + AND ( + "objectKey" IS NULL + OR char_length("objectKey") BETWEEN 1 AND 2048 + ) + ), + CONSTRAINT "SastArtifactIngestion_lifecycle_check" + CHECK ( + COALESCE( + ( + "status" = 'RECEIVING' + AND "objectKey" IS NULL + AND "observedContentDigest" IS NULL + AND "observedByteSize" IS NULL + AND "rejectionReason" IS NULL + AND "receivedAt" IS NULL + ) + OR ( + "status" IN ('PENDING_VALIDATION', 'ACCEPTED') + AND "objectKey" IS NOT NULL + AND "observedContentDigest" IS NOT NULL + AND "observedByteSize" = "declaredByteSize" + AND "rejectionReason" IS NULL + AND "receivedAt" IS NOT NULL + ) + OR ( + "status" = 'REJECTED' + AND "objectKey" IS NULL + AND char_length("rejectionReason") BETWEEN 1 AND 255 + AND "receivedAt" IS NOT NULL + ) + OR ( + "status" = 'QUARANTINED' + AND "objectKey" IS NOT NULL + AND "observedContentDigest" IS NOT NULL + AND "observedByteSize" IS NOT NULL + AND char_length("rejectionReason") BETWEEN 1 AND 255 + AND "receivedAt" IS NOT NULL + ), + false + ) + ) +); + +CREATE UNIQUE INDEX "SastArtifactIngestion_scannerRunId_key" + ON "SastArtifactIngestion"("scannerRunId"); +CREATE UNIQUE INDEX "SastArtifactIngestion_objectKey_key" + ON "SastArtifactIngestion"("objectKey"); +CREATE UNIQUE INDEX "SastArtifactIngestion_idempotency_scope_key" + ON "SastArtifactIngestion"("tenantId", "scanRequestId", "idempotencyKey"); +CREATE UNIQUE INDEX "SastArtifactIngestion_scanner_scope_key" + ON "SastArtifactIngestion"("scannerRunId", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId"); +CREATE INDEX "SastArtifactIngestion_tenantId_scanRequestId_status_idx" + ON "SastArtifactIngestion"("tenantId", "scanRequestId", "status"); +CREATE INDEX "SastArtifactIngestion_attemptId_status_idx" + ON "SastArtifactIngestion"("attemptId", "status"); +CREATE INDEX "SastArtifactIngestion_createdAt_idx" + ON "SastArtifactIngestion"("createdAt"); + +ALTER TABLE "SastArtifactIngestion" + ADD CONSTRAINT "SastArtifactIngestion_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastArtifactIngestion" + ADD CONSTRAINT "SastArtifactIngestion_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastArtifactIngestion" + ADD CONSTRAINT "SastArtifactIngestion_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastArtifactIngestion" + ADD CONSTRAINT "SastArtifactIngestion_attempt_scope_fkey" + FOREIGN KEY ("attemptId", "tenantId", "repositoryBindingId", "scanRequestId") + REFERENCES "SastScanAttempt"("id", "tenantId", "repositoryBindingId", "scanRequestId") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- ScannerRun scope uniqueness and the corresponding ingestion foreign key are +-- installed by the mandatory online schema step after this transaction. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f848d10..d582488 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -130,6 +130,14 @@ enum ScannerRunStatus { SKIPPED } +enum SastArtifactIngestionStatus { + RECEIVING + PENDING_VALIDATION + ACCEPTED + REJECTED + QUARANTINED +} + enum NormalizedFindingStatus { OPEN ACCEPTED @@ -295,20 +303,21 @@ model Tenant { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - integrations ScmIntegration[] - repositoryBindings RepositoryBinding[] - scanRequests ScanRequest[] - scannerRuns ScannerRun[] - normalizedFindings NormalizedFinding[] - evidencePacks EvidencePack[] - policyDecisions PolicyDecision[] - aiAdvisoryMetadata AiAdvisoryMetadata[] - waivers Waiver[] - suppressions Suppression[] - auditEvents AuditEvent[] - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] - users User[] + integrations ScmIntegration[] + repositoryBindings RepositoryBinding[] + scanRequests ScanRequest[] + scannerRuns ScannerRun[] + normalizedFindings NormalizedFinding[] + evidencePacks EvidencePack[] + policyDecisions PolicyDecision[] + aiAdvisoryMetadata AiAdvisoryMetadata[] + waivers Waiver[] + suppressions Suppression[] + auditEvents AuditEvent[] + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] + users User[] } model ScmIntegration { @@ -344,11 +353,12 @@ model RepositoryBinding { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) - scanRequests ScanRequest[] - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) + scanRequests ScanRequest[] + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -375,18 +385,19 @@ model ScanRequest { completedAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) - scannerRuns ScannerRun[] - findings NormalizedFinding[] - evidencePacks EvidencePack[] - policyDecisions PolicyDecision[] - aiAdvisoryMetadata AiAdvisoryMetadata[] - suppressions Suppression[] - auditEvents AuditEvent[] - sastQueueReservation SastQueueReservation? - sastCredentialLeases SastRepositoryCredentialLease[] - sastScanAttempts SastScanAttempt[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) + scannerRuns ScannerRun[] + findings NormalizedFinding[] + evidencePacks EvidencePack[] + policyDecisions PolicyDecision[] + aiAdvisoryMetadata AiAdvisoryMetadata[] + suppressions Suppression[] + auditEvents AuditEvent[] + sastQueueReservation SastQueueReservation? + sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] + sastArtifactIngestions SastArtifactIngestion[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -459,12 +470,13 @@ model SastScanAttempt { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanAttempt_repository_scope_fkey") - scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanAttempt_scan_scope_fkey") - scannerRuns ScannerRun[] - auditEvents AuditEvent[] @relation("SastScanAttemptAuditEvents") - finalAuditEvent AuditEvent? @relation("SastScanAttemptFinalAuditEvent", fields: [finalAuditEventId, id, tenantId], references: [id, attemptId, tenantId], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_finalAuditEventId_fkey") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastScanAttempt_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastScanAttempt_scan_scope_fkey") + scannerRuns ScannerRun[] + artifactIngestions SastArtifactIngestion[] + auditEvents AuditEvent[] @relation("SastScanAttemptAuditEvents") + finalAuditEvent AuditEvent? @relation("SastScanAttemptFinalAuditEvent", fields: [finalAuditEventId, id, tenantId], references: [id, attemptId, tenantId], onDelete: NoAction, onUpdate: NoAction, map: "SastScanAttempt_finalAuditEventId_fkey") @@unique([scanRequestId, attemptNumber]) @@unique([id, tenantId], map: "SastScanAttempt_id_tenantId_key") @@ -592,12 +604,16 @@ model ScannerRun { completedAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) - attempt SastScanAttempt? @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "ScannerRun_attempt_scope_fkey") - findings NormalizedFinding[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest @relation(fields: [scanRequestId], references: [id], onDelete: Cascade) + attempt SastScanAttempt? @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "ScannerRun_attempt_scope_fkey") + findings NormalizedFinding[] + // The mapped ingress scope index and relation FK are installed by the mandatory, + // blocking prisma:online-schema step before new-version traffic is admitted. + artifactIngestion SastArtifactIngestion? @@unique([attemptId, scanner]) + @@unique([id, attemptId, tenantId, repositoryBindingId, scanRequestId], map: "ScannerRun_ingress_scope_key") @@index([attemptId]) @@index([tenantId]) @@index([scanRequestId]) @@ -605,6 +621,42 @@ model ScannerRun { @@index([status]) } +model SastArtifactIngestion { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + scannerRunId String @unique + workloadIdentityRef String + idempotencyKey String + envelopeDigest String + declaredContentDigest String + observedContentDigest String? + declaredByteSize Int + observedByteSize Int? + objectKey String? @unique + identityValidated Boolean @default(false) + status SastArtifactIngestionStatus @default(RECEIVING) + validationMetadata Json? + rejectionReason String? + receivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "SastArtifactIngestion_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastArtifactIngestion_scan_scope_fkey") + attempt SastScanAttempt @relation(fields: [attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "SastArtifactIngestion_attempt_scope_fkey") + scannerRun ScannerRun @relation(fields: [scannerRunId, attemptId, tenantId, repositoryBindingId, scanRequestId], references: [id, attemptId, tenantId, repositoryBindingId, scanRequestId], onDelete: Cascade, map: "SastArtifactIngestion_scanner_run_scope_fkey") + + @@unique([tenantId, scanRequestId, idempotencyKey], map: "SastArtifactIngestion_idempotency_scope_key") + @@unique([scannerRunId, attemptId, tenantId, repositoryBindingId, scanRequestId], map: "SastArtifactIngestion_scanner_scope_key") + @@index([tenantId, scanRequestId, status]) + @@index([attemptId, status]) + @@index([createdAt]) +} + model NormalizedFinding { id String @id @default(uuid()) tenantId String diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs index eac015a..bfb2061 100644 --- a/apps/api/scripts/apply-online-sast-runtime-schema.mjs +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -26,6 +26,12 @@ const indexes = [ unique: true, create: 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AuditEvent_final_attempt_scope_key" ON "AuditEvent"("id", "attemptId", "tenantId")' + }, + { + name: 'ScannerRun_ingress_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ScannerRun_ingress_scope_key" ON "ScannerRun"("id", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId")' } ]; @@ -53,7 +59,7 @@ const constraints = [ }, { table: 'ScannerRun', - name: 'ScannerRun_runtime_metadata_check', + name: 'ScannerRun_runtime_metadata_v2_check', type: 'c', definition: `CHECK ( "attemptId" IS NULL @@ -62,7 +68,7 @@ const constraints = [ "repositoryBindingId" IS NOT NULL AND "required" = true AND "scanner" IN ('OPENGREP', 'TRIVY', 'SYFT') - AND "status"::text IN ('COMPLETED', 'FAILED', 'TIMED_OUT', 'QUARANTINED', 'KILLED') + AND "status"::text IN ('RUNNING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'QUARANTINED', 'KILLED') AND "wrapperDigest" ~ '^sha256:[a-f0-9]{64}$' AND "scannerImageDigest" ~ '^sha256:[a-f0-9]{64}$' AND "scannerSetDigest" ~ '^sha256:[a-f0-9]{64}$' @@ -70,7 +76,10 @@ const constraints = [ AND "profileDigest" ~ '^sha256:[a-f0-9]{64}$' AND char_length("preflightAttestationRef") BETWEEN 1 AND 8192 AND "preflightInventoryDigest" ~ '^sha256:[a-f0-9]{64}$' - AND "scannerWorkspaceInventoryDigest" ~ '^sha256:[a-f0-9]{64}$' + AND ( + "scannerWorkspaceInventoryDigest" IS NULL + OR "scannerWorkspaceInventoryDigest" ~ '^sha256:[a-f0-9]{64}$' + ) AND ( ( "scanner" = 'OPENGREP' @@ -92,20 +101,42 @@ const constraints = [ ) ) AND "artifactSchemaVersion" ~ '^sha256:[a-f0-9]{64}$' - AND "exitCode" IS NOT NULL - AND "timedOut" IS NOT NULL - AND "outputLimitExceeded" IS NOT NULL - AND "durationMilliseconds" IS NOT NULL - AND jsonb_typeof("stdoutMetadata") = 'object' - AND jsonb_typeof("stderrMetadata") = 'object' - AND jsonb_typeof("resourceMetadata") = 'object' + AND "startedAt" IS NOT NULL AND ( - "status" <> 'COMPLETED' + ( + "status" = 'RUNNING' + AND "completedAt" IS NULL + AND "exitCode" IS NULL + AND "timedOut" IS NULL + AND "outputLimitExceeded" IS NULL + AND "durationMilliseconds" IS NULL + AND jsonb_typeof("artifactMetadata") = 'object' + AND char_length("artifactMetadata" ->> 'artifactRef') BETWEEN 1 AND 2048 + AND ( + "rawArtifactObjectKey" IS NULL + OR char_length("rawArtifactObjectKey") BETWEEN 1 AND 2048 + ) + ) OR ( - jsonb_typeof("artifactMetadata") = 'object' - AND jsonb_typeof("artifactMetadata" -> 'byteSize') = 'number' - AND ("artifactMetadata" ->> 'byteSize')::numeric > 0 - AND char_length("rawArtifactObjectKey") BETWEEN 1 AND 2048 + "status" IN ('COMPLETED', 'FAILED', 'TIMED_OUT', 'QUARANTINED', 'KILLED') + AND "completedAt" IS NOT NULL + AND "completedAt" >= "startedAt" + AND "exitCode" IS NOT NULL + AND "timedOut" IS NOT NULL + AND "outputLimitExceeded" IS NOT NULL + AND "durationMilliseconds" IS NOT NULL + AND jsonb_typeof("stdoutMetadata") = 'object' + AND jsonb_typeof("stderrMetadata") = 'object' + AND jsonb_typeof("resourceMetadata") = 'object' + AND ( + "status" <> 'COMPLETED' + OR ( + jsonb_typeof("artifactMetadata") = 'object' + AND jsonb_typeof("artifactMetadata" -> 'byteSize') = 'number' + AND ("artifactMetadata" ->> 'byteSize')::numeric > 0 + AND char_length("rawArtifactObjectKey") BETWEEN 1 AND 2048 + ) + ) ) ) ), @@ -126,6 +157,21 @@ const constraints = [ type: 'f', definition: 'FOREIGN KEY ("finalAuditEventId", "id", "tenantId") REFERENCES "AuditEvent"("id", "attemptId", "tenantId") ON DELETE NO ACTION ON UPDATE NO ACTION' + }, + { + table: 'SastArtifactIngestion', + name: 'SastArtifactIngestion_scanner_run_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("scannerRunId", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId") REFERENCES "ScannerRun"("id", "attemptId", "tenantId", "repositoryBindingId", "scanRequestId") ON DELETE CASCADE ON UPDATE CASCADE' + } +]; + +const supersededConstraints = [ + { + table: 'ScannerRun', + name: 'ScannerRun_runtime_metadata_check', + replacement: 'ScannerRun_runtime_metadata_v2_check' } ]; @@ -217,6 +263,36 @@ async function readConstraint(table, name) { return rows[0]; } +async function dropSupersededConstraint(constraint) { + assertIdentifier(constraint.table); + assertIdentifier(constraint.name); + assertIdentifier(constraint.replacement); + const replacement = await readConstraint( + constraint.table, + constraint.replacement + ); + if (!replacement?.validated) { + throw new Error( + `Replacement constraint ${constraint.replacement} is not validated.` + ); + } + + const existing = await readConstraint(constraint.table, constraint.name); + if (existing) { + await prisma.$executeRawUnsafe( + `ALTER TABLE "${constraint.table}" DROP CONSTRAINT "${constraint.name}"` + ); + } + if (await readConstraint(constraint.table, constraint.name)) { + throw new Error( + `Superseded constraint ${constraint.name} was not removed.` + ); + } + process.stdout.write( + `superseded constraint removed: ${constraint.name}\n` + ); +} + function assertIdentifier(value) { if (!/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(value)) { throw new Error('Online schema identifier is invalid.'); @@ -230,6 +306,9 @@ async function main() { for (const constraint of constraints) { await applyConstraint(constraint); } + for (const constraint of supersededConstraints) { + await dropSupersededConstraint(constraint); + } } try { diff --git a/apps/api/src/scan-plane/current-sast-workload-identity.decorator.ts b/apps/api/src/scan-plane/current-sast-workload-identity.decorator.ts new file mode 100644 index 0000000..e91289c --- /dev/null +++ b/apps/api/src/scan-plane/current-sast-workload-identity.decorator.ts @@ -0,0 +1,21 @@ +import { + createParamDecorator, + type ExecutionContext +} from '@nestjs/common'; + +import type { AuthenticatedSastWorkloadIdentity } from './sast-workload-identity.authenticator'; +import type { SastWorkloadIdentityRequest } from './sast-workload-identity.guard'; + +export const CurrentSastWorkloadIdentity = createParamDecorator( + ( + _data: unknown, + context: ExecutionContext + ): Readonly => { + const request = + context.switchToHttp().getRequest(); + if (!request.sastWorkloadIdentity) { + throw new Error('SAST workload identity guard did not populate request context.'); + } + return request.sastWorkloadIdentity; + } +); diff --git a/apps/api/src/scan-plane/prisma-sast-artifact-ingress.store.ts b/apps/api/src/scan-plane/prisma-sast-artifact-ingress.store.ts new file mode 100644 index 0000000..834d070 --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-artifact-ingress.store.ts @@ -0,0 +1,493 @@ +import { + SAST_SCANNER_KINDS, + isSastScanPlanValid, + type SastScannerKind, + type SastScanPlan +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + type AbortSastArtifactIngressInput, + type CompleteSastArtifactIngressInput, + type RejectSastArtifactIngressInput, + type ReserveSastArtifactIngressInput, + SastArtifactIngressReplayConflictError, + SastArtifactIngressReservationRetryError, + type SastArtifactIngressReservation, + type SastArtifactIngressRejectionAudit, + SastArtifactIngressStateConflictError, + type SastArtifactIngressExpectedBinding, + SastArtifactIngressStore +} from './sast-artifact-ingress.store'; + +@Injectable() +export class PrismaSastArtifactIngressStore + extends SastArtifactIngressStore +{ + constructor(private readonly prisma: PrismaService) { + super(); + } + + async loadExpectedBinding( + scanRequestId: string, + scannerRunId: string + ): Promise { + const scannerRun = await this.prisma.scannerRun.findFirst({ + where: { + id: scannerRunId, + scanRequestId + }, + select: { + id: true, + scanner: true, + status: true, + artifactMetadata: true, + preflightAttestationRef: true, + preflightInventoryDigest: true, + attempt: { + select: { + id: true, + workloadIdentityRef: true, + stage: true, + attemptDeadlineAt: true + } + }, + scanRequest: { + select: { + sastQueueReservation: { + select: { + immutablePlan: true + } + } + } + } + } + }); + + const plan = scannerRun?.scanRequest.sastQueueReservation + ?.immutablePlan as unknown as SastScanPlan | undefined; + const artifactMetadata = this.asRecord(scannerRun?.artifactMetadata); + const artifactRef = artifactMetadata?.artifactRef; + if ( + !scannerRun?.attempt || + !plan || + !isSastScanPlanValid(plan) || + !SAST_SCANNER_KINDS.includes(scannerRun.scanner as SastScannerKind) || + typeof scannerRun.preflightAttestationRef !== 'string' || + !this.isDigest(scannerRun.preflightInventoryDigest) || + typeof artifactRef !== 'string' || + artifactRef.length === 0 + ) { + return null; + } + + return { + plan, + attemptId: scannerRun.attempt.id, + scannerRunId: scannerRun.id, + workloadIdentityRef: scannerRun.attempt.workloadIdentityRef, + preflightAttestationRef: scannerRun.preflightAttestationRef, + preflightInventoryDigest: scannerRun.preflightInventoryDigest, + scanner: scannerRun.scanner as SastScannerKind, + artifactRef, + attemptStage: scannerRun.attempt.stage, + attemptDeadlineAt: scannerRun.attempt.attemptDeadlineAt.toISOString(), + scannerRunStatus: scannerRun.status + }; + } + + async reserve( + input: Readonly + ): Promise { + try { + return await this.prisma.$transaction( + async (transaction) => { + const attempt = await transaction.sastScanAttempt.findFirst({ + where: { + id: input.expected.attemptId, + tenantId: input.expected.plan.tenantId, + repositoryBindingId: + input.expected.plan.repositoryState.repositoryBindingId, + scanRequestId: input.expected.plan.scanRequestId, + workloadIdentityRef: input.expected.workloadIdentityRef, + stage: 'SCANNING', + attemptDeadlineAt: { + gt: new Date(input.now) + } + }, + select: { id: true } + }); + if (!attempt) { + throw new SastArtifactIngressStateConflictError(); + } + + const existing = + await transaction.sastArtifactIngestion.findUnique({ + where: { + scannerRunId: input.expected.scannerRunId + } + }); + if (existing) { + return this.replay(existing, input); + } + + const scannerRun = await transaction.scannerRun.findFirst({ + where: { + id: input.expected.scannerRunId, + attemptId: input.expected.attemptId, + tenantId: input.expected.plan.tenantId, + repositoryBindingId: + input.expected.plan.repositoryState.repositoryBindingId, + scanRequestId: input.expected.plan.scanRequestId, + scanner: input.expected.scanner, + status: 'RUNNING' + }, + select: { id: true } + }); + if (!scannerRun) { + throw new SastArtifactIngressStateConflictError(); + } + + await transaction.sastArtifactIngestion.create({ + data: { + id: input.ingestionId, + tenantId: input.expected.plan.tenantId, + repositoryBindingId: + input.expected.plan.repositoryState.repositoryBindingId, + scanRequestId: input.expected.plan.scanRequestId, + attemptId: input.expected.attemptId, + scannerRunId: input.expected.scannerRunId, + workloadIdentityRef: input.expected.workloadIdentityRef, + idempotencyKey: input.idempotencyKey, + envelopeDigest: input.envelopeDigest, + declaredContentDigest: input.declaredContentDigest, + declaredByteSize: input.declaredByteSize, + identityValidated: true, + status: 'RECEIVING', + validationMetadata: { + workloadIdentityValidated: true + } + } + }); + await transaction.auditEvent.create({ + data: { + tenantId: input.expected.plan.tenantId, + scanRequestId: input.expected.plan.scanRequestId, + attemptId: input.expected.attemptId, + eventType: 'artifact.ingress_reserved', + actor: input.expected.workloadIdentityRef, + targetType: 'sast_artifact_ingestion', + targetId: input.ingestionId, + occurredAt: new Date(input.now), + metadata: { + scannerRunId: input.expected.scannerRunId, + scanner: input.expected.scanner, + envelopeDigest: input.envelopeDigest, + declaredByteSize: input.declaredByteSize, + workloadIdentityValidated: true + } + } + }); + + return { + kind: 'RESERVED' as const, + ingestionId: input.ingestionId, + state: 'RECEIVING' as const + }; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable + } + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + throw new SastArtifactIngressReservationRetryError(); + } + if (this.isUniqueViolation(error)) { + const existing = await this.prisma.sastArtifactIngestion.findUnique({ + where: { + scannerRunId: input.expected.scannerRunId + } + }); + if (existing) { + return this.replay(existing, input); + } + throw new SastArtifactIngressReplayConflictError(); + } + throw error; + } + } + + async complete( + input: Readonly + ): Promise { + await this.prisma.$transaction( + async (transaction) => { + const ingestion = await transaction.sastArtifactIngestion.findUnique({ + where: { id: input.ingestionId }, + select: { + tenantId: true, + scanRequestId: true, + repositoryBindingId: true, + attemptId: true, + scannerRunId: true, + workloadIdentityRef: true, + status: true + } + }); + if (!ingestion || ingestion.status !== 'RECEIVING') { + throw new SastArtifactIngressStateConflictError(); + } + const activeAttempt = await transaction.sastScanAttempt.findFirst({ + where: { + id: ingestion.attemptId, + tenantId: ingestion.tenantId, + repositoryBindingId: ingestion.repositoryBindingId, + scanRequestId: ingestion.scanRequestId, + stage: 'SCANNING', + attemptDeadlineAt: { + gt: new Date(input.receivedAt) + } + }, + select: { id: true } + }); + if (!activeAttempt) { + throw new SastArtifactIngressStateConflictError(); + } + + const update = await transaction.sastArtifactIngestion.updateMany({ + where: { + id: input.ingestionId, + status: 'RECEIVING' + }, + data: { + objectKey: input.objectKey, + observedContentDigest: input.observedContentDigest, + observedByteSize: input.observedByteSize, + status: 'PENDING_VALIDATION', + receivedAt: new Date(input.receivedAt), + validationMetadata: { + workloadIdentityValidated: true, + transportByteCountValidated: true + } + } + }); + if (update.count !== 1) { + throw new SastArtifactIngressStateConflictError(); + } + const scannerRunUpdate = await transaction.scannerRun.updateMany({ + where: { + id: ingestion.scannerRunId, + tenantId: ingestion.tenantId, + scanRequestId: ingestion.scanRequestId, + attemptId: ingestion.attemptId, + status: 'RUNNING', + rawArtifactObjectKey: null + }, + data: { + rawArtifactObjectKey: input.objectKey + } + }); + if (scannerRunUpdate.count !== 1) { + throw new SastArtifactIngressStateConflictError(); + } + + await transaction.auditEvent.create({ + data: { + tenantId: ingestion.tenantId, + scanRequestId: ingestion.scanRequestId, + attemptId: ingestion.attemptId, + eventType: 'artifact.ingress_received', + actor: ingestion.workloadIdentityRef, + targetType: 'sast_artifact_ingestion', + targetId: input.ingestionId, + occurredAt: new Date(input.receivedAt), + metadata: { + scannerRunId: ingestion.scannerRunId, + observedContentDigest: input.observedContentDigest, + observedByteSize: input.observedByteSize, + nextState: 'PENDING_VALIDATION' + } + } + }); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable + } + ); + } + + async reject( + input: Readonly + ): Promise { + await this.prisma.$transaction(async (transaction) => { + const ingestion = await transaction.sastArtifactIngestion.findUnique({ + where: { id: input.ingestionId }, + select: { + tenantId: true, + scanRequestId: true, + attemptId: true, + scannerRunId: true, + workloadIdentityRef: true, + status: true + } + }); + if (!ingestion || ingestion.status !== 'RECEIVING') { + throw new SastArtifactIngressStateConflictError(); + } + + await transaction.sastArtifactIngestion.update({ + where: { id: input.ingestionId }, + data: { + status: 'REJECTED', + rejectionReason: input.reasonCode, + observedContentDigest: input.observedContentDigest, + observedByteSize: input.observedByteSize, + receivedAt: new Date(input.rejectedAt), + validationMetadata: { + workloadIdentityValidated: true, + transportByteCountValidated: false + } + } + }); + await transaction.auditEvent.create({ + data: { + tenantId: ingestion.tenantId, + scanRequestId: ingestion.scanRequestId, + attemptId: ingestion.attemptId, + eventType: 'artifact.ingress_rejected', + actor: ingestion.workloadIdentityRef, + targetType: 'sast_artifact_ingestion', + targetId: input.ingestionId, + occurredAt: new Date(input.rejectedAt), + metadata: { + scannerRunId: ingestion.scannerRunId, + reasonCode: input.reasonCode, + observedByteSize: input.observedByteSize + } + } + }); + }); + } + + async abort( + input: Readonly + ): Promise { + await this.prisma.$transaction(async (transaction) => { + const ingestion = await transaction.sastArtifactIngestion.findUnique({ + where: { id: input.ingestionId }, + select: { + tenantId: true, + scanRequestId: true, + attemptId: true, + scannerRunId: true, + status: true + } + }); + if (!ingestion || ingestion.status !== 'RECEIVING') { + return; + } + + await transaction.sastArtifactIngestion.delete({ + where: { id: input.ingestionId } + }); + await transaction.auditEvent.create({ + data: { + tenantId: ingestion.tenantId, + scanRequestId: ingestion.scanRequestId, + attemptId: ingestion.attemptId, + eventType: 'artifact.ingress_aborted', + actor: 'scan-plane', + targetType: 'scanner_run', + targetId: ingestion.scannerRunId, + occurredAt: new Date(input.occurredAt), + metadata: { + reasonCode: input.reasonCode + } + } + }); + }); + } + + async recordRejectedRequest( + input: Readonly + ): Promise { + await this.prisma.auditEvent.create({ + data: { + tenantId: input.expected.plan.tenantId, + scanRequestId: input.expected.plan.scanRequestId, + attemptId: input.expected.attemptId, + eventType: 'artifact.ingress_rejected', + actor: input.certificateFingerprint, + targetType: 'scanner_run', + targetId: input.expected.scannerRunId, + occurredAt: new Date(input.occurredAt), + metadata: { + reasonCode: input.reasonCode, + workloadIdentityValidated: input.workloadIdentityValidated + } + } + }); + } + + private replay( + existing: { + id: string; + envelopeDigest: string; + idempotencyKey: string; + declaredContentDigest: string; + declaredByteSize: number; + status: string; + receivedAt: Date | null; + }, + input: Readonly + ): SastArtifactIngressReservation { + if ( + existing.envelopeDigest !== input.envelopeDigest || + existing.idempotencyKey !== input.idempotencyKey || + existing.declaredContentDigest !== input.declaredContentDigest || + existing.declaredByteSize !== input.declaredByteSize + ) { + throw new SastArtifactIngressReplayConflictError(); + } + if ( + existing.status !== 'PENDING_VALIDATION' || + existing.receivedAt === null + ) { + throw new SastArtifactIngressStateConflictError(); + } + + return { + kind: 'REPLAY', + ingestionId: existing.id, + state: 'PENDING_VALIDATION', + receivedAt: existing.receivedAt.toISOString() + }; + } + + private isUniqueViolation( + error: unknown + ): error is Prisma.PrismaClientKnownRequestError { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ); + } + + private asRecord( + value: Prisma.JsonValue | null | undefined + ): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; + } + + private isDigest(value: string | null): value is `sha256:${string}` { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/u.test(value); + } +} diff --git a/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts b/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts index 01a6d55..e9c540c 100644 --- a/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts +++ b/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts @@ -5,6 +5,7 @@ import { buildSastScanPlanDigestPreimage, isSastScanPlanValid, type SastScannerExecutionRecord, + type SastScannerInvocation, type SastScannerRuntimeAuditSignal, type SastScannerWrapperExecutionRequest, type SastScanPlan @@ -210,85 +211,158 @@ export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { ): Promise { const allowedPriorStages: SastScanAttemptStage[] = stage === 'SCANNING' ? ['VALIDATING'] : ['VALIDATING', 'SCANNING']; - const result = await this.prisma.sastScanAttempt.updateMany({ + const transition = async ( + client: Pick + ) => { + if (stage === 'CLEANUP_PENDING') { + const activeIngress = await client.sastArtifactIngestion.count({ + where: { + tenantId: request.plan.tenantId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + status: 'RECEIVING' + } + }); + if (activeIngress !== 0) { + throw securityViolation( + 'ARTIFACT_INGRESS_STILL_RECEIVING', + 'Cleanup cannot start while an artifact ingress is receiving bytes.' + ); + } + } + + const result = await client.sastScanAttempt.updateMany({ + where: { + id: request.attemptId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + stage: { in: allowedPriorStages } + }, + data: { stage } + }); + if (result.count !== 1) { + throw securityViolation( + 'SCAN_ATTEMPT_STAGE_CONFLICT', + 'Scan attempt stage transition was rejected.' + ); + } + }; + + if (stage === 'SCANNING') { + await transition(this.prisma); + return; + } + + try { + await this.prisma.$transaction( + async (transaction) => transition(transaction), + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable + } + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + throw retryableInfrastructureFailure( + 'ARTIFACT_INGRESS_STAGE_SERIALIZATION_CONFLICT', + 'Artifact ingress and cleanup stage transition must be retried.' + ); + } + throw error; + } + } + + async recordScannerRun( + request: Readonly, + record: Readonly + ): Promise { + const startedAt = new Date(record.observation.startedAt); + const completedAt = new Date(record.observation.completedAt); + const durationMilliseconds = completedAt.getTime() - startedAt.getTime(); + + const result = await this.prisma.scannerRun.updateMany({ where: { - id: request.attemptId, + id: record.scannerRunId, tenantId: request.plan.tenantId, repositoryBindingId: request.plan.repositoryState.repositoryBindingId, scanRequestId: request.plan.scanRequestId, - sandboxId: request.sandboxId, - workloadIdentityRef: request.workloadIdentityRef, - stage: { in: allowedPriorStages } + attemptId: request.attemptId, + scanner: record.invocation.scanner, + status: 'RUNNING' }, - data: { stage } + data: { + scannerWorkspaceInventoryDigest: + record.observation.scannerWorkspaceInventoryDigest, + exitCode: record.observation.exitCode, + terminationSignal: record.observation.terminationSignal, + timedOut: record.observation.timedOut, + outputLimitExceeded: record.observation.outputLimitExceeded, + durationMilliseconds, + stdoutMetadata: + record.observation.stdout as unknown as Prisma.InputJsonValue, + stderrMetadata: + record.observation.stderr as unknown as Prisma.InputJsonValue, + resourceMetadata: + record.observation.resources as unknown as Prisma.InputJsonValue, + artifactMetadata: record.observation.artifact + ? (record.observation.artifact as unknown as Prisma.InputJsonValue) + : Prisma.JsonNull, + status: this.scannerRunStatus(record.status), + errorMessage: + record.status === 'SUCCEEDED' ? null : record.status, + startedAt, + completedAt + } }); if (result.count !== 1) { throw securityViolation( - 'SCAN_ATTEMPT_STAGE_CONFLICT', - 'Scan attempt stage transition was rejected.' + 'SCANNER_RUN_STATE_CONFLICT', + 'Scanner terminal state did not match one running scanner record.' ); } } - async recordScannerRun( + async beginScannerRun( request: Readonly, - record: Readonly + scannerRunId: string, + invocation: Readonly, + startedAt: string ): Promise { - const startedAt = new Date(record.observation.startedAt); - const completedAt = new Date(record.observation.completedAt); - const durationMilliseconds = completedAt.getTime() - startedAt.getTime(); - try { await this.prisma.scannerRun.create({ data: { - id: record.scannerRunId, + id: scannerRunId, tenantId: request.plan.tenantId, scanRequestId: request.plan.scanRequestId, repositoryBindingId: request.plan.repositoryState.repositoryBindingId, attemptId: request.attemptId, - scanner: record.invocation.scanner, - scannerVersion: record.invocation.scannerVersion, - required: record.invocation.required, - wrapperDigest: record.invocation.wrapperDigest, - scannerImageDigest: record.invocation.scannerImageDigest, - ruleBundleDigest: record.invocation.ruleBundleDigest, - databaseDigest: - record.invocation.vulnerabilityDatabaseDigest, - scannerSetDigest: record.invocation.scannerSetDigest, - profileId: record.invocation.profileId, - profileDigest: record.invocation.profileDigest, - preflightAttestationRef: - record.invocation.preflightAttestationRef, - preflightInventoryDigest: - record.invocation.preflightInventoryDigest, - scannerWorkspaceInventoryDigest: - record.observation.scannerWorkspaceInventoryDigest, - artifactSchema: record.invocation.artifactSchema, - artifactSchemaVersion: - record.invocation.artifactSchemaVersion, - exitCode: record.observation.exitCode, - terminationSignal: record.observation.terminationSignal, - timedOut: record.observation.timedOut, - outputLimitExceeded: record.observation.outputLimitExceeded, - durationMilliseconds, - stdoutMetadata: - record.observation.stdout as unknown as Prisma.InputJsonValue, - stderrMetadata: - record.observation.stderr as unknown as Prisma.InputJsonValue, - resourceMetadata: - record.observation.resources as unknown as Prisma.InputJsonValue, - artifactMetadata: record.observation.artifact - ? (record.observation.artifact as unknown as Prisma.InputJsonValue) - : undefined, - status: this.scannerRunStatus(record.status), - rawArtifactObjectKey: - record.observation.artifact?.artifactRef, - errorMessage: - record.status === 'SUCCEEDED' ? null : record.status, - startedAt, - completedAt + scanner: invocation.scanner, + scannerVersion: invocation.scannerVersion, + required: invocation.required, + wrapperDigest: invocation.wrapperDigest, + scannerImageDigest: invocation.scannerImageDigest, + ruleBundleDigest: invocation.ruleBundleDigest, + databaseDigest: invocation.vulnerabilityDatabaseDigest, + scannerSetDigest: invocation.scannerSetDigest, + profileId: invocation.profileId, + profileDigest: invocation.profileDigest, + preflightAttestationRef: invocation.preflightAttestationRef, + preflightInventoryDigest: invocation.preflightInventoryDigest, + artifactSchema: invocation.artifactSchema, + artifactSchemaVersion: invocation.artifactSchemaVersion, + artifactMetadata: { + artifactRef: `${request.plan.resultIngressRef}/${invocation.scanner.toLowerCase()}` + }, + status: 'RUNNING', + startedAt: new Date(startedAt) } }); } catch (error) { @@ -302,6 +376,80 @@ export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { } } + async failScannerRun( + request: Readonly, + scannerRunId: string, + invocation: Readonly, + reasonCode: string, + completedAt: string + ): Promise { + const terminalAt = new Date(completedAt); + const started = await this.prisma.scannerRun.findFirst({ + where: { + id: scannerRunId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + scanner: invocation.scanner, + status: 'RUNNING' + }, + select: { startedAt: true } + }); + if (!started?.startedAt) { + return; + } + + const timedOut = /(?:DEADLINE|TIMEOUT)/u.test(reasonCode); + const result = await this.prisma.scannerRun.updateMany({ + where: { + id: scannerRunId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + scanner: invocation.scanner, + status: 'RUNNING' + }, + data: { + scannerWorkspaceInventoryDigest: + invocation.preflightInventoryDigest, + exitCode: -1, + timedOut, + outputLimitExceeded: false, + durationMilliseconds: Math.max( + 0, + terminalAt.getTime() - started.startedAt.getTime() + ), + stdoutMetadata: { + byteSize: 0, + truncated: false, + secretRedactionApplied: true + }, + stderrMetadata: { + byteSize: 0, + truncated: false, + secretRedactionApplied: true + }, + resourceMetadata: { + unavailable: true + }, + artifactMetadata: Prisma.JsonNull, + status: timedOut ? 'TIMED_OUT' : 'FAILED', + errorMessage: reasonCode, + completedAt: terminalAt + } + }); + if (result.count !== 1) { + throw securityViolation( + 'SCANNER_RUN_STATE_CONFLICT', + 'Scanner failure did not match one running scanner record.' + ); + } + } + async recordAuditSignal( signal: Readonly ): Promise { diff --git a/apps/api/src/scan-plane/sast-artifact-ingress.controller.ts b/apps/api/src/scan-plane/sast-artifact-ingress.controller.ts new file mode 100644 index 0000000..c6d4146 --- /dev/null +++ b/apps/api/src/scan-plane/sast-artifact-ingress.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Headers, + HttpCode, + HttpStatus, + Param, + Put, + Req, + UseGuards +} from '@nestjs/common'; +import { + SAST_ARTIFACT_ENVELOPE_HEADER, + SAST_ARTIFACT_IDEMPOTENCY_HEADER +} from '@aegisai/shared'; +import type { Request } from 'express'; + +import { CurrentSastWorkloadIdentity } from './current-sast-workload-identity.decorator'; +import { SastArtifactIngressPathDto } from './scan-plane.dto'; +import { SastArtifactIngressService } from './sast-artifact-ingress.service'; +import type { AuthenticatedSastWorkloadIdentity } from './sast-workload-identity.authenticator'; +import { SastWorkloadIdentityGuard } from './sast-workload-identity.guard'; + +@Controller('scan-plane/result-ingress') +@UseGuards(SastWorkloadIdentityGuard) +export class SastArtifactIngressController { + constructor(private readonly ingress: SastArtifactIngressService) {} + + @Put(':scanRequestId/scanner-runs/:scannerRunId') + @HttpCode(HttpStatus.ACCEPTED) + ingestArtifact( + @Param() params: SastArtifactIngressPathDto, + @Headers(SAST_ARTIFACT_ENVELOPE_HEADER) + envelopeHeader: string | undefined, + @Headers(SAST_ARTIFACT_IDEMPOTENCY_HEADER) + idempotencyKey: string | undefined, + @Headers('content-type') contentType: string | undefined, + @Headers('content-length') contentLength: string | undefined, + @CurrentSastWorkloadIdentity() + workloadIdentity: Readonly, + @Req() request: Request + ) { + return this.ingress.ingest({ + scanRequestId: params.scanRequestId, + scannerRunId: params.scannerRunId, + envelopeHeader, + idempotencyKey, + contentType, + contentLength, + workloadIdentity, + body: request + }); + } +} diff --git a/apps/api/src/scan-plane/sast-artifact-ingress.service.ts b/apps/api/src/scan-plane/sast-artifact-ingress.service.ts new file mode 100644 index 0000000..e31c7d3 --- /dev/null +++ b/apps/api/src/scan-plane/sast-artifact-ingress.service.ts @@ -0,0 +1,487 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { TextDecoder } from 'node:util'; + +import { + SAST_ARTIFACT_INGRESS_MEDIA_TYPE, + SAST_MAX_ARTIFACT_ENVELOPE_BYTES, + buildSastArtifactIngressIdempotencyKey, + canonicalizeScannerArtifactEnvelope, + isScannerArtifactEnvelopeBoundToPlan, + isScannerArtifactEnvelopeShapeValid, + type SastArtifactIngressReceipt, + type ScannerArtifactEnvelope +} from '@aegisai/shared'; +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, + PayloadTooLargeException, + ServiceUnavailableException, + UnsupportedMediaTypeException +} from '@nestjs/common'; + +import type { AuthenticatedSastWorkloadIdentity } from './sast-workload-identity.authenticator'; +import { + SastArtifactIngressReplayConflictError, + SastArtifactIngressReservationRetryError, + SastArtifactIngressStateConflictError, + SastArtifactIngressStore, + type SastArtifactIngressExpectedBinding +} from './sast-artifact-ingress.store'; +import { + SastArtifactObjectStore, + SastArtifactObjectStoreUnavailableError +} from './sast-artifact-object-store'; + +const MAX_BASE64URL_ENVELOPE_BYTES = + Math.ceil((SAST_MAX_ARTIFACT_ENVELOPE_BYTES * 4) / 3) + 4; + +export interface IngestSastArtifactInput { + scanRequestId: string; + scannerRunId: string; + envelopeHeader: string | undefined; + idempotencyKey: string | undefined; + contentType: string | undefined; + contentLength: string | undefined; + workloadIdentity: Readonly; + body: AsyncIterable; +} + +class SastArtifactTransportError extends Error { + constructor( + readonly reasonCode: + | 'ARTIFACT_BODY_EXCEEDS_DECLARED_SIZE' + | 'ARTIFACT_BODY_SIZE_MISMATCH' + ) { + super(reasonCode); + this.name = 'SastArtifactTransportError'; + } +} + +@Injectable() +export class SastArtifactIngressService { + constructor( + private readonly store: SastArtifactIngressStore, + private readonly objectStore: SastArtifactObjectStore + ) {} + + async ingest( + input: Readonly + ): Promise { + this.assertMediaType(input.contentType); + const envelope = this.decodeEnvelope(input.envelopeHeader); + if ( + envelope.scanRequestId !== input.scanRequestId || + envelope.scannerRunId !== input.scannerRunId + ) { + throw this.badRequest( + 'ARTIFACT_INGRESS_PATH_MISMATCH', + 'Artifact envelope does not match the ingress path.' + ); + } + + const expected = await this.store.loadExpectedBinding( + input.scanRequestId, + input.scannerRunId + ); + if (!expected) { + throw new NotFoundException({ + errorCode: 'ARTIFACT_INGRESS_NOT_FOUND', + message: 'The artifact ingress binding does not exist.' + }); + } + + await this.assertIdentity(input.workloadIdentity, envelope, expected); + this.assertIngressOpen(expected); + await this.assertEnvelopeBinding( + envelope, + expected, + input.workloadIdentity + ); + this.assertContentLength( + input.contentLength, + envelope.byteSize, + expected.plan.profile.limits.maxArtifactBytes + ); + + const requiredIdempotencyKey = + buildSastArtifactIngressIdempotencyKey(envelope); + if (input.idempotencyKey !== requiredIdempotencyKey) { + throw this.badRequest( + 'ARTIFACT_IDEMPOTENCY_KEY_INVALID', + 'Artifact idempotency key is missing or does not match the envelope.' + ); + } + + const canonicalEnvelope = canonicalizeScannerArtifactEnvelope(envelope); + const envelopeDigest = this.digest(canonicalEnvelope); + const ingestionId = `sast_ingestion_${randomUUID()}`; + const now = new Date().toISOString(); + + let reservation; + try { + reservation = await this.store.reserve({ + ingestionId, + envelopeDigest, + idempotencyKey: requiredIdempotencyKey, + expected, + declaredContentDigest: envelope.contentDigest, + declaredByteSize: envelope.byteSize, + now + }); + } catch (error) { + if (error instanceof SastArtifactIngressReplayConflictError) { + throw new ConflictException({ + errorCode: 'ARTIFACT_INGRESS_REPLAY_CONFLICT', + message: 'A different artifact was already submitted for this scanner run.' + }); + } + if (error instanceof SastArtifactIngressStateConflictError) { + throw new ConflictException({ + errorCode: 'ARTIFACT_INGRESS_CLOSED', + message: 'The artifact ingress lifecycle is not open for this scanner run.' + }); + } + if (error instanceof SastArtifactIngressReservationRetryError) { + throw this.unavailable( + 'ARTIFACT_INGRESS_RESERVATION_RETRY', + 'Artifact ingress reservation must be retried.' + ); + } + throw error; + } + + if (reservation.kind === 'REPLAY') { + await this.drainReplayBody(input.body, envelope.byteSize); + return { + ingestionId: reservation.ingestionId, + scannerRunId: envelope.scannerRunId, + state: 'PENDING_VALIDATION', + replayed: true, + receivedAt: reservation.receivedAt! + }; + } + + const observation = { + byteSize: 0, + hash: createHash('sha256') + }; + let objectKey: string | undefined; + try { + const write = await this.objectStore.put({ + ingestionId, + tenantId: envelope.tenantId, + repositoryBindingId: envelope.repositoryBindingId, + scanRequestId: envelope.scanRequestId, + attemptId: envelope.attemptId, + scannerRunId: envelope.scannerRunId, + body: this.observeBody(input.body, envelope.byteSize, observation) + }); + objectKey = this.validateObjectKey(write.objectKey); + if (observation.byteSize !== envelope.byteSize) { + throw new SastArtifactTransportError( + 'ARTIFACT_BODY_SIZE_MISMATCH' + ); + } + + const receivedAt = new Date().toISOString(); + const observedContentDigest = + `sha256:${observation.hash.digest('hex')}` as const; + await this.store.complete({ + ingestionId, + objectKey, + observedContentDigest, + observedByteSize: observation.byteSize, + receivedAt + }); + + return { + ingestionId, + scannerRunId: envelope.scannerRunId, + state: 'PENDING_VALIDATION', + replayed: false, + receivedAt + }; + } catch (error) { + if (error instanceof SastArtifactTransportError) { + const observedContentDigest = + error.reasonCode === 'ARTIFACT_BODY_SIZE_MISMATCH' + ? (`sha256:${observation.hash.digest('hex')}` as const) + : undefined; + await this.store.reject({ + ingestionId, + reasonCode: error.reasonCode, + observedContentDigest, + observedByteSize: observation.byteSize, + rejectedAt: new Date().toISOString() + }); + if (objectKey) { + await this.deleteStoredObject(objectKey); + } + if (error.reasonCode === 'ARTIFACT_BODY_EXCEEDS_DECLARED_SIZE') { + throw new PayloadTooLargeException({ + errorCode: error.reasonCode, + message: 'Artifact body exceeds its declared bounded size.' + }); + } + throw this.badRequest( + error.reasonCode, + 'Artifact body size does not match the envelope.' + ); + } + + const reasonCode = + error instanceof SastArtifactObjectStoreUnavailableError + ? 'ARTIFACT_OBJECT_STORE_UNAVAILABLE' + : 'ARTIFACT_OBJECT_WRITE_FAILED'; + await this.store.abort({ + ingestionId, + reasonCode, + occurredAt: new Date().toISOString() + }); + if (objectKey) { + await this.deleteStoredObject(objectKey); + } + throw this.unavailable( + reasonCode, + 'Artifact object storage is temporarily unavailable.' + ); + } + } + + private decodeEnvelope(header: string | undefined): ScannerArtifactEnvelope { + if ( + typeof header !== 'string' || + header.length === 0 || + Buffer.byteLength(header, 'ascii') > MAX_BASE64URL_ENVELOPE_BYTES || + !/^[A-Za-z0-9_-]+$/u.test(header) + ) { + throw this.badRequest( + 'ARTIFACT_ENVELOPE_HEADER_INVALID', + 'Artifact envelope header is missing or invalid.' + ); + } + + try { + const bytes = Buffer.from(header, 'base64url'); + if ( + bytes.length === 0 || + bytes.length > SAST_MAX_ARTIFACT_ENVELOPE_BYTES || + bytes.toString('base64url') !== header + ) { + throw new Error('non-canonical base64url'); + } + const json = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + const parsed = JSON.parse(json) as unknown; + if ( + !isScannerArtifactEnvelopeShapeValid(parsed) || + canonicalizeScannerArtifactEnvelope(parsed) !== json + ) { + throw new Error('non-canonical envelope'); + } + return Object.freeze(parsed); + } catch { + throw this.badRequest( + 'ARTIFACT_ENVELOPE_HEADER_INVALID', + 'Artifact envelope header is missing or invalid.' + ); + } + } + + private async assertIdentity( + identity: Readonly, + envelope: Readonly, + expected: Readonly + ): Promise { + if ( + identity.identityRef === expected.workloadIdentityRef && + envelope.workloadIdentityRef === expected.workloadIdentityRef + ) { + return; + } + + await this.store.recordRejectedRequest({ + expected, + certificateFingerprint: identity.certificateFingerprint, + reasonCode: 'ARTIFACT_WORKLOAD_IDENTITY_MISMATCH', + workloadIdentityValidated: false, + occurredAt: new Date().toISOString() + }); + throw new ForbiddenException({ + errorCode: 'ARTIFACT_WORKLOAD_IDENTITY_MISMATCH', + message: 'Workload identity does not match the scanner attempt.' + }); + } + + private assertIngressOpen( + expected: Readonly + ): void { + if ( + expected.attemptStage !== 'SCANNING' || + expected.scannerRunStatus !== 'RUNNING' || + Date.parse(expected.attemptDeadlineAt) <= Date.now() + ) { + throw new ConflictException({ + errorCode: 'ARTIFACT_INGRESS_CLOSED', + message: 'The artifact ingress lifecycle is not open for this scanner run.' + }); + } + } + + private async assertEnvelopeBinding( + envelope: Readonly, + expected: Readonly, + identity: Readonly + ): Promise { + const bound = + envelope.scanner === expected.scanner && + envelope.artifactRef === expected.artifactRef && + envelope.artifactRef === + `${expected.plan.resultIngressRef}/${expected.scanner.toLowerCase()}` && + isScannerArtifactEnvelopeBoundToPlan(envelope, expected.plan, { + attemptId: expected.attemptId, + scannerRunId: expected.scannerRunId, + workloadIdentityRef: identity.identityRef, + preflightAttestationRef: expected.preflightAttestationRef, + preflightInventoryDigest: expected.preflightInventoryDigest + }); + if (!bound) { + await this.store.recordRejectedRequest({ + expected, + certificateFingerprint: identity.certificateFingerprint, + reasonCode: 'ARTIFACT_SCOPE_BINDING_MISMATCH', + workloadIdentityValidated: true, + occurredAt: new Date().toISOString() + }); + throw new ForbiddenException({ + errorCode: 'ARTIFACT_SCOPE_BINDING_MISMATCH', + message: 'Artifact envelope does not match the durable scanner binding.' + }); + } + } + + private assertMediaType(contentType: string | undefined): void { + if (contentType?.toLowerCase() !== SAST_ARTIFACT_INGRESS_MEDIA_TYPE) { + throw new UnsupportedMediaTypeException({ + errorCode: 'ARTIFACT_MEDIA_TYPE_INVALID', + message: `Artifact bytes require ${SAST_ARTIFACT_INGRESS_MEDIA_TYPE}.` + }); + } + } + + private assertContentLength( + contentLength: string | undefined, + envelopeByteSize: number, + maximumBytes: number + ): void { + if ( + typeof contentLength !== 'string' || + !/^[1-9][0-9]{0,9}$/u.test(contentLength) + ) { + throw this.badRequest( + 'ARTIFACT_CONTENT_LENGTH_REQUIRED', + 'A positive canonical Content-Length is required.' + ); + } + const declaredLength = Number(contentLength); + if ( + !Number.isSafeInteger(declaredLength) || + declaredLength !== envelopeByteSize + ) { + throw this.badRequest( + 'ARTIFACT_CONTENT_LENGTH_MISMATCH', + 'Content-Length does not match the artifact envelope.' + ); + } + if (declaredLength > maximumBytes) { + throw new PayloadTooLargeException({ + errorCode: 'ARTIFACT_SIZE_LIMIT_EXCEEDED', + message: 'Artifact exceeds the immutable profile byte limit.' + }); + } + } + + private async *observeBody( + body: AsyncIterable, + maximumBytes: number, + observation: { byteSize: number; hash: ReturnType } + ): AsyncGenerator { + for await (const chunk of body) { + const bytes = Buffer.from(chunk); + observation.byteSize += bytes.byteLength; + if (observation.byteSize > maximumBytes) { + observation.byteSize = maximumBytes + 1; + throw new SastArtifactTransportError( + 'ARTIFACT_BODY_EXCEEDS_DECLARED_SIZE' + ); + } + observation.hash.update(bytes); + yield bytes; + } + } + + private async drainReplayBody( + body: AsyncIterable, + expectedBytes: number + ): Promise { + let observedBytes = 0; + for await (const chunk of body) { + observedBytes += chunk.byteLength; + if (observedBytes > expectedBytes) { + throw new PayloadTooLargeException({ + errorCode: 'ARTIFACT_BODY_EXCEEDS_DECLARED_SIZE', + message: 'Artifact replay body exceeds its declared bounded size.' + }); + } + } + if (observedBytes !== expectedBytes) { + throw this.badRequest( + 'ARTIFACT_BODY_SIZE_MISMATCH', + 'Artifact replay body size does not match the envelope.' + ); + } + } + + private validateObjectKey(objectKey: string): string { + if ( + typeof objectKey !== 'string' || + objectKey.length === 0 || + objectKey.length > 2048 || + [...objectKey].some((character) => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 31 || codePoint === 127; + }) + ) { + throw new SastArtifactObjectStoreUnavailableError(); + } + return objectKey; + } + + private async deleteStoredObject(objectKey: string): Promise { + try { + await this.objectStore.delete(objectKey); + } catch { + throw this.unavailable( + 'ARTIFACT_OBJECT_DELETE_FAILED', + 'Artifact object cleanup failed.' + ); + } + } + + private digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; + } + + private badRequest(errorCode: string, message: string): BadRequestException { + return new BadRequestException({ errorCode, message }); + } + + private unavailable( + errorCode: string, + message: string + ): ServiceUnavailableException { + return new ServiceUnavailableException({ errorCode, message }); + } +} diff --git a/apps/api/src/scan-plane/sast-artifact-ingress.store.ts b/apps/api/src/scan-plane/sast-artifact-ingress.store.ts new file mode 100644 index 0000000..8e40a87 --- /dev/null +++ b/apps/api/src/scan-plane/sast-artifact-ingress.store.ts @@ -0,0 +1,114 @@ +import type { + SastArtifactIngestionState, + SastScannerKind, + SastScanPlan +} from '@aegisai/shared'; + +export interface SastArtifactIngressExpectedBinding { + plan: Readonly; + attemptId: string; + scannerRunId: string; + workloadIdentityRef: string; + preflightAttestationRef: string; + preflightInventoryDigest: `sha256:${string}`; + scanner: SastScannerKind; + artifactRef: string; + attemptStage: string; + attemptDeadlineAt: string; + scannerRunStatus: string; +} + +export interface ReserveSastArtifactIngressInput { + ingestionId: string; + envelopeDigest: `sha256:${string}`; + idempotencyKey: string; + expected: Readonly; + declaredContentDigest: `sha256:${string}`; + declaredByteSize: number; + now: string; +} + +export interface SastArtifactIngressReservation { + kind: 'RESERVED' | 'REPLAY'; + ingestionId: string; + state: SastArtifactIngestionState; + receivedAt?: string; +} + +export interface CompleteSastArtifactIngressInput { + ingestionId: string; + objectKey: string; + observedContentDigest: `sha256:${string}`; + observedByteSize: number; + receivedAt: string; +} + +export interface RejectSastArtifactIngressInput { + ingestionId: string; + reasonCode: string; + observedContentDigest?: `sha256:${string}`; + observedByteSize?: number; + rejectedAt: string; +} + +export interface AbortSastArtifactIngressInput { + ingestionId: string; + reasonCode: string; + occurredAt: string; +} + +export interface SastArtifactIngressRejectionAudit { + expected: Readonly; + certificateFingerprint: `sha256:${string}`; + reasonCode: string; + workloadIdentityValidated: boolean; + occurredAt: string; +} + +export class SastArtifactIngressReplayConflictError extends Error { + constructor() { + super('A scanner run already has a different artifact submission.'); + this.name = 'SastArtifactIngressReplayConflictError'; + } +} + +export class SastArtifactIngressStateConflictError extends Error { + constructor() { + super('The artifact ingress lifecycle does not permit this transition.'); + this.name = 'SastArtifactIngressStateConflictError'; + } +} + +export class SastArtifactIngressReservationRetryError extends Error { + constructor() { + super('Artifact ingress reservation must be retried.'); + this.name = 'SastArtifactIngressReservationRetryError'; + } +} + +export abstract class SastArtifactIngressStore { + abstract loadExpectedBinding( + scanRequestId: string, + scannerRunId: string + ): Promise; + + abstract reserve( + input: Readonly + ): Promise; + + abstract complete( + input: Readonly + ): Promise; + + abstract reject( + input: Readonly + ): Promise; + + abstract abort( + input: Readonly + ): Promise; + + abstract recordRejectedRequest( + input: Readonly + ): Promise; +} diff --git a/apps/api/src/scan-plane/sast-artifact-object-store.ts b/apps/api/src/scan-plane/sast-artifact-object-store.ts new file mode 100644 index 0000000..7ca6a2b --- /dev/null +++ b/apps/api/src/scan-plane/sast-artifact-object-store.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; + +export interface SastArtifactObjectWrite { + ingestionId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + scannerRunId: string; + body: AsyncIterable; +} + +export interface SastArtifactObjectWriteResult { + objectKey: string; +} + +export abstract class SastArtifactObjectStore { + /** + * Implementations must create an immutable object and consume the body once. + * They must not expose a read method to the Scan Plane. + */ + abstract put( + input: Readonly + ): Promise; + + abstract delete(objectKey: string): Promise; +} + +export class SastArtifactObjectStoreUnavailableError extends Error { + constructor() { + super('No production SAST artifact object-store adapter is installed.'); + this.name = 'SastArtifactObjectStoreUnavailableError'; + } +} + +@Injectable() +export class UnavailableSastArtifactObjectStore + extends SastArtifactObjectStore +{ + put(): Promise { + return Promise.reject(new SastArtifactObjectStoreUnavailableError()); + } + + delete(): Promise { + return Promise.reject(new SastArtifactObjectStoreUnavailableError()); + } +} diff --git a/apps/api/src/scan-plane/sast-scanner-runtime.service.ts b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts index 45bc730..ee63fc9 100644 --- a/apps/api/src/scan-plane/sast-scanner-runtime.service.ts +++ b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts @@ -114,11 +114,13 @@ export class SastScannerRuntimeService { for (const invocation of invocations) { activeInvocation = invocation; + activeScannerRunId = `scanner_run_${randomUUID()}`; activeScannerTerminalAuditRecorded = false; const controller = new AbortController(); const operation = { request, invocation, + scannerRunId: activeScannerRunId, attemptDeadlineAt, signal: controller.signal }; @@ -129,7 +131,12 @@ export class SastScannerRuntimeService { invocation.scanner ); this.manifestVerifier.verify(request, invocation, manifest); - activeScannerRunId = `scanner_run_${randomUUID()}`; + await this.store.beginScannerRun( + request, + activeScannerRunId, + invocation, + new Date().toISOString() + ); await this.emitAudit( request, @@ -210,15 +217,31 @@ export class SastScannerRuntimeService { } ); activeScannerTerminalAuditRecorded = true; + activeInvocation = undefined; + activeScannerRunId = undefined; if (status !== 'SUCCEEDED') { throw this.scannerExecutionFailure(status, invocation.scanner); } - activeInvocation = undefined; - activeScannerRunId = undefined; } } catch (error) { runtimeFailure = this.normalizeFailure(error); + if (activeInvocation && activeScannerRunId) { + try { + await this.store.failScannerRun( + request, + activeScannerRunId, + activeInvocation, + runtimeFailure.reasonCode, + new Date().toISOString() + ); + } catch { + runtimeFailure = retryableInfrastructureFailure( + 'SCANNER_RUN_PERSISTENCE_FAILED', + 'Scanner failure state could not be persisted.' + ); + } + } if (activeInvocation && !activeScannerTerminalAuditRecorded) { try { await this.emitAudit( diff --git a/apps/api/src/scan-plane/sast-scanner-runtime.store.ts b/apps/api/src/scan-plane/sast-scanner-runtime.store.ts index 9438078..1299c6e 100644 --- a/apps/api/src/scan-plane/sast-scanner-runtime.store.ts +++ b/apps/api/src/scan-plane/sast-scanner-runtime.store.ts @@ -1,6 +1,7 @@ import type { SastFailureClass, SastScannerExecutionRecord, + SastScannerInvocation, SastScannerRuntimeAuditSignal, SastScannerWrapperExecutionRequest, SastSignedSandboxCleanupObservation @@ -42,11 +43,26 @@ export abstract class SastScannerRuntimeStore { > ): Promise; + abstract beginScannerRun( + request: Readonly, + scannerRunId: string, + invocation: Readonly, + startedAt: string + ): Promise; + abstract recordScannerRun( request: Readonly, record: Readonly ): Promise; + abstract failScannerRun( + request: Readonly, + scannerRunId: string, + invocation: Readonly, + reasonCode: string, + completedAt: string + ): Promise; + abstract recordAuditSignal( signal: Readonly ): Promise; diff --git a/apps/api/src/scan-plane/sast-workload-identity.authenticator.ts b/apps/api/src/scan-plane/sast-workload-identity.authenticator.ts new file mode 100644 index 0000000..85d4a91 --- /dev/null +++ b/apps/api/src/scan-plane/sast-workload-identity.authenticator.ts @@ -0,0 +1,108 @@ +import { createHash } from 'node:crypto'; +import type { PeerCertificate } from 'node:tls'; + +import { Injectable } from '@nestjs/common'; +import type { Request } from 'express'; + +export interface AuthenticatedSastWorkloadIdentity { + identityRef: string; + certificateFingerprint: `sha256:${string}`; +} + +interface MtlsSocket { + encrypted?: boolean; + authorized?: boolean; + getPeerCertificate?: (detailed?: boolean) => PeerCertificate; +} + +export abstract class SastWorkloadIdentityAuthenticator { + abstract authenticate( + request: Request + ): Promise; +} + +@Injectable() +export class DirectMtlsSastWorkloadIdentityAuthenticator + extends SastWorkloadIdentityAuthenticator +{ + authenticate( + request: Request + ): Promise { + const socket = request.socket as MtlsSocket; + if ( + socket.encrypted !== true || + socket.authorized !== true || + typeof socket.getPeerCertificate !== 'function' + ) { + return Promise.resolve(null); + } + + const certificate = socket.getPeerCertificate(false); + if ( + !certificate?.raw || + !this.isCertificateCurrentlyValid(certificate) || + typeof certificate.subjectaltname !== 'string' + ) { + return Promise.resolve(null); + } + + const identityRef = this.extractSingleSpiffeUri( + certificate.subjectaltname + ); + if (!identityRef) { + return Promise.resolve(null); + } + + return Promise.resolve({ + identityRef, + certificateFingerprint: `sha256:${createHash('sha256') + .update(certificate.raw) + .digest('hex')}` + }); + } + + private extractSingleSpiffeUri(subjectAlternativeName: string): string | null { + const uriEntries = subjectAlternativeName + .split(/,\s*/u) + .filter((entry) => entry.startsWith('URI:')) + .map((entry) => entry.slice(4)); + + if (uriEntries.length !== 1) { + return null; + } + + const identityRef = uriEntries[0]; + const spiffeId = + /^spiffe:\/\/([a-z0-9](?:[a-z0-9._-]{0,254}))((?:\/[A-Za-z0-9._-]+)+)$/u.exec( + identityRef + ); + if ( + identityRef.length === 0 || + Buffer.byteLength(identityRef, 'utf8') > 512 || + identityRef !== identityRef.trim() || + identityRef !== identityRef.normalize('NFC') || + !spiffeId || + spiffeId[2] + .slice(1) + .split('/') + .some((segment) => segment === '.' || segment === '..') + ) { + return null; + } + + return identityRef; + } + + private isCertificateCurrentlyValid(certificate: PeerCertificate): boolean { + const validFrom = Date.parse(certificate.valid_from); + const validTo = Date.parse(certificate.valid_to); + const now = Date.now(); + + return ( + Number.isFinite(validFrom) && + Number.isFinite(validTo) && + validFrom <= now && + now < validTo + ); + } +} diff --git a/apps/api/src/scan-plane/sast-workload-identity.guard.ts b/apps/api/src/scan-plane/sast-workload-identity.guard.ts new file mode 100644 index 0000000..820f689 --- /dev/null +++ b/apps/api/src/scan-plane/sast-workload-identity.guard.ts @@ -0,0 +1,39 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import type { Request } from 'express'; + +import { + type AuthenticatedSastWorkloadIdentity, + SastWorkloadIdentityAuthenticator +} from './sast-workload-identity.authenticator'; + +export type SastWorkloadIdentityRequest = Request & { + sastWorkloadIdentity?: Readonly; +}; + +@Injectable() +export class SastWorkloadIdentityGuard implements CanActivate { + constructor( + private readonly authenticator: SastWorkloadIdentityAuthenticator + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = + context.switchToHttp().getRequest(); + const identity = await this.authenticator.authenticate(request); + if (!identity) { + throw new UnauthorizedException({ + errorCode: 'SAST_WORKLOAD_IDENTITY_REQUIRED', + message: + 'A directly authenticated mTLS workload identity is required.' + }); + } + + request.sastWorkloadIdentity = Object.freeze({ ...identity }); + return true; + } +} diff --git a/apps/api/src/scan-plane/scan-plane.dto.ts b/apps/api/src/scan-plane/scan-plane.dto.ts index 95a4878..3bd9cca 100644 --- a/apps/api/src/scan-plane/scan-plane.dto.ts +++ b/apps/api/src/scan-plane/scan-plane.dto.ts @@ -15,6 +15,7 @@ import { } from 'class-validator'; const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; +const ROUTE_RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/; const VERSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export class RunMockScanPlaneDto { @@ -75,3 +76,13 @@ export class EvidenceAccessRequestDto extends ScanArtifactsQueryDto { @IsBoolean() metadataOnly?: boolean; } + +export class SastArtifactIngressPathDto { + @IsString() + @Matches(ROUTE_RESOURCE_ID) + scanRequestId!: string; + + @IsString() + @Matches(ROUTE_RESOURCE_ID) + scannerRunId!: string; +} diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index a421a8e..6f2d6cf 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -6,6 +6,21 @@ import { EvidenceExpiryTask } from "./evidence-expiry.task"; import { EvidenceObjectStorageService } from "./evidence-object-storage.service"; import { FindingsController } from "./findings.controller"; import { ScanPlaneController } from "./scan-plane.controller"; +import { SastArtifactIngressController } from './sast-artifact-ingress.controller'; +import { SastArtifactIngressService } from './sast-artifact-ingress.service'; +import { + PrismaSastArtifactIngressStore +} from './prisma-sast-artifact-ingress.store'; +import { SastArtifactIngressStore } from './sast-artifact-ingress.store'; +import { + SastArtifactObjectStore, + UnavailableSastArtifactObjectStore +} from './sast-artifact-object-store'; +import { + DirectMtlsSastWorkloadIdentityAuthenticator, + SastWorkloadIdentityAuthenticator +} from './sast-workload-identity.authenticator'; +import { SastWorkloadIdentityGuard } from './sast-workload-identity.guard'; import { ScanPlaneMockController } from './scan-plane-mock.controller'; import { ScanPlaneService } from "./scan-plane.service"; import { ScannerSandboxAdapterService } from "./scanner-sandbox-adapter.service"; @@ -40,12 +55,30 @@ import { imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], controllers: [ ScanPlaneController, + SastArtifactIngressController, FindingsController, EvidenceController, ...(isMockAnalysisFixtureEnabled() ? [ScanPlaneMockController] : []) ], providers: [ ScanPlaneService, + SastArtifactIngressService, + SastWorkloadIdentityGuard, + DirectMtlsSastWorkloadIdentityAuthenticator, + { + provide: SastWorkloadIdentityAuthenticator, + useExisting: DirectMtlsSastWorkloadIdentityAuthenticator + }, + PrismaSastArtifactIngressStore, + { + provide: SastArtifactIngressStore, + useExisting: PrismaSastArtifactIngressStore + }, + UnavailableSastArtifactObjectStore, + { + provide: SastArtifactObjectStore, + useExisting: UnavailableSastArtifactObjectStore + }, ScannerSandboxAdapterService, SandboxRuntimeAttestationService, ScannerWorkspaceManifestService, diff --git a/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts b/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts index 35c1563..5a7a1dc 100644 --- a/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts +++ b/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts @@ -12,6 +12,7 @@ import { retryableInfrastructureFailure } from './scanner-runtime.errors'; export interface ScannerSandboxRuntimeOperation { request: Readonly; invocation: Readonly; + scannerRunId: string; attemptDeadlineAt: string; signal: AbortSignal; } diff --git a/apps/api/test/scan-plane/prisma-sast-artifact-ingress.store.e2e-spec.ts b/apps/api/test/scan-plane/prisma-sast-artifact-ingress.store.e2e-spec.ts new file mode 100644 index 0000000..037b314 --- /dev/null +++ b/apps/api/test/scan-plane/prisma-sast-artifact-ingress.store.e2e-spec.ts @@ -0,0 +1,233 @@ +import type { SastScanPlan } from '@aegisai/shared'; + +import { PrismaService } from '../../src/prisma/prisma.service'; +import { PrismaSastArtifactIngressStore } from '../../src/scan-plane/prisma-sast-artifact-ingress.store'; +import { + SastArtifactIngressReplayConflictError, + type SastArtifactIngressExpectedBinding +} from '../../src/scan-plane/sast-artifact-ingress.store'; + +const DIGEST = `sha256:${'a'.repeat(64)}` as const; + +describe('PrismaSastArtifactIngressStore', () => { + it('reserves one scanner-run-bound ingress in a serializable lifecycle transaction', async () => { + const transaction = { + sastArtifactIngestion: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'ingestion-1' }) + }, + sastScanAttempt: { + findFirst: jest.fn().mockResolvedValue({ id: 'attempt-1' }) + }, + scannerRun: { + findFirst: jest.fn().mockResolvedValue({ id: 'scanner-run-1' }) + }, + auditEvent: { + create: jest.fn().mockResolvedValue({ id: 'audit-1' }) + } + }; + const prisma = { + $transaction: jest.fn( + async ( + operation: (client: typeof transaction) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastArtifactIngressStore( + prisma as unknown as PrismaService + ); + const now = '2026-07-24T18:00:00.000Z'; + + await expect( + store.reserve({ + ingestionId: 'ingestion-1', + envelopeDigest: DIGEST, + idempotencyKey: `sast-ingress-v1:scanner-run-1:${DIGEST}`, + expected: expectedBinding(), + declaredContentDigest: DIGEST, + declaredByteSize: 128, + now + }) + ).resolves.toEqual({ + kind: 'RESERVED', + ingestionId: 'ingestion-1', + state: 'RECEIVING' + }); + expect(transaction.sastScanAttempt.findFirst).toHaveBeenCalledWith({ + where: { + id: 'attempt-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + stage: 'SCANNING', + attemptDeadlineAt: { + gt: new Date(now) + } + }, + select: { id: true } + }); + expect(transaction.scannerRun.findFirst).toHaveBeenCalledWith({ + where: { + id: 'scanner-run-1', + attemptId: 'attempt-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + scanner: 'OPENGREP', + status: 'RUNNING' + }, + select: { id: true } + }); + expect(transaction.sastArtifactIngestion.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + id: 'ingestion-1', + scannerRunId: 'scanner-run-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + identityValidated: true, + status: 'RECEIVING' + }) + }); + }); + + it('atomically binds the opaque object key to ingestion and running scanner metadata', async () => { + const transaction = { + sastArtifactIngestion: { + findUnique: jest.fn().mockResolvedValue({ + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + repositoryBindingId: 'repository-1', + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + status: 'RECEIVING' + }), + updateMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + sastScanAttempt: { + findFirst: jest.fn().mockResolvedValue({ id: 'attempt-1' }) + }, + scannerRun: { + updateMany: jest.fn().mockResolvedValue({ count: 1 }) + }, + auditEvent: { + create: jest.fn().mockResolvedValue({ id: 'audit-1' }) + } + }; + const prisma = { + $transaction: jest.fn( + async ( + operation: (client: typeof transaction) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastArtifactIngressStore( + prisma as unknown as PrismaService + ); + + await store.complete({ + ingestionId: 'ingestion-1', + objectKey: 'raw-sast/ingestion-1', + observedContentDigest: DIGEST, + observedByteSize: 128, + receivedAt: '2026-07-24T18:00:01.000Z' + }); + + expect(transaction.scannerRun.updateMany).toHaveBeenCalledWith({ + where: { + id: 'scanner-run-1', + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + status: 'RUNNING', + rawArtifactObjectKey: null + }, + data: { + rawArtifactObjectKey: 'raw-sast/ingestion-1' + } + }); + expect(transaction.sastScanAttempt.findFirst).toHaveBeenCalledWith({ + where: { + id: 'attempt-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + stage: 'SCANNING', + attemptDeadlineAt: { + gt: new Date('2026-07-24T18:00:01.000Z') + } + }, + select: { id: true } + }); + expect(transaction.auditEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + eventType: 'artifact.ingress_received', + targetId: 'ingestion-1' + }) + }); + }); + + it('rejects a changed envelope replay without replacing the first artifact', async () => { + const existing = { + id: 'ingestion-1', + envelopeDigest: DIGEST, + idempotencyKey: `sast-ingress-v1:scanner-run-1:${DIGEST}`, + declaredContentDigest: DIGEST, + declaredByteSize: 128, + status: 'PENDING_VALIDATION', + receivedAt: new Date('2026-07-24T18:00:01.000Z') + }; + const transaction = { + sastScanAttempt: { + findFirst: jest.fn().mockResolvedValue({ id: 'attempt-1' }) + }, + sastArtifactIngestion: { + findUnique: jest.fn().mockResolvedValue(existing) + } + }; + const prisma = { + $transaction: jest.fn( + async ( + operation: (client: typeof transaction) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastArtifactIngressStore( + prisma as unknown as PrismaService + ); + + await expect( + store.reserve({ + ingestionId: 'ingestion-2', + envelopeDigest: `sha256:${'b'.repeat(64)}`, + idempotencyKey: existing.idempotencyKey, + expected: expectedBinding(), + declaredContentDigest: DIGEST, + declaredByteSize: 128, + now: '2026-07-24T18:00:02.000Z' + }) + ).rejects.toBeInstanceOf(SastArtifactIngressReplayConflictError); + }); +}); + +function expectedBinding(): SastArtifactIngressExpectedBinding { + return { + plan: { + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + repositoryState: { + repositoryBindingId: 'repository-1' + } + } as unknown as SastScanPlan, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + preflightAttestationRef: 'preflight://attempt-1', + preflightInventoryDigest: DIGEST, + scanner: 'OPENGREP', + artifactRef: 'result-ingress://tenant-1/scan-1/opengrep', + attemptStage: 'SCANNING', + attemptDeadlineAt: '2026-07-24T18:05:00.000Z', + scannerRunStatus: 'RUNNING' + }; +} diff --git a/apps/api/test/scan-plane/prisma-sast-scanner-runtime.store.e2e-spec.ts b/apps/api/test/scan-plane/prisma-sast-scanner-runtime.store.e2e-spec.ts index b4cb6da..2c6b444 100644 --- a/apps/api/test/scan-plane/prisma-sast-scanner-runtime.store.e2e-spec.ts +++ b/apps/api/test/scan-plane/prisma-sast-scanner-runtime.store.e2e-spec.ts @@ -3,6 +3,7 @@ import { isSastAttemptSequenceEligible, PrismaSastScannerRuntimeStore } from '../../src/scan-plane/prisma-sast-scanner-runtime.store'; +import type { SastScannerWrapperExecutionRequest } from '@aegisai/shared'; describe('PrismaSastScannerRuntimeStore', () => { it('admits attempt two only after attempt one has a durable retry-eligible infrastructure failure', () => { @@ -111,4 +112,46 @@ describe('PrismaSastScannerRuntimeStore', () => { } }); }); + + it('does not close an attempt while artifact bytes are still being received', async () => { + const attemptUpdate = jest.fn(); + const transaction = { + sastArtifactIngestion: { + count: jest.fn().mockResolvedValue(1) + }, + sastScanAttempt: { + updateMany: attemptUpdate + } + }; + const prisma = { + $transaction: jest.fn( + async ( + operation: (client: typeof transaction) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastScannerRuntimeStore( + prisma as unknown as PrismaService + ); + const request = { + attemptId: 'attempt-1', + sandboxId: 'sandbox-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + plan: { + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + repositoryState: { + repositoryBindingId: 'repository-1' + } + } + } as unknown as SastScannerWrapperExecutionRequest; + + await expect( + store.markStage(request, 'CLEANUP_PENDING') + ).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'ARTIFACT_INGRESS_STILL_RECEIVING' + }); + expect(attemptUpdate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/test/scan-plane/sast-artifact-ingress.e2e-spec.ts b/apps/api/test/scan-plane/sast-artifact-ingress.e2e-spec.ts new file mode 100644 index 0000000..4f454e3 --- /dev/null +++ b/apps/api/test/scan-plane/sast-artifact-ingress.e2e-spec.ts @@ -0,0 +1,653 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_APPROVED_PROFILE_DIGESTS, + SAST_ARTIFACT_ENVELOPE_HEADER, + SAST_ARTIFACT_IDEMPOTENCY_HEADER, + SAST_ARTIFACT_INGRESS_MEDIA_TYPE, + SAST_FORBIDDEN_CAPABILITIES, + SAST_SCAN_PROFILES, + buildSastArtifactIngressIdempotencyKey, + canonicalizeScannerArtifactEnvelope, + type ScannerArtifactEnvelope, + type SastScanPlan +} from '@aegisai/shared'; +import { ValidationPipe, type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { Request } from 'express'; +import request from 'supertest'; + +import { SastArtifactIngressController } from '../../src/scan-plane/sast-artifact-ingress.controller'; +import { SastArtifactIngressService } from '../../src/scan-plane/sast-artifact-ingress.service'; +import { + type AbortSastArtifactIngressInput, + type CompleteSastArtifactIngressInput, + type RejectSastArtifactIngressInput, + type ReserveSastArtifactIngressInput, + SastArtifactIngressReplayConflictError, + type SastArtifactIngressReservation, + type SastArtifactIngressRejectionAudit, + type SastArtifactIngressExpectedBinding, + SastArtifactIngressStore +} from '../../src/scan-plane/sast-artifact-ingress.store'; +import { + SastArtifactObjectStore, + type SastArtifactObjectWrite, + type SastArtifactObjectWriteResult +} from '../../src/scan-plane/sast-artifact-object-store'; +import { + DirectMtlsSastWorkloadIdentityAuthenticator, + type AuthenticatedSastWorkloadIdentity, + SastWorkloadIdentityAuthenticator +} from '../../src/scan-plane/sast-workload-identity.authenticator'; +import { SastWorkloadIdentityGuard } from '../../src/scan-plane/sast-workload-identity.guard'; + +const digest = (value: string): `sha256:${string}` => + `sha256:${createHash('sha256').update(value).digest('hex')}`; +const FIXED_COMMIT = 'a'.repeat(40); +const ARTIFACT_BYTES = Buffer.from('{"runs":[]}', 'utf8'); + +class InMemoryArtifactIngressStore extends SastArtifactIngressStore { + expected = buildExpectedBinding(); + rejectionAudits: SastArtifactIngressRejectionAudit[] = []; + reservations = new Map< + string, + { + input: ReserveSastArtifactIngressInput; + state: 'RECEIVING' | 'PENDING_VALIDATION' | 'REJECTED'; + receivedAt?: string; + } + >(); + completeError?: Error; + + loadExpectedBinding( + scanRequestId: string, + scannerRunId: string + ): Promise { + return Promise.resolve( + scanRequestId === this.expected.plan.scanRequestId && + scannerRunId === this.expected.scannerRunId + ? this.expected + : null + ); + } + + reserve( + input: Readonly + ): Promise { + const existing = this.reservations.get(input.expected.scannerRunId); + if (existing) { + if ( + existing.input.envelopeDigest !== input.envelopeDigest || + existing.input.idempotencyKey !== input.idempotencyKey || + existing.input.declaredContentDigest !== + input.declaredContentDigest || + existing.input.declaredByteSize !== input.declaredByteSize + ) { + throw new SastArtifactIngressReplayConflictError(); + } + return Promise.resolve({ + kind: 'REPLAY', + ingestionId: existing.input.ingestionId, + state: 'PENDING_VALIDATION', + receivedAt: existing.receivedAt + }); + } + this.reservations.set(input.expected.scannerRunId, { + input: structuredClone(input), + state: 'RECEIVING' + }); + return Promise.resolve({ + kind: 'RESERVED', + ingestionId: input.ingestionId, + state: 'RECEIVING' + }); + } + + complete( + input: Readonly + ): Promise { + if (this.completeError) { + return Promise.reject(this.completeError); + } + const reservation = [...this.reservations.values()].find( + (candidate) => candidate.input.ingestionId === input.ingestionId + ); + if (!reservation) throw new Error('missing reservation'); + reservation.state = 'PENDING_VALIDATION'; + reservation.receivedAt = input.receivedAt; + return Promise.resolve(); + } + + reject(input: Readonly): Promise { + const reservation = [...this.reservations.values()].find( + (candidate) => candidate.input.ingestionId === input.ingestionId + ); + if (reservation) reservation.state = 'REJECTED'; + return Promise.resolve(); + } + + abort(input: Readonly): Promise { + for (const [scannerRunId, reservation] of this.reservations.entries()) { + if (reservation.input.ingestionId === input.ingestionId) { + this.reservations.delete(scannerRunId); + } + } + return Promise.resolve(); + } + + recordRejectedRequest( + input: Readonly + ): Promise { + this.rejectionAudits.push(structuredClone(input)); + return Promise.resolve(); + } +} + +class InMemoryArtifactObjectStore extends SastArtifactObjectStore { + writes = 0; + objects = new Map(); + consumeBody = true; + deleteError?: Error; + + async put( + input: Readonly + ): Promise { + this.writes += 1; + const chunks: Buffer[] = []; + if (this.consumeBody) { + for await (const chunk of input.body) { + chunks.push(Buffer.from(chunk)); + } + } + const objectKey = `raw-sast/${input.ingestionId}`; + this.objects.set(objectKey, Buffer.concat(chunks)); + return { objectKey }; + } + + delete(objectKey: string): Promise { + if (this.deleteError) { + return Promise.reject(this.deleteError); + } + this.objects.delete(objectKey); + return Promise.resolve(); + } +} + +describe('SAST per-scan write-only artifact ingress', () => { + let app: INestApplication; + let ingressStore: InMemoryArtifactIngressStore; + let objectStore: InMemoryArtifactObjectStore; + let authenticatedIdentity: AuthenticatedSastWorkloadIdentity | null; + + beforeEach(async () => { + ingressStore = new InMemoryArtifactIngressStore(); + objectStore = new InMemoryArtifactObjectStore(); + authenticatedIdentity = { + identityRef: ingressStore.expected.workloadIdentityRef, + certificateFingerprint: digest('certificate') + }; + const authenticator = { + authenticate: jest.fn( + async () => authenticatedIdentity + ) + } satisfies Pick; + const module = await Test.createTestingModule({ + controllers: [SastArtifactIngressController], + providers: [ + SastArtifactIngressService, + SastWorkloadIdentityGuard, + { + provide: SastWorkloadIdentityAuthenticator, + useValue: authenticator + }, + { + provide: SastArtifactIngressStore, + useValue: ingressStore + }, + { + provide: SastArtifactObjectStore, + useValue: objectStore + } + ] + }).compile(); + app = module.createNestApplication(); + app.setGlobalPrefix('api'); + app.useGlobalPipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true + }) + ); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('streams one immutable artifact and returns only a validation-pending receipt', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + const response = await upload(app, envelope, ARTIFACT_BYTES).expect(202); + + expect(response.body).toMatchObject({ + scannerRunId: envelope.scannerRunId, + state: 'PENDING_VALIDATION', + replayed: false + }); + expect(response.body.ingestionId).toMatch(/^sast_ingestion_/); + expect(response.body).not.toHaveProperty('objectKey'); + expect(response.body).not.toHaveProperty('artifactBytes'); + expect(objectStore.writes).toBe(1); + expect([...objectStore.objects.values()][0]).toEqual(ARTIFACT_BYTES); + expect( + ingressStore.reservations.get(envelope.scannerRunId)?.state + ).toBe('PENDING_VALIDATION'); + }); + + it('has no artifact read route', async () => { + await request(app.getHttpServer()) + .get('/api/scan-plane/result-ingress/scan-1/scanner-runs/scanner-run-1') + .expect(404); + }); + + it('rejects unauthenticated and mismatched workload identities before object storage', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + authenticatedIdentity = null; + await upload(app, envelope, ARTIFACT_BYTES).expect(401); + + authenticatedIdentity = { + identityRef: 'spiffe://aegis/scan/attempt-other', + certificateFingerprint: digest('other-certificate') + }; + const mismatch = await upload(app, envelope, ARTIFACT_BYTES).expect(403); + + expect(mismatch.body.errorCode).toBe( + 'ARTIFACT_WORKLOAD_IDENTITY_MISMATCH' + ); + expect(objectStore.writes).toBe(0); + expect(ingressStore.reservations.size).toBe(0); + expect(ingressStore.rejectionAudits).toEqual([ + expect.objectContaining({ + reasonCode: 'ARTIFACT_WORKLOAD_IDENTITY_MISMATCH', + workloadIdentityValidated: false + }) + ]); + }); + + it('rejects cross-scan scope and closed lifecycle submissions', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + const wrongScope = { + ...envelope, + repositoryBindingId: 'repository-other' + }; + const scopeResponse = await upload( + app, + wrongScope, + ARTIFACT_BYTES + ).expect(403); + expect(scopeResponse.body.errorCode).toBe( + 'ARTIFACT_SCOPE_BINDING_MISMATCH' + ); + expect(ingressStore.rejectionAudits.at(-1)).toMatchObject({ + workloadIdentityValidated: true + }); + + ingressStore.expected = { + ...ingressStore.expected, + attemptStage: 'CLEANUP_PENDING' + }; + const closedResponse = await upload( + app, + envelope, + ARTIFACT_BYTES + ).expect(409); + expect(closedResponse.body.errorCode).toBe('ARTIFACT_INGRESS_CLOSED'); + expect(objectStore.writes).toBe(0); + }); + + it('handles an exact replay idempotently and rejects a changed envelope', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + const first = await upload(app, envelope, ARTIFACT_BYTES).expect(202); + const replay = await upload(app, envelope, ARTIFACT_BYTES).expect(202); + + expect(replay.body).toEqual({ + ...first.body, + replayed: true + }); + expect(objectStore.writes).toBe(1); + + const changedEnvelope = { + ...envelope, + producedAt: new Date(Date.now() - 1_000).toISOString() + }; + const conflict = await upload( + app, + changedEnvelope, + ARTIFACT_BYTES + ).expect(409); + expect(conflict.body.errorCode).toBe( + 'ARTIFACT_INGRESS_REPLAY_CONFLICT' + ); + expect(objectStore.writes).toBe(1); + }); + + it('requires canonical envelope, media type, idempotency key, and byte length', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + const endpoint = + `/api/scan-plane/result-ingress/${envelope.scanRequestId}` + + `/scanner-runs/${envelope.scannerRunId}`; + const nonCanonical = Buffer.from( + JSON.stringify(envelope, null, 2), + 'utf8' + ).toString('base64url'); + + await request(app.getHttpServer()) + .put(endpoint) + .set('content-type', SAST_ARTIFACT_INGRESS_MEDIA_TYPE) + .set(SAST_ARTIFACT_ENVELOPE_HEADER, nonCanonical) + .set( + SAST_ARTIFACT_IDEMPOTENCY_HEADER, + buildSastArtifactIngressIdempotencyKey(envelope) + ) + .send(ARTIFACT_BYTES) + .expect(400); + await request(app.getHttpServer()) + .put(endpoint) + .set('content-type', 'application/json') + .set(SAST_ARTIFACT_ENVELOPE_HEADER, encodeEnvelope(envelope)) + .set( + SAST_ARTIFACT_IDEMPOTENCY_HEADER, + buildSastArtifactIngressIdempotencyKey(envelope) + ) + .send(ARTIFACT_BYTES) + .expect(415); + await request(app.getHttpServer()) + .put(endpoint) + .set('content-type', SAST_ARTIFACT_INGRESS_MEDIA_TYPE) + .set(SAST_ARTIFACT_ENVELOPE_HEADER, encodeEnvelope(envelope)) + .set(SAST_ARTIFACT_IDEMPOTENCY_HEADER, 'caller-controlled') + .send(ARTIFACT_BYTES) + .expect(400); + + expect(objectStore.writes).toBe(0); + }); + + it('leaves no RECEIVING reservation when rejected-object cleanup fails', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + objectStore.consumeBody = false; + objectStore.deleteError = new Error('cleanup unavailable'); + + const response = await upload(app, envelope, ARTIFACT_BYTES).expect(503); + + expect(response.body.errorCode).toBe('ARTIFACT_OBJECT_DELETE_FAILED'); + expect( + ingressStore.reservations.get(envelope.scannerRunId)?.state + ).toBe('REJECTED'); + }); + + it('aborts the RECEIVING reservation before failed cleanup after persistence errors', async () => { + const envelope = buildEnvelope(ingressStore.expected.plan); + ingressStore.completeError = new Error('persistence unavailable'); + objectStore.deleteError = new Error('cleanup unavailable'); + + const response = await upload(app, envelope, ARTIFACT_BYTES).expect(503); + + expect(response.body.errorCode).toBe('ARTIFACT_OBJECT_DELETE_FAILED'); + expect(ingressStore.reservations.has(envelope.scannerRunId)).toBe(false); + }); +}); + +describe('DirectMtlsSastWorkloadIdentityAuthenticator', () => { + const authenticator = new DirectMtlsSastWorkloadIdentityAuthenticator(); + + it('accepts only one currently valid SPIFFE URI from an authorized direct TLS peer', async () => { + const raw = Buffer.from('certificate'); + const identity = await authenticator.authenticate({ + socket: { + encrypted: true, + authorized: true, + getPeerCertificate: () => ({ + raw, + subjectaltname: 'URI:spiffe://aegis/scan/attempt-1', + valid_from: new Date(Date.now() - 60_000).toUTCString(), + valid_to: new Date(Date.now() + 60_000).toUTCString() + }) + } + } as unknown as Request); + + expect(identity).toEqual({ + identityRef: 'spiffe://aegis/scan/attempt-1', + certificateFingerprint: digest('certificate') + }); + }); + + it('does not trust identity headers, unauthorized TLS, or ambiguous URI SANs', async () => { + await expect( + authenticator.authenticate({ + headers: { + 'x-forwarded-client-cert': + 'URI=spiffe://aegis/scan/attempt-1' + }, + socket: {} + } as unknown as Request) + ).resolves.toBeNull(); + await expect( + authenticator.authenticate({ + socket: { + encrypted: true, + authorized: false, + getPeerCertificate: () => ({}) + } + } as unknown as Request) + ).resolves.toBeNull(); + await expect( + authenticator.authenticate({ + socket: { + encrypted: true, + authorized: true, + getPeerCertificate: () => ({ + raw: Buffer.from('certificate'), + subjectaltname: + 'URI:spiffe://aegis/scan/attempt-1, URI:spiffe://aegis/scan/attempt-2', + valid_from: new Date(Date.now() - 60_000).toUTCString(), + valid_to: new Date(Date.now() + 60_000).toUTCString() + }) + } + } as unknown as Request) + ).resolves.toBeNull(); + }); + + it('rejects SPIFFE IDs with non-canonical trust domains or path segments', async () => { + const authenticate = (identityRef: string) => + authenticator.authenticate({ + socket: { + encrypted: true, + authorized: true, + getPeerCertificate: () => ({ + raw: Buffer.from('certificate'), + subjectaltname: `URI:${identityRef}`, + valid_from: new Date(Date.now() - 60_000).toUTCString(), + valid_to: new Date(Date.now() + 60_000).toUTCString() + }) + } + } as unknown as Request); + + await expect( + authenticate('spiffe://Aegis/scan/attempt-1') + ).resolves.toBeNull(); + await expect( + authenticate('spiffe://aegis/scan/attempt%2D1') + ).resolves.toBeNull(); + await expect( + authenticate('spiffe://aegis/scan/attempt@1') + ).resolves.toBeNull(); + await expect(authenticate('spiffe://aegis/scan/../attempt-1')).resolves.toBeNull(); + await expect( + authenticate('spiffe://aegis/scan/attempt_1.with-valid-chars') + ).resolves.toMatchObject({ + identityRef: 'spiffe://aegis/scan/attempt_1.with-valid-chars' + }); + }); +}); + +function buildEnvelope(plan: SastScanPlan): ScannerArtifactEnvelope { + const scanner = plan.scannerSet.scanners.OPENGREP; + const rule = plan.scannerSet.ruleBundles.find( + (bundle) => bundle.scanner === 'OPENGREP' + )!; + return { + tenantId: plan.tenantId, + repositoryBindingId: plan.repositoryState.repositoryBindingId, + scanRequestId: plan.scanRequestId, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + scanner: 'OPENGREP', + scannerVersion: scanner.version, + scannerImageDigest: scanner.digest, + wrapperDigest: scanner.wrapper.digest, + ruleBundleDigest: rule.digest, + scannerSetDigest: plan.scannerSet.scannerSetDigest, + profileId: plan.profile.id, + profileDigest: plan.profileDigest, + preflightAttestationRef: 'preflight://attempt-1', + preflightInventoryDigest: digest('inventory'), + scannerWorkspaceInventoryDigest: digest('inventory'), + inputCommitSha: plan.repositoryState.fixedCommitSha, + artifactSchema: 'OPENGREP_SARIF', + artifactSchemaVersion: plan.scannerSet.schemaBundle.digest, + artifactRef: `${plan.resultIngressRef}/opengrep`, + contentDigest: digest(ARTIFACT_BYTES.toString('utf8')), + byteSize: ARTIFACT_BYTES.byteLength, + recordCount: 0, + truncated: false, + exitCode: 0, + executionStatus: 'SUCCEEDED', + producedAt: new Date().toISOString() + }; +} + +function buildExpectedBinding(): SastArtifactIngressExpectedBinding { + const plan = buildPlan(); + return { + plan, + attemptId: 'attempt-1', + scannerRunId: 'scanner-run-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + preflightAttestationRef: 'preflight://attempt-1', + preflightInventoryDigest: digest('inventory'), + scanner: 'OPENGREP', + artifactRef: `${plan.resultIngressRef}/opengrep`, + attemptStage: 'SCANNING', + attemptDeadlineAt: new Date(Date.now() + 60_000).toISOString(), + scannerRunStatus: 'RUNNING' + }; +} + +function buildPlan(): SastScanPlan { + const profile = SAST_SCAN_PROFILES.JAVA_FAST_V1; + const signed = (value: string) => ({ + digest: digest(value), + signatureRef: `signature://${value}`, + provenanceRef: `provenance://${value}` + }); + const scanner = ( + kind: 'OPENGREP' | 'TRIVY' | 'SYFT', + value: string + ) => ({ + ...signed(`scanner-${value}`), + scanner: kind, + version: '1.0.0', + sbomRef: `sbom://${kind.toLowerCase()}`, + wrapper: signed(`wrapper-${value}`) + }); + const rule = (kind: 'OPENGREP' | 'TRIVY', value: string) => ({ + ...signed(`rule-${value}`), + bundleId: `${kind.toLowerCase()}-rules`, + version: '1', + state: 'ACTIVE' as const, + compatibilityRef: `compatibility://${value}`, + rolloutPolicyRef: `rollout://${value}`, + killSwitchRef: `kill-switch://${value}`, + scanner: kind, + source: 'PLATFORM_MANAGED' as const, + immutable: true as const, + customerExecutableConfigAllowed: false as const + }); + return { + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + canonicalScanKey: digest('canonical'), + profile, + profileDigest: SAST_APPROVED_PROFILE_DIGESTS[profile.id], + policyVersion: 'policy-v1', + repositoryState: { + repositoryBindingId: 'repository-1', + fixedCommitSha: FIXED_COMMIT, + targetRef: 'refs/heads/main', + inventoryDigest: digest('inventory'), + attestationRef: 'repository-attestation://scan-1', + shallowFetchPreferred: true, + submodulesEnabled: false, + lfsObjectsFetched: false + }, + scannerSet: { + scannerSetVersion: 'scanner-set-v1', + scannerSetDigest: digest('scanner-set'), + signatureRef: 'signature://scanner-set', + provenanceRef: 'provenance://scanner-set', + scanners: { + OPENGREP: scanner('OPENGREP', 'opengrep'), + TRIVY: scanner('TRIVY', 'trivy'), + SYFT: scanner('SYFT', 'syft') + }, + ruleBundles: [ + rule('OPENGREP', 'opengrep'), + rule('TRIVY', 'trivy') + ], + vulnerabilityDatabase: { + ...signed('trivy-db'), + databaseVersion: '2026-07-24', + publishedAt: '2026-07-24T00:00:00.000Z' + }, + schemaBundle: signed('schema'), + normalizerBundle: signed('normalizer'), + sbomSchema: 'CYCLONEDX_JSON', + rollbackRef: 'rollback://scanner-set-v0' + }, + isolationClass: 'HARDENED', + resultIngressRef: 'result-ingress://tenant-1/scan-1', + evidenceOutputRef: 'evidence-output://tenant-1/scan-1', + auditSinkRef: 'audit-sink://tenant-1/scan-1', + forbiddenCapabilities: [...SAST_FORBIDDEN_CAPABILITIES], + createdAt: new Date().toISOString() + }; +} + +function encodeEnvelope(envelope: ScannerArtifactEnvelope): string { + return Buffer.from( + canonicalizeScannerArtifactEnvelope(envelope), + 'utf8' + ).toString('base64url'); +} + +function upload( + app: INestApplication, + envelope: ScannerArtifactEnvelope, + bytes: Buffer +) { + return request(app.getHttpServer()) + .put( + `/api/scan-plane/result-ingress/${envelope.scanRequestId}` + + `/scanner-runs/${envelope.scannerRunId}` + ) + .set('content-type', SAST_ARTIFACT_INGRESS_MEDIA_TYPE) + .set(SAST_ARTIFACT_ENVELOPE_HEADER, encodeEnvelope(envelope)) + .set( + SAST_ARTIFACT_IDEMPOTENCY_HEADER, + buildSastArtifactIngressIdempotencyKey(envelope) + ) + .send(bytes); +} diff --git a/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts b/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts index dcbb80f..7487390 100644 --- a/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts +++ b/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts @@ -59,6 +59,8 @@ class InMemoryRuntimeStore extends SastScannerRuntimeStore { began = false; stages: string[] = []; scannerRuns: SastScannerExecutionRecord[] = []; + begunScannerRunIds: string[] = []; + failedScannerRunIds: string[] = []; auditSignals: SastScannerRuntimeAuditSignal[] = []; finished?: FinishSastAttemptInput; credentialCleanupDurable = true; @@ -84,6 +86,22 @@ class InMemoryRuntimeStore extends SastScannerRuntimeStore { return Promise.resolve(); } + beginScannerRun( + _request: SastScannerWrapperExecutionRequest, + scannerRunId: string + ): Promise { + this.begunScannerRunIds.push(scannerRunId); + return Promise.resolve(); + } + + failScannerRun( + _request: SastScannerWrapperExecutionRequest, + scannerRunId: string + ): Promise { + this.failedScannerRunIds.push(scannerRunId); + return Promise.resolve(); + } + recordAuditSignal(signal: SastScannerRuntimeAuditSignal): Promise { this.auditSignals.push(signal); return Promise.resolve(); @@ -346,10 +364,19 @@ describe('Pinned scanner wrapper and sandbox lifecycle', () => { expect( harness.provider.executeScanner.mock.calls[0][0] ).toMatchObject({ + scannerRunId: expect.stringMatching(/^scanner_run_/), attemptDeadlineAt: harness.request.sandboxAttestation.claims.attemptDeadlineAt, signal: expect.any(AbortSignal) }); + expect(harness.store.begunScannerRunIds).toEqual( + harness.store.scannerRuns.map((record) => record.scannerRunId) + ); + expect( + harness.provider.executeScanner.mock.calls.map( + ([operation]) => operation.scannerRunId + ) + ).toEqual(harness.store.begunScannerRunIds); for (let index = 0; index < 3; index += 1) { expect( harness.provider.readRepositoryManifest.mock.invocationCallOrder[index] diff --git a/apps/api/test/scan-plane/scanner-runtime-persistence.e2e-spec.ts b/apps/api/test/scan-plane/scanner-runtime-persistence.e2e-spec.ts index c1022e2..58e6d76 100644 --- a/apps/api/test/scan-plane/scanner-runtime-persistence.e2e-spec.ts +++ b/apps/api/test/scan-plane/scanner-runtime-persistence.e2e-spec.ts @@ -23,6 +23,13 @@ describe('Scanner runtime persistence and deployment contract', () => { ), 'utf8' ); + const ingressMigration = readFileSync( + resolve( + __dirname, + '../../prisma/migrations/20260724180000_sast_artifact_ingress/migration.sql' + ), + 'utf8' + ); const packageJson = JSON.parse( readFileSync(resolve(__dirname, '../../package.json'), 'utf8') ) as { @@ -41,6 +48,13 @@ describe('Scanner runtime persistence and deployment contract', () => { expect(schema).toMatch(/resourceMetadata\s+Json\?/); expect(schema).toMatch(/artifactMetadata\s+Json\?/); expect(schema).toMatch(/@@unique\(\[attemptId, scanner\]\)/); + expect(schema).toMatch(/model SastArtifactIngestion \{/); + expect(schema).toMatch( + /scannerRunId\s+String\s+@unique/ + ); + expect(schema).toMatch( + /status\s+SastArtifactIngestionStatus\s+@default\(RECEIVING\)/ + ); expect(migration).toContain( 'CONSTRAINT "SastScanAttempt_attempt_number_check"' @@ -87,7 +101,7 @@ describe('Scanner runtime persistence and deployment contract', () => { "name: 'ScannerRun_exit_code_check'" ); expect(onlineSchema).toContain( - "name: 'ScannerRun_runtime_metadata_check'" + "name: 'ScannerRun_runtime_metadata_v2_check'" ); expect(onlineSchema).toContain( `("artifactMetadata" ->> 'byteSize')::numeric > 0` @@ -120,14 +134,38 @@ describe('Scanner runtime persistence and deployment contract', () => { expect(onlineSchema).toContain( 'REFERENCES "AuditEvent"("id", "attemptId", "tenantId")' ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ScannerRun_ingress_scope_key"' + ); + expect(onlineSchema).toContain( + "name: 'SastArtifactIngestion_scanner_run_scope_fkey'" + ); + expect(ingressMigration).toContain( + 'CONSTRAINT "SastArtifactIngestion_identity_check"' + ); + expect(ingressMigration).toContain( + 'CONSTRAINT "SastArtifactIngestion_lifecycle_check"' + ); + expect(ingressMigration).not.toContain( + 'DROP CONSTRAINT IF EXISTS "ScannerRun_runtime_metadata_check"' + ); + expect(onlineSchema).toContain( + "replacement: 'ScannerRun_runtime_metadata_v2_check'" + ); + expect(onlineSchema).toContain( + 'superseded constraint removed:' + ); + expect(ingressMigration).not.toMatch( + /^\s*CREATE (?:UNIQUE )?INDEX CONCURRENTLY/m + ); expect(onlineSchema).toMatch(/OR COALESCE\(/); expect(onlineSchema).toContain(' NOT VALID'); expect(onlineSchema).toContain('DROP INDEX CONCURRENTLY IF EXISTS'); expect(migration).not.toMatch( /^\s*CREATE (?:UNIQUE )?INDEX CONCURRENTLY/m ); - expect(packageJson.scripts['prisma:migrate:deploy']).toContain( - 'corepack pnpm prisma:online-schema' + expect(packageJson.scripts['prisma:migrate:deploy']).toBe( + 'prisma migrate deploy --schema prisma/schema.prisma && corepack pnpm prisma:online-schema' ); expect(packageJson.scripts['prisma:online-schema']).toBe( 'node scripts/apply-online-sast-runtime-schema.mjs' @@ -204,6 +242,25 @@ describe('Scanner runtime persistence and deployment contract', () => { preScannerProjectionAttestationRequired: true, projectionMismatchPolicy: 'FAIL_CLOSED' }, + resultIngress: { + method: 'PUT', + mediaType: 'application/octet-stream', + directAuthorizedMtlsRequired: true, + singleSpiffeUriSanRequired: true, + lowercaseSpiffeTrustDomainRequired: true, + spiffePathSegmentPattern: '[A-Za-z0-9._-]+', + spiffePercentEncodingAllowed: false, + spiffeRelativePathSegmentsAllowed: false, + forwardedIdentityHeadersTrusted: false, + attemptStageRequired: 'SCANNING', + scannerRunStatusRequired: 'RUNNING', + oneImmutableObjectPerScannerRun: true, + readRouteAllowed: false, + responseObjectKeyAllowed: false, + acceptedTransportState: 'PENDING_VALIDATION', + defaultObjectStoreProvider: + 'FAIL_CLOSED_UNTIL_DATA_SECURITY_ADAPTER_INSTALLED' + }, productionMockAnalysisAllowed: false, scannerEntrypoints: { OPENGREP: { diff --git a/deploy/scanner-sandbox/provisioning-contract.json b/deploy/scanner-sandbox/provisioning-contract.json index d07d43a..6020322 100644 --- a/deploy/scanner-sandbox/provisioning-contract.json +++ b/deploy/scanner-sandbox/provisioning-contract.json @@ -123,6 +123,31 @@ "shortLived": true, "persistTokenValue": false }, + "resultIngress": { + "method": "PUT", + "pathTemplate": "/api/scan-plane/result-ingress/{scanRequestId}/scanner-runs/{scannerRunId}", + "mediaType": "application/octet-stream", + "envelopeHeader": "x-aegis-sast-artifact-envelope", + "envelopeEncoding": "CANONICAL_JSON_BASE64URL_UNPADDED", + "maximumEnvelopeBytes": 8192, + "positiveContentLengthRequired": true, + "idempotencyKeyTemplate": "sast-ingress-v1:{scannerRunId}:{contentDigest}", + "directAuthorizedMtlsRequired": true, + "singleSpiffeUriSanRequired": true, + "lowercaseSpiffeTrustDomainRequired": true, + "spiffePathSegmentPattern": "[A-Za-z0-9._-]+", + "spiffePercentEncodingAllowed": false, + "spiffeRelativePathSegmentsAllowed": false, + "forwardedIdentityHeadersTrusted": false, + "attemptStageRequired": "SCANNING", + "scannerRunStatusRequired": "RUNNING", + "signedAttemptDeadlineRequired": true, + "oneImmutableObjectPerScannerRun": true, + "readRouteAllowed": false, + "responseObjectKeyAllowed": false, + "acceptedTransportState": "PENDING_VALIDATION", + "defaultObjectStoreProvider": "FAIL_CLOSED_UNTIL_DATA_SECURITY_ADAPTER_INSTALLED" + }, "allowedOperations": [ "SCAN_SCOPED_REPOSITORY_FETCH", "STATIC_SCANNER_RUN", diff --git a/packages/shared/src/types/sast-runtime.ts b/packages/shared/src/types/sast-runtime.ts index fd1a59e..cbcc98f 100644 --- a/packages/shared/src/types/sast-runtime.ts +++ b/packages/shared/src/types/sast-runtime.ts @@ -50,6 +50,21 @@ export const SCANNER_EXECUTION_STATUSES = [ ] as const; export type ScannerExecutionStatus = (typeof SCANNER_EXECUTION_STATUSES)[number]; +export const SAST_ARTIFACT_INGRESS_MEDIA_TYPE = 'application/octet-stream'; +export const SAST_ARTIFACT_ENVELOPE_HEADER = 'x-aegis-sast-artifact-envelope'; +export const SAST_ARTIFACT_IDEMPOTENCY_HEADER = 'idempotency-key'; +export const SAST_MAX_ARTIFACT_ENVELOPE_BYTES = 8192; + +export const SAST_ARTIFACT_INGESTION_STATES = [ + 'RECEIVING', + 'PENDING_VALIDATION', + 'ACCEPTED', + 'REJECTED', + 'QUARANTINED' +] as const; +export type SastArtifactIngestionState = + (typeof SAST_ARTIFACT_INGESTION_STATES)[number]; + export const SAST_COVERAGE_STATES = ['PENDING', 'COMPLETE', 'PARTIAL', 'FAILED'] as const; export type SastCoverageState = (typeof SAST_COVERAGE_STATES)[number]; @@ -406,6 +421,7 @@ export interface SastScanPlan { export interface ScannerArtifactEnvelope { tenantId: string; + repositoryBindingId: string; scanRequestId: string; attemptId: string; scannerRunId: string; @@ -434,6 +450,14 @@ export interface ScannerArtifactEnvelope { producedAt: string; } +export interface SastArtifactIngressReceipt { + ingestionId: string; + scannerRunId: string; + state: Extract; + replayed: boolean; + receivedAt: string; +} + export interface ExpectedScannerArtifactBinding { attemptId: string; scannerRunId: string; @@ -951,6 +975,7 @@ export function isScannerArtifactEnvelopeBoundToPlan( ): boolean { if ( !envelope || + !isScannerArtifactEnvelopeShapeValid(envelope) || !plan || !expectedBinding || !SAST_SCANNER_KINDS.includes(envelope.scanner as SastScannerKind) || @@ -975,6 +1000,7 @@ export function isScannerArtifactEnvelopeBoundToPlan( return ( envelope.tenantId === plan.tenantId && + envelope.repositoryBindingId === plan.repositoryState.repositoryBindingId && envelope.scanRequestId === plan.scanRequestId && envelope.attemptId === expectedBinding.attemptId && envelope.scannerRunId === expectedBinding.scannerRunId && @@ -995,7 +1021,7 @@ export function isScannerArtifactEnvelopeBoundToPlan( isNonBlank(envelope.artifactRef) && isSha256Digest(envelope.contentDigest) && Number.isSafeInteger(envelope.byteSize) && - envelope.byteSize >= 0 && + envelope.byteSize > 0 && envelope.byteSize <= plan.profile.limits.maxArtifactBytes && Number.isSafeInteger(envelope.recordCount) && envelope.recordCount >= 0 && @@ -1009,6 +1035,150 @@ export function isScannerArtifactEnvelopeBoundToPlan( ); } +export function isScannerArtifactEnvelopeShapeValid( + value: unknown +): value is ScannerArtifactEnvelope { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const envelope = value as Record; + const requiredKeys = [ + 'tenantId', + 'repositoryBindingId', + 'scanRequestId', + 'attemptId', + 'scannerRunId', + 'workloadIdentityRef', + 'scanner', + 'scannerVersion', + 'scannerImageDigest', + 'wrapperDigest', + 'scannerSetDigest', + 'profileId', + 'profileDigest', + 'preflightAttestationRef', + 'preflightInventoryDigest', + 'scannerWorkspaceInventoryDigest', + 'inputCommitSha', + 'artifactSchema', + 'artifactSchemaVersion', + 'artifactRef', + 'contentDigest', + 'byteSize', + 'recordCount', + 'truncated', + 'exitCode', + 'executionStatus', + 'producedAt' + ] as const; + const optionalKeys = ['ruleBundleDigest'] as const; + const actualKeys = Object.keys(envelope); + if ( + requiredKeys.some((key) => !Object.hasOwn(envelope, key)) || + actualKeys.some( + (key) => + !(requiredKeys as readonly string[]).includes(key) && + !(optionalKeys as readonly string[]).includes(key) + ) + ) { + return false; + } + + return ( + isBoundedIngressText(envelope.tenantId, 256) && + isBoundedIngressText(envelope.repositoryBindingId, 256) && + isBoundedIngressText(envelope.scanRequestId, 256) && + isBoundedIngressText(envelope.attemptId, 256) && + isBoundedIngressText(envelope.scannerRunId, 256) && + isBoundedIngressText(envelope.workloadIdentityRef, 512) && + SAST_SCANNER_KINDS.includes(envelope.scanner as SastScannerKind) && + isBoundedIngressText(envelope.scannerVersion, 255) && + isSha256Digest(envelope.scannerImageDigest as string) && + isSha256Digest(envelope.wrapperDigest as string) && + (envelope.ruleBundleDigest === undefined || + isSha256Digest(envelope.ruleBundleDigest as string)) && + isSha256Digest(envelope.scannerSetDigest as string) && + SAST_PROFILE_IDS.includes(envelope.profileId as SastProfileId) && + isSha256Digest(envelope.profileDigest as string) && + isBoundedIngressText(envelope.preflightAttestationRef, 8192) && + isSha256Digest(envelope.preflightInventoryDigest as string) && + isSha256Digest(envelope.scannerWorkspaceInventoryDigest as string) && + typeof envelope.inputCommitSha === 'string' && + isGitCommitSha(envelope.inputCommitSha) && + ['OPENGREP_SARIF', 'TRIVY_JSON', 'CYCLONEDX_JSON'].includes( + envelope.artifactSchema as string + ) && + isBoundedIngressText(envelope.artifactSchemaVersion, 255) && + isBoundedIngressText(envelope.artifactRef, 2048) && + isSha256Digest(envelope.contentDigest as string) && + typeof envelope.byteSize === 'number' && + Number.isSafeInteger(envelope.byteSize) && + envelope.byteSize > 0 && + envelope.byteSize <= 268435456 && + typeof envelope.recordCount === 'number' && + Number.isSafeInteger(envelope.recordCount) && + envelope.recordCount >= 0 && + envelope.recordCount <= 250000 && + typeof envelope.truncated === 'boolean' && + typeof envelope.exitCode === 'number' && + Number.isSafeInteger(envelope.exitCode) && + envelope.exitCode >= -1 && + envelope.exitCode <= 255 && + SCANNER_EXECUTION_STATUSES.includes( + envelope.executionStatus as ScannerExecutionStatus + ) && + typeof envelope.producedAt === 'string' && + isIsoTimestamp(envelope.producedAt) + ); +} + +export function canonicalizeScannerArtifactEnvelope( + envelope: ScannerArtifactEnvelope +): string { + const canonicalEnvelope = { + tenantId: envelope.tenantId, + repositoryBindingId: envelope.repositoryBindingId, + scanRequestId: envelope.scanRequestId, + attemptId: envelope.attemptId, + scannerRunId: envelope.scannerRunId, + workloadIdentityRef: envelope.workloadIdentityRef, + scanner: envelope.scanner, + scannerVersion: envelope.scannerVersion, + scannerImageDigest: envelope.scannerImageDigest, + wrapperDigest: envelope.wrapperDigest, + ...(envelope.ruleBundleDigest === undefined + ? {} + : { ruleBundleDigest: envelope.ruleBundleDigest }), + scannerSetDigest: envelope.scannerSetDigest, + profileId: envelope.profileId, + profileDigest: envelope.profileDigest, + preflightAttestationRef: envelope.preflightAttestationRef, + preflightInventoryDigest: envelope.preflightInventoryDigest, + scannerWorkspaceInventoryDigest: + envelope.scannerWorkspaceInventoryDigest, + inputCommitSha: envelope.inputCommitSha, + artifactSchema: envelope.artifactSchema, + artifactSchemaVersion: envelope.artifactSchemaVersion, + artifactRef: envelope.artifactRef, + contentDigest: envelope.contentDigest, + byteSize: envelope.byteSize, + recordCount: envelope.recordCount, + truncated: envelope.truncated, + exitCode: envelope.exitCode, + executionStatus: envelope.executionStatus, + producedAt: envelope.producedAt + }; + + return JSON.stringify(canonicalEnvelope); +} + +export function buildSastArtifactIngressIdempotencyKey( + envelope: Pick +): string { + return `sast-ingress-v1:${envelope.scannerRunId}:${envelope.contentDigest}`; +} + export function isScannerArtifactEligibleForNormalization( envelope: ScannerArtifactEnvelope, plan: SastScanPlan, @@ -1510,6 +1680,20 @@ function isNonBlank(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } +function isBoundedIngressText( + value: unknown, + maximumUtf8Bytes: number +): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value === value.trim() && + value === value.normalize('NFC') && + !hasControlCharacters(value) && + new TextEncoder().encode(value).byteLength <= maximumUtf8Bytes + ); +} + function hasUniqueValues(values: readonly T[]): boolean { return new Set(values).size === values.length; } diff --git a/packages/shared/test/sast-runtime-behavior.test.mjs b/packages/shared/test/sast-runtime-behavior.test.mjs index 3cc36e8..7b99c3c 100644 --- a/packages/shared/test/sast-runtime-behavior.test.mjs +++ b/packages/shared/test/sast-runtime-behavior.test.mjs @@ -118,6 +118,7 @@ const buildPlan = () => ({ const buildArtifactEnvelope = (plan) => ({ tenantId: plan.tenantId, + repositoryBindingId: plan.repositoryState.repositoryBindingId, scanRequestId: plan.scanRequestId, attemptId: 'attempt-1', scannerRunId: 'scanner-run-1', @@ -230,6 +231,17 @@ test('scan plans and artifact envelopes bind fixed intent and reject normalizati assert.equal(runtime.isScannerSetDescriptorValid(plan.scannerSet), true); assert.equal(runtime.isSastScanPlanValid(plan), true); assert.equal(runtime.isSastScanPlanValid({}), false); + assert.equal(runtime.isScannerArtifactEnvelopeShapeValid(envelope), true); + assert.equal( + runtime.buildSastArtifactIngressIdempotencyKey(envelope), + `sast-ingress-v1:${envelope.scannerRunId}:${envelope.contentDigest}` + ); + assert.equal( + runtime.canonicalizeScannerArtifactEnvelope( + Object.fromEntries(Object.entries(envelope).reverse()) + ), + JSON.stringify(envelope) + ); assert.equal( runtime.isSastScanPlanValid({ ...plan, @@ -258,6 +270,21 @@ test('scan plans and artifact envelopes bind fixed intent and reject normalizati ), false ); + assert.equal( + runtime.isScannerArtifactEnvelopeBoundToPlan( + { ...envelope, repositoryBindingId: 'repository-other' }, + plan, + expectedBinding + ), + false + ); + assert.equal( + runtime.isScannerArtifactEnvelopeShapeValid({ + ...envelope, + callerControlledField: true + }), + false + ); assert.equal( runtime.isScannerArtifactEnvelopeBoundToPlan( { ...envelope, scanner: 'UNSUPPORTED' }, diff --git a/packages/shared/test/sast-runtime.test.mjs b/packages/shared/test/sast-runtime.test.mjs index c3a3ac6..94cb3be 100644 --- a/packages/shared/test/sast-runtime.test.mjs +++ b/packages/shared/test/sast-runtime.test.mjs @@ -84,12 +84,16 @@ test('scanner sets and scan plans bind every executable supply-chain artifact', 'VulnerabilityDatabaseDescriptor', 'ScannerSetDescriptor', 'ExpectedScannerArtifactBinding', + 'SastArtifactIngressReceipt', 'SastFileCoordinateMetadata', 'SAST_APPROVED_PROFILE_DIGESTS', 'isSignedSastArtifactDescriptorValid', 'isScannerSetDescriptorValid', 'isSastScanPlanValid', 'isScannerArtifactEnvelopeBoundToPlan', + 'isScannerArtifactEnvelopeShapeValid', + 'canonicalizeScannerArtifactEnvelope', + 'buildSastArtifactIngressIdempotencyKey', 'isScannerArtifactEligibleForNormalization', 'isSastFindingLocationValid' ]) { 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 b0c9fef..7c22586 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -374,6 +374,31 @@ write-only endpoint. Result ingress checks in this order: 8. secret-field and unsafe markup checks 9. produced timestamp and replay/idempotency key +The T029 transport is `PUT +/api/scan-plane/result-ingress/{scanRequestId}/scanner-runs/{scannerRunId}` with +`application/octet-stream`. `x-aegis-sast-artifact-envelope` carries at most 8 KiB of +canonical JSON encoded as unpadded base64url, `Idempotency-Key` is exactly +`sast-ingress-v1:{scannerRunId}:{contentDigest}`, and a positive canonical `Content-Length` +must equal the envelope byte count before streaming begins. The path, envelope, immutable +plan, active attempt, and pre-registered `RUNNING` scanner run must all agree. + +The ingress identity comes only from a directly authorized TLS peer certificate containing +exactly one bounded SPIFFE URI SAN. Caller headers, including forwarded client-certificate +headers, are never an identity source. The identity must match both the active durable attempt +and envelope before the artifact stream is passed to object storage. The attempt must remain +`SCANNING`, the scanner run must remain `RUNNING`, and the signed attempt deadline must not +have elapsed. SPIFFE syntax validation requires a lowercase trust domain, path segments limited +to `[A-Za-z0-9._-]+`, and rejects percent encoding plus `.` or `..` path segments. + +The Scan Plane object-store interface intentionally exposes only immutable `put` and cleanup +`delete`; it has no read method. A first upload creates one scanner-run-unique +`RECEIVING` record, atomically binds the opaque object key, observed byte count, and observed +digest, then returns only a `PENDING_VALIDATION` receipt. An exact retry returns the same +receipt without overwriting the object; changed-envelope replay fails closed. No GET route +exists and the object key is absent from all ingress responses. The default production +adapter remains unavailable until a Data/Security Plane object-store implementation is +installed, so local filesystem storage cannot become a production fallback. + Accepted artifacts become short-lived Data/Security objects. Rejected artifacts record metadata only. Security-significant mismatches are encrypted into an access-restricted quarantine prefix with the same maximum seven-day retention and no user access. diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index 54bdad5..7723b4f 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -272,7 +272,7 @@ Extends the production architecture scanner run with: Metadata that crosses out of the sandbox. -- tenant, scan, attempt, scanner run, and workload-identity references +- tenant, repository binding, scan, attempt, scanner run, and workload-identity references - scanner, wrapper, image, scanner-set, profile, rule, database, schema, and normalizer versions/digests - fixed input commit SHA @@ -287,6 +287,23 @@ Metadata that crosses out of the sandbox. The envelope never embeds raw artifact bytes. +### SastArtifactIngestion + +Operational write-only intake state before an `ArtifactIngestionDecision`. + +- tenant, repository binding, scan, attempt, scanner run, and workload-identity references +- scanner-run-unique idempotency key and canonical envelope digest +- declared and independently observed content digests and byte counts +- opaque Data/Security Plane object key, never returned by the ingress or user-facing APIs +- workload-identity validation result +- `RECEIVING | PENDING_VALIDATION | ACCEPTED | REJECTED | QUARANTINED` +- rejection reason, bounded validation metadata, received timestamp, and audit references + +Only a directly authenticated, attempt-bound workload can create the row. `RECEIVING` has no +object key or observed metadata. `PENDING_VALIDATION` has an immutable object key, matching +transport byte count, observed digest, and receipt timestamp; it is not yet eligible for +normalization. One scanner run can own at most one ingestion. + ### ArtifactIngestionDecision - envelope reference and digest diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index 76fbdb3..f414807 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -111,7 +111,8 @@ Fast and Deep lanes use separate queues and budgets but the same microVM securit ## Implemented Runtime Checkpoint -T022 through T028 are implemented as the complete Phase 5 runtime boundary: +T022 through T029 are implemented as the complete Phase 5 runtime boundary plus the first +Phase 6 ingress boundary: - Token Broker verifies a signed, bounded-lifetime workload attestation against tenant, repository binding, scan request, attempt, workload identity, and fixed commit. A durable @@ -183,12 +184,22 @@ T022 through T028 are implemented as the complete Phase 5 runtime boundary: - `ANALYSIS_CLIENT_MODE=mock`, the mock scan controller, legacy source collection, and `MockAnalysisApiClient` are test-only. Non-test configuration and runtime paths fail closed before repository credential decryption or source collection. +- Scanner runs are registered durably as `RUNNING` before sandbox execution so a scanner can + upload only while its attempt remains `SCANNING` and before the signed deadline. The + per-scan `application/octet-stream` ingress accepts canonical envelope metadata plus a + bounded stream only from a directly authenticated mTLS SPIFFE identity that exactly matches + the durable attempt. One immutable artifact is reserved per scanner run; exact replay is + idempotent, changed replay is rejected, and the response exposes only a + `PENDING_VALIDATION` receipt. +- The Scan Plane has no artifact read route and its object-store port exposes only immutable + write and cleanup delete. The default provider fails closed until the production + Data/Security Plane adapter is installed; raw object keys never enter user-facing responses. This checkpoint proves the provider-facing execution contract but does not claim that the provider microVM platform is live. The non-production opaque credential issuer and test runtime provider exist only to verify the handoff contract. Default production credential issuance and scanner execution both fail closed until live rollout installs provider-backed -GitHub App/GitLab scoped minting and microVM adapters. T029 is therefore the next implementation +GitHub App/GitLab scoped minting, microVM, and artifact object-store adapters. T030 is therefore the next implementation task; live deployment eligibility still requires the 005 rollout and the remaining 006 gates. ## Deployment Position diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 84886c3..721ad2e 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -45,7 +45,7 @@ ## Phase 6: Artifact Ingress and Normalization -- [ ] T029 Implement per-scan write-only artifact ingress and workload identity validation +- [x] T029 Implement per-scan write-only artifact ingress and workload identity validation - [ ] T030 Verify plan binding, schema, digest, byte/count, encoding, path, and coordinate limits - [ ] T031 Quarantine malformed, mismatched, oversized, and security-violating artifacts - [ ] T032 Implement versioned OpenGrep SARIF normalization with golden fixtures