From 7c13f0b4b6596e1672534aed56f181157f22a40e Mon Sep 17 00:00:00 2001 From: OziinG Date: Sat, 29 Aug 2026 19:24:19 +0900 Subject: [PATCH] Fail closed before weak Driver credentials reach runtime Remove the retired resident-number test seed instead of preserving another secret path, and extend the existing redacted scanner for high-confidence generic password assignments.\n\nConstraint: Production runtime secrets remain outside Git and may not be printed or rotated by this commit.\nRejected: Add a new scanner dependency | the existing redacted scanner and GitGuardian already cover the required path.\nConfidence: high\nScope-risk: moderate\nDirective: Keep the explicit test-fixture allowlist narrow; do not weaken the 32-character Driver JWT gate.\nTested: 2117 delivery-api tests passed; Prisma generation, lint, typecheck, build, compose config, worktree/history secret scans, scanner regression, ignore hygiene, and git diff --check passed.\nNot-tested: The retired production SSM key was not deleted because that is a separate external secret-store mutation. --- .github/workflows/ci.yml | 2 + .gitignore | 6 ++ apps/delivery-api/.env.example | 6 +- apps/delivery-api/docs/api/dsv-driver-auth.md | 2 +- .../driver/driver-auth.dependencies.ts | 7 +- .../modules/driver/driver-token-verifier.ts | 10 ++ .../src/modules/driver/driver.dependencies.ts | 3 +- .../dsv/dsv-driver-auth.dependencies.ts | 5 +- .../dsv/dsv-driver-test-identity.seed.ts | 99 ------------------- .../scripts/seed-dsv-driver-test-identity.ts | 32 ------ .../tests/driver-token-verifier.test.ts | 14 +++ .../tests/driver.dependencies.test.ts | 37 ++++--- .../dsv-driver-auth.dependencies.test.ts | 15 ++- .../dsv-driver-test-identity.seed.test.ts | 62 ------------ docs/development/script-tooling.md | 2 +- docs/security/secret-variable-contract.md | 2 +- scripts/check-ignore-hygiene.mjs | 1 + scripts/scan-secrets.sh | 84 +++++++++++++++- tests/security/scan-secrets.test.sh | 26 +++++ 19 files changed, 190 insertions(+), 225 deletions(-) delete mode 100644 apps/delivery-api/src/modules/dsv/dsv-driver-test-identity.seed.ts delete mode 100644 apps/delivery-api/src/scripts/seed-dsv-driver-test-identity.ts delete mode 100644 apps/delivery-api/tests/dsv-driver-test-identity.seed.test.ts create mode 100755 tests/security/scan-secrets.test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e517d08e..47eadb58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,8 @@ jobs: run: node scripts/check-ignore-hygiene.mjs - name: Secret scan staged/worktree run: scripts/scan-secrets.sh --worktree + - name: Secret scan regression + run: tests/security/scan-secrets.test.sh - name: Route Ops classifier tests if: needs.route-ops-changes.outputs.workflow_changed == 'true' || needs.route-ops-changes.outputs.full_required == 'true' run: node tests/ci/route-ops-change-classifier.test.mjs diff --git a/.gitignore b/.gitignore index 78012d50..467a9924 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,11 @@ backups/ .gitleaks-report.* .trufflehog-report.* .secret-scan/ +google-services.json +service-account*.json +firebase-adminsdk*.json +credentials.json +*.credentials.json # Evidence/private artifacts evidence/ @@ -78,6 +83,7 @@ docs/proof-media-production-evidence-manifest-*.md # Local orchestration/editor noise .omx/ +.worktrees/ worktrees/ .DS_Store Thumbs.db diff --git a/apps/delivery-api/.env.example b/apps/delivery-api/.env.example index 0a35b0c9..1e740efe 100644 --- a/apps/delivery-api/.env.example +++ b/apps/delivery-api/.env.example @@ -9,12 +9,10 @@ POSTGRES_USER=clever POSTGRES_PASSWORD=clever # Driver API auth secret; required for driver login/session and route access APIs. -JWT_SECRET=change-me +# Use at least 32 random characters and keep it in runtime secret storage. +JWT_SECRET= # Register the DSV Driver app login/signup routes only when explicitly enabled. CLEVER_DSV_DRIVER_AUTH_ENABLED=false -# Keyed fingerprint secret for the DSV Driver app's resident-number-front matching. -# Use at least 32 random characters and keep it in runtime secret storage. -DSV_DRIVER_IDENTITY_SECRET= # Generic server-side master key for encrypting customer connector credentials in DB. # Generate with: node -e "console.log('base64:' + require('crypto').randomBytes(32).toString('base64'))" diff --git a/apps/delivery-api/docs/api/dsv-driver-auth.md b/apps/delivery-api/docs/api/dsv-driver-auth.md index ba280e85..8a4d80bc 100644 --- a/apps/delivery-api/docs/api/dsv-driver-auth.md +++ b/apps/delivery-api/docs/api/dsv-driver-auth.md @@ -53,7 +53,7 @@ DSV 전용 Driver 앱은 기존 CLEVER Routes 앱의 Shopify 초대 및 전화 ## 운영 활성화 인증 라우트는 `CLEVER_DSV_DRIVER_AUTH_ENABLED=true`일 때만 등록된다. 이 값이 켜진 -환경에서는 `JWT_SECRET`이 누락되면 서버가 기동을 거부한다. +환경에서는 `JWT_SECRET`이 누락되거나 32자보다 짧으면 서버가 기동을 거부한다. 과거 `DsvDriverAccountSignupInvite` 테이블과 migration은 감사 이력 보존을 위해 유지하지만, 현행 서버는 토큰을 발급·검증·소비하지 않는다. diff --git a/apps/delivery-api/src/modules/driver/driver-auth.dependencies.ts b/apps/delivery-api/src/modules/driver/driver-auth.dependencies.ts index deb5049c..3c803a4a 100644 --- a/apps/delivery-api/src/modules/driver/driver-auth.dependencies.ts +++ b/apps/delivery-api/src/modules/driver/driver-auth.dependencies.ts @@ -3,6 +3,7 @@ import { PrismaDriverAuthRepository } from './driver-auth.repository.js'; import { PrismaDriverTokenAccessRepository } from './driver-token-access.repository.js'; import { PrismaDriverPushTokenService } from '../route-grouping/driver-push-token.service.js'; import type { DriverAuthDependencies } from '../../routes/driver-auth.routes.js'; +import { readDriverJwtSecret } from './driver-token-verifier.js'; type LoadDriverAuthDependenciesInput = { env: Partial>; @@ -12,10 +13,8 @@ type LoadDriverAuthDependenciesInput = { export function loadDriverAuthDependencies( input: LoadDriverAuthDependenciesInput ): DriverAuthDependencies | undefined { - const jwtSecret = input.env.JWT_SECRET?.trim(); - if (!jwtSecret) { - return undefined; - } + const jwtSecret = readDriverJwtSecret(input.env.JWT_SECRET); + if (jwtSecret === undefined) return undefined; return { driverAuthRepository: new PrismaDriverAuthRepository(input.prisma), diff --git a/apps/delivery-api/src/modules/driver/driver-token-verifier.ts b/apps/delivery-api/src/modules/driver/driver-token-verifier.ts index 5528e22d..81455f18 100644 --- a/apps/delivery-api/src/modules/driver/driver-token-verifier.ts +++ b/apps/delivery-api/src/modules/driver/driver-token-verifier.ts @@ -4,6 +4,16 @@ import { normalizeDriverCommerceDomain } from './driver-commerce-domain.js'; const DRIVER_AUDIENCE = 'clever-delivery-driver'; const DRIVER_ACCOUNT_AUDIENCE = 'clever-driver-account'; const DRIVER_ROUTE_AUDIENCE = 'clever-delivery-driver-route'; +const MIN_DRIVER_JWT_SECRET_CHARACTERS = 32; + +export function readDriverJwtSecret(value: string | undefined): string | undefined { + const secret = value?.trim(); + if (!secret) return undefined; + if (secret.length < MIN_DRIVER_JWT_SECRET_CHARACTERS) { + throw new Error('JWT_SECRET must contain at least 32 characters'); + } + return secret; +} export type VerifiedDriverAccountToken = { accountId: string; diff --git a/apps/delivery-api/src/modules/driver/driver.dependencies.ts b/apps/delivery-api/src/modules/driver/driver.dependencies.ts index 3f97c85e..207b7f69 100644 --- a/apps/delivery-api/src/modules/driver/driver.dependencies.ts +++ b/apps/delivery-api/src/modules/driver/driver.dependencies.ts @@ -11,6 +11,7 @@ import { PrismaDriverRouteAccessRepository } from './driver-route-access.reposit import { PrismaDriverRouteSessionRepository } from './driver-route-session.repository.js'; import { PrismaDriverSelfServiceRepository } from './driver-self-service.repository.js'; import { PrismaDriverTokenAccessRepository } from './driver-token-access.repository.js'; +import { readDriverJwtSecret } from './driver-token-verifier.js'; import { createS3DriverProofMediaStorage } from './driver-proof-media-s3-storage.js'; import { createHttpDriverProofMediaScanMonitor, @@ -123,7 +124,7 @@ export function loadDriverApiDependencies( input: LoadDriverApiDependenciesInput ): DriverApiDependencies | undefined { const completionInvariantMode = loadDriverRouteCompletionInvariantMode(input.env); - const jwtSecret = readOptional(input.env.JWT_SECRET); + const jwtSecret = readDriverJwtSecret(input.env.JWT_SECRET); if (jwtSecret === undefined) { return undefined; } diff --git a/apps/delivery-api/src/modules/dsv/dsv-driver-auth.dependencies.ts b/apps/delivery-api/src/modules/dsv/dsv-driver-auth.dependencies.ts index aa0ccfa4..4b1a4182 100644 --- a/apps/delivery-api/src/modules/dsv/dsv-driver-auth.dependencies.ts +++ b/apps/delivery-api/src/modules/dsv/dsv-driver-auth.dependencies.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from '@prisma/client'; import type { DsvDriverAuthDependencies } from '../../routes/dsv-driver-auth.routes.js'; +import { readDriverJwtSecret } from '../driver/driver-token-verifier.js'; import { PrismaDsvDriverAuthRepository } from './dsv-driver-auth.repository.js'; export type DsvDriverAuthRuntimeEnv = Partial { - assertApprovedTarget(input); - - const driver = await input.prisma.driver.findUnique({ - select: { - accountId: true, - displayName: true, - dsvProfile: { select: { residentNumberFrontFingerprint: true } }, - id: true, - phone: true, - status: true, - }, - where: { id: input.driverId }, - }); - if (driver === null) throw new Error('Approved DSV driver was not found'); - if (driver.displayName !== input.expectedName) throw new Error('Approved DSV driver name does not match'); - if (driver.status !== 'ACTIVE') throw new Error('Approved DSV driver is not active'); - if (driver.accountId !== null) throw new Error('Approved DSV driver is already linked to an account'); - if (driver.phone === null || driver.phone.trim() === '') { - throw new Error('Approved DSV driver has no phone number'); - } - if (driver.dsvProfile === null) throw new Error('Approved DSV driver has no DSV profile'); - - const fingerprint = fingerprintResidentNumberFront( - input.residentNumberFront, - input.identitySecret, - ); - const currentFingerprint = driver.dsvProfile.residentNumberFrontFingerprint; - if (currentFingerprint !== null && currentFingerprint !== fingerprint) { - throw new Error('Approved DSV driver already has a different identity fingerprint'); - } - if (!input.apply) return result('DRY_RUN', false); - if (currentFingerprint === fingerprint) return result('UNCHANGED', false); - - const update = await input.prisma.dsvDriverProfile.updateMany({ - data: { residentNumberFrontFingerprint: fingerprint }, - where: { - driverId: input.driverId, - residentNumberFrontFingerprint: null, - }, - }); - if (update.count !== 1) { - throw new Error('Approved DSV driver identity changed concurrently; no update was applied'); - } - return result('SEEDED', true); - - function result( - status: DsvDriverTestIdentitySeedResult['status'], - applied: boolean, - ): DsvDriverTestIdentitySeedResult { - return { - applied, - driverId: input.driverId, - name: input.expectedName, - status, - }; - } -} - -function assertApprovedTarget(input: { - approval: string; - driverId: string; - expectedName: string; -}): void { - if (input.approval !== APPROVED_DSV_DRIVER_TEST_IDENTITY.approval) { - throw new Error('Production test identity approval token does not match'); - } - if (input.driverId !== APPROVED_DSV_DRIVER_TEST_IDENTITY.driverId) { - throw new Error('Production test identity driver ID is not approved'); - } - if (input.expectedName !== APPROVED_DSV_DRIVER_TEST_IDENTITY.name) { - throw new Error('Production test identity driver name is not approved'); - } -} diff --git a/apps/delivery-api/src/scripts/seed-dsv-driver-test-identity.ts b/apps/delivery-api/src/scripts/seed-dsv-driver-test-identity.ts deleted file mode 100644 index 2bd57b05..00000000 --- a/apps/delivery-api/src/scripts/seed-dsv-driver-test-identity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { PrismaClient } from '@prisma/client'; - -import { seedDsvDriverTestIdentity } from '../modules/dsv/dsv-driver-test-identity.seed.js'; - -const prisma = new PrismaClient(); - -try { - const unexpectedArguments = process.argv.slice(2).filter((argument) => argument !== '--apply'); - if (unexpectedArguments.length > 0) throw new Error('Only --apply is supported'); - if (process.env.NODE_ENV !== 'production') { - throw new Error('This guarded seed is allowed only with NODE_ENV=production'); - } - - const result = await seedDsvDriverTestIdentity({ - apply: process.argv.includes('--apply'), - approval: requiredEnv('CLEVER_DSV_DRIVER_TEST_IDENTITY_APPROVAL'), - driverId: requiredEnv('CLEVER_DSV_DRIVER_TEST_IDENTITY_DRIVER_ID'), - expectedName: requiredEnv('CLEVER_DSV_DRIVER_TEST_IDENTITY_EXPECTED_NAME'), - identitySecret: requiredEnv('DSV_DRIVER_IDENTITY_SECRET'), - prisma, - residentNumberFront: requiredEnv('CLEVER_DSV_DRIVER_TEST_RESIDENT_NUMBER_FRONT'), - }); - process.stdout.write(`${JSON.stringify(result)}\n`); -} finally { - await prisma.$disconnect(); -} - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (value === undefined || value === '') throw new Error(`${name} is required`); - return value; -} diff --git a/apps/delivery-api/tests/driver-token-verifier.test.ts b/apps/delivery-api/tests/driver-token-verifier.test.ts index a987c9fd..60e6aab4 100644 --- a/apps/delivery-api/tests/driver-token-verifier.test.ts +++ b/apps/delivery-api/tests/driver-token-verifier.test.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'; import { describe, expect, test } from 'vitest'; import { + readDriverJwtSecret, signDriverRouteToken, signDriverToken, verifyDriverRouteToken, @@ -11,6 +12,19 @@ import { const secret = 'driver-secret'; const now = new Date('2026-05-07T06:10:00Z'); +describe('readDriverJwtSecret', () => { + test('keeps the API disabled when absent and rejects weak configured secrets', () => { + expect(readDriverJwtSecret(undefined)).toBeUndefined(); + expect(readDriverJwtSecret(' ')).toBeUndefined(); + expect(() => readDriverJwtSecret('short-secret')).toThrow( + 'JWT_SECRET must contain at least 32 characters' + ); + expect(readDriverJwtSecret(' test-driver-jwt-secret-32-characters ')).toBe( + 'test-driver-jwt-secret-32-characters' + ); + }); +}); + describe('verifyDriverToken', () => { test('signs route access with only the global account and assigned route scope', () => { const result = signDriverRouteToken( diff --git a/apps/delivery-api/tests/driver.dependencies.test.ts b/apps/delivery-api/tests/driver.dependencies.test.ts index 7555dbcc..7ef08bbe 100644 --- a/apps/delivery-api/tests/driver.dependencies.test.ts +++ b/apps/delivery-api/tests/driver.dependencies.test.ts @@ -35,11 +35,18 @@ describe('loadDriverApiDependencies', () => { expect(dependencies).toBeUndefined(); }); + test('rejects a configured JWT secret shorter than 32 characters', () => { + expect(() => loadDriverApiDependencies({ + env: { JWT_SECRET: 'short-secret' }, + prisma: {} as PrismaClient + })).toThrow('JWT_SECRET must contain at least 32 characters'); + }); + test('keeps local proof media storage as the default runtime backend', () => { const dependencies = loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_STORAGE_DIR: '/tmp/clever-proof-media', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -53,7 +60,7 @@ describe('loadDriverApiDependencies', () => { const dependencies = loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_RESERVATIONS_ENABLED: 'true', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -64,7 +71,7 @@ describe('loadDriverApiDependencies', () => { test('keeps assigned route reads independent from driver OSRM runtime config', () => { const dependencies = loadDriverApiDependencies({ env: { - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -82,7 +89,7 @@ describe('loadDriverApiDependencies', () => { DRIVER_ROUTE_MAP_PREVIEW_ENABLED: 'true', DRIVER_ROUTE_MAP_PREVIEW_SECRET: 'preview-secret', DRIVER_ROUTE_MAP_PREVIEW_TTL_SECONDS: '120', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -96,7 +103,7 @@ describe('loadDriverApiDependencies', () => { loadDriverApiDependencies({ env: { DRIVER_ROUTE_MAP_PREVIEW_ENABLED: 'true', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }) @@ -107,7 +114,7 @@ describe('loadDriverApiDependencies', () => { env: { DELIVERY_API_PUBLIC_URL: 'https://delivery.example.com/api', DRIVER_ROUTE_MAP_PREVIEW_ENABLED: 'true', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }) @@ -123,7 +130,7 @@ describe('loadDriverApiDependencies', () => { env: { DRIVER_PROOF_MEDIA_SCAN_MONITOR_BACKEND: ' ', DRIVER_PROOF_MEDIA_SCANNER_BACKEND: ' ', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -143,7 +150,7 @@ describe('loadDriverApiDependencies', () => { DRIVER_PROOF_MEDIA_S3_REGION: 'ap-northeast-2', DRIVER_PROOF_MEDIA_S3_SECRET_ACCESS_KEY: 'secret-test-key', DRIVER_PROOF_MEDIA_STORAGE_BACKEND: 's3', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -176,7 +183,7 @@ describe('loadDriverApiDependencies', () => { expect(() => loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_STORAGE_BACKEND: 's3', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient })).toThrow('DRIVER_PROOF_MEDIA_S3_BUCKET is required when DRIVER_PROOF_MEDIA_STORAGE_BACKEND=s3'); @@ -186,7 +193,7 @@ describe('loadDriverApiDependencies', () => { expect(() => loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_STORAGE_BACKEND: 'ftp', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient })).toThrow('DRIVER_PROOF_MEDIA_STORAGE_BACKEND must be local or s3'); @@ -199,7 +206,7 @@ describe('loadDriverApiDependencies', () => { DRIVER_PROOF_MEDIA_SCAN_MONITOR_URL: 'https://alerts.internal.example/proof-media-scan', DRIVER_PROOF_MEDIA_SCANNER_BACKEND: 'http', DRIVER_PROOF_MEDIA_SCANNER_URL: 'https://scanner.internal.example/scan', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient }); @@ -211,7 +218,7 @@ describe('loadDriverApiDependencies', () => { expect(() => loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_SCANNER_BACKEND: 'http', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient })).toThrow('DRIVER_PROOF_MEDIA_SCANNER_URL is required when DRIVER_PROOF_MEDIA_SCANNER_BACKEND=http'); @@ -221,7 +228,7 @@ describe('loadDriverApiDependencies', () => { expect(() => loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_SCANNER_BACKEND: 'clamd', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient })).toThrow('DRIVER_PROOF_MEDIA_SCANNER_BACKEND must be none or http'); @@ -231,7 +238,7 @@ describe('loadDriverApiDependencies', () => { expect(() => loadDriverApiDependencies({ env: { DRIVER_PROOF_MEDIA_SCAN_MONITOR_BACKEND: 'http', - JWT_SECRET: 'driver-secret' + JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma: {} as PrismaClient })).toThrow('DRIVER_PROOF_MEDIA_SCAN_MONITOR_URL is required when DRIVER_PROOF_MEDIA_SCAN_MONITOR_BACKEND=http'); @@ -327,7 +334,7 @@ describe('loadDriverApiDependencies', () => { sellerOrderId: 'order-1' }); const dependencies = loadDriverApiDependencies({ - env: { JWT_SECRET: 'driver-secret' }, + env: { JWT_SECRET: 'test-driver-jwt-secret-32-characters' }, prisma, routeGroupingService, routeOptimizationScheduler diff --git a/apps/delivery-api/tests/dsv-driver-auth.dependencies.test.ts b/apps/delivery-api/tests/dsv-driver-auth.dependencies.test.ts index efee9baf..78134387 100644 --- a/apps/delivery-api/tests/dsv-driver-auth.dependencies.test.ts +++ b/apps/delivery-api/tests/dsv-driver-auth.dependencies.test.ts @@ -22,14 +22,25 @@ describe('DSV driver auth runtime dependency gate', () => { })).toThrow('requires JWT_SECRET'); }); + test('fails fast when the enabled JWT credential is shorter than 32 characters', () => { + expect(() => loadDsvDriverAuthDependencies({ + env: { + CLEVER_DSV_DRIVER_AUTH_ENABLED: 'true', + JWT_SECRET: 'short-secret', + }, + nodeEnv: 'production', + prisma, + })).toThrow('JWT_SECRET must contain at least 32 characters'); + }); + test('loads the repository only when explicitly enabled with complete secrets', () => { expect(loadDsvDriverAuthDependencies({ env: { CLEVER_DSV_DRIVER_AUTH_ENABLED: 'true', - JWT_SECRET: 'jwt', + JWT_SECRET: 'test-driver-jwt-secret-32-characters', }, nodeEnv: 'production', prisma, - })).toMatchObject({ jwtSecret: 'jwt' }); + })).toMatchObject({ jwtSecret: 'test-driver-jwt-secret-32-characters' }); }); }); diff --git a/apps/delivery-api/tests/dsv-driver-test-identity.seed.test.ts b/apps/delivery-api/tests/dsv-driver-test-identity.seed.test.ts deleted file mode 100644 index 4424d8f4..00000000 --- a/apps/delivery-api/tests/dsv-driver-test-identity.seed.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; - -import { - APPROVED_DSV_DRIVER_TEST_IDENTITY, - seedDsvDriverTestIdentity, -} from '../src/modules/dsv/dsv-driver-test-identity.seed.js'; - -const baseDriver = { - accountId: null, - displayName: APPROVED_DSV_DRIVER_TEST_IDENTITY.name, - dsvProfile: { residentNumberFrontFingerprint: null }, - id: APPROVED_DSV_DRIVER_TEST_IDENTITY.driverId, - phone: '01012345678', - status: 'ACTIVE', -}; - -describe('guarded production DSV driver test identity seed', () => { - test('rejects every target except the explicitly approved production driver', async () => { - await expect(seed({ driverId: '00000000-0000-0000-0000-000000000000' })).rejects.toThrow('not approved'); - await expect(seed({ approval: 'wrong' })).rejects.toThrow('approval token'); - await expect(seed({ expectedName: '다른 배송원' })).rejects.toThrow('name is not approved'); - }); - - test('dry-runs without persisting and applies only a keyed fingerprint', async () => { - const dryRun = await seed(); - expect(dryRun).toMatchObject({ applied: false, status: 'DRY_RUN' }); - expect(updateMany).not.toHaveBeenCalled(); - - const applied = await seed({ apply: true }); - expect(applied).toMatchObject({ applied: true, status: 'SEEDED' }); - const update = updateMany.mock.calls[0]?.[0] as { data: { residentNumberFrontFingerprint: string } }; - expect(update.data.residentNumberFrontFingerprint).toMatch(/^[a-f0-9]{64}$/u); - expect(update.data.residentNumberFrontFingerprint).not.toContain('9001011'); - }); -}); - -const findUnique = vi.fn((input: unknown) => { - void input; - return Promise.resolve(baseDriver); -}); -const updateMany = vi.fn((input: unknown) => { - void input; - return Promise.resolve({ count: 1 }); -}); - -function seed(overrides: Partial[0]> = {}) { - findUnique.mockClear(); - updateMany.mockClear(); - return seedDsvDriverTestIdentity({ - apply: false, - approval: APPROVED_DSV_DRIVER_TEST_IDENTITY.approval, - driverId: APPROVED_DSV_DRIVER_TEST_IDENTITY.driverId, - expectedName: APPROVED_DSV_DRIVER_TEST_IDENTITY.name, - identitySecret: 'identity-secret-that-is-at-least-32-characters', - prisma: { - driver: { findUnique }, - dsvDriverProfile: { updateMany }, - } as never, - residentNumberFront: '9001011', - ...overrides, - }); -} diff --git a/docs/development/script-tooling.md b/docs/development/script-tooling.md index b2e1bda9..dc7408cc 100644 --- a/docs/development/script-tooling.md +++ b/docs/development/script-tooling.md @@ -6,7 +6,7 @@ This repo keeps a small set of root-level scripts because deployment and Route O - `scripts/ssm-simple-route-ops-deploy.sh` — current production deploy lane: GitHub Actions publishes digest-addressable changed images, then SSM pulls, runs the same-image migration service, stages static assets only when needed, recreates only `delivery-api`, and rolls back from the previous `.deploy/current-image.env` if health fails. The local `--publish` flag is a manual fallback, not the normal CI path; missing `write:packages` in `gh auth status` is warning-only while Docker/GHCR push failures remain fatal. -- `scripts/scan-secrets.sh` — local/CI secret hygiene utility for staged, worktree, and history scans. +- `scripts/scan-secrets.sh` — local/CI secret hygiene utility for staged, worktree, and history scans, including redacted high-confidence generic password assignments. - `scripts/monitor-route-ops-production.sh` — read-only SSM monitor wrapper for production health, container status, redacted recent logs, canonical active-assignment invariants, informational recurring seller-key counts, anonymous cross-app customer-email outbox counts, and authenticated smoke through the deployed runtime image. - `scripts/package-wordpress-plugin.sh` — explicit packaging helper for the WordPress plugin artifact. diff --git a/docs/security/secret-variable-contract.md b/docs/security/secret-variable-contract.md index 57ac7588..d94ac5f1 100644 --- a/docs/security/secret-variable-contract.md +++ b/docs/security/secret-variable-contract.md @@ -11,7 +11,7 @@ These variables are maintained in `apps/delivery-api/.env` for every environment | --- | --- | --- | | `DATABASE_URL` | always | Prisma/PostgreSQL connection string. Treat as secret because it contains DB credentials. | | `POSTGRES_PASSWORD` | compose-managed Postgres | PostgreSQL user password used to build `DATABASE_URL`. | -| `JWT_SECRET` | driver/mobile APIs enabled | Signs and verifies driver session tokens. | +| `JWT_SECRET` | driver/mobile APIs enabled | Signs and verifies driver session tokens. Must contain at least 32 random characters. | | `CREDENTIAL_ENCRYPTION_KEY` | any DB-stored connector credential exists | 32-byte AES-GCM master key used to encrypt/decrypt customer connector secrets stored in DB. | | `DRIVER_PROOF_MEDIA_S3_SECRET_ACCESS_KEY` | S3 proof-media backend enabled | S3-compatible storage secret access key. | | `DRIVER_PROOF_MEDIA_S3_SESSION_TOKEN` | temporary S3 credentials used | Optional temporary S3 session token. | diff --git a/scripts/check-ignore-hygiene.mjs b/scripts/check-ignore-hygiene.mjs index 6d33a4c3..c2a3e29d 100755 --- a/scripts/check-ignore-hygiene.mjs +++ b/scripts/check-ignore-hygiene.mjs @@ -11,6 +11,7 @@ const riskyRules = [ { name: 'database/dump', re: /(^|\/)[^/]*(?:\.sql|\.sql\.gz|\.dump|\.sqlite|\.sqlite3|\.db|\.db-shm|\.db-wal|\.bak)$/i }, { name: 'playwright/browser artifact', re: /(^|\/)(?:output|playwright-report|test-results|blob-report|\.playwright|playwright\/\.auth)(?:\/|$)|(^|\/)(?:storageState[^/]*\.json|[^/]*cookies[^/]*\.txt|trace\.zip|[^/]*\.har)$/i }, { name: 'secret scan output', re: /(^|\/)(?:\.gitleaks-report\..*|\.trufflehog-report\..*|\.secret-scan)(?:\/|$)/i }, + { name: 'credential JSON', re: /(^|\/)(?:google-services|service-account[^/]*|firebase-adminsdk[^/]*|credentials|[^/]+\.credentials)\.json$/i }, ]; function listGit(args) { diff --git a/scripts/scan-secrets.sh b/scripts/scan-secrets.sh index 63b79529..0edead86 100755 --- a/scripts/scan-secrets.sh +++ b/scripts/scan-secrets.sh @@ -54,6 +54,43 @@ const allow = [ /wJalrXUtnFEMI\/K7MDENG\/bPxRfiCYEXAMPLEKEY/, /sk_live_\[A-Za-z0-9\]\+/, ]; +const genericCredentialAssignment = /(?:^|[\s,{])([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*(?:(["'`])([^"'`\s,;}]+)\2|([^\s,;}]+))/gu; +const allowedGenericCredentialValues = new Set([ + 'StrongPassw0rd!', + 'KnownStrongPassw0rd!', + 'PreviousStrongPassw0rd!', + 'NewStrongPassw0rd!', + 'CurrentStrongPassw0rd!', + 'FreshStrongPassw0rd!', + 'WrongPassw0rd!', + 'StrongPass!234', + 'CurrentPass!234', + 'NewStrongPass!234', + 'local-demo-password-2026', + 'correct-password-2026', + 'wrong-password', + 'replacement-password-2026', + 'temporary-password-2026', +]); + +function hasSuspiciousGenericCredential(line) { + for (const match of line.matchAll(genericCredentialAssignment)) { + const key = match[1] ?? ''; + const quoted = match[2] !== undefined; + const value = match[3] ?? match[4] ?? ''; + if (!/(?:password|passwd|pwd|jwt[_-]?secret)/iu.test(key)) continue; + if (/(?:hash|digest|ciphertext|migrationname|migrationpath)$/iu.test(key)) continue; + if (!quoted && (key !== key.toUpperCase() || !/^[A-Z0-9_]+$/u.test(key))) continue; + if (value.length < 8 || /\$\{|[([{]|=>/u.test(value)) continue; + if (/^(?:test|example|dummy|fake|mock|fixture|placeholder|sample|change[-_]?me)(?:[-_]|$)/iu.test(value)) continue; + if (allowedGenericCredentialValues.has(value)) continue; + const classes = [/[a-z]/u, /[A-Z]/u, /\d/u, /[^A-Za-z0-9]/u] + .filter((re) => re.test(value)).length; + if (classes >= 3) return true; + } + return false; +} + let findings = 0; let scanned = 0; for (const file of paths) { @@ -69,6 +106,10 @@ for (const file of paths) { for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; if (allow.some((re) => re.test(line))) continue; + if (hasSuspiciousGenericCredential(line)) { + findings += 1; + console.error(`${label}: ${file}:${i + 1}: generic_password_assignment: `); + } for (const rule of rules) { if (rule.re.test(line)) { findings += 1; @@ -127,21 +168,62 @@ const patterns = [ { name: 'slack_token', pattern: String.raw`\bxox[baprs]-[A-Za-z0-9-]{20,}\b` }, { name: 'stripe_live_key', pattern: String.raw`\bsk_live_[A-Za-z0-9]{16,}\b` }, { name: 'aws_access_key', pattern: String.raw`\b(AKIA|ASIA)[0-9A-Z]{16}\b` }, + { name: 'generic_password_assignment', pattern: '(password|passwd|pwd|jwt[_-]?secret)[A-Za-z0-9_-]*[[:space:]]*[:=]' }, ]; const allow = [/AKIA_TEST\b/, /AKIA_TEST_VALUE\b/, /AKIAIOSFODNN7EXAMPLE\b/, /wJalrXUtnFEMI\/K7MDENG\/bPxRfiCYEXAMPLEKEY/, /sk_live_\[A-Za-z0-9\]\+/]; +const genericCredentialAssignment = /(?:^|[\s,{])([A-Za-z_][A-Za-z0-9_-]*)\s*[:=]\s*(?:(["'`])([^"'`\s,;}]+)\2|([^\s,;}]+))/gu; +const allowedGenericCredentialValues = new Set([ + 'StrongPassw0rd!', + 'KnownStrongPassw0rd!', + 'PreviousStrongPassw0rd!', + 'NewStrongPassw0rd!', + 'CurrentStrongPassw0rd!', + 'FreshStrongPassw0rd!', + 'WrongPassw0rd!', + 'StrongPass!234', + 'CurrentPass!234', + 'NewStrongPass!234', + 'local-demo-password-2026', + 'correct-password-2026', + 'wrong-password', + 'replacement-password-2026', + 'temporary-password-2026', +]); + +function hasSuspiciousGenericCredential(line) { + for (const match of line.matchAll(genericCredentialAssignment)) { + const key = match[1] ?? ''; + const quoted = match[2] !== undefined; + const value = match[3] ?? match[4] ?? ''; + if (!/(?:password|passwd|pwd|jwt[_-]?secret)/iu.test(key)) continue; + if (/(?:hash|digest|ciphertext|migrationname|migrationpath)$/iu.test(key)) continue; + if (!quoted && (key !== key.toUpperCase() || !/^[A-Z0-9_]+$/u.test(key))) continue; + if (value.length < 8 || /\$\{|[([{]|=>/u.test(value)) continue; + if (/^(?:test|example|dummy|fake|mock|fixture|placeholder|sample|change[-_]?me)(?:[-_]|$)/iu.test(value)) continue; + if (allowedGenericCredentialValues.has(value)) continue; + const classes = [/[a-z]/u, /[A-Z]/u, /\d/u, /[^A-Za-z0-9]/u] + .filter((re) => re.test(value)).length; + if (classes >= 3) return true; + } + return false; +} + const commits = execFileSync('git', ['rev-list', '--all'], { encoding: 'utf8' }).split('\n').filter(Boolean); let findings = 0; for (const commit of commits) { for (const rule of patterns) { let out = ''; try { - out = execFileSync('git', ['grep', '-I', '-n', '-E', '-e', rule.pattern, commit], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 20 * 1024 * 1024 }); + const grepArgs = ['grep', '-I', '-n', '-E']; + if (rule.name === 'generic_password_assignment') grepArgs.push('-i'); + out = execFileSync('git', [...grepArgs, '-e', rule.pattern, commit], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 20 * 1024 * 1024 }); } catch (error) { if (error.status === 1) continue; throw error; } for (const line of out.split('\n').filter(Boolean)) { if (allow.some((re) => re.test(line))) continue; + if (rule.name === 'generic_password_assignment' && !hasSuspiciousGenericCredential(line)) continue; const first = line.indexOf(':'); const second = line.indexOf(':', first + 1); const third = line.indexOf(':', second + 1); diff --git a/tests/security/scan-secrets.test.sh b/tests/security/scan-secrets.test.sh new file mode 100755 index 00000000..26319719 --- /dev/null +++ b/tests/security/scan-secrets.test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +TEST_REPO="$(mktemp -d "${TMPDIR:-/tmp}/route-secret-scan-test.XXXXXX")" +trap 'rm -rf "$TEST_REPO"' EXIT + +git -C "$TEST_REPO" init --quiet +mkdir -p "$TEST_REPO/scripts" +cp "$ROOT/scripts/scan-secrets.sh" "$TEST_REPO/scripts/scan-secrets.sh" +printf "password: 'test-password-01'\nJWT_SECRET='test-jwt-secret'\n" > "$TEST_REPO/fixtures.ts" +git -C "$TEST_REPO" add scripts/scan-secrets.sh fixtures.ts + +(cd "$TEST_REPO" && scripts/scan-secrets.sh --worktree >/dev/null) + +credential='ProdPassword9854!' +printf 'PASSWORD=%s\n' "$credential" > "$TEST_REPO/leaked.txt" +if output="$(cd "$TEST_REPO" && scripts/scan-secrets.sh --worktree 2>&1)"; then + echo 'Expected the generic password fixture to fail secret scanning' >&2 + exit 1 +fi +grep -F 'generic_password_assignment: ' <<<"$output" >/dev/null +if grep -F "$credential" <<<"$output" >/dev/null; then + echo 'Secret scanner output exposed the matched value' >&2 + exit 1 +fi