Files
familysync/.planning/phases/16-ci-dependency-audit-and-security-checks/16-01-PLAN.md
T
Lucas Berger e039c85a22 docs(16): create phase plan — 6 plans, 2 waves (dep audit, security checks, image hygiene)
SEC-01/02, DEP-01/02, IMG-01/02/03, CI-03. Wave 1: image-hygiene runtime guard (TDD), audit+outdated wrappers (TDD), eslint-plugin-security fold, gitleaks config+baseline+.dockerignore. Wave 2: ci.yml security job + gate wiring, publish.yml hygiene assertions + boot-smoke. esbuild GHSA-gv7w-rqvm-qjhr waivered in 16-02 before the gate goes live.
2026-06-12 23:23:18 -04:00

164 lines
11 KiB
Markdown

---
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\\(\\)"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — write failing unit tests for assertNotDevBypassInProduction()</name>
<read_first>
- 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)
</read_first>
<behavior>
- 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
</behavior>
<action>
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`.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>The test file exists, encodes the 3 cases, and fails because src/lib/bootGuards.ts is absent.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement bootGuards.ts and wire it into index.ts</name>
<read_first>
- 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)
</read_first>
<action>
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`.
</action>
<verify>
<automated>cd apps/api && pnpm test -- --run tests/lib/bootGuards.test.ts && pnpm typecheck</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>The guard function exists, is imported and called first in isMainModule(), and all 3 unit tests pass with typecheck green.</done>
</task>
<task type="auto">
<name>Task 3: Bake ENV NODE_ENV=production into the production Dockerfile stage</name>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>awk '/FROM base AS production/{p=1} p&&/ENV NODE_ENV=production/{print "FOUND"; exit}' apps/api/Dockerfile | grep -q FOUND && echo OK</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>The production stage bakes NODE_ENV=production; no other stage is affected.</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
- `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
</verification>
<success_criteria>
- `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
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-01-SUMMARY.md` when done.
</output>