diff --git a/.env.example b/.env.example index 431f052..6f65f0d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ THROTTLE_TTL_MS=60000 THROTTLE_LIMIT=120 # Generate a unique 64-character hex key per environment: openssl rand -hex 32 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 +CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITHUB_APP_ID= diff --git a/apps/api/.env.example b/apps/api/.env.example index 431f052..6f65f0d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -14,6 +14,9 @@ THROTTLE_TTL_MS=60000 THROTTLE_LIMIT=120 # Generate a unique 64-character hex key per environment: openssl rand -hex 32 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 +CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITHUB_APP_ID= diff --git a/apps/api/prisma/migrations/20260724110000_repository_binding_lease_scope_index/migration.sql b/apps/api/prisma/migrations/20260724110000_repository_binding_lease_scope_index/migration.sql new file mode 100644 index 0000000..ca6dcc0 --- /dev/null +++ b/apps/api/prisma/migrations/20260724110000_repository_binding_lease_scope_index/migration.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX CONCURRENTLY "RepositoryBinding_id_tenantId_key" +ON "RepositoryBinding"("id", "tenantId"); diff --git a/apps/api/prisma/migrations/20260724111000_scan_request_lease_scope_index/migration.sql b/apps/api/prisma/migrations/20260724111000_scan_request_lease_scope_index/migration.sql new file mode 100644 index 0000000..474601c --- /dev/null +++ b/apps/api/prisma/migrations/20260724111000_scan_request_lease_scope_index/migration.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX CONCURRENTLY "ScanRequest_id_tenantId_repositoryBindingId_key" +ON "ScanRequest"("id", "tenantId", "repositoryBindingId"); diff --git a/apps/api/prisma/migrations/20260724120000_sast_repository_credential_lease/migration.sql b/apps/api/prisma/migrations/20260724120000_sast_repository_credential_lease/migration.sql new file mode 100644 index 0000000..48b7f8e --- /dev/null +++ b/apps/api/prisma/migrations/20260724120000_sast_repository_credential_lease/migration.sql @@ -0,0 +1,79 @@ +CREATE TYPE "SastCredentialLeaseStatus" AS ENUM ('RESERVED', 'ISSUED', 'WIPED', 'REVOKED'); + +CREATE TABLE "SastRepositoryCredentialLease" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "repositoryBindingId" TEXT NOT NULL, + "scanRequestId" TEXT NOT NULL, + "attemptId" TEXT NOT NULL, + "workloadIdentityRef" TEXT NOT NULL, + "commitSha" TEXT NOT NULL, + "credentialFingerprint" TEXT, + "status" "SastCredentialLeaseStatus" NOT NULL DEFAULT 'RESERVED', + "issuedAt" TIMESTAMP(3) NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "wipedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SastRepositoryCredentialLease_pkey" PRIMARY KEY ("id"), + CONSTRAINT "SastRepositoryCredentialLease_expiry_check" + CHECK ("expiresAt" > "issuedAt"), + CONSTRAINT "SastRepositoryCredentialLease_commit_sha_check" + CHECK ("commitSha" ~ '^([0-9a-f]{40}|[0-9a-f]{64})$'), + CONSTRAINT "SastRepositoryCredentialLease_fingerprint_check" + CHECK ( + ("status" = 'RESERVED' AND "credentialFingerprint" IS NULL) + OR + ("status" IN ('ISSUED', 'WIPED') AND "credentialFingerprint" ~ '^sha256:[0-9a-f]{64}$') + OR + ("status" = 'REVOKED' AND ( + "credentialFingerprint" IS NULL + OR "credentialFingerprint" ~ '^sha256:[0-9a-f]{64}$' + )) + ), + CONSTRAINT "SastRepositoryCredentialLease_lifecycle_check" + CHECK ( + ("status" IN ('RESERVED', 'ISSUED') + AND "wipedAt" IS NULL + AND "revokedAt" IS NULL) + OR + ("status" = 'WIPED' + AND "wipedAt" IS NOT NULL + AND "wipedAt" >= "issuedAt" + AND "revokedAt" IS NULL) + OR + ("status" = 'REVOKED' + AND "revokedAt" IS NOT NULL + AND "revokedAt" >= "issuedAt" + AND "wipedAt" IS NULL) + ) +); + +CREATE UNIQUE INDEX "SastRepositoryCredentialLease_tenantId_attemptId_key" +ON "SastRepositoryCredentialLease"("tenantId", "attemptId"); + +CREATE INDEX "SastRepositoryCredentialLease_tenantId_scanRequestId_status_idx" +ON "SastRepositoryCredentialLease"("tenantId", "scanRequestId", "status"); + +CREATE INDEX "SastRepositoryCredentialLease_repositoryBindingId_status_idx" +ON "SastRepositoryCredentialLease"("repositoryBindingId", "status"); + +CREATE INDEX "SastRepositoryCredentialLease_expiresAt_status_idx" +ON "SastRepositoryCredentialLease"("expiresAt", "status"); + +ALTER TABLE "SastRepositoryCredentialLease" +ADD CONSTRAINT "SastRepositoryCredentialLease_tenantId_fkey" +FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastRepositoryCredentialLease" +ADD CONSTRAINT "SastCredentialLease_repository_scope_fkey" +FOREIGN KEY ("repositoryBindingId", "tenantId") +REFERENCES "RepositoryBinding"("id", "tenantId") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "SastRepositoryCredentialLease" +ADD CONSTRAINT "SastCredentialLease_scan_scope_fkey" +FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId") +REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId") +ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8269cad..b56bf05 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -82,6 +82,13 @@ enum ArchitectureScanStatus { CANCELED } +enum SastCredentialLeaseStatus { + RESERVED + ISSUED + WIPED + REVOKED +} + enum IsolationClass { STANDARD HARDENED @@ -269,18 +276,19 @@ model Tenant { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - integrations ScmIntegration[] - repositoryBindings RepositoryBinding[] - scanRequests ScanRequest[] - scannerRuns ScannerRun[] - normalizedFindings NormalizedFinding[] - evidencePacks EvidencePack[] - policyDecisions PolicyDecision[] - aiAdvisoryMetadata AiAdvisoryMetadata[] - waivers Waiver[] - suppressions Suppression[] - auditEvents AuditEvent[] - users User[] + integrations ScmIntegration[] + repositoryBindings RepositoryBinding[] + scanRequests ScanRequest[] + scannerRuns ScannerRun[] + normalizedFindings NormalizedFinding[] + evidencePacks EvidencePack[] + policyDecisions PolicyDecision[] + aiAdvisoryMetadata AiAdvisoryMetadata[] + waivers Waiver[] + suppressions Suppression[] + auditEvents AuditEvent[] + sastCredentialLeases SastRepositoryCredentialLease[] + users User[] } model ScmIntegration { @@ -316,10 +324,12 @@ model RepositoryBinding { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) - scanRequests ScanRequest[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + integration ScmIntegration @relation(fields: [scmIntegrationId], references: [id], onDelete: Cascade) + scanRequests ScanRequest[] + sastCredentialLeases SastRepositoryCredentialLease[] + @@unique([id, tenantId]) @@unique([tenantId, scmIntegrationId, providerRepoId]) @@index([tenantId]) @@index([tenantId, status]) @@ -344,8 +354,8 @@ model ScanRequest { completedAt DateTime? updatedAt DateTime @updatedAt - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + repositoryBinding RepositoryBinding @relation(fields: [repositoryBindingId], references: [id], onDelete: Cascade) scannerRuns ScannerRun[] findings NormalizedFinding[] evidencePacks EvidencePack[] @@ -354,7 +364,9 @@ model ScanRequest { suppressions Suppression[] auditEvents AuditEvent[] sastQueueReservation SastQueueReservation? + sastCredentialLeases SastRepositoryCredentialLease[] + @@unique([id, tenantId, repositoryBindingId]) @@index([tenantId]) @@index([repositoryBindingId]) @@index([status]) @@ -376,6 +388,33 @@ model SastQueueLedger { reservations SastQueueReservation[] } +model SastRepositoryCredentialLease { + id String @id + tenantId String + repositoryBindingId String + scanRequestId String + attemptId String + workloadIdentityRef String + commitSha String + credentialFingerprint String? + status SastCredentialLeaseStatus @default(RESERVED) + issuedAt DateTime + expiresAt DateTime + wipedAt DateTime? + revokedAt 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: "SastCredentialLease_repository_scope_fkey") + scanRequest ScanRequest @relation(fields: [scanRequestId, tenantId, repositoryBindingId], references: [id, tenantId, repositoryBindingId], onDelete: Cascade, map: "SastCredentialLease_scan_scope_fkey") + + @@unique([tenantId, attemptId]) + @@index([tenantId, scanRequestId, status]) + @@index([repositoryBindingId, status]) + @@index([expiresAt, status]) +} + model SastQueueTenantUsage { ledgerId String tenantId String diff --git a/apps/api/src/config/config.module.ts b/apps/api/src/config/config.module.ts index 88efa68..bc6e3c3 100644 --- a/apps/api/src/config/config.module.ts +++ b/apps/api/src/config/config.module.ts @@ -1,72 +1,11 @@ import { Global, Module } from '@nestjs/common'; import { ConfigModule as NestConfigModule } from '@nestjs/config'; -import * as Joi from 'joi'; import { ENV_FILE_PATHS } from './config.paths'; +import { ENVIRONMENT_VALIDATION_SCHEMA } from './config.schema'; import { ConfigService } from './config.service'; -export const ENVIRONMENT_VALIDATION_SCHEMA = Joi.object({ - NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), - PORT: Joi.number().port().default(3000), - DATABASE_URL: Joi.string().uri().required(), - REDIS_URL: Joi.string().uri().required(), - SESSION_SECRET: Joi.string().min(32).required(), - CSRF_SECRET: Joi.string().min(32).required(), - GITHUB_CLIENT_ID: Joi.string().required(), - GITHUB_CLIENT_SECRET: Joi.string().required(), - GITHUB_APP_ID: Joi.string().allow('').default(''), - GITHUB_APP_PRIVATE_KEY: Joi.string().allow('').default(''), - GITLAB_CLIENT_ID: Joi.string().required(), - GITLAB_CLIENT_SECRET: Joi.string().required(), - GITLAB_API_BASE_URL: Joi.string().uri().default('https://gitlab.com/api/v4'), - APP_URL: Joi.string().when('NODE_ENV', { - is: 'production', - then: Joi.string().uri({ scheme: ['https'] }).required(), - otherwise: Joi.string().uri().required() - }), - FRONTEND_URL: Joi.string().when('NODE_ENV', { - is: 'production', - then: Joi.string().uri({ scheme: ['https'] }).required(), - otherwise: Joi.string().uri().required() - }), - SESSION_COOKIE_NAME: Joi.string().default('connect.sid'), - CSRF_COOKIE_NAME: Joi.string().default('csrf_token'), - COOKIE_DOMAIN: Joi.string().allow('').optional(), - COOKIE_SECURE: Joi.string().when('NODE_ENV', { - is: 'production', - then: Joi.valid('true').required(), - otherwise: Joi.valid('true', 'false').default('false') - }), - SESSION_TTL_SECONDS: Joi.number().integer().min(900).max(86_400).default(28_800), - THROTTLE_TTL_MS: Joi.number().integer().min(1_000).max(3_600_000).default(60_000), - THROTTLE_LIMIT: Joi.number().integer().min(1).max(10_000).default(120), - TOKEN_ENCRYPTION_KEY: Joi.string().length(64).required(), - ANALYSIS_CLIENT_MODE: Joi.string().valid('mock', 'internal').default('mock'), - 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), - INTERNAL_API_SECRET: Joi.when('NODE_ENV', { - is: 'production', - then: Joi.string().min(32).required(), - otherwise: Joi.string().allow('').default('') - }), - GITHUB_APP_WEBHOOK_SECRET: Joi.when('NODE_ENV', { - is: 'production', - then: Joi.string().min(32).required(), - otherwise: Joi.string().allow('').default('') - }), - GITLAB_WEBHOOK_SECRET: Joi.when('NODE_ENV', { - is: 'production', - then: Joi.string().min(32).required(), - otherwise: Joi.string().allow('').default('') - }), - REPORT_STORAGE_PATH: Joi.string().default('./tmp/reports'), - REPORT_EXPIRY_HOURS: Joi.number().integer().positive().default(24), - REPORT_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000), - EVIDENCE_STORAGE_PATH: Joi.string().default('./tmp/evidence'), - EVIDENCE_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000), - TEAMS_WEBHOOK_URL: Joi.string().uri().allow('').optional() -}); +export { ENVIRONMENT_VALIDATION_SCHEMA } from './config.schema'; @Global() @Module({ diff --git a/apps/api/src/config/config.schema.ts b/apps/api/src/config/config.schema.ts new file mode 100644 index 0000000..42311a6 --- /dev/null +++ b/apps/api/src/config/config.schema.ts @@ -0,0 +1,101 @@ +import * as Joi from 'joi'; + +export const ENVIRONMENT_VALIDATION_SCHEMA = Joi.object({ + NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'), + PORT: Joi.number().port().default(3000), + DATABASE_URL: Joi.string().uri().required(), + REDIS_URL: Joi.string().uri().required(), + SESSION_SECRET: Joi.string().min(32).required(), + CSRF_SECRET: Joi.string().min(32).required(), + GITHUB_CLIENT_ID: Joi.string().required(), + GITHUB_CLIENT_SECRET: Joi.string().required(), + GITHUB_APP_ID: Joi.string().allow('').default(''), + GITHUB_APP_PRIVATE_KEY: Joi.string().allow('').default(''), + GITLAB_CLIENT_ID: Joi.string().required(), + GITLAB_CLIENT_SECRET: Joi.string().required(), + GITLAB_API_BASE_URL: Joi.string().uri().default('https://gitlab.com/api/v4'), + APP_URL: Joi.string().when('NODE_ENV', { + is: 'production', + then: Joi.string().uri({ scheme: ['https'] }).required(), + otherwise: Joi.string().uri().required() + }), + FRONTEND_URL: Joi.string().when('NODE_ENV', { + is: 'production', + then: Joi.string().uri({ scheme: ['https'] }).required(), + otherwise: Joi.string().uri().required() + }), + SESSION_COOKIE_NAME: Joi.string().default('connect.sid'), + CSRF_COOKIE_NAME: Joi.string().default('csrf_token'), + COOKIE_DOMAIN: Joi.string().allow('').optional(), + COOKIE_SECURE: Joi.string().when('NODE_ENV', { + is: 'production', + then: Joi.valid('true').required(), + otherwise: Joi.valid('true', 'false').default('false') + }), + SESSION_TTL_SECONDS: Joi.number().integer().min(900).max(86_400).default(28_800), + THROTTLE_TTL_MS: Joi.number().integer().min(1_000).max(3_600_000).default(60_000), + THROTTLE_LIMIT: Joi.number().integer().min(1).max(10_000).default(120), + TOKEN_ENCRYPTION_KEY: Joi.string().hex().length(64).lowercase().required(), + WORKLOAD_ATTESTATION_KEY: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string() + .hex() + .length(64) + .lowercase() + .invalid(Joi.ref('TOKEN_ENCRYPTION_KEY')) + .required(), + otherwise: Joi.string() + .hex() + .length(64) + .lowercase() + .invalid(Joi.ref('TOKEN_ENCRYPTION_KEY')) + .default('a'.repeat(64)) + }), + PREFLIGHT_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')) + .required(), + otherwise: Joi.string() + .hex() + .length(64) + .lowercase() + .invalid(Joi.ref('TOKEN_ENCRYPTION_KEY')) + .invalid(Joi.ref('WORKLOAD_ATTESTATION_KEY')) + .default('b'.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'), + 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), + INTERNAL_API_SECRET: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string().min(32).required(), + otherwise: Joi.string().allow('').default('') + }), + GITHUB_APP_WEBHOOK_SECRET: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string().min(32).required(), + otherwise: Joi.string().allow('').default('') + }), + GITLAB_WEBHOOK_SECRET: Joi.when('NODE_ENV', { + is: 'production', + then: Joi.string().min(32).required(), + otherwise: Joi.string().allow('').default('') + }), + REPORT_STORAGE_PATH: Joi.string().default('./tmp/reports'), + REPORT_EXPIRY_HOURS: Joi.number().integer().positive().default(24), + REPORT_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000), + EVIDENCE_STORAGE_PATH: Joi.string().default('./tmp/evidence'), + EVIDENCE_EXPIRY_INTERVAL_MS: Joi.number().integer().positive().default(15 * 60 * 1000), + TEAMS_WEBHOOK_URL: Joi.string().uri().allow('').optional() +}); diff --git a/apps/api/src/config/config.types.ts b/apps/api/src/config/config.types.ts index eaf7014..63a416e 100644 --- a/apps/api/src/config/config.types.ts +++ b/apps/api/src/config/config.types.ts @@ -26,6 +26,9 @@ export interface EnvironmentVariables { THROTTLE_TTL_MS: number; THROTTLE_LIMIT: number; TOKEN_ENCRYPTION_KEY: string; + WORKLOAD_ATTESTATION_KEY: string; + PREFLIGHT_ATTESTATION_KEY: string; + CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS: number; ANALYSIS_CLIENT_MODE: AnalysisClientMode; AI_SERVER_URL: string; USE_INTERNAL_AI: BooleanString; diff --git a/apps/api/src/control-plane/control-plane.service.ts b/apps/api/src/control-plane/control-plane.service.ts index b26965a..46a25e2 100644 --- a/apps/api/src/control-plane/control-plane.service.ts +++ b/apps/api/src/control-plane/control-plane.service.ts @@ -31,7 +31,8 @@ import type { GithubWebhookRepositoryInput, InstallIntegrationInput, InstallIntegrationOptions, - InstallRepositoryInput + InstallRepositoryInput, + RepositoryFetchTarget } from "./control-plane.types"; import { GithubAppInstallationClient } from "./github-app-installation.client"; import { GithubAppInstallationStateService } from "./github-app-installation-state.service"; @@ -158,6 +159,23 @@ export class ControlPlaneService { return this.scanRequestStore.listRepositoryBindings(tenantId); } + async getRepositoryFetchTarget( + tenantId: string, + repositoryBindingId: string + ): Promise { + const context = await this.scanRequestStore.findRepositoryContext( + tenantId, + repositoryBindingId + ); + if (!context) { + throw new NotFoundException('Active repository binding not found for fetch.'); + } + return { + provider: context.integration.provider, + fullName: context.repositoryBinding.fullName + }; + } + async reconcileGithubInstallationWebhook( event: string, input: GithubInstallationWebhookInput diff --git a/apps/api/src/control-plane/control-plane.types.ts b/apps/api/src/control-plane/control-plane.types.ts index 30e0f0b..5438964 100644 --- a/apps/api/src/control-plane/control-plane.types.ts +++ b/apps/api/src/control-plane/control-plane.types.ts @@ -82,3 +82,8 @@ export interface InstallIntegrationOptions { export interface BuildScanRequestOptions { isolationClass: IsolationClass; } + +export interface RepositoryFetchTarget { + provider: ScmProvider; + fullName: string; +} diff --git a/apps/api/src/scan-plane/credential-tmpfs-verifier.service.ts b/apps/api/src/scan-plane/credential-tmpfs-verifier.service.ts new file mode 100644 index 0000000..785be39 --- /dev/null +++ b/apps/api/src/scan-plane/credential-tmpfs-verifier.service.ts @@ -0,0 +1,21 @@ +import { statfs } from 'node:fs/promises'; + +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; + +const TMPFS_MAGIC = 0x01021994; + +export abstract class CredentialTmpfsVerifier { + abstract assertTmpfs(path: string): Promise; +} + +@Injectable() +export class NodeCredentialTmpfsVerifier extends CredentialTmpfsVerifier { + async assertTmpfs(path: string): Promise { + const stats = await statfs(path); + if (Number(stats.type) !== TMPFS_MAGIC) { + throw new ServiceUnavailableException( + 'Repository credential handoff requires a verified tmpfs mount.' + ); + } + } +} diff --git a/apps/api/src/scan-plane/repository-fetch.service.ts b/apps/api/src/scan-plane/repository-fetch.service.ts new file mode 100644 index 0000000..c762757 --- /dev/null +++ b/apps/api/src/scan-plane/repository-fetch.service.ts @@ -0,0 +1,823 @@ +import { createHash } from 'node:crypto'; +import { + chmod, + lstat, + mkdtemp, + mkdir, + readdir, + realpath, + rm, + unlink, + writeFile +} from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { TextDecoder } from 'node:util'; + +import { + type ScmProvider, + type SastRepositoryFetchMetadata, + type SastRepositoryPreflightInput, + type SastRepositoryTreeEntry, + type TokenBrokerIssueRequest +} from '@aegisai/shared'; +import { + BadRequestException, + Injectable, + ServiceUnavailableException +} from '@nestjs/common'; + +import { ControlPlaneService } from '../control-plane/control-plane.service'; +import { TokenBrokerService } from '../token-broker/token-broker.service'; +import { CredentialTmpfsVerifier } from './credential-tmpfs-verifier.service'; +import { + RepositoryGitExecutor, + type RepositoryGitCommandResult +} from './repository-git-executor'; + +const FULL_COMMIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const GIT_COMMAND_TIMEOUT_MS = 120_000; +const TREE_ENRICHMENT_CONCURRENCY = 16; +const MAX_TREE_INVENTORY_OUTPUT_BYTES = 64 * 1024 * 1024; +const MAX_SYMLINK_TARGET_BYTES = 4096; +const ALLOWED_GIT_TREE_ENTRIES = new Set([ + '100644:blob', + '100755:blob', + '120000:blob', + '160000:commit' +]); + +type ParsedGitTreeEntry = SastRepositoryTreeEntry; +type PendingRepositoryFetchResult = Omit & { + metadata: Omit; +}; + +export interface RepositoryFetchInput { + scratchRoot: string; + workspaceRoot: string; + credentialTmpfsRoot: string; + limits: SastRepositoryPreflightInput['limits']; + tokenRequest: TokenBrokerIssueRequest; +} + +export interface RepositoryFetchResult { + metadata: SastRepositoryFetchMetadata; + workspaceRoot: string; + entries: SastRepositoryTreeEntry[]; +} + +@Injectable() +export class RepositoryFetchService { + constructor( + private readonly tokenBroker: TokenBrokerService, + private readonly git: RepositoryGitExecutor, + private readonly controlPlane: ControlPlaneService, + private readonly credentialTmpfsVerifier: CredentialTmpfsVerifier + ) {} + + async fetch(input: RepositoryFetchInput): Promise { + this.validateInput(input); + const target = await this.controlPlane.getRepositoryFetchTarget( + input.tokenRequest.tenantId, + input.tokenRequest.repositoryBindingId + ); + const remote = this.buildRemote(target.provider, target.fullName); + await this.prepareEmptyWorkspace(input.scratchRoot, input.workspaceRoot); + const credentialDirectory = await this.createCredentialDirectory( + input.scratchRoot, + input.credentialTmpfsRoot + ); + + try { + return await this.tokenBroker.withCredential(input.tokenRequest, async (credential) => { + let environment: Record | undefined; + let remoteAdded = false; + let completed: PendingRepositoryFetchResult | undefined; + let credentialFilesWiped = false; + try { + environment = await this.createGitEnvironment( + credentialDirectory, + credential, + target.provider + ); + await this.run(input.workspaceRoot, environment, ['init', '--quiet', '.']); + await this.run(input.workspaceRoot, environment, [ + 'remote', + 'add', + 'origin', + remote.toString() + ]); + remoteAdded = true; + await this.run(input.workspaceRoot, environment, [ + 'config', + '--local', + 'fetch.recurseSubmodules', + 'false' + ]); + await this.run(input.workspaceRoot, environment, [ + 'config', + '--local', + 'submodule.recurse', + 'false' + ]); + await this.run(input.workspaceRoot, environment, [ + 'fetch', + '--quiet', + '--no-tags', + '--depth=1', + '--no-recurse-submodules', + 'origin', + input.tokenRequest.commitSha + ]); + + const fetchedCommit = this.singleLine( + await this.run(input.workspaceRoot, environment, [ + 'rev-parse', + 'FETCH_HEAD^{commit}' + ]) + ); + if (fetchedCommit !== input.tokenRequest.commitSha) { + throw new BadRequestException('Fetched commit does not match the immutable scan SHA.'); + } + + const tree = await this.run( + input.workspaceRoot, + environment, + ['ls-tree', '-r', '-z', '-l', input.tokenRequest.commitSha], + MAX_TREE_INVENTORY_OUTPUT_BYTES + ); + const parsedEntries = this.parseTree( + tree.stdout, + input.tokenRequest.commitSha.length + ); + this.assertMaterializationLimits(parsedEntries, input.limits); + const entries = await this.enrichEntries( + input.workspaceRoot, + environment, + parsedEntries + ); + const countObjects = this.singleLineBlock( + await this.run(input.workspaceRoot, environment, ['count-objects', '-v']) + ); + const counts = this.parseObjectCounts(countObjects); + if (counts.fetchedBytes > input.limits.maxRepositoryBytes) { + throw new BadRequestException( + 'Fetched Git object storage exceeds the repository byte limit.' + ); + } + + await this.run(input.workspaceRoot, environment, [ + 'checkout', + '--quiet', + '--detach', + '--force', + input.tokenRequest.commitSha + ]); + const headCommit = this.singleLine( + await this.run(input.workspaceRoot, environment, ['rev-parse', 'HEAD']) + ); + if (headCommit !== input.tokenRequest.commitSha) { + throw new BadRequestException('Detached checkout does not match the fixed commit SHA.'); + } + const shallow = this.singleLine( + await this.run(input.workspaceRoot, environment, [ + 'rev-parse', + '--is-shallow-repository' + ]) + ); + if (shallow !== 'true') { + throw new BadRequestException('Repository fetch did not produce a shallow checkout.'); + } + + await this.run(input.workspaceRoot, environment, [ + 'remote', + 'remove', + 'origin' + ]); + remoteAdded = false; + await this.removeGitMetadata(input.workspaceRoot); + + completed = { + metadata: { + attemptId: input.tokenRequest.attemptId, + fixedCommitSha: input.tokenRequest.commitSha, + remoteHost: remote.hostname.toLowerCase(), + objectCount: counts.objectCount, + fetchedBytes: counts.fetchedBytes, + shallow: true, + detachedHead: true, + submodulesFetched: false, + lfsObjectsFetched: false, + archivesExpanded: false, + gitMetadataRemoved: true + }, + workspaceRoot: resolve(input.workspaceRoot), + entries + }; + } finally { + if (remoteAdded && environment) { + await this.run(input.workspaceRoot, environment, [ + 'remote', + 'remove', + 'origin' + ]).catch(() => undefined); + } + credentialFilesWiped = await this.destroyCredentialFiles( + credentialDirectory, + credential.byteLength + ); + } + if (!completed || !credentialFilesWiped) { + throw new ServiceUnavailableException( + 'Repository fetch cleanup evidence is incomplete.' + ); + } + return { + ...completed, + metadata: { + ...completed.metadata, + credentialWiped: true + } + }; + }); + } finally { + await this.removeCredentialDirectory(input.scratchRoot, credentialDirectory); + } + } + + private validateInput(input: RepositoryFetchInput): void { + if ( + !FULL_COMMIT_SHA.test(input.tokenRequest.commitSha) || + input.tokenRequest.principal !== 'REPO_READ' || + !this.validPositiveLimit(input.limits?.maxRepositoryBytes) || + !this.validPositiveLimit(input.limits?.maxSelectedBytes) || + !this.validPositiveLimit(input.limits?.maxFileCount) || + !this.validPositiveLimit(input.limits?.maxSingleFileBytes) || + !this.validPositiveLimit(input.limits?.maxPathDepth) + ) { + throw new BadRequestException('Repository fetch requires a full fixed commit and REPO_READ.'); + } + this.assertDisjointPaths(input.workspaceRoot, input.credentialTmpfsRoot); + } + + private buildRemote(provider: ScmProvider, fullName: string): URL { + const githubName = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; + const gitlabName = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/; + const pathSegments = fullName.split('/'); + if ( + fullName.length > 512 || + (provider === 'GITHUB' && !githubName.test(fullName)) || + (provider === 'GITLAB' && !gitlabName.test(fullName)) || + pathSegments.some( + (segment) => + segment === '.' || segment === '..' || segment.length > 255 + ) + ) { + throw new BadRequestException('Durable repository name is outside the SCM path policy.'); + } + const expectedHost = provider === 'GITHUB' ? 'github.com' : 'gitlab.com'; + const remote = new URL(`https://${expectedHost}/${fullName}.git`); + if ( + remote.protocol !== 'https:' || + remote.hostname.toLowerCase() !== expectedHost || + remote.username || + remote.password || + remote.port || + remote.search || + remote.hash + ) { + throw new BadRequestException('Repository remote is outside the approved SCM host policy.'); + } + return remote; + } + + private async prepareEmptyWorkspace(scratchRoot: string, workspaceRoot: string): Promise { + this.assertDescendant(scratchRoot, workspaceRoot, 'workspace'); + await this.assertRealDirectory(scratchRoot, 'scratch root'); + await this.ensureRealDirectoryPath( + scratchRoot, + dirname(resolve(workspaceRoot)), + 'workspace parent' + ); + try { + await mkdir(resolve(workspaceRoot), { recursive: false }); + } catch { + const entries = await readdir(resolve(workspaceRoot)).catch(() => null); + if (!entries || entries.length !== 0) { + throw new BadRequestException('Repository workspace must be new or empty.'); + } + } + await this.assertRealDescendant(scratchRoot, workspaceRoot, 'workspace'); + } + + private async createCredentialDirectory( + scratchRoot: string, + credentialTmpfsRoot: string + ): Promise { + this.assertDescendant(scratchRoot, credentialTmpfsRoot, 'credential tmpfs'); + await this.assertRealDirectory(scratchRoot, 'scratch root'); + await this.ensureRealDirectoryPath( + scratchRoot, + credentialTmpfsRoot, + 'credential tmpfs' + ); + await chmod(resolve(credentialTmpfsRoot), 0o700); + await this.assertRealDescendant( + scratchRoot, + credentialTmpfsRoot, + 'credential tmpfs' + ); + await this.credentialTmpfsVerifier.assertTmpfs(resolve(credentialTmpfsRoot)); + const directory = await mkdtemp(join(resolve(credentialTmpfsRoot), 'aegis-credential-')); + this.assertDescendant(credentialTmpfsRoot, directory, 'credential lease'); + await chmod(directory, 0o700); + await this.assertRealDescendant( + credentialTmpfsRoot, + directory, + 'credential lease' + ); + return directory; + } + + private async createGitEnvironment( + credentialDirectory: string, + credential: Uint8Array, + provider: ScmProvider + ): Promise> { + const credentialPath = join(credentialDirectory, 'credential'); + const askPassPath = join(credentialDirectory, 'askpass.sh'); + const globalConfigPath = join(credentialDirectory, 'gitconfig'); + await writeFile(credentialPath, credential, { mode: 0o600, flag: 'wx' }); + await writeFile( + askPassPath, + [ + '#!/bin/sh', + 'case "$1" in', + ' *Username*) printf "%s\\n" "$AEGIS_GIT_USERNAME" ;;', + ' *) exec /bin/cat "$AEGIS_CREDENTIAL_FILE" ;;', + 'esac', + '' + ].join('\n'), + { mode: 0o700, flag: 'wx' } + ); + await writeFile(globalConfigPath, '', { mode: 0o600, flag: 'wx' }); + + return { + GIT_ASKPASS: askPassPath, + GIT_TERMINAL_PROMPT: '0', + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: globalConfigPath, + GIT_LFS_SKIP_SMUDGE: '1', + GIT_OPTIONAL_LOCKS: '0', + AEGIS_CREDENTIAL_FILE: credentialPath, + AEGIS_GIT_USERNAME: provider === 'GITHUB' ? 'x-access-token' : 'oauth2' + }; + } + + private async destroyCredentialFiles( + credentialDirectory: string, + credentialBytes: number + ): Promise { + const credentialPath = join(credentialDirectory, 'credential'); + const paths = [ + credentialPath, + join(credentialDirectory, 'askpass.sh'), + join(credentialDirectory, 'gitconfig') + ]; + await writeFile(credentialPath, Buffer.alloc(credentialBytes), { + mode: 0o600, + flag: 'w' + }).catch(() => undefined); + await Promise.all(paths.map((path) => this.unlinkIfPresent(path))); + const remaining = await Promise.all( + paths.map((path) => + lstat(path).then( + () => true, + (error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + return false; + } + throw error; + } + ) + ) + ); + if (remaining.some(Boolean)) { + throw new ServiceUnavailableException( + 'Repository credential files could not be removed.' + ); + } + return true; + } + + private async unlinkIfPresent(path: string): Promise { + try { + await unlink(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + + private async removeCredentialDirectory( + scratchRoot: string, + credentialDirectory: string + ): Promise { + this.assertDescendant(scratchRoot, credentialDirectory, 'credential cleanup'); + await this.assertRealDescendant( + scratchRoot, + credentialDirectory, + 'credential cleanup' + ); + await rm(credentialDirectory, { recursive: true, force: true }); + } + + private async removeGitMetadata(workspaceRoot: string): Promise { + const gitMetadataPath = resolve(workspaceRoot, '.git'); + this.assertDescendant(workspaceRoot, gitMetadataPath, 'git metadata cleanup'); + await this.assertRealDescendant( + workspaceRoot, + gitMetadataPath, + 'git metadata cleanup' + ); + await rm(gitMetadataPath, { recursive: true, force: true }); + } + + private async run( + cwd: string, + environment: Record, + args: string[], + maxOutputBytes?: number + ): Promise { + return this.git.run({ + args, + cwd, + environment, + timeoutMilliseconds: GIT_COMMAND_TIMEOUT_MS, + maxOutputBytes + }); + } + + private parseTree( + output: Buffer, + expectedObjectIdLength: number + ): ParsedGitTreeEntry[] { + const decoder = new TextDecoder('utf-8', { fatal: true }); + return output + .subarray(0, output.length > 0 && output[output.length - 1] === 0 ? -1 : undefined) + .toString('binary') + .split('\0') + .filter(Boolean) + .map((binaryRecord) => { + const record = Buffer.from(binaryRecord, 'binary'); + const tab = record.indexOf(0x09); + if (tab < 0) { + throw new BadRequestException('Git tree inventory record is malformed.'); + } + const header = record.subarray(0, tab).toString('ascii'); + const match = + /^(\d{6}) (blob|commit) ([0-9a-f]{40}|[0-9a-f]{64})\s+(-|\d+)$/.exec( + header + ); + if (!match) { + throw new BadRequestException('Git tree inventory metadata is malformed.'); + } + if (match[3].length !== expectedObjectIdLength) { + throw new BadRequestException( + 'Git tree object format does not match the fixed commit.' + ); + } + const pathBytes = record.subarray(tab + 1); + let path: string; + let pathEncodingValid = true; + try { + path = decoder.decode(pathBytes); + } catch { + pathEncodingValid = false; + path = `[invalid-utf8:${createHash('sha256') + .update(pathBytes) + .digest('hex') + .slice(0, 16)}]`; + } + const mode = match[1]; + const objectType = match[2]; + const rawByteSize = match[4]; + const byteSize = rawByteSize === '-' ? 0 : Number(rawByteSize); + if ( + !ALLOWED_GIT_TREE_ENTRIES.has(`${mode}:${objectType}`) || + (objectType === 'commit' && rawByteSize !== '-') || + (objectType === 'blob' && rawByteSize === '-') || + !Number.isSafeInteger(byteSize) || + byteSize < 0 + ) { + throw new BadRequestException('Git tree entry type or size is outside policy.'); + } + return { + path, + pathEncodingValid, + kind: + mode === '120000' + ? 'SYMLINK' + : mode === '160000' || objectType === 'commit' + ? 'SUBMODULE' + : 'FILE', + byteSize, + gitObjectId: `${ + match[3].length === 40 ? 'sha1' : 'sha256' + }:${match[3]}` as `sha1:${string}` | `sha256:${string}`, + executable: mode === '100755', + lfsPointer: false + }; + }); + } + + private async enrichEntries( + workspaceRoot: string, + environment: Record, + entries: ParsedGitTreeEntry[] + ): Promise { + const enriched = new Array(entries.length); + let cursor = 0; + let failed = false; + let firstError: unknown; + const worker = async () => { + while (!failed && cursor < entries.length) { + const index = cursor; + cursor += 1; + try { + enriched[index] = await this.enrichEntry( + workspaceRoot, + environment, + entries[index] + ); + } catch (error) { + failed = true; + firstError = error; + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(TREE_ENRICHMENT_CONCURRENCY, entries.length) }, + worker + ) + ); + if (failed) { + throw firstError; + } + return enriched; + } + + private async enrichEntry( + workspaceRoot: string, + environment: Record, + parsedEntry: ParsedGitTreeEntry + ): Promise { + const entry: SastRepositoryTreeEntry = { + path: parsedEntry.path, + pathEncodingValid: parsedEntry.pathEncodingValid, + kind: parsedEntry.kind, + byteSize: parsedEntry.byteSize, + gitObjectId: parsedEntry.gitObjectId, + executable: parsedEntry.executable, + symlinkTarget: parsedEntry.symlinkTarget, + symlinkTargetEncodingValid: parsedEntry.symlinkTargetEncodingValid, + lfsPointer: parsedEntry.lfsPointer + }; + if (entry.kind === 'SYMLINK') { + if (entry.byteSize > MAX_SYMLINK_TARGET_BYTES) { + throw new BadRequestException( + 'Symlink target exceeds the pre-materialization safety limit.' + ); + } + const targetBytes = await this.readGitBlob( + workspaceRoot, + environment, + entry, + MAX_SYMLINK_TARGET_BYTES + ); + const decoder = new TextDecoder('utf-8', { fatal: true }); + try { + return { + ...entry, + symlinkTarget: decoder.decode(targetBytes), + symlinkTargetEncodingValid: true + }; + } catch { + return { + ...entry, + symlinkTarget: `[invalid-utf8:${createHash('sha256') + .update(targetBytes) + .digest('hex') + .slice(0, 16)}]`, + symlinkTargetEncodingValid: false + }; + } + } + if (entry.kind !== 'FILE' || entry.byteSize > 1024) { + return entry; + } + + const prefix = await this.readGitBlob( + workspaceRoot, + environment, + entry, + 1024 + ); + return { + ...entry, + lfsPointer: prefix + .subarray(0, 256) + .toString('utf8') + .startsWith('version https://git-lfs.github.com/spec/v1\n') + }; + } + + private async readGitBlob( + workspaceRoot: string, + environment: Record, + entry: SastRepositoryTreeEntry, + maxOutputBytes: number + ): Promise { + const objectId = entry.gitObjectId.slice(entry.gitObjectId.indexOf(':') + 1); + const result = await this.run( + workspaceRoot, + environment, + ['cat-file', 'blob', objectId], + maxOutputBytes + ); + if (result.stdout.length !== entry.byteSize) { + throw new BadRequestException( + 'Git blob size does not match the attested tree inventory.' + ); + } + return result.stdout; + } + + private assertMaterializationLimits( + entries: SastRepositoryTreeEntry[], + limits: SastRepositoryPreflightInput['limits'] + ): void { + if (entries.length > limits.maxFileCount) { + throw new BadRequestException( + 'Repository file count exceeds the pre-materialization limit.' + ); + } + let repositoryBytes = 0; + for (const entry of entries) { + repositoryBytes += entry.byteSize; + if (!Number.isSafeInteger(repositoryBytes)) { + throw new BadRequestException( + 'Repository byte count exceeds safe integer bounds.' + ); + } + if (entry.byteSize > limits.maxSingleFileBytes) { + throw new BadRequestException( + 'Repository entry exceeds the pre-materialization file byte limit.' + ); + } + const depth = entry.path.replace(/\\/g, '/').split('/').filter(Boolean).length; + if (depth > limits.maxPathDepth) { + throw new BadRequestException( + 'Repository path depth exceeds the pre-materialization limit.' + ); + } + if (repositoryBytes > limits.maxRepositoryBytes) { + throw new BadRequestException( + 'Repository bytes exceed the pre-materialization limit.' + ); + } + } + } + + private validPositiveLimit(value: number | undefined): boolean { + return Number.isSafeInteger(value) && (value ?? 0) > 0; + } + + private parseObjectCounts(output: string): { objectCount: number; fetchedBytes: number } { + const values = new Map(); + for (const line of output.split(/\r?\n/).filter(Boolean)) { + const match = /^([a-z-]+):\s+(\d+)$/.exec(line); + if (!match || values.has(match[1])) { + throw new BadRequestException('Git object metadata is invalid.'); + } + const value = Number(match[2]); + if (!Number.isSafeInteger(value) || value < 0) { + throw new BadRequestException('Git object metadata is invalid.'); + } + values.set(match[1], value); + } + for (const required of ['count', 'in-pack', 'size', 'size-pack']) { + if (!values.has(required)) { + throw new BadRequestException('Git object metadata is incomplete.'); + } + } + const objectCount = (values.get('count') ?? 0) + (values.get('in-pack') ?? 0); + const fetchedKiB = (values.get('size') ?? 0) + (values.get('size-pack') ?? 0); + const fetchedBytes = fetchedKiB * 1024; + if ( + !Number.isSafeInteger(objectCount) || + !Number.isSafeInteger(fetchedBytes) + ) { + throw new BadRequestException('Git object metadata is invalid.'); + } + return { + objectCount, + fetchedBytes + }; + } + + private singleLine(result: RepositoryGitCommandResult): string { + return result.stdout.toString('utf8').trim(); + } + + private singleLineBlock(result: RepositoryGitCommandResult): string { + return result.stdout.toString('utf8').trim(); + } + + private assertDescendant(root: string, target: string, label: string): void { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + const delta = relative(resolvedRoot, resolvedTarget); + if ( + !delta || + isAbsolute(delta) || + delta === '..' || + delta.startsWith(`..${sep}`) || + resolve(resolvedRoot, delta) !== resolvedTarget + ) { + throw new BadRequestException(`${label} must be a strict descendant of the scratch root.`); + } + } + + private assertDisjointPaths(first: string, second: string): void { + const resolvedFirst = resolve(first); + const resolvedSecond = resolve(second); + const firstToSecond = relative(resolvedFirst, resolvedSecond); + const secondToFirst = relative(resolvedSecond, resolvedFirst); + const isNested = (delta: string) => + delta !== '' && delta !== '..' && !delta.startsWith(`..${sep}`); + if ( + resolvedFirst === resolvedSecond || + isNested(firstToSecond) || + isNested(secondToFirst) + ) { + throw new BadRequestException( + 'Repository workspace and credential tmpfs paths must be disjoint.' + ); + } + } + + private async assertRealDescendant( + root: string, + target: string, + label: string + ): Promise { + const [realRoot, realTarget] = await Promise.all([ + this.assertRealDirectory(root, `${label} root`), + this.assertRealDirectory(target, label) + ]); + this.assertDescendant(realRoot, realTarget, label); + } + + private async assertRealDirectory(path: string, label: string): Promise { + const resolvedPath = resolve(path); + const stats = await lstat(resolvedPath).catch(() => null); + if (!stats || !stats.isDirectory() || stats.isSymbolicLink()) { + throw new BadRequestException(`${label} must be a real directory.`); + } + return realpath(resolvedPath); + } + + private async ensureRealDirectoryPath( + root: string, + target: string, + label: string + ): Promise { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + const delta = relative(resolvedRoot, resolvedTarget); + if (delta === '') { + await this.assertRealDirectory(resolvedRoot, label); + return; + } + if (isAbsolute(delta) || delta === '..' || delta.startsWith(`..${sep}`)) { + throw new BadRequestException(`${label} must remain inside the scratch root.`); + } + + const realRoot = await this.assertRealDirectory(resolvedRoot, `${label} root`); + let cursor = resolvedRoot; + for (const segment of delta.split(sep)) { + cursor = join(cursor, segment); + try { + await mkdir(cursor, { recursive: false, mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + } + const realCursor = await this.assertRealDirectory(cursor, label); + this.assertDescendant(realRoot, realCursor, label); + } + } +} diff --git a/apps/api/src/scan-plane/repository-git-executor.ts b/apps/api/src/scan-plane/repository-git-executor.ts new file mode 100644 index 0000000..364989a --- /dev/null +++ b/apps/api/src/scan-plane/repository-git-executor.ts @@ -0,0 +1,102 @@ +import { spawn } from 'node:child_process'; + +import { Injectable } from '@nestjs/common'; + +export interface RepositoryGitCommand { + args: string[]; + cwd: string; + environment: Record; + timeoutMilliseconds: number; + maxOutputBytes?: number; +} + +export interface RepositoryGitCommandResult { + stdout: Buffer; + stderr: Buffer; +} + +export abstract class RepositoryGitExecutor { + abstract run(command: RepositoryGitCommand): Promise; +} + +@Injectable() +export class NodeRepositoryGitExecutor extends RepositoryGitExecutor { + run(command: RepositoryGitCommand): Promise { + const maxOutputBytes = command.maxOutputBytes ?? 32 * 1024 * 1024; + return new Promise((resolve, reject) => { + const child = spawn('git', command.args, { + cwd: command.cwd, + env: this.buildEnvironment(command.environment), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let outputBytes = 0; + let settled = false; + let timeout: NodeJS.Timeout | undefined = undefined; + + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + if (timeout) { + clearTimeout(timeout); + } + if (error) { + reject(error); + return; + } + resolve({ + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr) + }); + }; + + const append = (target: Buffer[], chunk: Buffer) => { + if (settled) { + return; + } + outputBytes += chunk.length; + if (outputBytes > maxOutputBytes) { + child.kill('SIGKILL'); + finish(new Error('Git command output exceeded the bounded runtime limit.')); + return; + } + target.push(chunk); + }; + + child.stdout.on('data', (chunk: Buffer) => append(stdout, chunk)); + child.stderr.on('data', (chunk: Buffer) => append(stderr, chunk)); + child.on('error', (error) => finish(error)); + child.on('close', (code, signal) => { + if (code !== 0) { + finish( + new Error( + `Git command failed with exit code ${String(code)} and signal ${String(signal)}.` + ) + ); + return; + } + finish(); + }); + + timeout = setTimeout(() => { + child.kill('SIGKILL'); + finish(new Error('Git command exceeded the bounded runtime timeout.')); + }, command.timeoutMilliseconds); + timeout.unref(); + }); + } + + private buildEnvironment(environment: Record): NodeJS.ProcessEnv { + const base: NodeJS.ProcessEnv = {}; + for (const key of ['PATH', 'SystemRoot', 'WINDIR']) { + if (process.env[key]) { + base[key] = process.env[key]; + } + } + return { ...base, ...environment }; + } +} diff --git a/apps/api/src/scan-plane/repository-preflight-attestation.service.ts b/apps/api/src/scan-plane/repository-preflight-attestation.service.ts new file mode 100644 index 0000000..27fbda0 --- /dev/null +++ b/apps/api/src/scan-plane/repository-preflight-attestation.service.ts @@ -0,0 +1,105 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +import { Injectable } from '@nestjs/common'; +import type { SastPreflightDecision } from '@aegisai/shared'; + +import { ConfigService } from '../config/config.service'; + +export interface RepositoryPreflightAttestationClaims { + attemptId: string; + fixedCommitSha: string; + pathPolicyVersion: string; + inventoryDigest: `sha256:${string}`; + decision: SastPreflightDecision; + issuedAt: string; +} + +@Injectable() +export class RepositoryPreflightAttestationService { + constructor(private readonly config: ConfigService) {} + + issue( + claims: Omit, + now = new Date() + ): string { + const complete: RepositoryPreflightAttestationClaims = { + ...claims, + issuedAt: now.toISOString() + }; + const payload = Buffer.from(this.canonical(complete), 'utf8').toString('base64url'); + return `attestation://sast-preflight/v1/${payload}.${this.sign(payload)}`; + } + + verify( + attestationRef: string, + expected: Omit + ): boolean { + const prefix = 'attestation://sast-preflight/v1/'; + if ( + typeof attestationRef !== 'string' || + attestationRef.length > 8192 || + !attestationRef.startsWith(prefix) + ) { + return false; + } + const encoded = attestationRef.slice(prefix.length); + const separator = encoded.lastIndexOf('.'); + if (separator < 1) { + return false; + } + const payload = encoded.slice(0, separator); + const signature = encoded.slice(separator + 1); + if ( + !/^[A-Za-z0-9_-]{43}$/.test(signature) || + !this.safeEqual(signature, this.sign(payload)) + ) { + return false; + } + try { + const claims = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8') + ) as RepositoryPreflightAttestationClaims; + return ( + this.canonical(claims) === Buffer.from(payload, 'base64url').toString('utf8') && + Number.isFinite(Date.parse(claims.issuedAt)) && + claims.attemptId === expected.attemptId && + claims.fixedCommitSha === expected.fixedCommitSha && + claims.pathPolicyVersion === expected.pathPolicyVersion && + claims.inventoryDigest === expected.inventoryDigest && + claims.decision === expected.decision + ); + } catch { + return false; + } + } + + private canonical(claims: RepositoryPreflightAttestationClaims): string { + return JSON.stringify({ + version: '1', + attemptId: claims.attemptId, + fixedCommitSha: claims.fixedCommitSha, + pathPolicyVersion: claims.pathPolicyVersion, + inventoryDigest: claims.inventoryDigest, + decision: claims.decision, + issuedAt: claims.issuedAt + }); + } + + private sign(payload: string): string { + const key = Buffer.from(this.config.get('PREFLIGHT_ATTESTATION_KEY'), 'hex'); + try { + return createHmac('sha256', key).update(payload, 'utf8').digest('base64url'); + } finally { + key.fill(0); + } + } + + private safeEqual(actual: string, expected: string): boolean { + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); + } +} diff --git a/apps/api/src/scan-plane/repository-preflight.service.ts b/apps/api/src/scan-plane/repository-preflight.service.ts new file mode 100644 index 0000000..a243a14 --- /dev/null +++ b/apps/api/src/scan-plane/repository-preflight.service.ts @@ -0,0 +1,697 @@ +import { createHash } from 'node:crypto'; + +import { + SAST_REPOSITORY_ENTRY_KINDS, + SAST_PREFLIGHT_REASON_CODES, + SAST_PREFLIGHT_SELECTION_MODES, + type SastPreflightDecision, + type SastPreflightReasonCode, + type SastRepositoryEntryClassification, + type SastRepositoryPreflightInput, + type SastRepositoryPreflightResult, + type SastRepositoryPreflightSelection, + type SastRepositoryTreeEntry +} from '@aegisai/shared'; +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { RepositoryPreflightAttestationService } from './repository-preflight-attestation.service'; + +const MAX_PATH_BYTES = 4096; +const DRIVE_ROOT = /^[A-Za-z]:/; +const ARCHIVE_SUFFIXES = [ + '.7z', + '.bz2', + '.ear', + '.gz', + '.jar', + '.rar', + '.tar', + '.tar.bz2', + '.tar.gz', + '.tar.xz', + '.tgz', + '.war', + '.xz', + '.zip' +]; +const VENDOR_SEGMENTS = new Set([ + 'deps', + 'external', + 'node_modules', + 'third-party', + 'third_party', + 'vendor' +]); +const GENERATED_SEGMENTS = new Set(['build', 'dist', 'gen', 'generated', 'target']); +const FIXTURE_SEGMENTS = new Set([ + '__tests__', + 'fixture', + 'fixtures', + 'sample', + 'samples', + 'test', + 'tests' +]); +const REJECT_REASONS = new Set([ + 'PATH_INVALID_UTF8', + 'PATH_NUL_OR_CONTROL', + 'PATH_ABSOLUTE', + 'PATH_DRIVE_OR_UNC', + 'PATH_PARENT_TRAVERSAL', + 'PATH_LENGTH_LIMIT_EXCEEDED', + 'PATH_CASE_COLLISION', + 'PATH_UNICODE_COLLISION', + 'PATH_DUPLICATE', + 'SYMLINK_INVALID_UTF8', + 'SYMLINK_OUTSIDE_ROOT', + 'SYMLINK_CYCLE', + 'PATH_DEPTH_LIMIT_EXCEEDED', + 'REPOSITORY_BYTES_LIMIT_EXCEEDED', + 'SELECTED_BYTES_LIMIT_EXCEEDED', + 'FILE_COUNT_LIMIT_EXCEEDED', + 'SINGLE_FILE_BYTES_LIMIT_EXCEEDED' +]); +const RESTRICTED_REASONS = new Set([ + 'SYMLINK_PRESENT', + 'SUBMODULE_PRESENT', + 'LFS_POINTER_PRESENT' +]); + +interface EvaluatedEntry { + input: SastRepositoryTreeEntry; + normalizedPath: string; + normalizedTarget?: string; + classification: SastRepositoryEntryClassification; + selected: boolean; +} + +interface NormalizedSelection { + mode: SastRepositoryPreflightSelection['mode']; + paths: readonly string[]; + pathSet: ReadonlySet; +} + +@Injectable() +export class RepositoryPreflightService { + constructor( + private readonly attestation: RepositoryPreflightAttestationService + ) {} + + evaluate(input: SastRepositoryPreflightInput): SastRepositoryPreflightResult { + this.validateInput(input); + const reasons = new Set(); + const rejectedPaths = new Set(); + const exactPaths = new Map(); + const caseFoldedPaths = new Map(); + const evaluated: EvaluatedEntry[] = []; + const selection = this.normalizeSelection(input.selection); + + for (const entry of [...input.entries].sort((left, right) => + Buffer.from(left.path).compare(Buffer.from(right.path)) + )) { + const normalizedPath = this.normalizePath(entry, reasons, rejectedPaths); + if (normalizedPath) { + this.detectCollisions( + entry.path, + normalizedPath, + exactPaths, + caseFoldedPaths, + reasons, + rejectedPaths + ); + } + const depth = normalizedPath ? normalizedPath.split('/').length : 0; + if (depth > input.limits.maxPathDepth) { + reasons.add('PATH_DEPTH_LIMIT_EXCEEDED'); + rejectedPaths.add(this.displayPath(entry.path)); + } + if (entry.byteSize > input.limits.maxSingleFileBytes) { + reasons.add('SINGLE_FILE_BYTES_LIMIT_EXCEEDED'); + rejectedPaths.add(this.displayPath(entry.path)); + } + const normalizedTarget = + entry.kind === 'SYMLINK' && normalizedPath + ? this.resolveSymlink( + normalizedPath, + entry.symlinkTarget ?? '', + entry.symlinkTargetEncodingValid ?? false, + reasons, + rejectedPaths + ) + : undefined; + const classification = this.classify(entry, normalizedPath, input); + evaluated.push({ + input: entry, + normalizedPath, + normalizedTarget, + classification, + selected: + this.isScannable(classification) && + (selection.mode === 'ALL_SCANNABLE' || + selection.pathSet.has(normalizedPath)) + }); + } + + this.detectSymlinkCycles(evaluated, reasons, rejectedPaths); + const counters = this.counters(evaluated); + const repositoryBytes = this.safeSum(evaluated.map((entry) => entry.input.byteSize)); + const selectedBytes = this.safeSum( + evaluated + .filter((entry) => entry.selected) + .map((entry) => entry.input.byteSize) + ); + let maxSingleFileBytes = 0; + let maxPathDepth = 0; + for (const entry of evaluated) { + maxSingleFileBytes = Math.max(maxSingleFileBytes, entry.input.byteSize); + maxPathDepth = Math.max( + maxPathDepth, + entry.normalizedPath ? entry.normalizedPath.split('/').length : 0 + ); + } + + if (input.entries.length > input.limits.maxFileCount) { + reasons.add('FILE_COUNT_LIMIT_EXCEEDED'); + } + if (repositoryBytes > input.limits.maxRepositoryBytes) { + reasons.add('REPOSITORY_BYTES_LIMIT_EXCEEDED'); + } + if (selectedBytes > input.limits.maxSelectedBytes) { + reasons.add('SELECTED_BYTES_LIMIT_EXCEEDED'); + } + if (counters.symlinkCount > 0) { + reasons.add('SYMLINK_PRESENT'); + } + if (counters.submoduleCount > 0) { + reasons.add('SUBMODULE_PRESENT'); + } + if (counters.lfsPointerCount > 0) { + reasons.add('LFS_POINTER_PRESENT'); + } + if (counters.archiveCount > 0) { + reasons.add('ARCHIVE_PRESENT'); + } + + const reasonCodes = SAST_PREFLIGHT_REASON_CODES.filter((reason) => reasons.has(reason)); + const decision = this.decision(reasonCodes); + const inventoryDigest = this.inventoryDigest(evaluated, selection); + const attestationRef = this.attestation.issue({ + attemptId: input.attemptId, + fixedCommitSha: input.fixedCommitSha, + pathPolicyVersion: input.pathPolicyVersion, + inventoryDigest, + decision + }); + + return { + attemptId: input.attemptId, + fixedCommitSha: input.fixedCommitSha, + pathPolicyVersion: input.pathPolicyVersion, + inventoryDigest, + attestationRef, + decision, + reasonCodes, + repositoryBytes, + selectedBytes, + maxSingleFileBytes, + maxPathDepth, + counts: counters, + rejectedPaths: [...rejectedPaths].sort().slice(0, 100) + }; + } + + private validateInput(input: SastRepositoryPreflightInput): void { + if ( + !input.attemptId || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(input.fixedCommitSha) || + !input.pathPolicyVersion || + !Array.isArray(input.entries) || + input.pathPolicy.pathNormalizationRequired !== true || + input.pathPolicy.rejectAbsolutePaths !== true || + input.pathPolicy.rejectParentTraversal !== true || + input.pathPolicy.rejectCaseFoldCollisions !== true || + input.pathPolicy.symlinkPolicy !== 'REJECT_OUTSIDE_ROOT' || + input.pathPolicy.submodulePolicy !== 'DISABLED_BY_DEFAULT' || + input.pathPolicy.lfsPolicy !== 'POINTER_METADATA_ONLY_BY_DEFAULT' || + input.pathPolicy.archivePolicy !== 'DO_NOT_EXPAND' || + input.pathPolicy.generatedCodePolicy !== 'INDEX_BUT_SUPPRESS_BY_DEFAULT' || + input.pathPolicy.vendorCodePolicy !== 'DEPENDENCY_ONLY_BY_DEFAULT' || + input.pathPolicy.fixturePolicy !== 'SCAN_WITH_NON_BLOCKING_DEFAULT' || + !this.validPositiveLimit(input.limits.maxRepositoryBytes) || + !this.validPositiveLimit(input.limits.maxSelectedBytes) || + !this.validPositiveLimit(input.limits.maxFileCount) || + !this.validPositiveLimit(input.limits.maxSingleFileBytes) || + !this.validPositiveLimit(input.limits.maxPathDepth) || + !this.validStringList(input.sourceExtensions) || + !this.validStringList(input.manifestNames) || + !input.selection || + !SAST_PREFLIGHT_SELECTION_MODES.includes(input.selection.mode) || + !Array.isArray(input.selection.paths) || + input.selection.paths.length > input.limits.maxFileCount || + (input.selection.mode === 'ALL_SCANNABLE' && + input.selection.paths.length !== 0) + ) { + throw new BadRequestException('Repository preflight input is incomplete.'); + } + const expectedObjectId = + input.fixedCommitSha.length === 40 + ? /^sha1:[0-9a-f]{40}$/ + : /^sha256:[0-9a-f]{64}$/; + for (const entry of input.entries) { + if ( + !entry || + typeof entry.path !== 'string' || + typeof entry.pathEncodingValid !== 'boolean' || + !SAST_REPOSITORY_ENTRY_KINDS.includes(entry.kind) || + !Number.isSafeInteger(entry.byteSize) || + entry.byteSize < 0 || + typeof entry.gitObjectId !== 'string' || + !expectedObjectId.test(entry.gitObjectId) || + typeof entry.executable !== 'boolean' || + typeof entry.lfsPointer !== 'boolean' || + (entry.symlinkTarget !== undefined && + typeof entry.symlinkTarget !== 'string') || + (entry.symlinkTargetEncodingValid !== undefined && + typeof entry.symlinkTargetEncodingValid !== 'boolean') || + (entry.kind === 'SYMLINK' && + (typeof entry.symlinkTarget !== 'string' || + typeof entry.symlinkTargetEncodingValid !== 'boolean')) || + (entry.kind !== 'SYMLINK' && + (entry.symlinkTarget !== undefined || + entry.symlinkTargetEncodingValid !== undefined)) + ) { + throw new BadRequestException('Repository preflight entry metadata is invalid.'); + } + } + } + + private validPositiveLimit(value: number): boolean { + return Number.isSafeInteger(value) && value > 0; + } + + private validStringList(value: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length <= 256 && + value.every( + (item) => + typeof item === 'string' && + item.length > 0 && + item.length <= 255 && + !hasControlCharacters(item) + ) + ); + } + + private normalizeSelection( + selection: Readonly + ): NormalizedSelection { + const paths = selection.paths + .map((path) => this.normalizeSelectionPath(path)) + .sort((left, right) => Buffer.from(left).compare(Buffer.from(right))); + if (new Set(paths).size !== paths.length) { + throw new BadRequestException( + 'Repository preflight selection contains duplicate normalized paths.' + ); + } + return { + mode: selection.mode, + paths, + pathSet: new Set(paths) + }; + } + + private normalizeSelectionPath(path: string): string { + if ( + typeof path !== 'string' || + !path || + hasControlCharacters(path) || + Buffer.byteLength(path, 'utf8') > MAX_PATH_BYTES + ) { + throw new BadRequestException( + 'Repository preflight selection path is invalid.' + ); + } + const separated = path.replace(/\\/g, '/'); + if ( + separated.startsWith('/') || + path.startsWith('\\\\') || + DRIVE_ROOT.test(separated) + ) { + throw new BadRequestException( + 'Repository preflight selection path is invalid.' + ); + } + const segments = separated.split('/'); + if (segments.includes('..')) { + throw new BadRequestException( + 'Repository preflight selection path is invalid.' + ); + } + const normalized = segments + .filter((segment) => segment && segment !== '.') + .map((segment) => segment.normalize('NFC')) + .join('/'); + if (!normalized) { + throw new BadRequestException( + 'Repository preflight selection path is invalid.' + ); + } + return normalized; + } + + private normalizePath( + entry: SastRepositoryTreeEntry, + reasons: Set, + rejectedPaths: Set + ): string { + const display = this.displayPath(entry.path); + if (!entry.pathEncodingValid) { + reasons.add('PATH_INVALID_UTF8'); + rejectedPaths.add(display); + return ''; + } + if (hasControlCharacters(entry.path)) { + reasons.add('PATH_NUL_OR_CONTROL'); + rejectedPaths.add(display); + return ''; + } + if (Buffer.byteLength(entry.path, 'utf8') > MAX_PATH_BYTES) { + reasons.add('PATH_LENGTH_LIMIT_EXCEEDED'); + rejectedPaths.add(display); + return ''; + } + const separated = entry.path.replace(/\\/g, '/'); + if ( + separated.startsWith('/') || + entry.path.startsWith('\\\\') || + DRIVE_ROOT.test(separated) + ) { + reasons.add( + entry.path.startsWith('\\\\') || DRIVE_ROOT.test(separated) + ? 'PATH_DRIVE_OR_UNC' + : 'PATH_ABSOLUTE' + ); + rejectedPaths.add(display); + return ''; + } + const segments = separated.split('/'); + if (segments.includes('..')) { + reasons.add('PATH_PARENT_TRAVERSAL'); + rejectedPaths.add(display); + return ''; + } + const normalized = segments + .filter((segment) => segment && segment !== '.') + .map((segment) => segment.normalize('NFC')) + .join('/'); + if (!normalized) { + reasons.add('PATH_PARENT_TRAVERSAL'); + rejectedPaths.add(display); + } + return normalized; + } + + private detectCollisions( + rawPath: string, + normalizedPath: string, + exactPaths: Map, + caseFoldedPaths: Map, + reasons: Set, + rejectedPaths: Set + ): void { + const exact = exactPaths.get(normalizedPath); + if (exact !== undefined) { + reasons.add(exact === rawPath ? 'PATH_DUPLICATE' : 'PATH_UNICODE_COLLISION'); + rejectedPaths.add(this.displayPath(exact)); + rejectedPaths.add(this.displayPath(rawPath)); + } else { + exactPaths.set(normalizedPath, rawPath); + } + + const folded = normalizedPath.toLocaleLowerCase('en-US').normalize('NFC'); + const prior = caseFoldedPaths.get(folded); + if (prior && prior !== normalizedPath) { + reasons.add('PATH_CASE_COLLISION'); + rejectedPaths.add(this.displayPath(prior)); + rejectedPaths.add(this.displayPath(rawPath)); + } else { + caseFoldedPaths.set(folded, normalizedPath); + } + } + + private resolveSymlink( + path: string, + target: string, + targetEncodingValid: boolean, + reasons: Set, + rejectedPaths: Set + ): string | undefined { + if (!targetEncodingValid) { + reasons.add('SYMLINK_INVALID_UTF8'); + rejectedPaths.add(this.displayPath(path)); + return undefined; + } + if ( + !target || + hasControlCharacters(target) || + target.startsWith('/') || + target.startsWith('\\\\') || + DRIVE_ROOT.test(target) + ) { + reasons.add('SYMLINK_OUTSIDE_ROOT'); + rejectedPaths.add(this.displayPath(path)); + return undefined; + } + const base = path.split('/').slice(0, -1); + for (const segment of target.replace(/\\/g, '/').split('/')) { + if (!segment || segment === '.') { + continue; + } + if (segment === '..') { + if (base.length === 0) { + reasons.add('SYMLINK_OUTSIDE_ROOT'); + rejectedPaths.add(this.displayPath(path)); + return undefined; + } + base.pop(); + } else { + base.push(segment.normalize('NFC')); + } + } + return base.join('/'); + } + + private detectSymlinkCycles( + entries: EvaluatedEntry[], + reasons: Set, + rejectedPaths: Set + ): void { + const links = new Map( + entries + .filter( + (entry): entry is EvaluatedEntry & { normalizedTarget: string } => + entry.input.kind === 'SYMLINK' && + entry.normalizedTarget !== undefined + ) + .map((entry) => [entry.normalizedPath, entry.normalizedTarget]) + ); + for (const start of links.keys()) { + const visited = new Set([start]); + let cursor = links.get(start) ?? ''; + let cycleDetected = false; + while (!cycleDetected) { + const segments = cursor ? cursor.split('/') : []; + let expanded = false; + for (let index = 1; index <= segments.length; index += 1) { + const linkPath = segments.slice(0, index).join('/'); + const replacement = links.get(linkPath); + if (replacement === undefined) { + continue; + } + if (visited.has(linkPath)) { + cycleDetected = true; + break; + } + visited.add(linkPath); + cursor = [replacement, ...segments.slice(index)] + .filter(Boolean) + .join('/'); + expanded = true; + break; + } + if (!expanded) { + break; + } + } + if (cycleDetected) { + reasons.add('SYMLINK_CYCLE'); + for (const path of visited) { + rejectedPaths.add(this.displayPath(path)); + } + } + } + } + + private classify( + entry: SastRepositoryTreeEntry, + normalizedPath: string, + input: SastRepositoryPreflightInput + ): SastRepositoryEntryClassification { + const lower = normalizedPath.toLocaleLowerCase('en-US').normalize('NFC'); + const segments = lower.split('/'); + const baseName = segments.at(-1) ?? ''; + if (entry.lfsPointer) { + return 'LFS_POINTER'; + } + if (ARCHIVE_SUFFIXES.some((suffix) => lower.endsWith(suffix))) { + return 'ARCHIVE'; + } + if (segments.some((segment) => VENDOR_SEGMENTS.has(segment))) { + return 'VENDOR'; + } + if ( + segments.some((segment) => GENERATED_SEGMENTS.has(segment)) || + /\.generated\.[^/]+$/.test(lower) || + lower.endsWith('.min.js') + ) { + return 'GENERATED'; + } + if (segments.some((segment) => FIXTURE_SEGMENTS.has(segment))) { + return 'FIXTURE'; + } + if (segments.some((segment) => segment.startsWith('.'))) { + return 'HIDDEN_SYSTEM'; + } + if (input.manifestNames.some((name) => name.toLocaleLowerCase('en-US') === baseName)) { + return 'MANIFEST'; + } + if ( + input.sourceExtensions.some((extension) => + lower.endsWith(extension.toLocaleLowerCase('en-US')) + ) + ) { + return 'SOURCE'; + } + return 'OTHER'; + } + + private counters(entries: EvaluatedEntry[]): SastRepositoryPreflightResult['counts'] { + const directories = new Set(); + for (const entry of entries) { + const segments = entry.normalizedPath.split('/'); + for (let index = 1; index < segments.length; index += 1) { + directories.add(segments.slice(0, index).join('/')); + } + } + return { + fileCount: entries.filter((entry) => entry.input.kind === 'FILE').length, + directoryCount: directories.size, + symlinkCount: entries.filter((entry) => entry.input.kind === 'SYMLINK').length, + submoduleCount: entries.filter((entry) => entry.input.kind === 'SUBMODULE').length, + lfsPointerCount: entries.filter((entry) => entry.input.lfsPointer).length, + archiveCount: entries.filter((entry) => entry.classification === 'ARCHIVE').length, + generatedCount: entries.filter((entry) => entry.classification === 'GENERATED').length, + vendorCount: entries.filter((entry) => entry.classification === 'VENDOR').length, + fixtureCount: entries.filter((entry) => entry.classification === 'FIXTURE').length, + hiddenSystemCount: entries.filter( + (entry) => entry.classification === 'HIDDEN_SYSTEM' + ).length + }; + } + + private isScannable(classification: SastRepositoryEntryClassification): boolean { + return ['SOURCE', 'MANIFEST', 'GENERATED', 'FIXTURE'].includes(classification); + } + + private safeSum(values: number[]): number { + let total = 0; + for (const value of values) { + total += value; + if (!Number.isSafeInteger(total)) { + throw new BadRequestException('Repository byte counters exceed safe integer bounds.'); + } + } + return total; + } + + private decision(reasonCodes: SastPreflightReasonCode[]): SastPreflightDecision { + if (reasonCodes.some((reason) => REJECT_REASONS.has(reason))) { + return 'REJECT'; + } + if (reasonCodes.some((reason) => RESTRICTED_REASONS.has(reason))) { + return 'RESTRICTED_ESCALATION'; + } + return 'ACCEPT'; + } + + private inventoryDigest( + entries: EvaluatedEntry[], + selection: NormalizedSelection + ): `sha256:${string}` { + const records = [ + Buffer.from( + JSON.stringify(['selection', selection.mode, selection.paths]), + 'utf8' + ), + ...entries.map((entry) => + Buffer.from( + JSON.stringify([ + entry.normalizedPath, + entry.input.pathEncodingValid, + entry.input.kind, + entry.input.byteSize, + entry.input.gitObjectId, + entry.input.executable, + entry.normalizedTarget ?? null, + entry.input.symlinkTargetEncodingValid ?? null, + entry.input.lfsPointer, + entry.classification, + entry.selected + ]), + 'utf8' + ) + ) + ].sort(Buffer.compare); + const digest = createHash('sha256'); + for (const record of records) { + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(record.length); + digest.update(length); + digest.update(record); + } + return `sha256:${digest.digest('hex')}`; + } + + private displayPath(path: string): string { + return path + .normalize('NFC') + .split('') + .map((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return isUnsafeControlCodePoint(codePoint) + ? `\\u{${codePoint.toString(16).padStart(4, '0')}}` + : character; + }) + .join('') + .slice(0, 512); + } +} + +function hasControlCharacters(value: string): boolean { + return Array.from(value).some((character) => + isUnsafeControlCodePoint(character.codePointAt(0) ?? 0) + ); +} + +function isUnsafeControlCodePoint(codePoint: number): boolean { + return ( + codePoint <= 31 || + (codePoint >= 127 && codePoint <= 159) || + codePoint === 0x061c || + codePoint === 0x200e || + codePoint === 0x200f || + (codePoint >= 0x202a && codePoint <= 0x202e) || + (codePoint >= 0x2066 && codePoint <= 0x2069) + ); +} diff --git a/apps/api/src/scan-plane/scan-plane.module.ts b/apps/api/src/scan-plane/scan-plane.module.ts index 1e10663..c092706 100644 --- a/apps/api/src/scan-plane/scan-plane.module.ts +++ b/apps/api/src/scan-plane/scan-plane.module.ts @@ -9,15 +9,41 @@ import { ScanPlaneService } from "./scan-plane.service"; import { ScannerSandboxAdapterService } from "./scanner-sandbox-adapter.service"; import { ConfigModule } from "../config/config.module"; import { ControlPlaneModule } from '../control-plane/control-plane.module'; +import { TokenBrokerModule } from '../token-broker/token-broker.module'; +import { + NodeRepositoryGitExecutor, + RepositoryGitExecutor +} from './repository-git-executor'; +import { RepositoryFetchService } from './repository-fetch.service'; +import { RepositoryPreflightAttestationService } from './repository-preflight-attestation.service'; +import { RepositoryPreflightService } from './repository-preflight.service'; +import { + CredentialTmpfsVerifier, + NodeCredentialTmpfsVerifier +} from './credential-tmpfs-verifier.service'; @Module({ - imports: [ConfigModule, ControlPlaneModule], + imports: [ConfigModule, ControlPlaneModule, TokenBrokerModule], controllers: [ScanPlaneController, FindingsController, EvidenceController], providers: [ ScanPlaneService, ScannerSandboxAdapterService, + RepositoryFetchService, + RepositoryPreflightService, + RepositoryPreflightAttestationService, + NodeCredentialTmpfsVerifier, + { + provide: CredentialTmpfsVerifier, + useExisting: NodeCredentialTmpfsVerifier + }, + NodeRepositoryGitExecutor, + { + provide: RepositoryGitExecutor, + useExisting: NodeRepositoryGitExecutor + }, EvidenceObjectStorageService, EvidenceExpiryTask - ] + ], + exports: [RepositoryFetchService, RepositoryPreflightService] }) export class ScanPlaneModule {} diff --git a/apps/api/src/token-broker/credential-lease-expiry.task.ts b/apps/api/src/token-broker/credential-lease-expiry.task.ts new file mode 100644 index 0000000..785c293 --- /dev/null +++ b/apps/api/src/token-broker/credential-lease-expiry.task.ts @@ -0,0 +1,48 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit +} from '@nestjs/common'; + +import { ConfigService } from '../config/config.service'; +import { RepositoryCredentialLeaseStore } from './repository-credential-lease.store'; + +@Injectable() +export class CredentialLeaseExpiryTask + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(CredentialLeaseExpiryTask.name); + private timer: NodeJS.Timeout | null = null; + + constructor( + private readonly leases: RepositoryCredentialLeaseStore, + private readonly config: ConfigService + ) {} + + onModuleInit(): void { + if (this.config.isTest()) { + return; + } + this.timer = setInterval(() => { + void this.revokeExpired().catch((error: unknown) => { + this.logger.error( + 'Failed to revoke expired repository credential leases.', + error as Error + ); + }); + }, this.config.get('CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS')); + this.timer.unref?.(); + } + + onModuleDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + revokeExpired(referenceTime = new Date()): Promise { + return this.leases.revokeExpired(referenceTime.toISOString()); + } +} diff --git a/apps/api/src/token-broker/prisma-repository-credential-lease.store.ts b/apps/api/src/token-broker/prisma-repository-credential-lease.store.ts new file mode 100644 index 0000000..1f1b9df --- /dev/null +++ b/apps/api/src/token-broker/prisma-repository-credential-lease.store.ts @@ -0,0 +1,226 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { SastCredentialLeaseMetadata } from '@aegisai/shared'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + RepositoryCredentialLeaseStore, + type ReserveRepositoryCredentialLeaseInput +} from './repository-credential-lease.store'; + +@Injectable() +export class PrismaRepositoryCredentialLeaseStore extends RepositoryCredentialLeaseStore { + constructor(private readonly prisma: PrismaService) { + super(); + } + + async reserve( + input: ReserveRepositoryCredentialLeaseInput + ): Promise { + try { + const row = await this.prisma.$transaction( + async (transaction) => { + const scanRequest = await transaction.scanRequest.findFirst({ + where: { + id: input.scanRequestId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + commitSha: input.commitSha, + status: 'RUNNING', + repositoryBinding: { + status: 'ACTIVE', + integration: { status: 'ACTIVE' } + } + } + }); + if (!scanRequest) { + throw new ConflictException( + 'Credential lease scope does not match an active durable scan request.' + ); + } + + return transaction.sastRepositoryCredentialLease.create({ + data: { + id: input.credentialId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + commitSha: input.commitSha, + issuedAt: new Date(input.issuedAt), + expiresAt: new Date(input.expiresAt) + } + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable } + ); + return this.toMetadata(row); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException('A credential was already reserved for this scan attempt.'); + } + throw error; + } + } + + async activate( + credentialId: string, + tenantId: string, + attemptId: string, + credentialFingerprint: `sha256:${string}` + ): Promise { + const updated = await this.prisma.sastRepositoryCredentialLease.updateMany({ + where: { + id: credentialId, + tenantId, + attemptId, + status: 'RESERVED' + }, + data: { status: 'ISSUED', credentialFingerprint } + }); + if (updated.count !== 1) { + throw new ConflictException('Credential lease is not in the reserved state.'); + } + return this.requireAttemptLease(credentialId, tenantId, attemptId); + } + + async markWiped( + credentialId: string, + tenantId: string, + attemptId: string, + wipedAt: string + ): Promise { + const updated = await this.prisma.sastRepositoryCredentialLease.updateMany({ + where: { id: credentialId, tenantId, attemptId, status: 'ISSUED' }, + data: { status: 'WIPED', wipedAt: new Date(wipedAt) } + }); + if (updated.count === 1) { + return this.requireAttemptLease(credentialId, tenantId, attemptId); + } + + const existing = await this.findAttemptLease(credentialId, tenantId, attemptId); + if (!existing) { + throw new NotFoundException('Credential lease not found for attempt.'); + } + if (existing.status === 'WIPED') { + return this.toMetadata(existing); + } + throw new ConflictException('Only an issued credential lease can be marked wiped.'); + } + + async revoke( + credentialId: string, + tenantId: string, + attemptId: string, + revokedAt: string + ): Promise { + const updated = await this.prisma.sastRepositoryCredentialLease.updateMany({ + where: { + id: credentialId, + tenantId, + attemptId, + status: { in: ['RESERVED', 'ISSUED'] } + }, + data: { status: 'REVOKED', revokedAt: new Date(revokedAt) } + }); + if (updated.count === 1) { + return this.requireAttemptLease(credentialId, tenantId, attemptId); + } + + const existing = await this.findAttemptLease(credentialId, tenantId, attemptId); + if (!existing) { + throw new NotFoundException('Credential lease not found for attempt.'); + } + if (existing.status === 'REVOKED' || existing.status === 'WIPED') { + return this.toMetadata(existing); + } + throw new ConflictException('Credential lease cannot be revoked from its current state.'); + } + + async revokeExpired(referenceTime: string): Promise { + const timestamp = new Date(referenceTime); + if (!Number.isFinite(timestamp.getTime())) { + throw new Error('Credential lease expiry reference time is invalid.'); + } + const updated = await this.prisma.sastRepositoryCredentialLease.updateMany({ + where: { + status: { in: ['RESERVED', 'ISSUED'] }, + expiresAt: { lte: timestamp } + }, + data: { + status: 'REVOKED', + revokedAt: timestamp + } + }); + return updated.count; + } + + async findByAttempt( + tenantId: string, + attemptId: string + ): Promise { + const row = await this.prisma.sastRepositoryCredentialLease.findFirst({ + where: { tenantId, attemptId } + }); + return row ? this.toMetadata(row) : null; + } + + private async requireAttemptLease( + credentialId: string, + tenantId: string, + attemptId: string + ): Promise { + const row = await this.findAttemptLease(credentialId, tenantId, attemptId); + if (!row) { + throw new NotFoundException('Credential lease not found for attempt.'); + } + return this.toMetadata(row); + } + + private findAttemptLease( + credentialId: string, + tenantId: string, + attemptId: string + ) { + return this.prisma.sastRepositoryCredentialLease.findFirst({ + where: { id: credentialId, tenantId, attemptId } + }); + } + + private toMetadata(row: { + id: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + commitSha: string; + credentialFingerprint: string | null; + status: 'RESERVED' | 'ISSUED' | 'WIPED' | 'REVOKED'; + issuedAt: Date; + expiresAt: Date; + wipedAt: Date | null; + revokedAt: Date | null; + }): SastCredentialLeaseMetadata { + return { + credentialId: row.id, + tenantId: row.tenantId, + repositoryBindingId: row.repositoryBindingId, + scanRequestId: row.scanRequestId, + attemptId: row.attemptId, + workloadIdentityRef: row.workloadIdentityRef, + commitSha: row.commitSha, + credentialFingerprint: + (row.credentialFingerprint as `sha256:${string}` | null) ?? undefined, + status: row.status, + issuedAt: row.issuedAt.toISOString(), + expiresAt: row.expiresAt.toISOString(), + wipedAt: row.wipedAt?.toISOString(), + revokedAt: row.revokedAt?.toISOString() + }; + } +} diff --git a/apps/api/src/token-broker/repository-credential-lease.store.ts b/apps/api/src/token-broker/repository-credential-lease.store.ts new file mode 100644 index 0000000..f8f3b56 --- /dev/null +++ b/apps/api/src/token-broker/repository-credential-lease.store.ts @@ -0,0 +1,47 @@ +import type { SastCredentialLeaseMetadata } from '@aegisai/shared'; + +export interface ReserveRepositoryCredentialLeaseInput { + credentialId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + commitSha: string; + issuedAt: string; + expiresAt: string; +} + +export abstract class RepositoryCredentialLeaseStore { + abstract reserve( + input: ReserveRepositoryCredentialLeaseInput + ): Promise; + + abstract activate( + credentialId: string, + tenantId: string, + attemptId: string, + credentialFingerprint: `sha256:${string}` + ): Promise; + + abstract markWiped( + credentialId: string, + tenantId: string, + attemptId: string, + wipedAt: string + ): Promise; + + abstract revoke( + credentialId: string, + tenantId: string, + attemptId: string, + revokedAt: string + ): Promise; + + abstract revokeExpired(referenceTime: string): Promise; + + abstract findByAttempt( + tenantId: string, + attemptId: string + ): Promise; +} diff --git a/apps/api/src/token-broker/token-broker.controller.ts b/apps/api/src/token-broker/token-broker.controller.ts index 1141073..fd19280 100644 --- a/apps/api/src/token-broker/token-broker.controller.ts +++ b/apps/api/src/token-broker/token-broker.controller.ts @@ -2,7 +2,10 @@ import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { InternalServiceGuard } from '../common/security/internal-service.guard'; import { TokenBrokerService } from "./token-broker.service"; -import { TokenBrokerIssueDto } from './token-broker.dto'; +import { + TokenBrokerIssueDto, + TokenBrokerLeaseCompletionDto +} from './token-broker.dto'; @Controller("token-broker") export class TokenBrokerController { @@ -13,4 +16,10 @@ export class TokenBrokerController { issue(@Body() body: TokenBrokerIssueDto) { return this.tokenBrokerService.issue(body); } + + @Post('leases/complete') + @UseGuards(InternalServiceGuard) + completeLease(@Body() body: TokenBrokerLeaseCompletionDto) { + return this.tokenBrokerService.completeLease(body); + } } diff --git a/apps/api/src/token-broker/token-broker.dto.ts b/apps/api/src/token-broker/token-broker.dto.ts index 68074b2..5a2cc54 100644 --- a/apps/api/src/token-broker/token-broker.dto.ts +++ b/apps/api/src/token-broker/token-broker.dto.ts @@ -1,7 +1,82 @@ -import { Equals, IsInt, IsString, Matches, Max, Min } from 'class-validator'; -import { MAX_SCAN_CREDENTIAL_TTL_SECONDS } from '@aegisai/shared'; +import { Type } from 'class-transformer'; +import { + Equals, + IsIn, + IsInt, + IsString, + Matches, + Max, + Min, + ValidateNested +} from 'class-validator'; +import { + MAX_SCAN_CREDENTIAL_TTL_SECONDS, + WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE, + WORKLOAD_IDENTITY_ATTESTATION_ISSUER, + WORKLOAD_IDENTITY_ATTESTATION_VERSION +} from '@aegisai/shared'; const RESOURCE_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; +const FULL_COMMIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const ISO_TIMESTAMP = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/; + +class WorkloadIdentityAttestationClaimsDto { + @Equals(WORKLOAD_IDENTITY_ATTESTATION_VERSION) + version!: typeof WORKLOAD_IDENTITY_ATTESTATION_VERSION; + + @Equals(WORKLOAD_IDENTITY_ATTESTATION_ISSUER) + issuer!: typeof WORKLOAD_IDENTITY_ATTESTATION_ISSUER; + + @Equals(WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE) + audience!: typeof WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE; + + @IsString() + @Matches(RESOURCE_ID) + tenantId!: string; + + @IsString() + @Matches(RESOURCE_ID) + repositoryBindingId!: string; + + @IsString() + @Matches(RESOURCE_ID) + scanRequestId!: string; + + @IsString() + @Matches(RESOURCE_ID) + attemptId!: string; + + @IsString() + @Matches(RESOURCE_ID) + workloadIdentityRef!: string; + + @IsString() + @Matches(FULL_COMMIT_SHA) + commitSha!: string; + + @IsString() + @Matches(RESOURCE_ID) + nonce!: string; + + @IsString() + @Matches(ISO_TIMESTAMP) + issuedAt!: string; + + @IsString() + @Matches(ISO_TIMESTAMP) + expiresAt!: string; +} + +class WorkloadIdentityAttestationDto { + @ValidateNested() + @Type(() => WorkloadIdentityAttestationClaimsDto) + claims!: WorkloadIdentityAttestationClaimsDto; + + @IsString() + @Matches(/^sha256:[0-9a-f]{64}$/) + signature!: `sha256:${string}`; +} export class TokenBrokerIssueDto { @IsString() @@ -16,11 +91,23 @@ export class TokenBrokerIssueDto { @Matches(RESOURCE_ID) scanRequestId!: string; + @IsString() + @Matches(RESOURCE_ID) + attemptId!: string; + + @IsString() + @Matches(RESOURCE_ID) + workloadIdentityRef!: string; + + @ValidateNested() + @Type(() => WorkloadIdentityAttestationDto) + workloadIdentityAttestation!: WorkloadIdentityAttestationDto; + @Equals('REPO_READ') principal!: 'REPO_READ'; @IsString() - @Matches(/^[0-9a-fA-F]{7,64}$/) + @Matches(FULL_COMMIT_SHA) commitSha!: string; @IsInt() @@ -32,3 +119,40 @@ export class TokenBrokerIssueDto { @Matches(RESOURCE_ID) auditReason!: string; } + +export class TokenBrokerLeaseCompletionDto { + @IsString() + @Matches(RESOURCE_ID) + credentialId!: string; + + @IsString() + @Matches(RESOURCE_ID) + tenantId!: string; + + @IsString() + @Matches(RESOURCE_ID) + repositoryBindingId!: string; + + @IsString() + @Matches(RESOURCE_ID) + scanRequestId!: string; + + @IsString() + @Matches(RESOURCE_ID) + attemptId!: string; + + @IsString() + @Matches(RESOURCE_ID) + workloadIdentityRef!: string; + + @ValidateNested() + @Type(() => WorkloadIdentityAttestationDto) + workloadIdentityAttestation!: WorkloadIdentityAttestationDto; + + @IsString() + @Matches(FULL_COMMIT_SHA) + commitSha!: string; + + @IsIn(['WIPED', 'REVOKED']) + disposition!: 'WIPED' | 'REVOKED'; +} diff --git a/apps/api/src/token-broker/token-broker.module.ts b/apps/api/src/token-broker/token-broker.module.ts index 2a11ba2..1b91a60 100644 --- a/apps/api/src/token-broker/token-broker.module.ts +++ b/apps/api/src/token-broker/token-broker.module.ts @@ -2,13 +2,28 @@ import { Module } from "@nestjs/common"; import { ControlPlaneModule } from '../control-plane/control-plane.module'; import { AuditEventsController } from "./audit-events.controller"; +import { CredentialLeaseExpiryTask } from './credential-lease-expiry.task'; +import { PrismaRepositoryCredentialLeaseStore } from './prisma-repository-credential-lease.store'; +import { RepositoryCredentialLeaseStore } from './repository-credential-lease.store'; import { TokenCredentialIssuerService } from "./token-credential-issuer.service"; import { TokenBrokerController } from "./token-broker.controller"; import { TokenBrokerService } from "./token-broker.service"; +import { WorkloadIdentityAttestationService } from './workload-identity-attestation.service'; @Module({ imports: [ControlPlaneModule], controllers: [TokenBrokerController, AuditEventsController], - providers: [TokenBrokerService, TokenCredentialIssuerService] + providers: [ + TokenBrokerService, + TokenCredentialIssuerService, + WorkloadIdentityAttestationService, + CredentialLeaseExpiryTask, + PrismaRepositoryCredentialLeaseStore, + { + provide: RepositoryCredentialLeaseStore, + useExisting: PrismaRepositoryCredentialLeaseStore + } + ], + exports: [TokenBrokerService, WorkloadIdentityAttestationService] }) export class TokenBrokerModule {} diff --git a/apps/api/src/token-broker/token-broker.service.ts b/apps/api/src/token-broker/token-broker.service.ts index 98d8164..92860e3 100644 --- a/apps/api/src/token-broker/token-broker.service.ts +++ b/apps/api/src/token-broker/token-broker.service.ts @@ -1,56 +1,149 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException +} from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { MAX_SCAN_CREDENTIAL_TTL_SECONDS, + type SastCredentialLeaseMetadata, + type TokenBrokerLeaseCompletionRequest, type TokenBrokerIssueRequest } from '@aegisai/shared'; import { ControlPlaneService } from '../control-plane/control-plane.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RepositoryCredentialLeaseStore } from './repository-credential-lease.store'; import { TokenCredentialIssuerService } from "./token-credential-issuer.service"; import type { TokenBrokerAuditEvent, TokenBrokerIssueResponse } from "./token-broker.types"; +import { WorkloadIdentityAttestationService } from './workload-identity-attestation.service'; @Injectable() export class TokenBrokerService { - private readonly auditEvents: TokenBrokerAuditEvent[] = []; - constructor( private readonly tokenCredentialIssuer: TokenCredentialIssuerService, - private readonly controlPlaneService: ControlPlaneService + private readonly controlPlaneService: ControlPlaneService, + private readonly workloadIdentityAttestation: WorkloadIdentityAttestationService, + private readonly credentialLeaseStore: RepositoryCredentialLeaseStore, + private readonly prisma: PrismaService ) {} async issue(input: TokenBrokerIssueRequest): Promise { await this.assertBoundToScan(input); - const issuedCredential = this.tokenCredentialIssuer.issue(input); - const response: TokenBrokerIssueResponse = { - ...input, - credentialId: `credential_${randomUUID()}`, - ...issuedCredential, - expiresInSeconds: input.ttlSeconds, - auditEventType: "token.issued" - }; - - this.auditEvents.push({ - id: `audit_event_${randomUUID()}`, - tenantId: input.tenantId, - eventType: "token.issued", - actor: "token-broker", - targetType: "scan_request", - targetId: input.scanRequestId, - occurredAt: issuedCredential.issuedAt, - metadata: { + const issuedCredential = await this.reserveAndIssue(input); + try { + return { + tenantId: input.tenantId, repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, principal: input.principal, commitSha: input.commitSha, ttlSeconds: input.ttlSeconds, - auditReason: input.auditReason - } - }); + auditReason: input.auditReason, + credentialId: issuedCredential.credentialId, + credentialType: issuedCredential.credentialType, + credentialValue: issuedCredential.credential.revealForTransport(), + issuedAt: issuedCredential.issuedAt, + expiresAt: issuedCredential.expiresAt, + expiresInSeconds: input.ttlSeconds, + auditEventType: "token.issued" + }; + } finally { + issuedCredential.credential.wipe(); + } + } - return response; + async withCredential( + input: TokenBrokerIssueRequest, + consumer: (credential: Uint8Array) => Promise + ): Promise { + await this.assertBoundToScan(input); + const issued = await this.reserveAndIssue(input); + try { + return await issued.credential.use(consumer); + } finally { + issued.credential.wipe(); + await this.credentialLeaseStore.markWiped( + issued.credentialId, + input.tenantId, + input.attemptId, + this.terminalTimestamp(issued.issuedAt) + ); + } } - listAuditEvents(tenantId: string): TokenBrokerAuditEvent[] { - return this.auditEvents.filter((event) => event.tenantId === tenantId); + async completeLease( + input: TokenBrokerLeaseCompletionRequest + ): Promise { + this.workloadIdentityAttestation.verify(input.workloadIdentityAttestation, { + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + commitSha: input.commitSha + }); + const lease = await this.credentialLeaseStore.findByAttempt( + input.tenantId, + input.attemptId + ); + if (!lease) { + throw new NotFoundException('Credential lease not found for attempt.'); + } + if ( + lease.credentialId !== input.credentialId || + lease.repositoryBindingId !== input.repositoryBindingId || + lease.scanRequestId !== input.scanRequestId || + lease.workloadIdentityRef !== input.workloadIdentityRef || + lease.commitSha !== input.commitSha + ) { + throw new BadRequestException( + 'Credential cleanup does not match the immutable lease scope.' + ); + } + const terminalAt = this.terminalTimestamp(lease.issuedAt); + if (input.disposition === 'WIPED') { + return this.credentialLeaseStore.markWiped( + input.credentialId, + input.tenantId, + input.attemptId, + terminalAt + ); + } + if (input.disposition === 'REVOKED') { + return this.credentialLeaseStore.revoke( + input.credentialId, + input.tenantId, + input.attemptId, + terminalAt + ); + } + throw new BadRequestException( + 'Credential cleanup disposition is outside policy.' + ); + } + + async listAuditEvents(tenantId: string): Promise { + const rows = await this.prisma.auditEvent.findMany({ + where: { + tenantId, + eventType: 'token.issued', + actor: 'token-broker' + }, + orderBy: [{ occurredAt: 'asc' }, { id: 'asc' }] + }); + return rows.map((row) => ({ + id: row.id, + tenantId: row.tenantId, + eventType: 'token.issued', + actor: 'token-broker', + targetType: row.targetType, + targetId: row.targetId, + occurredAt: row.occurredAt.toISOString(), + metadata: row.metadata as TokenBrokerAuditEvent['metadata'] + })); } private async assertBoundToScan(input: TokenBrokerIssueRequest): Promise { @@ -63,6 +156,15 @@ export class TokenBrokerService { throw new BadRequestException('Token scope or TTL is outside the scan credential policy.'); } + this.workloadIdentityAttestation.verify(input.workloadIdentityAttestation, { + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + commitSha: input.commitSha + }); + const scanRequest = await this.controlPlaneService.getScanRequest( input.tenantId, input.scanRequestId @@ -74,4 +176,81 @@ export class TokenBrokerService { throw new BadRequestException('Token request does not match the immutable scan scope.'); } } + + private async reserveAndIssue(input: TokenBrokerIssueRequest) { + const credentialId = `credential_${randomUUID()}`; + const issuedAt = new Date(); + const expiresAt = new Date(issuedAt.getTime() + input.ttlSeconds * 1000); + await this.credentialLeaseStore.reserve({ + credentialId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + commitSha: input.commitSha, + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString() + }); + + let issuedCredential: ReturnType | undefined; + try { + issuedCredential = this.tokenCredentialIssuer.issue(input, issuedAt); + await this.credentialLeaseStore.activate( + credentialId, + input.tenantId, + input.attemptId, + issuedCredential.credential.fingerprint() + ); + await this.recordIssueAudit(input, credentialId, issuedCredential.issuedAt); + return { + credentialId, + ...issuedCredential + }; + } catch (error) { + issuedCredential?.credential.wipe(); + await this.credentialLeaseStore.revoke( + credentialId, + input.tenantId, + input.attemptId, + this.terminalTimestamp(issuedAt.toISOString()) + ); + throw error; + } + } + + private terminalTimestamp(issuedAt: string): string { + return new Date( + Math.max(Date.now(), Date.parse(issuedAt)) + ).toISOString(); + } + + private async recordIssueAudit( + input: TokenBrokerIssueRequest, + credentialId: string, + occurredAt: string + ): Promise { + await this.prisma.auditEvent.create({ + data: { + id: `audit_event_${randomUUID()}`, + tenantId: input.tenantId, + scanRequestId: input.scanRequestId, + eventType: "token.issued", + actor: "token-broker", + targetType: "scan_request", + targetId: input.scanRequestId, + occurredAt: new Date(occurredAt), + metadata: { + repositoryBindingId: input.repositoryBindingId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + credentialId, + principal: input.principal, + commitSha: input.commitSha, + ttlSeconds: input.ttlSeconds, + auditReason: input.auditReason + } + } + }); + } } diff --git a/apps/api/src/token-broker/token-broker.types.ts b/apps/api/src/token-broker/token-broker.types.ts index 0c2a1f1..be5d6ee 100644 --- a/apps/api/src/token-broker/token-broker.types.ts +++ b/apps/api/src/token-broker/token-broker.types.ts @@ -1,6 +1,7 @@ import type { AuditEvent, ScmPrincipal, TokenBrokerIssueRequest } from '@aegisai/shared'; -export interface TokenBrokerIssueResponse extends TokenBrokerIssueRequest { +export interface TokenBrokerIssueResponse + extends Omit { credentialId: string; credentialType: "SCM_REPOSITORY_ACCESS"; credentialValue: string; @@ -13,6 +14,9 @@ export interface TokenBrokerIssueResponse extends TokenBrokerIssueRequest { export interface TokenBrokerAuditEvent extends AuditEvent { metadata: { repositoryBindingId: string; + attemptId: string; + workloadIdentityRef: string; + credentialId: string; principal: Extract; commitSha: string; ttlSeconds: number; diff --git a/apps/api/src/token-broker/token-credential-issuer.service.ts b/apps/api/src/token-broker/token-credential-issuer.service.ts index 9e2cfe0..382c96f 100644 --- a/apps/api/src/token-broker/token-credential-issuer.service.ts +++ b/apps/api/src/token-broker/token-credential-issuer.service.ts @@ -1,40 +1,86 @@ -import { Injectable } from "@nestjs/common"; -import { randomBytes } from "node:crypto"; +import { Injectable, ServiceUnavailableException } from "@nestjs/common"; +import { createHash, randomBytes } from "node:crypto"; import type { TokenBrokerIssueRequest } from '@aegisai/shared'; +import { ConfigService } from '../config/config.service'; + export interface IssuedTokenCredential { credentialType: "SCM_REPOSITORY_ACCESS"; - credentialValue: string; issuedAt: string; expiresAt: string; + credential: EphemeralTokenCredential; +} + +export class EphemeralTokenCredential { + private readonly bytes: Buffer; + private wiped = false; + + constructor(value: string) { + this.bytes = Buffer.from(value, 'utf8'); + } + + fingerprint(): `sha256:${string}` { + this.assertAvailable(); + return `sha256:${createHash('sha256').update(this.bytes).digest('hex')}`; + } + + revealForTransport(): string { + this.assertAvailable(); + return this.bytes.toString('utf8'); + } + + async use(consumer: (credential: Uint8Array) => Promise): Promise { + this.assertAvailable(); + try { + return await consumer(this.bytes); + } finally { + this.wipe(); + } + } + + wipe(): void { + if (!this.wiped) { + this.bytes.fill(0); + this.wiped = true; + } + } + + isWiped(): boolean { + return this.wiped && this.bytes.every((value) => value === 0); + } + + toJSON(): never { + throw new Error('Ephemeral credentials are not serializable.'); + } + + private assertAvailable(): void { + if (this.wiped) { + throw new Error('Ephemeral credential has already been wiped.'); + } + } } @Injectable() export class TokenCredentialIssuerService { - issue(input: TokenBrokerIssueRequest): IssuedTokenCredential { - const issuedAt = new Date(); + constructor(private readonly config: ConfigService) {} + + issue(input: TokenBrokerIssueRequest, issuedAt = new Date()): IssuedTokenCredential { + if (this.config.isProduction()) { + throw new ServiceUnavailableException( + 'Provider-backed repository credential minting is not active for production.' + ); + } const expiresAt = new Date(issuedAt.getTime() + input.ttlSeconds * 1000); return { credentialType: "SCM_REPOSITORY_ACCESS", - credentialValue: this.buildCredentialValue(input), + credential: new EphemeralTokenCredential(this.buildCredentialValue()), issuedAt: issuedAt.toISOString(), expiresAt: expiresAt.toISOString() }; } - private buildCredentialValue(input: TokenBrokerIssueRequest): string { - const randomPart = randomBytes(24).toString("base64url"); - const scopePart = Buffer.from( - [ - input.tenantId, - input.repositoryBindingId, - input.scanRequestId, - input.commitSha, - input.principal - ].join(":") - ).toString("base64url"); - - return `aegis_tb_${scopePart}.${randomPart}`; + private buildCredentialValue(): string { + return `aegis_tb_${randomBytes(32).toString("base64url")}`; } } diff --git a/apps/api/src/token-broker/workload-identity-attestation.service.ts b/apps/api/src/token-broker/workload-identity-attestation.service.ts new file mode 100644 index 0000000..fd3acc1 --- /dev/null +++ b/apps/api/src/token-broker/workload-identity-attestation.service.ts @@ -0,0 +1,154 @@ +import { + createHmac, + randomUUID, + timingSafeEqual +} from 'node:crypto'; + +import { + MAX_WORKLOAD_IDENTITY_ATTESTATION_TTL_SECONDS, + WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE, + WORKLOAD_IDENTITY_ATTESTATION_ISSUER, + WORKLOAD_IDENTITY_ATTESTATION_VERSION, + type WorkloadIdentityAttestation, + type WorkloadIdentityAttestationClaims +} from '@aegisai/shared'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; + +import { ConfigService } from '../config/config.service'; + +const MAX_CLOCK_SKEW_MS = 30_000; + +export interface WorkloadIdentityScope { + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + commitSha: string; +} + +@Injectable() +export class WorkloadIdentityAttestationService { + constructor(private readonly config: ConfigService) {} + + issue( + scope: WorkloadIdentityScope, + options: { now?: Date; ttlSeconds?: number } = {} + ): WorkloadIdentityAttestation { + const now = options.now ?? new Date(); + const ttlSeconds = options.ttlSeconds ?? 120; + if ( + !Number.isInteger(ttlSeconds) || + ttlSeconds < 1 || + ttlSeconds > MAX_WORKLOAD_IDENTITY_ATTESTATION_TTL_SECONDS + ) { + throw new Error('Workload identity attestation TTL is outside policy.'); + } + + const claims: WorkloadIdentityAttestationClaims = { + version: WORKLOAD_IDENTITY_ATTESTATION_VERSION, + issuer: WORKLOAD_IDENTITY_ATTESTATION_ISSUER, + audience: WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE, + ...scope, + nonce: randomUUID(), + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + ttlSeconds * 1000).toISOString() + }; + + return { + claims, + signature: this.sign(claims) + }; + } + + verify( + attestation: WorkloadIdentityAttestation, + expected: WorkloadIdentityScope, + now = new Date() + ): void { + const claims = attestation?.claims; + if (!claims || !attestation.signature) { + this.reject(); + } + + const issuedAt = this.parseCanonicalTimestamp(claims.issuedAt); + const expiresAt = this.parseCanonicalTimestamp(claims.expiresAt); + const lifetime = expiresAt - issuedAt; + const expectedSignature = this.sign(claims); + + if ( + claims.version !== WORKLOAD_IDENTITY_ATTESTATION_VERSION || + claims.issuer !== WORKLOAD_IDENTITY_ATTESTATION_ISSUER || + claims.audience !== WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE || + !claims.nonce || + issuedAt > now.getTime() + MAX_CLOCK_SKEW_MS || + expiresAt <= now.getTime() || + lifetime < 1_000 || + lifetime > MAX_WORKLOAD_IDENTITY_ATTESTATION_TTL_SECONDS * 1000 || + claims.tenantId !== expected.tenantId || + claims.repositoryBindingId !== expected.repositoryBindingId || + claims.scanRequestId !== expected.scanRequestId || + claims.attemptId !== expected.attemptId || + claims.workloadIdentityRef !== expected.workloadIdentityRef || + claims.commitSha !== expected.commitSha || + !this.safeEqual(attestation.signature, expectedSignature) + ) { + this.reject(); + } + } + + private sign(claims: WorkloadIdentityAttestationClaims): `sha256:${string}` { + const key = Buffer.from(this.config.get('WORKLOAD_ATTESTATION_KEY'), 'hex'); + try { + const digest = createHmac('sha256', key) + .update(this.canonicalClaims(claims), 'utf8') + .digest('hex'); + return `sha256:${digest}`; + } finally { + key.fill(0); + } + } + + private canonicalClaims(claims: WorkloadIdentityAttestationClaims): string { + return JSON.stringify([ + claims.version, + claims.issuer, + claims.audience, + claims.tenantId, + claims.repositoryBindingId, + claims.scanRequestId, + claims.attemptId, + claims.workloadIdentityRef, + claims.commitSha, + claims.nonce, + claims.issuedAt, + claims.expiresAt + ]); + } + + private parseCanonicalTimestamp(value: string): number { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) { + this.reject(); + } + return timestamp; + } + + private safeEqual(actual: string, expected: string): boolean { + if (!/^sha256:[0-9a-f]{64}$/.test(actual)) { + return false; + } + const actualBuffer = Buffer.from(actual); + const expectedBuffer = Buffer.from(expected); + return ( + actualBuffer.length === expectedBuffer.length && + timingSafeEqual(actualBuffer, expectedBuffer) + ); + } + + private reject(): never { + throw new UnauthorizedException( + 'Workload identity attestation is invalid, expired, or outside the requested attempt scope.' + ); + } +} diff --git a/apps/api/test/architecture/production-architecture-contracts.e2e-spec.ts b/apps/api/test/architecture/production-architecture-contracts.e2e-spec.ts index fdb2157..92aaf70 100644 --- a/apps/api/test/architecture/production-architecture-contracts.e2e-spec.ts +++ b/apps/api/test/architecture/production-architecture-contracts.e2e-spec.ts @@ -41,6 +41,37 @@ describe('production scan architecture contracts', () => { ), 'utf8' ); + const credentialLeaseMigration = readFileSync( + join( + __dirname, + '../../prisma/migrations/20260724120000_sast_repository_credential_lease/migration.sql' + ), + 'utf8' + ); + const credentialLeaseScopeIndexes = [ + readFileSync( + join( + __dirname, + '../../prisma/migrations/20260724110000_repository_binding_lease_scope_index/migration.sql' + ), + 'utf8' + ), + readFileSync( + join( + __dirname, + '../../prisma/migrations/20260724111000_scan_request_lease_scope_index/migration.sql' + ), + 'utf8' + ) + ].join('\n'); + const tokenBrokerService = readFileSync( + join(__dirname, '../../src/token-broker/token-broker.service.ts'), + 'utf8' + ); + const repositoryFetchService = readFileSync( + join(__dirname, '../../src/scan-plane/repository-fetch.service.ts'), + 'utf8' + ); const scanRequestStore = readFileSync( join(__dirname, '../../src/control-plane/prisma-control-plane-scan-request.store.ts'), 'utf8' @@ -115,8 +146,27 @@ describe('production scan architecture contracts', () => { tenantId: 'tenant_a', repositoryBindingId: 'repo_1', scanRequestId: 'scan_1', + attemptId: 'attempt_1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt_1', + workloadIdentityAttestation: { + claims: { + version: '1', + issuer: 'aegisai-sandbox-provisioner', + audience: 'aegisai-token-broker', + tenantId: 'tenant_a', + repositoryBindingId: 'repo_1', + scanRequestId: 'scan_1', + attemptId: 'attempt_1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt_1', + commitSha: 'a'.repeat(40), + nonce: 'nonce_1', + issuedAt: '2026-07-24T00:00:00.000Z', + expiresAt: '2026-07-24T00:02:00.000Z' + }, + signature: `sha256:${'b'.repeat(64)}` + }, principal: 'REPO_READ', - commitSha: 'abc123', + commitSha: 'a'.repeat(40), ttlSeconds: 600, auditReason: 'scan-fetch' }; @@ -280,4 +330,58 @@ describe('production scan architecture contracts', () => { /enforcementAction|blockRequested|policyOverride|findingOverride|waiverApplied|staleSuppressed/ ); }); + + it('persists only attempt-bound credential lease metadata and hardens fixed-SHA fetch', () => { + const leaseModel = modelBody('SastRepositoryCredentialLease'); + expect(leaseModel).toContain('attemptId'); + expect(leaseModel).toContain('credentialFingerprint'); + expect(leaseModel).toContain('workloadIdentityRef'); + expect(leaseModel).toContain('@@unique([tenantId, attemptId])'); + expect(leaseModel).not.toMatch(/credentialValue|accessToken|refreshToken|secretValue/); + expect(credentialLeaseMigration).toContain( + 'CREATE TABLE "SastRepositoryCredentialLease"' + ); + expect(credentialLeaseMigration).toContain( + 'CREATE UNIQUE INDEX "SastRepositoryCredentialLease_tenantId_attemptId_key"' + ); + expect(credentialLeaseMigration).toContain( + 'FOREIGN KEY ("scanRequestId", "tenantId", "repositoryBindingId")' + ); + expect(credentialLeaseMigration).toContain( + 'REFERENCES "ScanRequest"("id", "tenantId", "repositoryBindingId")' + ); + expect(credentialLeaseMigration).toContain( + 'FOREIGN KEY ("repositoryBindingId", "tenantId")' + ); + expect(credentialLeaseMigration).toContain( + 'REFERENCES "RepositoryBinding"("id", "tenantId")' + ); + expect(credentialLeaseMigration).toContain( + 'ADD CONSTRAINT "SastCredentialLease_scan_scope_fkey"' + ); + expect(credentialLeaseMigration).toMatch( + /REFERENCES "ScanRequest"\("id", "tenantId", "repositoryBindingId"\)\s+ON DELETE CASCADE/ + ); + expect(credentialLeaseMigration).toContain( + 'CONSTRAINT "SastRepositoryCredentialLease_lifecycle_check"' + ); + expect(credentialLeaseScopeIndexes).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY "RepositoryBinding_id_tenantId_key"' + ); + expect(credentialLeaseScopeIndexes).toContain( + 'CREATE UNIQUE INDEX CONCURRENTLY "ScanRequest_id_tenantId_repositoryBindingId_key"' + ); + expect(credentialLeaseMigration).not.toMatch( + /credentialValue|accessToken|refreshToken|secretValue/ + ); + expect(tokenBrokerService).toContain('workloadIdentityAttestation.verify'); + expect(tokenBrokerService).toContain('credentialLeaseStore.reserve'); + expect(tokenBrokerService).toContain('credentialLeaseStore.markWiped'); + expect(tokenBrokerService).not.toMatch(/private readonly auditEvents|new Map/); + expect(repositoryFetchService).toContain("'--depth=1'"); + expect(repositoryFetchService).toContain("'--no-recurse-submodules'"); + expect(repositoryFetchService).toContain("GIT_LFS_SKIP_SMUDGE: '1'"); + expect(repositoryFetchService).toContain('credentialTmpfsVerifier.assertTmpfs'); + expect(repositoryFetchService).not.toMatch(/execSync|shell:\s*true/); + }); }); 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 aebf06a..3704b43 100644 --- a/apps/api/test/config/config.env-files.e2e-spec.ts +++ b/apps/api/test/config/config.env-files.e2e-spec.ts @@ -2,26 +2,82 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { ENV_FILE_PATHS } from '../../src/config/config.paths'; +import { ENVIRONMENT_VALIDATION_SCHEMA } from '../../src/config/config.schema'; describe('Config environment files', () => { - it('uses replace-me placeholders for TOKEN_ENCRYPTION_KEY in example env files', () => { + it('uses distinct replace-me placeholders for every encryption and attestation key', () => { const examplePaths = [ resolve(__dirname, '../../.env.example'), - resolve(__dirname, '../../../../.env.example') + resolve(__dirname, '../../../../.env.example'), + resolve(__dirname, '../../../../deploy/oracle/.env.example') ]; for (const examplePath of examplePaths) { const contents = readFileSync(examplePath, 'utf8'); - const tokenLine = contents - .split(/\r?\n/) - .find((line) => line.startsWith('TOKEN_ENCRYPTION_KEY=')); + const placeholderValues: string[] = []; + for (const key of [ + 'TOKEN_ENCRYPTION_KEY', + 'WORKLOAD_ATTESTATION_KEY', + 'PREFLIGHT_ATTESTATION_KEY' + ]) { + const keyLine = contents + .split(/\r?\n/) + .find((line) => line.startsWith(`${key}=`)); - expect(tokenLine).toBeDefined(); - expect(tokenLine).toContain('REPLACE_WITH'); - expect(tokenLine).not.toMatch(/TOKEN_ENCRYPTION_KEY=[0-9a-fA-F]{64}$/); + expect(keyLine).toBeDefined(); + expect(keyLine).toContain('REPLACE_WITH'); + expect(keyLine).not.toMatch(new RegExp(`${key}=[0-9a-fA-F]{64}$`)); + placeholderValues.push(keyLine?.slice(key.length + 1) ?? ''); + } + expect(new Set(placeholderValues).size).toBe(placeholderValues.length); } }); + it('rejects reuse across encryption and attestation keys', () => { + const environment = { + NODE_ENV: 'development', + 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) + }; + + expect(ENVIRONMENT_VALIDATION_SCHEMA.validate(environment).error).toBeUndefined(); + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...environment, + WORKLOAD_ATTESTATION_KEY: environment.TOKEN_ENCRYPTION_KEY + }).error + ).toBeDefined(); + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...environment, + PREFLIGHT_ATTESTATION_KEY: environment.TOKEN_ENCRYPTION_KEY + }).error + ).toBeDefined(); + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...environment, + PREFLIGHT_ATTESTATION_KEY: environment.WORKLOAD_ATTESTATION_KEY + }).error + ).toBeDefined(); + expect( + ENVIRONMENT_VALIDATION_SCHEMA.validate({ + ...environment, + TOKEN_ENCRYPTION_KEY: environment.WORKLOAD_ATTESTATION_KEY.toUpperCase() + }).error + ).toBeDefined(); + }); + it('loads env files from deterministic workspace and api locations', () => { expect(ENV_FILE_PATHS).toEqual([ resolve(__dirname, '../../../../.env'), diff --git a/apps/api/test/scan-plane/repository-fetch.service.e2e-spec.ts b/apps/api/test/scan-plane/repository-fetch.service.e2e-spec.ts new file mode 100644 index 0000000..4013f10 --- /dev/null +++ b/apps/api/test/scan-plane/repository-fetch.service.e2e-spec.ts @@ -0,0 +1,366 @@ +import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { + SAST_SCAN_PROFILES, + type TokenBrokerIssueRequest +} from '@aegisai/shared'; + +import { + RepositoryGitExecutor, + type RepositoryGitCommand, + type RepositoryGitCommandResult +} from '../../src/scan-plane/repository-git-executor'; +import { RepositoryFetchService } from '../../src/scan-plane/repository-fetch.service'; +import type { CredentialTmpfsVerifier } from '../../src/scan-plane/credential-tmpfs-verifier.service'; +import type { TokenBrokerService } from '../../src/token-broker/token-broker.service'; +import type { ControlPlaneService } from '../../src/control-plane/control-plane.service'; + +class RecordingGitExecutor extends RepositoryGitExecutor { + readonly commands: RepositoryGitCommand[] = []; + + constructor( + private readonly commitSha: string, + private readonly workspaceRoot: string, + private readonly treeOutput?: string + ) { + super(); + } + + async run(command: RepositoryGitCommand): Promise { + this.commands.push(command); + const args = command.args.join(' '); + if (args === 'init --quiet .') { + await mkdir(join(this.workspaceRoot, '.git'), { recursive: true }); + await writeFile(join(this.workspaceRoot, '.git', 'config'), '[core]\n'); + } + if (args.startsWith('checkout ')) { + await mkdir(join(this.workspaceRoot, 'src'), { recursive: true }); + await mkdir(join(this.workspaceRoot, 'assets'), { recursive: true }); + await writeFile(join(this.workspaceRoot, 'src', 'App.java'), 'class App {}'); + await writeFile( + join(this.workspaceRoot, 'assets', 'large.bin'), + [ + 'version https://git-lfs.github.com/spec/v1', + `oid sha256:${'f'.repeat(64)}`, + 'size 1000000', + '' + ].join('\n') + ); + } + if (args === 'rev-parse FETCH_HEAD^{commit}' || args === 'rev-parse HEAD') { + return this.output(`${this.commitSha}\n`); + } + if (args === 'rev-parse --is-shallow-repository') { + return this.output('true\n'); + } + if (args === `ls-tree -r -z -l ${this.commitSha}`) { + const tree = + this.treeOutput ?? + [ + `100644 blob ${'1'.repeat(40)} 12\tsrc/App.java\0`, + `100644 blob ${'2'.repeat(40)} 130\tassets/large.bin\0` + ].join(''); + return { stdout: Buffer.from(tree), stderr: Buffer.alloc(0) }; + } + if (args === `cat-file blob ${'1'.repeat(40)}`) { + return this.output('class App {}'); + } + if (args === `cat-file blob ${'2'.repeat(40)}`) { + const pointer = Buffer.alloc(130); + pointer.write('version https://git-lfs.github.com/spec/v1\n'); + return { stdout: pointer, stderr: Buffer.alloc(0) }; + } + if (args === 'count-objects -v') { + return this.output('count: 2\nsize: 4\nin-pack: 5\nsize-pack: 8\n'); + } + return this.output(''); + } + + private output(stdout: string): RepositoryGitCommandResult { + return { stdout: Buffer.from(stdout), stderr: Buffer.alloc(0) }; + } +} + +describe('RepositoryFetchService', () => { + const commitSha = 'a'.repeat(40); + const fetchLimits = SAST_SCAN_PROFILES.JAVA_DEEP_V1.limits; + let scratchRoot: string; + let workspaceRoot: string; + let credentialTmpfsRoot: string; + + beforeEach(async () => { + scratchRoot = await mkdtemp(join(tmpdir(), 'aegis-fetch-test-')); + workspaceRoot = join(scratchRoot, 'workspace'); + credentialTmpfsRoot = join(scratchRoot, 'credential-tmpfs'); + }); + + afterEach(async () => { + await rm(scratchRoot, { recursive: true, force: true }); + }); + + const tokenRequest = (): TokenBrokerIssueRequest => { + const scope = { + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-1', + commitSha + }; + return { + ...scope, + workloadIdentityAttestation: { + claims: { + version: '1', + issuer: 'aegisai-sandbox-provisioner', + audience: 'aegisai-token-broker', + ...scope, + nonce: 'nonce-1', + issuedAt: '2026-07-24T00:00:00.000Z', + expiresAt: '2026-07-24T00:02:00.000Z' + }, + signature: `sha256:${'c'.repeat(64)}` + }, + principal: 'REPO_READ', + ttlSeconds: 120, + auditReason: 'fixed-commit-fetch' + }; + }; + + it('uses one fixed SHA with shallow fetch while keeping the credential out of argv and env', async () => { + const secret = 'repository-secret-value'; + let credentialAfterUse: Uint8Array | undefined; + const tokenBroker = { + withCredential: jest.fn( + async ( + _request: TokenBrokerIssueRequest, + consumer: (credential: Uint8Array) => Promise + ) => { + const credential = Buffer.from(secret); + credentialAfterUse = credential; + try { + return await consumer(credential); + } finally { + credential.fill(0); + } + } + ) + } as unknown as TokenBrokerService; + const executor = new RecordingGitExecutor(commitSha, workspaceRoot); + const service = new RepositoryFetchService( + tokenBroker, + executor, + { + getRepositoryFetchTarget: jest.fn().mockResolvedValue({ + provider: 'GITHUB', + fullName: 'acme/service' + }) + } as unknown as ControlPlaneService, + { assertTmpfs: jest.fn() } as unknown as CredentialTmpfsVerifier + ); + + const result = await service.fetch({ + scratchRoot, + workspaceRoot, + credentialTmpfsRoot, + limits: fetchLimits, + tokenRequest: tokenRequest() + }); + + expect(result.metadata).toEqual({ + attemptId: 'attempt-1', + fixedCommitSha: commitSha, + remoteHost: 'github.com', + objectCount: 7, + fetchedBytes: 12 * 1024, + shallow: true, + detachedHead: true, + submodulesFetched: false, + lfsObjectsFetched: false, + archivesExpanded: false, + gitMetadataRemoved: true, + credentialWiped: true + }); + expect(result.entries).toEqual([ + expect.objectContaining({ path: 'src/App.java', lfsPointer: false }), + expect.objectContaining({ path: 'assets/large.bin', lfsPointer: true }) + ]); + expect( + executor.commands.map((command) => command.args).find((args) => args[0] === 'fetch') + ).toEqual([ + 'fetch', + '--quiet', + '--no-tags', + '--depth=1', + '--no-recurse-submodules', + 'origin', + commitSha + ]); + expect( + executor.commands + .map((command) => command.args) + .filter((args) => args[0] === 'remote') + ).toEqual([ + ['remote', 'add', 'origin', 'https://github.com/acme/service.git'], + ['remote', 'remove', 'origin'] + ]); + expect( + JSON.stringify( + executor.commands.map((command) => ({ + args: command.args, + environment: command.environment + })) + ) + ).not.toContain(secret); + expect( + executor.commands.some((command) => + command.args.some((argument) => /refs\/heads|refs\/tags|main|master/.test(argument)) + ) + ).toBe(false); + const commandNames = executor.commands.map((command) => command.args[0]); + expect(commandNames.indexOf('ls-tree')).toBeLessThan( + commandNames.indexOf('checkout') + ); + expect(credentialAfterUse && [...credentialAfterUse].every((value) => value === 0)).toBe(true); + expect(await readdir(credentialTmpfsRoot)).toEqual([]); + expect(await readdir(workspaceRoot)).not.toContain('.git'); + }); + + it('rejects invalid durable repository names and non-fixed commit identifiers', async () => { + const service = new RepositoryFetchService( + { withCredential: jest.fn() } as unknown as TokenBrokerService, + new RecordingGitExecutor(commitSha, workspaceRoot), + { + getRepositoryFetchTarget: jest.fn().mockResolvedValue({ + provider: 'GITHUB', + fullName: 'https://evil.example/repository' + }) + } as unknown as ControlPlaneService, + { assertTmpfs: jest.fn() } as unknown as CredentialTmpfsVerifier + ); + const base = { + scratchRoot, + workspaceRoot, + credentialTmpfsRoot, + limits: fetchLimits, + tokenRequest: tokenRequest() + }; + + await expect(service.fetch(base)).rejects.toThrow('repository name'); + + const validTargetService = new RepositoryFetchService( + { withCredential: jest.fn() } as unknown as TokenBrokerService, + new RecordingGitExecutor(commitSha, workspaceRoot), + { + getRepositoryFetchTarget: jest.fn().mockResolvedValue({ + provider: 'GITHUB', + fullName: 'acme/service' + }) + } as unknown as ControlPlaneService, + { assertTmpfs: jest.fn() } as unknown as CredentialTmpfsVerifier + ); + await expect( + validTargetService.fetch({ + ...base, + tokenRequest: { ...tokenRequest(), commitSha: 'main' } + }) + ).rejects.toThrow('full fixed commit'); + + await expect( + validTargetService.fetch({ + ...base, + credentialTmpfsRoot: join(workspaceRoot, 'credentials') + }) + ).rejects.toThrow('must be disjoint'); + }); + + it('rejects Git tree entry modes outside the regular-file, symlink, and submodule policy', async () => { + const tokenBroker = { + withCredential: jest.fn( + async ( + _request: TokenBrokerIssueRequest, + consumer: (credential: Uint8Array) => Promise + ) => { + const credential = Buffer.from('repository-secret-value'); + try { + return await consumer(credential); + } finally { + credential.fill(0); + } + } + ) + } as unknown as TokenBrokerService; + const invalidTree = `060000 blob ${'1'.repeat(40)} 12\tsrc/App.java\0`; + const service = new RepositoryFetchService( + tokenBroker, + new RecordingGitExecutor(commitSha, workspaceRoot, invalidTree), + { + getRepositoryFetchTarget: jest.fn().mockResolvedValue({ + provider: 'GITHUB', + fullName: 'acme/service' + }) + } as unknown as ControlPlaneService, + { assertTmpfs: jest.fn() } as unknown as CredentialTmpfsVerifier + ); + + await expect( + service.fetch({ + scratchRoot, + workspaceRoot, + credentialTmpfsRoot, + limits: fetchLimits, + tokenRequest: tokenRequest() + }) + ).rejects.toThrow('entry type or size'); + expect(await readdir(credentialTmpfsRoot)).toEqual([]); + }); + + it('rejects expanded tree limits before checkout materializes repository files', async () => { + const tokenBroker = { + withCredential: jest.fn( + async ( + _request: TokenBrokerIssueRequest, + consumer: (credential: Uint8Array) => Promise + ) => { + const credential = Buffer.from('repository-secret-value'); + try { + return await consumer(credential); + } finally { + credential.fill(0); + } + } + ) + } as unknown as TokenBrokerService; + const executor = new RecordingGitExecutor(commitSha, workspaceRoot); + const service = new RepositoryFetchService( + tokenBroker, + executor, + { + getRepositoryFetchTarget: jest.fn().mockResolvedValue({ + provider: 'GITHUB', + fullName: 'acme/service' + }) + } as unknown as ControlPlaneService, + { assertTmpfs: jest.fn() } as unknown as CredentialTmpfsVerifier + ); + + await expect( + service.fetch({ + scratchRoot, + workspaceRoot, + credentialTmpfsRoot, + limits: { + ...fetchLimits, + maxRepositoryBytes: 100 + }, + tokenRequest: tokenRequest() + }) + ).rejects.toThrow('pre-materialization limit'); + expect( + executor.commands.some((command) => command.args[0] === 'checkout') + ).toBe(false); + expect(await readdir(credentialTmpfsRoot)).toEqual([]); + }); +}); diff --git a/apps/api/test/scan-plane/repository-preflight.service.e2e-spec.ts b/apps/api/test/scan-plane/repository-preflight.service.e2e-spec.ts new file mode 100644 index 0000000..024d595 --- /dev/null +++ b/apps/api/test/scan-plane/repository-preflight.service.e2e-spec.ts @@ -0,0 +1,316 @@ +import { SAST_SCAN_PROFILES, type SastRepositoryTreeEntry } from '@aegisai/shared'; + +import { RepositoryPreflightAttestationService } from '../../src/scan-plane/repository-preflight-attestation.service'; +import { RepositoryPreflightService } from '../../src/scan-plane/repository-preflight.service'; +import type { ConfigService } from '../../src/config/config.service'; + +const file = ( + path: string, + byteSize = 10, + overrides: Partial = {} +): SastRepositoryTreeEntry => { + const entry: SastRepositoryTreeEntry = { + path, + pathEncodingValid: true, + kind: 'FILE', + byteSize, + gitObjectId: `sha1:${'1'.repeat(40)}`, + executable: false, + lfsPointer: false, + ...overrides + }; + if ( + entry.kind === 'SYMLINK' && + entry.symlinkTargetEncodingValid === undefined + ) { + entry.symlinkTargetEncodingValid = true; + } + return entry; +}; + +describe('RepositoryPreflightService', () => { + const attestation = new RepositoryPreflightAttestationService({ + get: jest.fn(() => 'b'.repeat(64)) + } as unknown as ConfigService); + const service = new RepositoryPreflightService(attestation); + const profile = SAST_SCAN_PROFILES.JAVA_DEEP_V1; + const baseInput = (entries: SastRepositoryTreeEntry[]) => ({ + attemptId: 'attempt-preflight-1', + fixedCommitSha: 'a'.repeat(40), + pathPolicyVersion: 'path-policy-v1', + pathPolicy: profile.pathPolicy, + limits: profile.limits, + sourceExtensions: profile.sourceExtensions, + manifestNames: profile.manifestNames, + selection: { + mode: 'ALL_SCANNABLE' as const, + paths: [] + }, + entries + }); + + it('produces an order-independent inventory digest and signed accepted attestation', () => { + const entries = [ + file('src/main/java/App.java', 100), + file('pom.xml', 20), + file('docs/source.zip', 500) + ]; + const first = service.evaluate(baseInput(entries)); + const second = service.evaluate(baseInput([...entries].reverse())); + + expect(first).toMatchObject({ + decision: 'ACCEPT', + reasonCodes: ['ARCHIVE_PRESENT'], + repositoryBytes: 620, + selectedBytes: 120, + counts: { + fileCount: 3, + directoryCount: 4, + archiveCount: 1 + } + }); + expect(first.inventoryDigest).toBe(second.inventoryDigest); + const contentChanged = service.evaluate( + baseInput([ + { ...entries[0], gitObjectId: `sha1:${'2'.repeat(40)}` }, + entries[1], + entries[2] + ]) + ); + expect(contentChanged.inventoryDigest).not.toBe(first.inventoryDigest); + expect( + attestation.verify(first.attestationRef, { + attemptId: first.attemptId, + fixedCommitSha: first.fixedCommitSha, + pathPolicyVersion: first.pathPolicyVersion, + inventoryDigest: first.inventoryDigest, + decision: first.decision + }) + ).toBe(true); + expect( + attestation.verify(`${first.attestationRef}tampered`, { + attemptId: first.attemptId, + fixedCommitSha: first.fixedCommitSha, + pathPolicyVersion: first.pathPolicyVersion, + inventoryDigest: first.inventoryDigest, + decision: first.decision + }) + ).toBe(false); + expect( + attestation.verify('attestation://sast-preflight/v1/'.padEnd(9000, 'a'), { + attemptId: first.attemptId, + fixedCommitSha: first.fixedCommitSha, + pathPolicyVersion: first.pathPolicyVersion, + inventoryDigest: first.inventoryDigest, + decision: first.decision + }) + ).toBe(false); + }); + + it('rejects traversal, absolute/UNC/drive paths, controls, collisions, and over-limit input', () => { + const decomposed = 'src/cafe\u0301.java'; + const composed = 'src/café.java'; + const result = service.evaluate({ + ...baseInput([ + file('[invalid-utf8]', 10, { pathEncodingValid: false }), + file('../escape.java'), + file('/root.java'), + file('C:\\root.java'), + file('\\\\server\\share\\root.java'), + file('src/bad\u0000name.java'), + file('src/\u202eright-to-left.java'), + file('src/App.java'), + file('src/app.java'), + file('src/Duplicate.java'), + file('src/Duplicate.java'), + file(decomposed), + file(composed), + file('x'.repeat(4097)), + file('src/Huge.java', 101) + ]), + limits: { + maxRepositoryBytes: 100, + maxSelectedBytes: 50, + maxFileCount: 5, + maxSingleFileBytes: 100, + maxPathDepth: 1 + } + }); + + expect(result.decision).toBe('REJECT'); + expect(result.reasonCodes).toEqual( + expect.arrayContaining([ + 'PATH_INVALID_UTF8', + 'PATH_NUL_OR_CONTROL', + 'PATH_ABSOLUTE', + 'PATH_DRIVE_OR_UNC', + 'PATH_PARENT_TRAVERSAL', + 'PATH_LENGTH_LIMIT_EXCEEDED', + 'PATH_CASE_COLLISION', + 'PATH_UNICODE_COLLISION', + 'PATH_DUPLICATE', + 'PATH_DEPTH_LIMIT_EXCEEDED', + 'REPOSITORY_BYTES_LIMIT_EXCEEDED', + 'SELECTED_BYTES_LIMIT_EXCEEDED', + 'FILE_COUNT_LIMIT_EXCEEDED', + 'SINGLE_FILE_BYTES_LIMIT_EXCEEDED' + ]) + ); + expect(JSON.stringify(result.rejectedPaths)).not.toContain('\u0000'); + expect(JSON.stringify(result.rejectedPaths)).not.toContain('\u202e'); + }); + + it('escalates safe links, submodules, and LFS pointers but rejects escape and cycles', () => { + const restricted = service.evaluate( + baseInput([ + file('src/App.java'), + file('src/link', 8, { + kind: 'SYMLINK', + symlinkTarget: 'App.java' + }), + file('modules/payments', 0, { kind: 'SUBMODULE' }), + file('assets/large.bin', 120, { lfsPointer: true }) + ]) + ); + expect(restricted.decision).toBe('RESTRICTED_ESCALATION'); + expect(restricted.reasonCodes).toEqual( + expect.arrayContaining([ + 'SYMLINK_PRESENT', + 'SUBMODULE_PRESENT', + 'LFS_POINTER_PRESENT' + ]) + ); + + const rejected = service.evaluate( + baseInput([ + file('src/outside', 8, { + kind: 'SYMLINK', + symlinkTarget: '../../etc/passwd' + }), + file('a', 1, { kind: 'SYMLINK', symlinkTarget: 'b' }), + file('b', 1, { kind: 'SYMLINK', symlinkTarget: 'a' }), + file('prefix-a', 1, { + kind: 'SYMLINK', + symlinkTarget: 'prefix-b/child' + }), + file('prefix-b', 1, { + kind: 'SYMLINK', + symlinkTarget: 'prefix-a' + }) + ]) + ); + expect(rejected.decision).toBe('REJECT'); + expect(rejected.reasonCodes).toEqual( + expect.arrayContaining(['SYMLINK_OUTSIDE_ROOT', 'SYMLINK_CYCLE']) + ); + + const invalidTarget = service.evaluate( + baseInput([ + file('invalid-target', 8, { + kind: 'SYMLINK', + symlinkTarget: '[invalid-utf8]', + symlinkTargetEncodingValid: false + }) + ]) + ); + expect(invalidTarget.decision).toBe('REJECT'); + expect(invalidTarget.reasonCodes).toContain('SYMLINK_INVALID_UTF8'); + }); + + it('classifies generated, vendor, fixture, hidden, archive, and LFS entries deterministically', () => { + const result = service.evaluate( + baseInput([ + file('generated/Model.java'), + file('vendor/Library.java'), + file('fixtures/Vulnerable.java'), + file('.cache/Hidden.java'), + file('bundle.tar.gz'), + file('large.bin', 120, { lfsPointer: true }) + ]) + ); + expect(result.counts).toMatchObject({ + generatedCount: 1, + vendorCount: 1, + fixtureCount: 1, + hiddenSystemCount: 1, + archiveCount: 1, + lfsPointerCount: 1 + }); + expect(result.reasonCodes).toEqual( + expect.arrayContaining(['ARCHIVE_PRESENT', 'LFS_POINTER_PRESENT']) + ); + }); + + it('binds deterministic changed-path selection and counts only selected Fast bytes', () => { + const entries = [ + file('src/main/java/App.java', 100), + file('src/main/java/Large.java', 300), + file('pom.xml', 20) + ]; + const selected = service.evaluate({ + ...baseInput(entries), + limits: { + ...profile.limits, + maxSelectedBytes: 150 + }, + selection: { + mode: 'PATH_ALLOWLIST', + paths: ['src\\main\\java\\App.java'] + } + }); + const differentSelection = service.evaluate({ + ...baseInput(entries), + selection: { + mode: 'PATH_ALLOWLIST', + paths: ['src/main/java/Large.java'] + } + }); + + expect(selected.selectedBytes).toBe(100); + expect(selected.reasonCodes).not.toContain('SELECTED_BYTES_LIMIT_EXCEEDED'); + expect(selected.inventoryDigest).not.toBe(differentSelection.inventoryDigest); + }); + + it('rejects malformed runtime metadata before evaluating hostile paths', () => { + const malformedEntry = { + ...file('src/App.java'), + kind: 'DEVICE' + }; + expect(() => + service.evaluate({ + ...baseInput([]), + entries: [malformedEntry] as unknown as SastRepositoryTreeEntry[] + }) + ).toThrow('Repository preflight entry metadata is invalid.'); + + expect(() => + service.evaluate( + baseInput([ + file('src/App.java', 10, { + gitObjectId: `sha256:${'1'.repeat(64)}` + }) + ]) + ) + ).toThrow('Repository preflight entry metadata is invalid.'); + + expect(() => + service.evaluate({ + ...baseInput([file('src/App.java')]), + limits: { + ...profile.limits, + maxFileCount: Number.MAX_SAFE_INTEGER + 1 + } + }) + ).toThrow('Repository preflight input is incomplete.'); + + expect(() => + service.evaluate({ + ...baseInput([file('src/App.java')]), + selection: { + mode: 'PATH_ALLOWLIST', + paths: ['src/App.java', 'src/./App.java'] + } + }) + ).toThrow('Repository preflight selection contains duplicate normalized paths.'); + }); +}); diff --git a/apps/api/test/support/in-memory-repository-credential-lease.store.ts b/apps/api/test/support/in-memory-repository-credential-lease.store.ts new file mode 100644 index 0000000..91734ad --- /dev/null +++ b/apps/api/test/support/in-memory-repository-credential-lease.store.ts @@ -0,0 +1,171 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; +import type { SastCredentialLeaseMetadata } from '@aegisai/shared'; + +import { + RepositoryCredentialLeaseStore, + type ReserveRepositoryCredentialLeaseInput +} from '../../src/token-broker/repository-credential-lease.store'; + +export class InMemoryRepositoryCredentialLeaseStore extends RepositoryCredentialLeaseStore { + private readonly leases = new Map(); + private lockTail: Promise = Promise.resolve(); + + async reserve( + input: ReserveRepositoryCredentialLeaseInput + ): Promise { + return this.exclusive(() => { + if ( + Array.from(this.leases.values()).some( + (lease) => + lease.tenantId === input.tenantId && + lease.attemptId === input.attemptId + ) + ) { + throw new ConflictException('A credential was already reserved for this scan attempt.'); + } + const lease: SastCredentialLeaseMetadata = { + credentialId: input.credentialId, + tenantId: input.tenantId, + repositoryBindingId: input.repositoryBindingId, + scanRequestId: input.scanRequestId, + attemptId: input.attemptId, + workloadIdentityRef: input.workloadIdentityRef, + commitSha: input.commitSha, + status: 'RESERVED', + issuedAt: input.issuedAt, + expiresAt: input.expiresAt + }; + this.leases.set(lease.credentialId, lease); + return this.clone(lease); + }); + } + + async activate( + credentialId: string, + tenantId: string, + attemptId: string, + credentialFingerprint: `sha256:${string}` + ): Promise { + return this.exclusive(() => { + const lease = this.requireAttempt(credentialId, tenantId, attemptId); + if (lease.status !== 'RESERVED') { + throw new ConflictException('Credential lease is not in the reserved state.'); + } + lease.status = 'ISSUED'; + lease.credentialFingerprint = credentialFingerprint; + return this.clone(lease); + }); + } + + async markWiped( + credentialId: string, + tenantId: string, + attemptId: string, + wipedAt: string + ): Promise { + return this.exclusive(() => { + const lease = this.requireAttempt(credentialId, tenantId, attemptId); + if (lease.status === 'WIPED') { + return this.clone(lease); + } + if (lease.status !== 'ISSUED') { + throw new ConflictException('Only an issued credential lease can be marked wiped.'); + } + lease.status = 'WIPED'; + lease.wipedAt = wipedAt; + return this.clone(lease); + }); + } + + async revoke( + credentialId: string, + tenantId: string, + attemptId: string, + revokedAt: string + ): Promise { + return this.exclusive(() => { + const lease = this.requireAttempt(credentialId, tenantId, attemptId); + if (lease.status === 'WIPED' || lease.status === 'REVOKED') { + return this.clone(lease); + } + if (lease.status !== 'RESERVED' && lease.status !== 'ISSUED') { + throw new ConflictException( + 'Credential lease cannot be revoked from its current state.' + ); + } + lease.status = 'REVOKED'; + lease.revokedAt = revokedAt; + return this.clone(lease); + }); + } + + async revokeExpired(referenceTime: string): Promise { + return this.exclusive(() => { + const timestamp = Date.parse(referenceTime); + if (!Number.isFinite(timestamp)) { + throw new Error('Credential lease expiry reference time is invalid.'); + } + let revoked = 0; + for (const lease of this.leases.values()) { + if ( + (lease.status === 'RESERVED' || lease.status === 'ISSUED') && + Date.parse(lease.expiresAt) <= timestamp + ) { + lease.status = 'REVOKED'; + lease.revokedAt = referenceTime; + revoked += 1; + } + } + return revoked; + }); + } + + async findByAttempt( + tenantId: string, + attemptId: string + ): Promise { + const lease = Array.from(this.leases.values()).find( + (candidate) => + candidate.tenantId === tenantId && candidate.attemptId === attemptId + ); + return lease ? this.clone(lease) : null; + } + + private require(credentialId: string): SastCredentialLeaseMetadata { + const lease = this.leases.get(credentialId); + if (!lease) { + throw new NotFoundException('Credential lease not found.'); + } + return lease; + } + + private requireAttempt( + credentialId: string, + tenantId: string, + attemptId: string + ): SastCredentialLeaseMetadata { + const lease = this.require(credentialId); + if (lease.tenantId !== tenantId || lease.attemptId !== attemptId) { + throw new NotFoundException('Credential lease not found for attempt.'); + } + return lease; + } + + private clone(lease: SastCredentialLeaseMetadata): SastCredentialLeaseMetadata { + return { ...lease }; + } + + private async exclusive(operation: () => T | Promise): Promise { + const predecessor = this.lockTail; + let release!: () => void; + this.lockTail = new Promise((resolve) => { + release = resolve; + }); + await predecessor; + try { + return await operation(); + } finally { + release(); + } + } +} diff --git a/apps/api/test/token-broker/credential-lease-expiry.task.e2e-spec.ts b/apps/api/test/token-broker/credential-lease-expiry.task.e2e-spec.ts new file mode 100644 index 0000000..81cd991 --- /dev/null +++ b/apps/api/test/token-broker/credential-lease-expiry.task.e2e-spec.ts @@ -0,0 +1,24 @@ +import type { ConfigService } from '../../src/config/config.service'; +import { CredentialLeaseExpiryTask } from '../../src/token-broker/credential-lease-expiry.task'; +import type { RepositoryCredentialLeaseStore } from '../../src/token-broker/repository-credential-lease.store'; + +describe('CredentialLeaseExpiryTask', () => { + it('atomically revokes expired nonterminal leases at the supplied reference time', async () => { + const leases = { + revokeExpired: jest.fn().mockResolvedValue(2) + } as unknown as RepositoryCredentialLeaseStore; + const task = new CredentialLeaseExpiryTask( + leases, + { + isTest: jest.fn(() => true), + get: jest.fn(() => 60_000) + } as unknown as ConfigService + ); + const referenceTime = new Date('2026-07-24T00:10:00.000Z'); + + await expect(task.revokeExpired(referenceTime)).resolves.toBe(2); + expect(leases.revokeExpired).toHaveBeenCalledWith( + referenceTime.toISOString() + ); + }); +}); diff --git a/apps/api/test/token-broker/prisma-repository-credential-lease.store.e2e-spec.ts b/apps/api/test/token-broker/prisma-repository-credential-lease.store.e2e-spec.ts new file mode 100644 index 0000000..99cf6c6 --- /dev/null +++ b/apps/api/test/token-broker/prisma-repository-credential-lease.store.e2e-spec.ts @@ -0,0 +1,243 @@ +import { ConflictException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaRepositoryCredentialLeaseStore } from '../../src/token-broker/prisma-repository-credential-lease.store'; +import type { PrismaService } from '../../src/prisma/prisma.service'; + +describe('PrismaRepositoryCredentialLeaseStore', () => { + const issuedAt = new Date('2026-07-24T00:00:00.000Z'); + const expiresAt = new Date('2026-07-24T00:02:00.000Z'); + const baseRow = () => ({ + id: 'credential-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-1', + commitSha: 'a'.repeat(40), + credentialFingerprint: null as string | null, + status: 'RESERVED' as 'RESERVED' | 'ISSUED' | 'WIPED' | 'REVOKED', + issuedAt, + expiresAt, + wipedAt: null as Date | null, + revokedAt: null as Date | null + }); + const reserveInput = { + credentialId: 'credential-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-1', + commitSha: 'a'.repeat(40), + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString() + }; + + it('reserves only against an active durable scan scope and records no secret value', async () => { + const row = baseRow(); + const transaction = { + scanRequest: { findFirst: jest.fn().mockResolvedValue({ id: 'scan-1' }) }, + sastRepositoryCredentialLease: { + create: jest.fn().mockResolvedValue(row) + } + }; + const prisma = { + $transaction: jest.fn( + async (operation: (client: typeof transaction) => Promise) => + operation(transaction) + ) + }; + const store = new PrismaRepositoryCredentialLeaseStore( + prisma as unknown as PrismaService + ); + + await expect(store.reserve(reserveInput)).resolves.toEqual({ + credentialId: 'credential-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + scanRequestId: 'scan-1', + attemptId: 'attempt-1', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-1', + commitSha: 'a'.repeat(40), + status: 'RESERVED', + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), + wipedAt: undefined, + revokedAt: undefined + }); + expect(transaction.scanRequest.findFirst).toHaveBeenCalledWith({ + where: expect.objectContaining({ + id: 'scan-1', + tenantId: 'tenant-1', + repositoryBindingId: 'repository-1', + commitSha: 'a'.repeat(40), + status: 'RUNNING', + repositoryBinding: { + status: 'ACTIVE', + integration: { status: 'ACTIVE' } + } + }) + }); + expect(JSON.stringify(transaction.sastRepositoryCredentialLease.create.mock.calls)).not.toMatch( + /credentialValue|accessToken|refreshToken|secretValue/ + ); + }); + + it('fails closed for inactive scope and duplicate attempt reservation', async () => { + const inactiveTransaction = { + scanRequest: { findFirst: jest.fn().mockResolvedValue(null) }, + sastRepositoryCredentialLease: { create: jest.fn() } + }; + const inactiveStore = new PrismaRepositoryCredentialLeaseStore({ + $transaction: jest.fn( + async (operation: (client: typeof inactiveTransaction) => Promise) => + operation(inactiveTransaction) + ) + } as unknown as PrismaService); + await expect(inactiveStore.reserve(reserveInput)).rejects.toBeInstanceOf( + ConflictException + ); + + const duplicate = new Prisma.PrismaClientKnownRequestError('duplicate attempt', { + code: 'P2002', + clientVersion: '5.22.0', + meta: { target: ['attemptId'] } + }); + const duplicateTransaction = { + scanRequest: { findFirst: jest.fn().mockResolvedValue({ id: 'scan-1' }) }, + sastRepositoryCredentialLease: { + create: jest.fn().mockRejectedValue(duplicate) + } + }; + const duplicateStore = new PrismaRepositoryCredentialLeaseStore({ + $transaction: jest.fn( + async (operation: (client: typeof duplicateTransaction) => Promise) => + operation(duplicateTransaction) + ) + } as unknown as PrismaService); + await expect(duplicateStore.reserve(reserveInput)).rejects.toThrow( + 'already reserved' + ); + }); + + it('allows only monotonic RESERVED to ISSUED to WIPED lifecycle updates', async () => { + const row = baseRow(); + const delegate = { + updateMany: jest.fn().mockImplementation( + async ({ data }: { data: Record }) => { + if (data.status === 'ISSUED') { + row.status = 'ISSUED'; + row.credentialFingerprint = data.credentialFingerprint as string; + } + if (data.status === 'WIPED') { + row.status = 'WIPED'; + row.wipedAt = data.wipedAt as Date; + } + if (data.status === 'REVOKED') { + row.status = 'REVOKED'; + row.revokedAt = data.revokedAt as Date; + } + return { count: 1 }; + } + ), + findUnique: jest.fn().mockImplementation(async () => row), + findFirst: jest.fn().mockImplementation(async () => row) + }; + const store = new PrismaRepositoryCredentialLeaseStore({ + sastRepositoryCredentialLease: delegate + } as unknown as PrismaService); + + await expect( + store.activate( + 'credential-1', + 'tenant-1', + 'attempt-1', + `sha256:${'b'.repeat(64)}` + ) + ).resolves.toMatchObject({ status: 'ISSUED' }); + await expect( + store.markWiped( + 'credential-1', + 'tenant-1', + 'attempt-1', + '2026-07-24T00:01:00.000Z' + ) + ).resolves.toMatchObject({ + status: 'WIPED', + wipedAt: '2026-07-24T00:01:00.000Z' + }); + expect(delegate.updateMany).toHaveBeenLastCalledWith({ + where: { + id: 'credential-1', + tenantId: 'tenant-1', + attemptId: 'attempt-1', + status: 'ISSUED' + }, + data: { + status: 'WIPED', + wipedAt: new Date('2026-07-24T00:01:00.000Z') + } + }); + }); + + it('keeps a wiped lease terminal when concurrent revocation loses the race', async () => { + const row = { + ...baseRow(), + status: 'WIPED' as const, + credentialFingerprint: `sha256:${'b'.repeat(64)}`, + wipedAt: new Date('2026-07-24T00:01:00.000Z') + }; + const delegate = { + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + findFirst: jest.fn().mockResolvedValue(row) + }; + const store = new PrismaRepositoryCredentialLeaseStore({ + sastRepositoryCredentialLease: delegate + } as unknown as PrismaService); + + await expect( + store.revoke( + 'credential-1', + 'tenant-1', + 'attempt-1', + '2026-07-24T00:01:01.000Z' + ) + ).resolves.toMatchObject({ + status: 'WIPED', + revokedAt: undefined + }); + expect(delegate.updateMany).toHaveBeenCalledWith({ + where: { + id: 'credential-1', + tenantId: 'tenant-1', + attemptId: 'attempt-1', + status: { in: ['RESERVED', 'ISSUED'] } + }, + data: { + status: 'REVOKED', + revokedAt: new Date('2026-07-24T00:01:01.000Z') + } + }); + }); + + it('revokes only expired reserved or issued leases in one durable update', async () => { + const updateMany = jest.fn().mockResolvedValue({ count: 2 }); + const store = new PrismaRepositoryCredentialLeaseStore({ + sastRepositoryCredentialLease: { updateMany } + } as unknown as PrismaService); + const referenceTime = '2026-07-24T00:10:00.000Z'; + + await expect(store.revokeExpired(referenceTime)).resolves.toBe(2); + expect(updateMany).toHaveBeenCalledWith({ + where: { + status: { in: ['RESERVED', 'ISSUED'] }, + expiresAt: { lte: new Date(referenceTime) } + }, + data: { + status: 'REVOKED', + revokedAt: new Date(referenceTime) + } + }); + }); +}); diff --git a/apps/api/test/token-broker/token-broker.e2e-spec.ts b/apps/api/test/token-broker/token-broker.e2e-spec.ts index 8839c01..243aef6 100644 --- a/apps/api/test/token-broker/token-broker.e2e-spec.ts +++ b/apps/api/test/token-broker/token-broker.e2e-spec.ts @@ -1,13 +1,33 @@ import { INestApplication } from "@nestjs/common"; import { Test } from "@nestjs/testing"; import request from "supertest"; +import type { TokenBrokerIssueRequest } from '@aegisai/shared'; 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 { RepositoryCredentialLeaseStore } from '../../src/token-broker/repository-credential-lease.store'; +import { WorkloadIdentityAttestationService } from '../../src/token-broker/workload-identity-attestation.service'; +import { TokenBrokerService } from '../../src/token-broker/token-broker.service'; +import { InMemoryRepositoryCredentialLeaseStore } from '../support/in-memory-repository-credential-lease.store'; import { TestInternalServiceGuard, TestSessionAuthGuard } from '../support/security-guards'; describe("Token Broker and audit skeleton (e2e)", () => { let app: INestApplication; + let workloadAttestation: WorkloadIdentityAttestationService; + let tokenBroker: TokenBrokerService; + let credentialLeases: InMemoryRepositoryCredentialLeaseStore; + const auditRows: Array<{ + id: string; + tenantId: string; + eventType: string; + actor: string; + targetType: string; + targetId: string; + occurredAt: Date; + metadata: Record; + }> = []; + const commitOne = 'a'.repeat(40); + const commitTwo = 'b'.repeat(40); beforeAll(async () => { process.env.NODE_ENV = "test"; @@ -30,6 +50,7 @@ describe("Token Broker and audit skeleton (e2e)", () => { import("../../src/prisma/prisma.service") ]); + credentialLeases = new InMemoryRepositoryCredentialLeaseStore(); const moduleRef = await Test.createTestingModule({ imports: [AppModule] }) @@ -39,7 +60,28 @@ describe("Token Broker and audit skeleton (e2e)", () => { $disconnect: jest.fn().mockResolvedValue(undefined), onModuleInit: jest.fn().mockResolvedValue(undefined), onModuleDestroy: jest.fn().mockResolvedValue(undefined), - $queryRawUnsafe: jest.fn().mockResolvedValue([{ result: 1 }]) + $queryRawUnsafe: jest.fn().mockResolvedValue([{ result: 1 }]), + auditEvent: { + create: jest.fn().mockImplementation( + async ({ data }: { data: (typeof auditRows)[number] }) => { + auditRows.push(data); + return data; + } + ), + findMany: jest.fn().mockImplementation( + async ({ + where + }: { + where: { tenantId: string; eventType: string; actor: string }; + }) => + auditRows.filter( + (row) => + row.tenantId === where.tenantId && + row.eventType === where.eventType && + row.actor === where.actor + ) + ) + } }) .overrideProvider(ControlPlaneService) .useValue({ @@ -48,9 +90,11 @@ describe("Token Broker and audit skeleton (e2e)", () => { tenantId, repositoryBindingId: scanRequestId === 'scan_request_2' ? 'repository_binding_2' : 'repository_binding_1', - commitSha: scanRequestId === 'scan_request_2' ? 'def456' : 'abc123' + commitSha: scanRequestId === 'scan_request_2' ? commitTwo : commitOne })) }) + .overrideProvider(RepositoryCredentialLeaseStore) + .useValue(credentialLeases) .overrideGuard(SessionAuthGuard) .useClass(TestSessionAuthGuard) .overrideGuard(InternalServiceGuard) @@ -61,6 +105,8 @@ describe("Token Broker and audit skeleton (e2e)", () => { app.setGlobalPrefix("api"); await app.init(); + workloadAttestation = app.get(WorkloadIdentityAttestationService); + tokenBroker = app.get(TokenBrokerService); }); afterAll(async () => { @@ -77,18 +123,39 @@ describe("Token Broker and audit skeleton (e2e)", () => { return body as T; }; + const issueBody = ( + attemptId: string, + overrides: Partial<{ + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + workloadIdentityRef: string; + commitSha: string; + ttlSeconds: number; + }> = {} + ): TokenBrokerIssueRequest => { + const scope = { + tenantId: overrides.tenantId ?? "tenant_gamma", + repositoryBindingId: overrides.repositoryBindingId ?? "repository_binding_1", + scanRequestId: overrides.scanRequestId ?? "scan_request_1", + attemptId, + workloadIdentityRef: + overrides.workloadIdentityRef ?? `spiffe://aegisai/scan/${attemptId}`, + commitSha: overrides.commitSha ?? commitOne + }; + return { + ...scope, + workloadIdentityAttestation: workloadAttestation.issue(scope), + principal: "REPO_READ" as const, + ttlSeconds: overrides.ttlSeconds ?? 600, + auditReason: "scan-fetch" + }; + }; + it("issues scan-scoped short-lived credential values without persisting them", async () => { const response = await request(app.getHttpServer()) .post("/api/token-broker/issue") - .send({ - tenantId: "tenant_gamma", - repositoryBindingId: "repository_binding_1", - scanRequestId: "scan_request_1", - principal: "REPO_READ", - commitSha: "abc123", - ttlSeconds: 600, - auditReason: "scan-fetch" - }) + .send(issueBody('attempt-1')) .expect(201); const responseData = dataOf>(response.body); @@ -97,8 +164,10 @@ describe("Token Broker and audit skeleton (e2e)", () => { tenantId: "tenant_gamma", repositoryBindingId: "repository_binding_1", scanRequestId: "scan_request_1", + attemptId: "attempt-1", + workloadIdentityRef: "spiffe://aegisai/scan/attempt-1", principal: "REPO_READ", - commitSha: "abc123", + commitSha: commitOne, ttlSeconds: 600, expiresInSeconds: 600, auditEventType: "token.issued", @@ -113,34 +182,134 @@ describe("Token Broker and audit skeleton (e2e)", () => { const secondResponse = await request(app.getHttpServer()) .post("/api/token-broker/issue") - .send({ - tenantId: "tenant_gamma", - repositoryBindingId: "repository_binding_1", - scanRequestId: "scan_request_1", - principal: "REPO_READ", - commitSha: "abc123", - ttlSeconds: 600, - auditReason: "scan-fetch" - }) + .send(issueBody('attempt-2')) .expect(201); expect(dataOf>(secondResponse.body).credentialValue).not.toBe( responseData.credentialValue ); + expect(JSON.stringify(responseData)).not.toMatch(/workloadIdentityAttestation|signature/i); + await expect( + credentialLeases.findByAttempt('tenant_gamma', 'attempt-1') + ).resolves.toMatchObject({ + status: 'ISSUED', + credentialFingerprint: expect.stringMatching(/^sha256:[0-9a-f]{64}$/) + }); + expect( + JSON.stringify( + await credentialLeases.findByAttempt('tenant_gamma', 'attempt-1') + ) + ).not.toContain(responseData.credentialValue); + + const cleanupScope = issueBody('attempt-1'); + const cleanup = await request(app.getHttpServer()) + .post('/api/token-broker/leases/complete') + .send({ + credentialId: responseData.credentialId, + tenantId: cleanupScope.tenantId, + repositoryBindingId: cleanupScope.repositoryBindingId, + scanRequestId: cleanupScope.scanRequestId, + attemptId: cleanupScope.attemptId, + workloadIdentityRef: cleanupScope.workloadIdentityRef, + workloadIdentityAttestation: + cleanupScope.workloadIdentityAttestation, + commitSha: cleanupScope.commitSha, + disposition: 'WIPED' + }) + .expect(201); + + expect(dataOf>(cleanup.body)).toMatchObject({ + credentialId: responseData.credentialId, + attemptId: 'attempt-1', + status: 'WIPED', + wipedAt: expect.any(String) + }); + await expect( + credentialLeases.findByAttempt('tenant_gamma', 'attempt-1') + ).resolves.toMatchObject({ status: 'WIPED' }); + }); + + it('rejects attempt replay and tampered workload identity attestations', async () => { + const body = issueBody('attempt-replay'); + await request(app.getHttpServer()).post('/api/token-broker/issue').send(body).expect(201); + await request(app.getHttpServer()).post('/api/token-broker/issue').send(body).expect(409); + + const tampered = issueBody('attempt-tampered'); + tampered.workloadIdentityAttestation.claims.workloadIdentityRef = + 'spiffe://aegisai/scan/other-attempt'; + await request(app.getHttpServer()) + .post('/api/token-broker/issue') + .send(tampered) + .expect(401); + + const expired = issueBody('attempt-expired'); + expired.workloadIdentityAttestation = workloadAttestation.issue( + { + tenantId: expired.tenantId, + repositoryBindingId: expired.repositoryBindingId, + scanRequestId: expired.scanRequestId, + attemptId: expired.attemptId, + workloadIdentityRef: expired.workloadIdentityRef, + commitSha: expired.commitSha + }, + { now: new Date('2020-01-01T00:00:00.000Z'), ttlSeconds: 60 } + ); + await request(app.getHttpServer()) + .post('/api/token-broker/issue') + .send(expired) + .expect(401); + }); + + it('scopes attempt replay protection to the tenant', async () => { + await request(app.getHttpServer()) + .post('/api/token-broker/issue') + .send(issueBody('attempt-shared')) + .expect(201); + await request(app.getHttpServer()) + .post('/api/token-broker/issue') + .send( + issueBody('attempt-shared', { + tenantId: 'tenant_epsilon', + repositoryBindingId: 'repository_binding_2', + scanRequestId: 'scan_request_2', + commitSha: commitTwo + }) + ) + .expect(201); + }); + + it('zeroizes the in-memory handoff and records wiped lease evidence after fetch use', async () => { + const body = issueBody('attempt-handoff'); + let observed: Uint8Array | undefined; + const result = await tokenBroker.withCredential(body, async (credential) => { + observed = credential; + expect(Buffer.from(credential).toString('utf8')).toMatch(/^aegis_tb_/); + return 'fetch-complete'; + }); + + expect(result).toBe('fetch-complete'); + expect(observed && [...observed].every((value) => value === 0)).toBe(true); + await expect( + credentialLeases.findByAttempt('tenant_gamma', 'attempt-handoff') + ).resolves.toMatchObject({ + status: 'WIPED', + wipedAt: expect.any(String) + }); }); it("records tenant-scoped audit events for token issuance", async () => { await request(app.getHttpServer()) .post("/api/token-broker/issue") - .send({ - tenantId: "tenant_delta", - repositoryBindingId: "repository_binding_2", - scanRequestId: "scan_request_2", - principal: "REPO_READ", - commitSha: "def456", - ttlSeconds: 300, - auditReason: "scan-fetch" - }) + .send( + issueBody('attempt-audit', { + tenantId: 'tenant_delta', + repositoryBindingId: 'repository_binding_2', + scanRequestId: 'scan_request_2', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-audit', + commitSha: commitTwo, + ttlSeconds: 300 + }) + ) .expect(201); const audit = await request(app.getHttpServer()) @@ -159,8 +328,11 @@ describe("Token Broker and audit skeleton (e2e)", () => { targetId: "scan_request_2", metadata: expect.objectContaining({ repositoryBindingId: "repository_binding_2", + attemptId: 'attempt-audit', + workloadIdentityRef: 'spiffe://aegisai/scan/attempt-audit', + credentialId: expect.stringMatching(/^credential_/), principal: "REPO_READ", - commitSha: "def456", + commitSha: commitTwo, ttlSeconds: 300, auditReason: "scan-fetch" }) diff --git a/apps/api/test/token-broker/token-credential-issuer.service.e2e-spec.ts b/apps/api/test/token-broker/token-credential-issuer.service.e2e-spec.ts new file mode 100644 index 0000000..ce01b61 --- /dev/null +++ b/apps/api/test/token-broker/token-credential-issuer.service.e2e-spec.ts @@ -0,0 +1,17 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import type { TokenBrokerIssueRequest } from '@aegisai/shared'; + +import type { ConfigService } from '../../src/config/config.service'; +import { TokenCredentialIssuerService } from '../../src/token-broker/token-credential-issuer.service'; + +describe('TokenCredentialIssuerService', () => { + it('fails closed in production until a provider-backed minting adapter is active', () => { + const issuer = new TokenCredentialIssuerService({ + isProduction: () => true + } as ConfigService); + + expect(() => + issuer.issue({ ttlSeconds: 60 } as TokenBrokerIssueRequest) + ).toThrow(ServiceUnavailableException); + }); +}); diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example index 332d343..27ee9e0 100644 --- a/deploy/oracle/.env.example +++ b/deploy/oracle/.env.example @@ -18,6 +18,9 @@ SESSION_TTL_SECONDS=28800 THROTTLE_TTL_MS=60000 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 +CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS=60000 GITHUB_CLIENT_ID=github-client-id GITHUB_CLIENT_SECRET=github-client-secret GITLAB_CLIENT_ID=gitlab-client-id diff --git a/deploy/oracle/BOOTSTRAP.md b/deploy/oracle/BOOTSTRAP.md index 28c58d1..120f887 100644 --- a/deploy/oracle/BOOTSTRAP.md +++ b/deploy/oracle/BOOTSTRAP.md @@ -87,6 +87,9 @@ Required runtime values include: - `SESSION_SECRET` - `CSRF_SECRET` - `TOKEN_ENCRYPTION_KEY` +- `WORKLOAD_ATTESTATION_KEY` +- `PREFLIGHT_ATTESTATION_KEY` +- `CREDENTIAL_LEASE_EXPIRY_INTERVAL_MS` - OAuth client ids and secrets - `AI_PORT` - `AI_SERVER_URL` @@ -98,6 +101,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. + ## 7. Bootstrap Infra Once Run the one-time infra bootstrap from the deploy directory: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5a11c01..8cc3776 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,3 +12,4 @@ export * from './types/ai-inference-runtime'; export * from './types/deployment-operations'; export * from './types/sast-runtime'; export * from './types/sast-planning'; +export * from './types/sast-fetch'; diff --git a/packages/shared/src/types/production-architecture.ts b/packages/shared/src/types/production-architecture.ts index 10ccb29..a0aa8b1 100644 --- a/packages/shared/src/types/production-architecture.ts +++ b/packages/shared/src/types/production-architecture.ts @@ -355,12 +355,56 @@ export interface TokenBrokerIssueRequest { tenantId: string; repositoryBindingId: string; scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + workloadIdentityAttestation: WorkloadIdentityAttestation; principal: Extract; commitSha: string; ttlSeconds: number; auditReason: string; } +export const WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE = 'aegisai-token-broker' as const; +export const WORKLOAD_IDENTITY_ATTESTATION_ISSUER = + 'aegisai-sandbox-provisioner' as const; +export const WORKLOAD_IDENTITY_ATTESTATION_VERSION = '1' as const; +export const MAX_WORKLOAD_IDENTITY_ATTESTATION_TTL_SECONDS = 5 * 60; + +export interface WorkloadIdentityAttestationClaims { + version: typeof WORKLOAD_IDENTITY_ATTESTATION_VERSION; + issuer: typeof WORKLOAD_IDENTITY_ATTESTATION_ISSUER; + audience: typeof WORKLOAD_IDENTITY_ATTESTATION_AUDIENCE; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + commitSha: string; + nonce: string; + issuedAt: string; + expiresAt: string; +} + +export interface WorkloadIdentityAttestation { + claims: WorkloadIdentityAttestationClaims; + signature: `sha256:${string}`; +} + +export interface TokenBrokerLeaseCompletionRequest + extends Pick< + TokenBrokerIssueRequest, + | 'tenantId' + | 'repositoryBindingId' + | 'scanRequestId' + | 'attemptId' + | 'workloadIdentityRef' + | 'workloadIdentityAttestation' + | 'commitSha' + > { + credentialId: string; + disposition: 'WIPED' | 'REVOKED'; +} + export const MAX_SCAN_CREDENTIAL_TTL_SECONDS = 10 * 60; export const MAX_EVIDENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/packages/shared/src/types/sast-fetch.ts b/packages/shared/src/types/sast-fetch.ts new file mode 100644 index 0000000..a26e4b3 --- /dev/null +++ b/packages/shared/src/types/sast-fetch.ts @@ -0,0 +1,159 @@ +import type { SastPathPolicy, SastResourceLimits } from './sast-runtime'; + +export const SAST_REPOSITORY_ENTRY_KINDS = [ + 'FILE', + 'SYMLINK', + 'SUBMODULE' +] as const; +export type SastRepositoryEntryKind = (typeof SAST_REPOSITORY_ENTRY_KINDS)[number]; + +export const SAST_REPOSITORY_ENTRY_CLASSIFICATIONS = [ + 'SOURCE', + 'MANIFEST', + 'GENERATED', + 'VENDOR', + 'FIXTURE', + 'HIDDEN_SYSTEM', + 'LFS_POINTER', + 'ARCHIVE', + 'OTHER' +] as const; +export type SastRepositoryEntryClassification = + (typeof SAST_REPOSITORY_ENTRY_CLASSIFICATIONS)[number]; + +export const SAST_PREFLIGHT_DECISIONS = [ + 'ACCEPT', + 'REJECT', + 'RESTRICTED_ESCALATION' +] as const; +export type SastPreflightDecision = (typeof SAST_PREFLIGHT_DECISIONS)[number]; + +export const SAST_PREFLIGHT_SELECTION_MODES = [ + 'ALL_SCANNABLE', + 'PATH_ALLOWLIST' +] as const; +export type SastPreflightSelectionMode = + (typeof SAST_PREFLIGHT_SELECTION_MODES)[number]; + +export interface SastRepositoryPreflightSelection { + mode: SastPreflightSelectionMode; + paths: readonly string[]; +} + +export const SAST_PREFLIGHT_REASON_CODES = [ + 'PATH_INVALID_UTF8', + 'PATH_NUL_OR_CONTROL', + 'PATH_ABSOLUTE', + 'PATH_DRIVE_OR_UNC', + 'PATH_PARENT_TRAVERSAL', + 'PATH_LENGTH_LIMIT_EXCEEDED', + 'PATH_CASE_COLLISION', + 'PATH_UNICODE_COLLISION', + 'PATH_DUPLICATE', + 'SYMLINK_INVALID_UTF8', + 'SYMLINK_OUTSIDE_ROOT', + 'SYMLINK_CYCLE', + 'PATH_DEPTH_LIMIT_EXCEEDED', + 'REPOSITORY_BYTES_LIMIT_EXCEEDED', + 'SELECTED_BYTES_LIMIT_EXCEEDED', + 'FILE_COUNT_LIMIT_EXCEEDED', + 'SINGLE_FILE_BYTES_LIMIT_EXCEEDED', + 'SYMLINK_PRESENT', + 'SUBMODULE_PRESENT', + 'LFS_POINTER_PRESENT', + 'ARCHIVE_PRESENT' +] as const; +export type SastPreflightReasonCode = (typeof SAST_PREFLIGHT_REASON_CODES)[number]; + +export interface SastRepositoryTreeEntry { + path: string; + pathEncodingValid: boolean; + kind: SastRepositoryEntryKind; + byteSize: number; + gitObjectId: `sha1:${string}` | `sha256:${string}`; + executable: boolean; + symlinkTarget?: string; + symlinkTargetEncodingValid?: boolean; + lfsPointer: boolean; +} + +export interface SastRepositoryPreflightInput { + attemptId: string; + fixedCommitSha: string; + pathPolicyVersion: string; + pathPolicy: Readonly; + limits: Pick< + SastResourceLimits, + | 'maxRepositoryBytes' + | 'maxSelectedBytes' + | 'maxFileCount' + | 'maxSingleFileBytes' + | 'maxPathDepth' + >; + sourceExtensions: readonly string[]; + manifestNames: readonly string[]; + selection: Readonly; + entries: readonly SastRepositoryTreeEntry[]; +} + +export interface SastRepositoryPreflightCounts { + fileCount: number; + directoryCount: number; + symlinkCount: number; + submoduleCount: number; + lfsPointerCount: number; + archiveCount: number; + generatedCount: number; + vendorCount: number; + fixtureCount: number; + hiddenSystemCount: number; +} + +export interface SastRepositoryPreflightResult { + attemptId: string; + fixedCommitSha: string; + pathPolicyVersion: string; + inventoryDigest: `sha256:${string}`; + attestationRef: string; + decision: SastPreflightDecision; + reasonCodes: SastPreflightReasonCode[]; + repositoryBytes: number; + selectedBytes: number; + maxSingleFileBytes: number; + maxPathDepth: number; + counts: SastRepositoryPreflightCounts; + rejectedPaths: string[]; +} + +export interface SastRepositoryFetchMetadata { + attemptId: string; + fixedCommitSha: string; + remoteHost: string; + objectCount: number; + fetchedBytes: number; + shallow: true; + detachedHead: true; + submodulesFetched: false; + lfsObjectsFetched: false; + archivesExpanded: false; + gitMetadataRemoved: true; + credentialWiped: true; +} + +export type SastCredentialLeaseStatus = 'RESERVED' | 'ISSUED' | 'WIPED' | 'REVOKED'; + +export interface SastCredentialLeaseMetadata { + credentialId: string; + tenantId: string; + repositoryBindingId: string; + scanRequestId: string; + attemptId: string; + workloadIdentityRef: string; + commitSha: string; + credentialFingerprint?: `sha256:${string}`; + status: SastCredentialLeaseStatus; + issuedAt: string; + expiresAt: string; + wipedAt?: string; + revokedAt?: string; +} 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 0fd1b1c..29a6749 100644 --- a/specs/006-production-sast-runtime-design/contracts/sast-runtime.md +++ b/specs/006-production-sast-runtime-design/contracts/sast-runtime.md @@ -206,8 +206,11 @@ or recognizing an extension cannot create a language-complete profile. 2. Provisioner establishes a unique workload identity and empty encrypted scratch volume. 3. Sandbox exchanges its attested identity for one fixed-scan repo-read credential through Token Broker. -4. Sandbox fetches the fixed commit SHA. It never resolves a mutable ref itself. -5. Credential is held in memory or tmpfs, excluded from process arguments, and wiped before +4. Sandbox fetches the fixed commit SHA. It never resolves a mutable ref itself. Before checkout, + 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. 6. Fetch metadata records the remote host, fixed commit, object count, and byte count, but never records URL userinfo or credential material. @@ -229,6 +232,11 @@ Preflight runs before any scanner and in the same microVM boundary. Validation o 7. Refuse archive expansion. 8. Produce an inventory digest and `ACCEPT`, `REJECT`, or `RESTRICTED_ESCALATION` decision. +The selection input is explicit: Deep uses `ALL_SCANNABLE`, while Fast supplies the +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. + 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 attestation reference and digest as immutable wrapper inputs. Immediately before each scanner @@ -239,6 +247,27 @@ envelope. A missing attestation, stale attempt binding, re-manifest failure, or a `SECURITY_VIOLATION`: the scanner does not start, the sandbox is terminated, the attempt and artifact metadata are quarantined, and coverage cannot become complete. +The T022-T024 runtime implementation persists only attempt-bound credential lease metadata and +a SHA-256 credential fingerprint. The credential value remains in an opaque memory buffer and a +verified tmpfs handoff file, is never accepted in command arguments, and is zeroized after fetch. +Lease reservation requires a `RUNNING` durable scan and database-enforced tenant/repository/scan +association. Workload and preflight signing keys are distinct from each other and from the token +encryption key. Replay uniqueness is tenant plus attempt scoped. Distributed HTTP consumers +complete the lease with a fresh scope-bound workload attestation and `WIPED` or `REVOKED` +disposition; a bounded background reconciliation atomically revokes expired nonterminal leases. +The durable active repository binding determines the SCM host and repository path; callers cannot +substitute a remote URL. Fetch uses the full fixed SHA with `--depth=1`, no tags, no submodule +recursion, LFS smudge disabled, pre-checkout tree/object limit enforcement, detached checkout +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. +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 +value as a live SCM token. + ## Scanner Wrapper Contract A wrapper is an immutable image entrypoint with no shell interpolation. It accepts a typed diff --git a/specs/006-production-sast-runtime-design/data-model.md b/specs/006-production-sast-runtime-design/data-model.md index 426b659..54bdad5 100644 --- a/specs/006-production-sast-runtime-design/data-model.md +++ b/specs/006-production-sast-runtime-design/data-model.md @@ -208,6 +208,25 @@ One execution attempt for a plan. A retry creates a new attempt. Maximum automatic attempts are two and only retryable infrastructure failures qualify. +### SastRepositoryCredentialLease + +Durable, tenant-and-attempt-unique metadata for the repository credential handoff. It contains no +credential value. + +- tenant, repository binding, scan request, and attempt identifiers +- workload identity reference and full fixed commit SHA +- opaque credential ID and SHA-256 credential fingerprint +- `RESERVED | ISSUED | WIPED | REVOKED` +- issued, expiry, wiped, and revoked timestamps + +The database enforces one lease per tenant/attempt, valid fixed-SHA/fingerprint forms, expiry after +issuance, and terminal timestamp consistency. Lifecycle mutations are tenant- and attempt-scoped +conditional updates so concurrent wipe/revoke handling cannot reopen or rewrite a terminal +lease. Composite foreign keys bind the lease to one tenant/repository/scan tuple even if +application checks fail, and reservation is allowed only while that durable scan request is +`RUNNING`. An attested HTTP completion records distributed wipe/revoke, while periodic expiry +reconciliation atomically revokes stale `RESERVED` or `ISSUED` leases. + ### SandboxLifecycleEvent - tenant, scan, attempt, and sandbox identifiers @@ -221,8 +240,10 @@ The final successful lifecycle requires a `TERMINATED` event and cleanup evidenc ### RepositoryPreflightResult -- attempt ID, fixed commit SHA, path-policy version, and canonical path inventory digest +- attempt ID, fixed commit SHA, path-policy version, and canonical path/content inventory digest +- each entry's Git object ID, normalized metadata, and bytewise length-prefixed digest binding - signed attestation reference bound to the accepted decision and inventory digest +- `ALL_SCANNABLE` or normalized changed/context path allowlist selection bound into that digest - repository and selected byte totals - file, directory, symlink, LFS pointer, submodule, and archive counts - maximum depth diff --git a/specs/006-production-sast-runtime-design/quickstart.md b/specs/006-production-sast-runtime-design/quickstart.md index f0b34bb..6bcbb6b 100644 --- a/specs/006-production-sast-runtime-design/quickstart.md +++ b/specs/006-production-sast-runtime-design/quickstart.md @@ -107,6 +107,38 @@ Fast and Deep lanes use separate queues and budgets but the same microVM securit 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. +## Implemented Runtime Checkpoint + +T022 through T024 are implemented as the first Phase 5 runtime slice: + +- Token Broker verifies a signed, bounded-lifetime workload attestation against tenant, + repository binding, scan request, attempt, workload identity, and fixed commit. A durable + tenant/attempt-unique lease prevents replay while persisting only a SHA-256 credential fingerprint + and lifecycle metadata, never the credential value. Reservation requires a `RUNNING` durable + scan, composite foreign keys preserve the complete tenant/repository/scan binding, and the + workload/preflight signing keys cannot reuse the token encryption key. Attested HTTP cleanup + records distributed wipe/revoke and periodic reconciliation revokes expired nonterminal leases. +- The fetch runtime derives the GitHub Cloud or GitLab Cloud remote from the active durable + repository binding, requires a verified tmpfs credential mount, and uses an opaque + in-memory credential through `GIT_ASKPASS`. It performs only a full-SHA, `--depth=1`, + no-tag, no-submodule fetch with LFS smudge disabled, enforces profile file/expanded-byte/ + single-file/depth limits from Git tree/object metadata before checkout, then removes the remote + and `.git` metadata before wiping the credential file and memory buffer. +- Preflight normalizes separators and Unicode NFC, rejects unsafe roots/traversal/control + paths and duplicate/case/Unicode collisions, resolves symlinks lexically without following outside + the root, enforces profile limits, binds `ALL_SCANNABLE` or normalized Fast changed/context + path selection into selected-byte accounting and the inventory digest, classifies + generated/vendor/fixture/hidden/LFS/ + 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. + ## Deployment Position Oracle VPS and Docker Compose remain dev/demo paths. Production SAST execution requires the diff --git a/specs/006-production-sast-runtime-design/tasks.md b/specs/006-production-sast-runtime-design/tasks.md index 589a1c7..631bb42 100644 --- a/specs/006-production-sast-runtime-design/tasks.md +++ b/specs/006-production-sast-runtime-design/tasks.md @@ -35,9 +35,9 @@ ## Phase 5: Hardened Fetch and Scanner Wrapper -- [ ] T022 Implement per-attempt short-lived repo-read token handoff -- [ ] T023 Implement shallow fixed-commit fetch with submodule/LFS/archive defaults -- [ ] T024 Implement hostile path, symlink, case-collision, size, count, and file-type preflight +- [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 @@ -90,5 +90,6 @@ - [ ] Provision the live production Kubernetes cluster through `005-production-deployment-operations` - [ ] Roll out the provider-specific production microVM platform through `005-production-deployment-operations` +- [ ] Install provider-backed GitHub App/GitLab repo-read credential minting adapters during live provider rollout - [ ] Add language-specific SAST profiles beyond Java after independent corpus and gate approval - [ ] Add build-assisted or dynamic analysis; prohibited in v1 and requires a separate threat model