--- phase: 16-ci-dependency-audit-and-security-checks plan: 01 type: tdd wave: 1 depends_on: [] files_modified: - apps/api/src/lib/bootGuards.ts - apps/api/tests/lib/bootGuards.test.ts - apps/api/src/index.ts - apps/api/Dockerfile autonomous: true requirements: [IMG-01] must_haves: truths: - "A production image with DEV_AUTH_BYPASS=true refuses to boot (process exits non-zero) instead of silently no-op'ing" - "The production Docker stage bakes NODE_ENV=production so the devBypass hard guard is actually engaged in the shipped image" - "The boot guard is a unit-tested exported function, not inline startup logic" artifacts: - path: "apps/api/src/lib/bootGuards.ts" provides: "assertNotDevBypassInProduction() exported guard function" exports: ["assertNotDevBypassInProduction"] - path: "apps/api/tests/lib/bootGuards.test.ts" provides: "Unit tests for the boot guard (3 cases)" - path: "apps/api/Dockerfile" provides: "ENV NODE_ENV=production in the production stage" contains: "ENV NODE_ENV=production" key_links: - from: "apps/api/src/index.ts" to: "apps/api/src/lib/bootGuards.ts" via: "import + call as first statement in isMainModule()" pattern: "assertNotDevBypassInProduction\\(\\)" --- Implement the dev/prod image-boundary runtime enforcement (D-07 + D-08). Bake `ENV NODE_ENV=production` into the production Dockerfile stage so the existing `devBypass.ts` hard guard is actually engaged in the shipped image, and add a boot-time refuse-to-boot guard `assertNotDevBypassInProduction()` that exits non-zero when `NODE_ENV==='production'` AND `DEV_AUTH_BYPASS==='true'`. Purpose: Today the production image sets no `NODE_ENV`, so the `devBypass.ts` hard guard (`NODE_ENV==='production'` first) is only safe by accident (the second `DEV_AUTH_BYPASS !== 'true'` check passes when unset). An operator who accidentally sets `DEV_AUTH_BYPASS=true` in the production compose would silently bypass auth. D-07 engages the guard; D-08 turns a silent misconfig into a loud, immediate failure. The app holds real family credentials — this is the highest-leverage, lowest-cost hardening in the phase. Output: A testable exported guard function (the primary Wave 0 test asset), its unit tests, the `index.ts` wiring, and the Dockerfile `ENV` line. This plan is consumed by 16-06 (publish.yml boot-smoke verifies the guard fires in the actual built image). @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md @.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md Task 1: RED — write failing unit tests for assertNotDevBypassInProduction() - apps/api/tests/lib/bootGuards.test.ts (file being created) - apps/api/tests/auth/devBypass.test.ts (EXACT analog: afterEach env-restoration pattern at lines 18-29, three-case structure, vitest imports) - apps/api/src/auth/devBypass.ts (the existing hard-guard idiom this mirrors — lines 58-76) - apps/api/tests/fixtures/ (note: tests live in tests/, NEVER in src/ — see memory) - Test 1: NODE_ENV='production' AND DEV_AUTH_BYPASS='true' → calls process.exit(1) (spy throws so the call is observable) - Test 2: NODE_ENV='development' AND DEV_AUTH_BYPASS='true' → does NOT call process.exit - Test 3: NODE_ENV='production' AND DEV_AUTH_BYPASS unset → does NOT call process.exit Create apps/api/tests/lib/bootGuards.test.ts. Import { describe, it, expect, vi, afterEach } from 'vitest' and { assertNotDevBypassInProduction } from '../../src/lib/bootGuards.js'. Capture originalNodeEnv and originalBypassFlag at describe scope; restore both in afterEach exactly like devBypass.test.ts (delete DEV_AUTH_BYPASS when originalBypassFlag is undefined). In each test set process.env.NODE_ENV and process.env.DEV_AUTH_BYPASS, install vi.spyOn(process,'exit').mockImplementation(() => { throw new Error('process.exit called'); }), and assert: Test 1 expects the call to throw 'process.exit called' and expect(exitSpy).toHaveBeenCalledWith(1); Tests 2 and 3 expect it NOT to throw and exitSpy NOT to have been called. Call exitSpy.mockRestore() at the end of each test. Run the suite to confirm it FAILS because src/lib/bootGuards.ts does not exist yet. Commit: `test(16-01): add failing tests for boot-time dev-bypass guard`. cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts 2>&1 | grep -Eq 'Cannot find|Failed to load|No test files|failed|error' && echo RED-OK - tests/lib/bootGuards.test.ts exists with exactly 3 `it(...)` cases matching the behavior block - Running the suite fails (module under test does not yet exist) — RED state confirmed - afterEach restores NODE_ENV and DEV_AUTH_BYPASS (delete when originally undefined) The test file exists, encodes the 3 cases, and fails because src/lib/bootGuards.ts is absent. Task 2: GREEN — implement bootGuards.ts and wire it into index.ts - apps/api/src/lib/bootGuards.ts (file being created) - apps/api/src/auth/devBypass.ts (JSDoc + env-at-call-time pattern to mirror; lines 1-25, 58-76) - apps/api/src/index.ts (the isMainModule() block at lines 112-147 — guard call goes FIRST inside it; import block lines 1-18) - apps/api/src/lib/ (sibling utilities follow this dir's conventions — listAccess.ts, rank.ts) Create apps/api/src/lib/bootGuards.ts exporting `assertNotDevBypassInProduction(): void`. Logic: `if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') { console.error('[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. This configuration is forbidden. Refusing to start.'); process.exit(1); }`. Add a JSDoc block (mirror devBypass.ts style) stating it is D-08, exported for unit-testing without forking a process, and MUST be called as the FIRST statement inside the isMainModule() block. Then in apps/api/src/index.ts add `import { assertNotDevBypassInProduction } from './lib/bootGuards.js';` to the existing import block, and call `assertNotDevBypassInProduction();` as the FIRST statement inside `if (isMainModule()) {` — before the VAPID config (currently line 116), before startBrokerPoller/startOutboxWorker/startReminderScheduler, before serve(). Do NOT change the top-level `devBypassActive` computation (line 24). Commit: `feat(16-01): add boot-time refuse-to-boot guard for dev-bypass in production`. cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts && pnpm typecheck - `assertNotDevBypassInProduction` is exported from src/lib/bootGuards.ts - `grep -n "assertNotDevBypassInProduction()" apps/api/src/index.ts` shows the call inside the isMainModule() block, before VAPID/worker/serve lines - `pnpm test -- --run tests/lib/bootGuards.test.ts` is GREEN (3/3) - `pnpm typecheck` (apps/api) passes — guard placement does not break the startup module The guard function exists, is imported and called first in isMainModule(), and all 3 unit tests pass with typecheck green. Task 3: Bake ENV NODE_ENV=production into the production Dockerfile stage - apps/api/Dockerfile (the production stage at lines 35-46; the gap D-07 fixes) - .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Dockerfile section — exact placement: after WORKDIR /app/apps/api, before COPY --from=pwa-builder) In apps/api/Dockerfile, in the `FROM base AS production` stage, add `ENV NODE_ENV=production` after the `WORKDIR /app/apps/api` line (currently line 41) and before the `COPY --from=pwa-builder` line (currently line 45). Add a one-line comment above it referencing D-07 ("Enforce production identity — engages the NODE_ENV=production hard guard in devBypass.ts"). Do NOT add ENV to the `base`, `builder`, `dev`, or `pwa-builder` stages — only `production`. Leave the CMD line unchanged. awk '/FROM base AS production/{p=1} p&&/ENV NODE_ENV=production/{print "FOUND"; exit}' apps/api/Dockerfile | grep -q FOUND && echo OK - `ENV NODE_ENV=production` appears within the `production` stage (after `FROM base AS production`), not in any other stage - The line sits between `WORKDIR /app/apps/api` and `COPY --from=pwa-builder` - `grep -c "ENV NODE_ENV=production" apps/api/Dockerfile` returns exactly 1 The production stage bakes NODE_ENV=production; no other stage is affected. ## Trust Boundaries | Boundary | Description | |----------|-------------| | operator config → production container | An operator-supplied env (docker-compose, Unraid) crosses into the running process; DEV_AUTH_BYPASS is attacker-equivalent if it slips into prod | | Docker image build → shipped artifact | The baked image environment (NODE_ENV) is the last line of defense before runtime | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-16-01 | Elevation of Privilege | Production container with DEV_AUTH_BYPASS=true (misconfigured compose) | mitigate | D-07: `ENV NODE_ENV=production` baked into the production stage engages the `devBypass.ts` hard guard so the bypass middleware can never inject DEV_USER in the shipped image (Task 3) | | T-16-02 | Elevation of Privilege | Silent no-op of the dev-bypass guard hides the misconfiguration | mitigate | D-08: `assertNotDevBypassInProduction()` throws and `process.exit(1)` on NODE_ENV=production + DEV_AUTH_BYPASS=true, converting a silent bypass into an immediate crash (Tasks 1-2); verified in the built image by 16-06 boot-smoke | | T-16-03 | Tampering | Guard placed too late in startup (DB/port opened before it fires) | accept | Guard is the FIRST statement in isMainModule(), before VAPID/workers/serve — no port or DB connection precedes it (Task 2 placement rule). Residual risk nil given enforced placement | - `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` → 3/3 green - `pnpm --filter @familysync/api typecheck` → green (startup module intact) - Dockerfile production stage contains exactly one `ENV NODE_ENV=production` - Full image-level proof (boot-smoke) is deferred to plan 16-06 against the built image - `assertNotDevBypassInProduction()` exists, is exported, imported in index.ts, and called first in isMainModule() - Unit tests cover all 3 env combinations and pass - The production Dockerfile stage bakes NODE_ENV=production - No change to dev/builder/pwa-builder stages or to the top-level devBypassActive logic Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-01-SUMMARY.md` when done.