chore: archive v1.1 phase directories to milestones/v1.1-phases/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-18 22:21:38 -04:00
co-authored by Claude Opus 4.8
parent a2890d1542
commit c7955a46b9
243 changed files with 0 additions and 0 deletions
@@ -0,0 +1,163 @@
---
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>
@@ -0,0 +1,86 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "01"
subsystem: api-security
tags: [security, boot-guard, docker, tdd]
dependency_graph:
requires: []
provides: [assertNotDevBypassInProduction, bootGuards.ts, ENV NODE_ENV=production]
affects: [apps/api/src/index.ts, apps/api/Dockerfile]
tech_stack:
added: []
patterns: [TDD RED/GREEN, process.exit spy, boot-time guard]
key_files:
created:
- apps/api/src/lib/bootGuards.ts
- apps/api/tests/lib/bootGuards.test.ts
modified:
- apps/api/src/index.ts
- apps/api/Dockerfile
decisions:
- "D-07: ENV NODE_ENV=production baked into production Dockerfile stage — engages devBypass.ts hard guard at image build time, not at runtime"
- "D-08: assertNotDevBypassInProduction() placed as first statement in isMainModule() — boot-time refuse-to-boot guard converts silent misconfig into loud exit(1)"
- "Guard evaluated at call time (not import time) — allows unit tests to set env vars before calling without module cache manipulation"
metrics:
duration_seconds: 188
completed_date: "2026-06-13"
tasks_completed: 3
files_changed: 4
---
# Phase 16 Plan 01: Boot-time Dev-Bypass Guard Summary
**One-liner:** Boot-time refuse-to-boot guard (`assertNotDevBypassInProduction`) plus `ENV NODE_ENV=production` baked into the production Dockerfile stage, turning a silent auth-bypass misconfiguration into an immediate non-zero exit.
## What Was Built
### Task 1 — RED (test commit 8414e89)
Created `apps/api/tests/lib/bootGuards.test.ts` with 3 test cases:
1. `NODE_ENV=production` + `DEV_AUTH_BYPASS=true``process.exit(1)` is called (spy throws to make it observable)
2. `NODE_ENV=development` + `DEV_AUTH_BYPASS=true` → no `process.exit`
3. `NODE_ENV=production` + `DEV_AUTH_BYPASS` unset → no `process.exit`
Suite failed with `Cannot find module '../../src/lib/bootGuards.js'` — RED state confirmed.
### Task 2 — GREEN (feat commit c2ffd1c)
- Created `apps/api/src/lib/bootGuards.ts` exporting `assertNotDevBypassInProduction(): void`
- JSDoc documents D-08, call-time env evaluation, and required placement rule
- Added import to `apps/api/src/index.ts`
- Added call as the **first** statement in `isMainModule()` block (before VAPID config, workers, serve())
- 3/3 unit tests pass, `pnpm typecheck` green
### Task 3 — Dockerfile ENV (chore commit 5b4f32a)
- Added `ENV NODE_ENV=production` to the `production` stage in `apps/api/Dockerfile`
- Placed between `WORKDIR /app/apps/api` and `COPY --from=pwa-builder` (exactly as specified)
- Comment references D-07
- Exactly 1 occurrence; no other stage is affected
## Deviations from Plan
None — plan executed exactly as written.
## TDD Gate Compliance
- RED gate commit: `8414e89``test(16-01): add failing tests for boot-time dev-bypass guard`
- GREEN gate commit: `c2ffd1c``feat(16-01): add boot-time refuse-to-boot guard for dev-bypass in production`
- REFACTOR: not needed — implementation was clean on first pass
## Threat Surface Scan
No new network endpoints, auth paths, file access patterns, or schema changes introduced. The boot guard adds a startup-time process.exit — no new externally-reachable surface.
## Known Stubs
None.
## Self-Check: PASSED
- `apps/api/src/lib/bootGuards.ts` — FOUND
- `apps/api/tests/lib/bootGuards.test.ts` — FOUND
- `apps/api/src/index.ts` modified — assertNotDevBypassInProduction() called at line 115
- `apps/api/Dockerfile``ENV NODE_ENV=production` present in production stage
Commits:
- `8414e89` — test(16-01): add failing tests for boot-time dev-bypass guard
- `c2ffd1c` — feat(16-01): add boot-time refuse-to-boot guard for dev-bypass in production
- `5b4f32a` — chore(16-01): bake ENV NODE_ENV=production into production Dockerfile stage
@@ -0,0 +1,176 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: 02
type: tdd
wave: 1
depends_on: []
files_modified:
- scripts/check-audit.mjs
- scripts/audit-allowlist.json
- scripts/check-outdated.mjs
- scripts/outdated-pins.json
- scripts/__tests__/check-audit.test.mjs
autonomous: true
requirements: [DEP-01, DEP-02]
must_haves:
truths:
- "The audit wrapper exits non-zero when an unwaived High or Critical advisory exists, and exits zero when it is waived in the committed allowlist"
- "The pre-existing esbuild High advisory GHSA-gv7w-rqvm-qjhr is waived in scripts/audit-allowlist.json with reason + reviewer BEFORE the audit gate goes live, so the first audit-gated PR does not fail immediately"
- "The outdated wrapper always exits 0, classifies entries into tiers (AUDIT-ADVISORY / MAJOR-BEHIND / INTENTIONAL-PIN / ROUTINE-DRIFT), and respects intentional pins from outdated-pins.json"
artifacts:
- path: "scripts/check-audit.mjs"
provides: "pnpm audit wrapper — blocks unwaived High+Critical, prints moderate/low advisory"
- path: "scripts/audit-allowlist.json"
provides: "Committed GHSA waiver list (reason + reviewer + expires), seeded with GHSA-gv7w-rqvm-qjhr"
contains: "GHSA-gv7w-rqvm-qjhr"
- path: "scripts/check-outdated.mjs"
provides: "pnpm outdated wrapper — tiered advisory report, always exits 0"
- path: "scripts/outdated-pins.json"
provides: "Intentional-pin reason map (eslint, @eslint/js, zod, @types/node)"
key_links:
- from: "scripts/check-audit.mjs"
to: "scripts/audit-allowlist.json"
via: "readFileSync + filter by github_advisory_id"
pattern: "audit-allowlist"
- from: "scripts/check-outdated.mjs"
to: "scripts/outdated-pins.json"
via: "readFileSync + pin-reason lookup"
pattern: "outdated-pins"
---
<objective>
Build the dependency-audit gate (D-04/D-05) and the advisory-only outdated report (D-06/OQ-01) as committed Node.js wrapper scripts, plus the two committed JSON config files (the GHSA waiver allowlist and the intentional-pin reason map).
Purpose: `pnpm audit` must FAIL the build on unwaived High+Critical advisories while keeping waivers auditable (reason + reviewer in a PR-reviewed file, not a silent ignore). A live audit RIGHT NOW reports a High advisory `GHSA-gv7w-rqvm-qjhr` (esbuild, dev transitive via drizzle-kit/vitest/vite). This advisory must be seeded into the allowlist with justification in THIS plan so that when 16-05 turns the audit gate on, the first gated PR does not fail immediately (Pitfall 4). Separately, `pnpm outdated` must run advisory-only — never gating — and respect the intentional exact-version pins in CLAUDE.md while still distinctly flagging a pin that is dangerously behind or actively vulnerable (OQ-01).
Output: Two wrapper scripts + two JSON config files + a unit test for the audit wrapper's blocking/waiving logic. Consumed by 16-05 (the ci.yml security job invokes both scripts).
</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-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Seed the audit allowlist and the outdated-pins reason map</name>
<read_first>
- scripts/audit-allowlist.json (file being created)
- scripts/outdated-pins.json (file being created)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (pnpm audit Allowlist section — exact reason text for GHSA-gv7w-rqvm-qjhr; OQ-01 section — outdated-pins.json format and reasons)
- CLAUDE.md (the intentional exact-version pins this report must respect)
</read_first>
<action>
Create scripts/audit-allowlist.json as a JSON object keyed by GHSA id. Seed exactly one entry: GHSA-gv7w-rqvm-qjhr with fields reason (esbuild integrity-check advisory; transitive dev-only via drizzle-kit/vitest/vite; not in the production runtime — esbuild never runs in the shipped image; patched in esbuild >=0.28.1, will resolve when drizzle-kit bumps the transitive pin), reviewer set to "luc", and expires set to "2026-09-01". Create scripts/outdated-pins.json as a flat package-to-reason string map with entries for eslint (ESLint 10 breaks eslint-plugin-react@7.37.5, jsx-eslint#3977 — unpin when supported), @eslint/js (pinned with eslint, same constraint), zod (zod v4 is a breaking API change; pin at 3.x until migration planned), and @types/node (pinned to Node 22 LTS types; Node 25 is not LTS). Both files must be valid JSON — no trailing commas, no comments. Commit: `chore(16-02): seed audit allowlist (esbuild GHSA waiver) + outdated pin reasons`.
</action>
<verify>
<automated>node -e "const a=require('./scripts/audit-allowlist.json'); const p=require('./scripts/outdated-pins.json'); if(!a['GHSA-gv7w-rqvm-qjhr']||!a['GHSA-gv7w-rqvm-qjhr'].reason||!a['GHSA-gv7w-rqvm-qjhr'].reviewer) throw new Error('allowlist seed missing fields'); for(const k of ['eslint','@eslint/js','zod','@types/node']) if(!p[k]) throw new Error('missing pin reason: '+k); console.log('OK')"</automated>
</verify>
<acceptance_criteria>
- scripts/audit-allowlist.json is valid JSON containing GHSA-gv7w-rqvm-qjhr with non-empty reason and reviewer fields
- scripts/outdated-pins.json is valid JSON containing reason strings for eslint, @eslint/js, zod, @types/node
- The verify command prints OK
</acceptance_criteria>
<done>Both committed config files exist, are valid JSON, and carry the seeded esbuild waiver + the four intentional-pin reasons.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement check-audit.mjs (blocking wrapper) with a unit test over its filter logic</name>
<read_first>
- scripts/check-audit.mjs (file being created)
- scripts/__tests__/check-audit.test.mjs (test file being created)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (pnpm audit wrapper section — exact JSON shape: audit.advisories keyed object, each adv has .severity, .github_advisory_id, .module_name, .title; Pitfall 1: use `pnpm audit --json` WITHOUT --audit-level so all severities appear; Pitfall 7: ignoreCves removed in pnpm v11)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Shared Patterns — node: prefix for built-in imports)
- apps/api/src/index.ts (lines 1-2 — node: import-prefix convention)
</read_first>
<behavior>
- Given an audit JSON with a High advisory NOT in the allowlist → the filter returns it as blocking (script would exit 1)
- Given the same High advisory WITH its GHSA id in the allowlist → the filter returns empty (script would exit 0)
- Given only moderate/low advisories → the filter returns empty blocking set (script exits 0) and lists them as advisory
- Given no advisories → exits 0
</behavior>
<action>
Create scripts/check-audit.mjs: import execSync from 'node:child_process' and readFileSync from 'node:fs'. Structure the severity-filter as an EXPORTED pure function (e.g. export function selectBlocking(advisories, allowlist) and export function partitionAdvisories(...)) so it is unit-testable without spawning pnpm; the script's main body (run only when invoked directly) reads scripts/audit-allowlist.json, runs `pnpm audit --json` (no --audit-level — Pitfall 1) capturing stdout with stdio ignore on stderr, JSON-parses it, calls the pure function to find High+Critical advisories whose github_advisory_id is NOT a key in the allowlist, prints any such blocking advisories to stderr and exits 1, otherwise prints a PASS line plus the moderate/low advisory list to stdout and exits 0. Then create scripts/__tests__/check-audit.test.mjs that imports the pure function(s) and asserts the four behavior cases above against hand-built fixture objects (do NOT shell out to pnpm in the test). Use vitest (run via `pnpm --filter @familysync/api test` is NOT correct here — these scripts are root-level; run the test file directly with `node --test` OR with `npx vitest run scripts/__tests__/check-audit.test.mjs`). Prefer `node --test` with node:assert so no extra dependency is needed. Commit: `feat(16-02): add check-audit.mjs blocking wrapper + unit tests`.
</action>
<verify>
<automated>node --test scripts/__tests__/check-audit.test.mjs</automated>
</verify>
<acceptance_criteria>
- scripts/check-audit.mjs exports a pure severity/allowlist filter function and only runs pnpm audit when executed directly (guarded by an import.meta check)
- scripts/__tests__/check-audit.test.mjs covers: unwaived High → blocking; waived High → not blocking; moderate/low only → not blocking; none → not blocking
- `node --test scripts/__tests__/check-audit.test.mjs` passes
- The script uses `pnpm audit --json` with NO --audit-level flag (grep confirms `--audit-level` is absent)
</acceptance_criteria>
<done>check-audit.mjs blocks unwaived High+Critical, honors the allowlist, and its filter logic is unit-tested and green.</done>
</task>
<task type="auto">
<name>Task 3: Implement check-outdated.mjs (advisory-only, tiered, pin-aware)</name>
<read_first>
- scripts/check-outdated.mjs (file being created)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (OQ-01 section — `pnpm outdated --format json -r` JSON shape: keyed by package with current/latest/wanted/isDeprecated/dependencyType/dependentPackages; the four tiers AUDIT-ADVISORY / MAJOR-BEHIND / INTENTIONAL-PIN / ROUTINE-DRIFT; major-behind = parseInt(latest major) > parseInt(current major); AUDIT-ADVISORY cross-checks `pnpm audit --json` module_name against the pinned current version; MUST always exit 0 — D-06)
- scripts/outdated-pins.json (created in Task 1 — the intentional-pin reason source)
- scripts/check-audit.mjs (Task 2 — reuse its audit-parsing approach for the cross-check)
</read_first>
<action>
Create scripts/check-outdated.mjs: import execSync from 'node:child_process' and readFileSync from 'node:fs'. Run `pnpm outdated --format json -r` capturing stdout (tolerate non-zero exit from pnpm outdated itself — it exits non-zero when anything is outdated; wrap in try/catch and read the captured output regardless). JSON-parse the output. Read scripts/outdated-pins.json. Also run `pnpm audit --json` (all severities) and collect the set of vulnerable module_names. Classify each outdated entry into exactly one tier in priority order: AUDIT-ADVISORY (the package name appears in the audit vulnerable set), else MAJOR-BEHIND-INTENTIONAL (latest major > current major AND the package has an outdated-pins.json reason — print the reason), else MAJOR-BEHIND-UNPINNED (latest major > current major with NO pin reason — the "dangerously behind" flag), else ROUTINE-DRIFT (same major). Print a grouped human-readable report to stdout (no PR comment / no Gitea API — D-13). The script MUST `process.exit(0)` unconditionally at the end — it never gates (D-06). Commit: `feat(16-02): add check-outdated.mjs advisory-only tiered report`.
</action>
<verify>
<automated>node scripts/check-outdated.mjs; echo "exit=$?" | grep -q "exit=0" && echo OK</automated>
</verify>
<acceptance_criteria>
- `node scripts/check-outdated.mjs` exits 0 even though the repo currently has outdated packages
- Output groups packages under AUDIT-ADVISORY / MAJOR-BEHIND-INTENTIONAL / MAJOR-BEHIND-UNPINNED / ROUTINE-DRIFT headings
- Packages listed in outdated-pins.json appear under the INTENTIONAL tier with their reason, not as a liability
- `grep -c "process.exit(0)" scripts/check-outdated.mjs` is >= 1 and there is no `process.exit(1)` reachable from the report path
</acceptance_criteria>
<done>check-outdated.mjs produces a tiered, pin-aware advisory report and always exits 0.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| npm registry → lockfile → build | A transitive dependency may carry a known CVE; the audit gate is where it surfaces |
| waiver author → CI gate | A waiver suppresses a real advisory; abuse (silent ignore) would defeat the gate |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-04 | Tampering / Information Disclosure | Transitive dependency with a known High/Critical CVE reaches the build | mitigate | D-04: check-audit.mjs exits 1 on any unwaived High+Critical advisory (Task 2); wired blocking by 16-05 |
| T-16-05 | Repudiation | Waiver/allowlist abuse — an advisory silently ignored with no accountability | mitigate | D-05: audit-allowlist.json requires reason + reviewer per GHSA, is committed and PR-reviewed (Task 1); the wrapper waives ONLY entries present in that file, nothing implicit |
| T-16-06 | Tampering | A pinned version is itself actively vulnerable but hidden as "intentional pin" noise | mitigate | OQ-01: check-outdated.mjs cross-checks `pnpm audit` module_names and surfaces vulnerable pins under the distinct AUDIT-ADVISORY tier (Task 3), separating real liability from routine drift |
| T-16-07 | Denial of Service | A stale/expired waiver permanently suppresses an advisory | accept | Waivers carry an `expires` date for human review (Task 1); enforcement of expiry is advisory only this phase — not gating |
</threat_model>
<verification>
- `node --test scripts/__tests__/check-audit.test.mjs` → green (blocking/waiving logic proven without network)
- `node scripts/check-outdated.mjs` → exit 0, tiered report printed
- `node -e "require('./scripts/audit-allowlist.json')['GHSA-gv7w-rqvm-qjhr']"` → defined (the seed waiver exists before the gate is wired)
- The audit wrapper uses `pnpm audit --json` with no `--audit-level` (Pitfall 1 honored)
</verification>
<success_criteria>
- Audit wrapper blocks unwaived High+Critical, honors the committed allowlist, unit-tested
- The esbuild GHSA-gv7w-rqvm-qjhr High advisory is waived with justification BEFORE 16-05 turns the gate on
- Outdated wrapper is advisory-only (always exit 0), tiered, pin-aware, and flags vulnerable pins distinctly
- Both JSON config files are valid and self-documenting (reason + reviewer)
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-02-SUMMARY.md` when done.
</output>
@@ -0,0 +1,92 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "02"
subsystem: ci-security
tags: [dependency-audit, pnpm-audit, pnpm-outdated, allowlist, tdd]
dependency_graph:
requires: []
provides: [scripts/check-audit.mjs, scripts/audit-allowlist.json, scripts/check-outdated.mjs, scripts/outdated-pins.json]
affects: [16-05-ci-security-job]
tech_stack:
added: []
patterns: [node-wrapper-script, tdd-red-green, audit-allowlist-pattern]
key_files:
created:
- scripts/check-audit.mjs
- scripts/audit-allowlist.json
- scripts/check-outdated.mjs
- scripts/outdated-pins.json
- scripts/__tests__/check-audit.test.mjs
modified: []
decisions:
- "D-04/D-05: Audit wrapper uses committed allowlist (audit-allowlist.json) with reason+reviewer+expiry per GHSA; Option B over native pnpm.auditConfig.ignoreGhsas (no accountability metadata in native approach)"
- "D-06: check-outdated.mjs always exits 0; tiered report never gates"
- "Pitfall 1 honored: pnpm audit --json with NO --audit-level flag"
- "TDD gate: test(16-02) RED commit precedes feat(16-02) GREEN commit"
metrics:
duration: 25
completed: "2026-06-13"
tasks: 3
files: 5
---
# Phase 16 Plan 02: Dependency Audit Gate + Outdated Report Summary
**One-liner:** pnpm audit blocking wrapper with committed GHSA allowlist (esbuild waiver pre-seeded) plus tiered outdated report — both as standalone Node.js scripts, TDD-verified.
## What Was Built
### Task 1 — Audit allowlist + pin reasons (chore, `0f101bd`)
- `scripts/audit-allowlist.json`: committed GHSA waiver map. Seeded with `GHSA-gv7w-rqvm-qjhr` (esbuild High advisory, transitive dev-only via drizzle-kit/vitest/vite, not in production image). Each entry carries `reason`, `reviewer`, and `expires` fields for auditability.
- `scripts/outdated-pins.json`: flat package→reason map for four intentional pins: eslint (ESLint 10 breaks eslint-plugin-react), @eslint/js (same), zod (v4 breaking API), @types/node (Node 22 LTS types).
### Task 2 — check-audit.mjs blocking wrapper, TDD (`7ac8b19` RED → `6eb5107` GREEN)
- `scripts/check-audit.mjs`: exports two pure functions (`selectBlocking`, `partitionAdvisories`) for unit testing. Main body runs only when invoked directly (import.meta.url guard). Uses `pnpm audit --json` with no `--audit-level` (Pitfall 1 honored). Exits 1 on unwaived High/Critical; exits 0 with advisory report for moderate/low.
- `scripts/__tests__/check-audit.test.mjs`: 5 cases via `node:test` + `node:assert` (no extra deps). Covers: unwaived High → blocking; waived High → not blocking; moderate/low only → not blocking; no advisories → not blocking; mixed → correct partition.
- All 5 tests green.
### Task 3 — check-outdated.mjs tiered report (`baf2e3a`)
- `scripts/check-outdated.mjs`: classifies outdated packages into four tiers (AUDIT-ADVISORY > MAJOR-BEHIND-INTENTIONAL > MAJOR-BEHIND-UNPINNED > ROUTINE-DRIFT). Cross-checks `pnpm audit --json` to surface pinned-but-vulnerable packages under AUDIT-ADVISORY. Reads `outdated-pins.json` to label intentional pins with their reason. Always `process.exit(0)` — never gates (D-06).
- Live run output: eslint/@eslint/js/zod/@types/node correctly under INTENTIONAL, @vitejs/plugin-react/jsdom/typescript under UNPINNED, hono/mysql2/@types/react under ROUTINE-DRIFT.
## Verification Results
- `node --test scripts/__tests__/check-audit.test.mjs` → 5/5 pass
- `node scripts/check-outdated.mjs` → exit 0, tiered report printed
- `node -e "require('./scripts/audit-allowlist.json')['GHSA-gv7w-rqvm-qjhr']"` → defined
- `grep "execSync" scripts/check-audit.mjs``pnpm audit --json` (no `--audit-level`)
- `grep -c "process.exit(0)" scripts/check-outdated.mjs` → 1
- `grep "process.exit(1)" scripts/check-outdated.mjs` → absent
## TDD Gate Compliance
| Gate | Commit | Message |
|------|--------|---------|
| RED | 7ac8b19 | test(16-02): add failing tests for check-audit.mjs filter logic |
| GREEN | 6eb5107 | feat(16-02): add check-audit.mjs blocking wrapper + unit tests |
TDD gate sequence correct: test commit precedes implementation commit.
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None. All scripts are fully functional with live data.
## Threat Flags
No new threat surface introduced. Files created are scripts (no network endpoints, no auth paths, no schema changes).
## Self-Check: PASSED
- `scripts/check-audit.mjs` — exists ✓
- `scripts/audit-allowlist.json` — exists ✓ (GHSA-gv7w-rqvm-qjhr present)
- `scripts/check-outdated.mjs` — exists ✓
- `scripts/outdated-pins.json` — exists ✓
- `scripts/__tests__/check-audit.test.mjs` — exists ✓
- Commits 0f101bd, 7ac8b19, 6eb5107, baf2e3a — all present in git log ✓
@@ -0,0 +1,135 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- pnpm-lock.yaml
- eslint.config.js
autonomous: true
requirements: [SEC-02]
must_haves:
truths:
- "eslint-plugin-security runs as part of the existing pnpm lint gate, as blocking errors (not warnings)"
- "pnpm lint passes green across both apps with the security plugin active — existing detect-object-injection / fs-filename noise is triaged (rule-tuned or targeted eslint-disable with justification), not left red"
artifacts:
- path: "eslint.config.js"
provides: "eslint-plugin-security recommended config folded in before prettierConfig"
contains: "eslint-plugin-security"
- path: "package.json"
provides: "eslint-plugin-security added to root devDependencies"
contains: "eslint-plugin-security"
key_links:
- from: "eslint.config.js"
to: "eslint-plugin-security"
via: "import pluginSecurity + spread configs.recommended"
pattern: "pluginSecurity"
---
<objective>
Fold eslint-plugin-security into the existing root flat ESLint config (D-03) so its rules run as blocking ERRORS inside the current `pnpm lint` step, and triage the resulting violations across the existing codebase so the gate goes green.
Purpose: The phase 13 ESLint gate already runs on every PR in fast-checks. Adding a static security lint here costs nothing extra in CI (same install, same step). The plugin is heuristic and noisy — `detect-object-injection` fires on every `obj[key]` (pervasive in Drizzle ORM and TS generics) and `detect-non-literal-fs-filename` can fire on dynamic path construction. The user explicitly chose `error` over `warn`, accepting that triage of existing code is expected work, not a blocker.
Output: eslint-plugin-security in root devDependencies (pinned), the flat-config block, and whatever targeted suppressions / rule-tunes are needed to make `pnpm lint` green. Consumed by 16-05 (no new ci.yml step — the existing lint step now enforces it; 16-05 only documents the fold).
</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-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@eslint.config.js
</context>
<tasks>
<task type="auto">
<name>Task 1: Install eslint-plugin-security and fold it into the flat config</name>
<read_first>
- package.json (root devDependencies block — lines 18-27; existing pinned eslint tooling versions)
- eslint.config.js (the whole file — section 5 prettierConfig MUST remain last; the existing `files: ['apps/**/*.{ts,tsx}']` block pattern at lines 27-43)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (eslint.config.js section — exact import + spread placement before prettierConfig; detect-object-injection guidance)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (eslint-plugin-security Integration section — version 4.0.1 (or 3.0.1), flat-config wiring, ESLint 9.39.4 compatibility, the 15-rule table)
</read_first>
<action>
Add eslint-plugin-security to root devDependencies pinned to an EXACT version (4.0.1; or 3.0.1 if the executor prefers more bake time — both are flat-config compatible with the pinned ESLint 9.39.4). Use `pnpm add -D -w eslint-plugin-security@<version>` so pnpm-lock.yaml updates. Do NOT upgrade ESLint. In eslint.config.js: add `import pluginSecurity from 'eslint-plugin-security';` to the import block (lines 7-11), and insert a NEW config block with `files: ['apps/**/*.{ts,tsx}']` that spreads `...pluginSecurity.configs.recommended` and its `...pluginSecurity.configs.recommended.rules` — placed AFTER section 4 (disableTypeChecked) and BEFORE `prettierConfig` (which must stay the last element). Add a section-header comment ("eslint-plugin-security: blocking errors per D-03"). Do not yet add per-rule overrides — Task 2 decides those after measuring noise. Commit: `chore(16-03): add eslint-plugin-security to root flat config (D-03)`.
</action>
<verify>
<automated>node -e "const p=require('./package.json'); if(!p.devDependencies['eslint-plugin-security']) throw new Error('not in devDependencies'); console.log('dep OK')" && grep -q "pluginSecurity" eslint.config.js && grep -nq "prettierConfig" eslint.config.js && echo CONFIG-OK</automated>
</verify>
<acceptance_criteria>
- eslint-plugin-security present in root devDependencies at an exact pinned version; pnpm-lock.yaml updated
- eslint.config.js imports pluginSecurity and spreads configs.recommended in an `apps/**/*.{ts,tsx}` block placed before prettierConfig
- prettierConfig remains the final element of the exported config array
- ESLint version unchanged (still 9.39.4)
</acceptance_criteria>
<done>The security plugin is installed and registered in the flat config, before prettier, without touching the ESLint pin.</done>
</task>
<task type="auto">
<name>Task 2: Triage security-rule violations until pnpm lint is green</name>
<read_first>
- eslint.config.js (the security block added in Task 1)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Triage Strategy section — Option A: disable detect-object-injection globally with a justification comment + inline disable at true risk sites; Option B: keep error + annotate each site; other high-noise candidates: detect-non-literal-fs-filename, detect-possible-timing-attacks)
- apps/api/src (Drizzle ORM bracket-access and generic patterns that trigger detect-object-injection)
- apps/pwa/src (any dynamic bracket access / fs-like patterns)
</read_first>
<action>
Run `pnpm lint` and capture every security/* violation grouped by rule. For each rule decide: (a) genuine risk → fix the code; (b) whole-codebase false positive (e.g. security/detect-object-injection on Drizzle/TS-generic bracket access where the key is schema-derived or zod-validated, not user-controlled) → disable that single rule in the eslint.config.js security block with an inline comment justifying why (note that real user-controlled key risks are guarded by zod validation); (c) a small number of site-specific false positives → add `// eslint-disable-next-line security/<rule> -- <justification>` at each site. Prefer the minimal change that keeps the maximum number of rules at error: disable only the rules that are pervasively false-positive (likely just detect-object-injection, possibly detect-non-literal-fs-filename), and annotate individual sites for the rest. Re-run `pnpm lint` until it is green with `--max-warnings 0`. Do NOT introduce blanket `/* eslint-disable */` file headers. Commit: `chore(16-03): triage eslint-plugin-security findings to green`.
</action>
<verify>
<automated>pnpm lint</automated>
</verify>
<acceptance_criteria>
- `pnpm lint` exits 0 across both apps with the security plugin active
- Any globally disabled security rule has an inline justification comment in eslint.config.js (no silent `off`)
- No blanket file-level `/* eslint-disable */` headers were added; suppressions are rule-specific with `-- justification`
- The majority of the 15 security rules remain at error (only pervasively-false-positive rules are disabled)
</acceptance_criteria>
<done>pnpm lint is green with eslint-plugin-security enforcing as errors; suppressions are minimal and justified.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer source → committed code | Static security lint inspects source at code-analysis time, before it ships |
| user-controlled input → object/property access | detect-object-injection targets this; real risk only when the key is attacker-controlled and unvalidated |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-08 | Tampering / Information Disclosure | Insecure code patterns (eval, child_process with variables, unsafe regex/ReDoS, pseudo-random crypto) introduced in source | mitigate | D-03: eslint-plugin-security rules run as blocking errors in the lint gate (Tasks 1-2), failing the PR on flagged patterns |
| T-16-09 | Elevation of Privilege | Prototype-pollution / object-injection via user-controlled bracket keys | mitigate | detect-object-injection findings triaged: real user-controlled sites are zod-validated; the rule is disabled globally ONLY because the remaining hits are schema-derived keys (Task 2 justification), not because the risk is ignored |
| T-16-10 | Repudiation | Suppression comments hide a genuine vulnerability without accountability | accept | Every suppression carries a `-- justification`; no blanket file disables (Task 2); residual risk is the reviewer trusting the justification, accepted for a two-person repo with PR review |
</threat_model>
<verification>
- `pnpm lint` → exit 0 (both apps, --max-warnings 0)
- `grep -n "security/detect-object-injection" eslint.config.js` → if present, an adjacent justification comment exists
- eslint-plugin-security pinned in package.json devDependencies; pnpm-lock.yaml reflects it
</verification>
<success_criteria>
- eslint-plugin-security folded into the existing flat config as blocking errors, prettierConfig still last
- pnpm lint green with the plugin active
- Suppressions are rule-specific and justified; the ESLint pin is untouched
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-03-SUMMARY.md` when done.
</output>
@@ -0,0 +1,116 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "03"
subsystem: infra
tags: [eslint, security, eslint-plugin-security, static-analysis, ci]
# Dependency graph
requires:
- phase: 13-real-lint-gate-eslint
provides: root flat ESLint config (eslint.config.js) that this plan extends
provides:
- eslint-plugin-security folded into the existing pnpm lint gate as blocking errors (D-03)
- 14 of 15 security rules active; detect-object-injection disabled globally with justification
- Targeted inline suppressions at 2 detect-non-literal-fs-filename false-positive sites
affects:
- 16-05 (documents the lint gate fold; no new ci.yml step needed — lint already enforces it)
# Tech tracking
tech-stack:
added:
- eslint-plugin-security@3.0.1 (root devDependencies, pinned exact)
patterns:
- Security rules folded into existing lint step: no extra CI install cost, same pnpm lint gate
- High-FP rules disabled globally with inline justification comment; site-specific FPs get eslint-disable-next-line with rationale
key-files:
created: []
modified:
- eslint.config.js
- package.json
- pnpm-lock.yaml
- apps/api/src/index.ts
- apps/api/tests/broker/expand.test.ts
key-decisions:
- "D-03-SEC-VERSION: Pinned eslint-plugin-security@3.0.1 (not 4.0.1) — 3.0.1 has more bake time; both are flat-config compatible"
- "D-03-OBJ-INJECT: detect-object-injection disabled globally — all hits were numeric loop indices (arr[i]) and schema-derived keys, not user-controlled input; remaining 14 rules enforced at error"
- "D-03-FS-FILENAME: detect-non-literal-fs-filename suppressed at 2 sites (realpathSync(process.argv[1]) and test fixture readFileSync) — both are runtime/test-controlled paths, not user input"
patterns-established:
- "Security lint fold: add security plugin block before prettierConfig (must stay last); disable only pervasively-FP rules globally with justification"
- "Inline suppression format: // eslint-disable-next-line security/<rule> -- <rationale>"
requirements-completed: [SEC-02]
# Metrics
duration: 2min
completed: 2026-06-13
---
# Phase 16 Plan 03: eslint-plugin-security Static Lint Gate Summary
**eslint-plugin-security@3.0.1 folded into the existing pnpm lint gate as 14 blocking error-level rules; detect-object-injection disabled globally for Drizzle/TS-generic FPs; pnpm lint green**
## Performance
- **Duration:** 2 min
- **Started:** 2026-06-13T09:21:47Z
- **Completed:** 2026-06-13T09:24:29Z
- **Tasks:** 2
- **Files modified:** 5
## Accomplishments
- eslint-plugin-security@3.0.1 installed to root devDependencies (exact pin)
- Flat config extended: new security block (`files: apps/**/*.{ts,tsx}`) with `...pluginSecurity.configs.recommended` spread, placed before `prettierConfig` (which stays last)
- Triaged 4 total violations: 2 detect-non-literal-fs-filename (inline suppressions with justification), 2 detect-object-injection (globally disabled with justification comment)
- `pnpm lint` exits 0 with `--max-warnings 0` across both apps; ESLint pin unchanged at 9.39.4
## Task Commits
1. **Task 1: Install eslint-plugin-security and fold it into the flat config** - `826a23a` (chore)
2. **Task 2: Triage security-rule violations until pnpm lint is green** - `59e49ec` (chore)
## Files Created/Modified
- `eslint.config.js` — added `pluginSecurity` import + security config block (section 5, before prettierConfig); detect-object-injection globally disabled with justification
- `package.json` — eslint-plugin-security@3.0.1 added to root devDependencies
- `pnpm-lock.yaml` — lockfile updated to reflect new package
- `apps/api/src/index.ts` — inline `eslint-disable-next-line` for `detect-non-literal-fs-filename` on `realpathSync(process.argv[1])`
- `apps/api/tests/broker/expand.test.ts` — inline `eslint-disable-next-line` for `detect-non-literal-fs-filename` on test-fixture `readFileSync`
## Decisions Made
- **Version choice:** Pinned eslint-plugin-security@3.0.1 (not 4.0.1) — 4.0.1 was published the same day as phase research (freshness concern); 3.0.1 is stable and flat-config compatible with ESLint 9.39.4.
- **detect-object-injection disabled globally:** After running lint and auditing all 2 hits: both were `ranks[i] > ranks[i - 1]` numeric loop index comparisons in tests — not user-controlled keys. Disabling the single highest-noise rule globally while keeping the remaining 14 rules at error. Matches RESEARCH triage Option A recommendation.
- **detect-non-literal-fs-filename: inline suppressions at 2 sites:** Not disabled globally because only 2 hits exist and both are clearly false positives. Site-level suppression is the minimal-change approach that keeps the rule active for any future truly dynamic `fs.*` calls.
## Deviations from Plan
None — plan executed exactly as written. Triage decision to disable detect-object-injection globally vs. annotating sites (Option A vs. B per RESEARCH) was explicitly delegated to the executor; Option A was chosen after confirming all hits were numeric loop indices.
## Issues Encountered
None. Only 4 lint violations found (2 rules, 2 sites each), far fewer than the "dozens" anticipated for Drizzle ORM bracket access — the codebase does not have heavy obj[key] usage in API source files.
## Threat Surface Scan
No new network endpoints, auth paths, file access patterns, or schema changes introduced. This plan adds only dev-tooling configuration.
## Known Stubs
None.
## User Setup Required
None — no external service configuration required. The security lint fold is automatic via `pnpm lint` (existing CI step).
## Next Phase Readiness
- Plan 16-04 (gitleaks secret scanning) is ready to proceed
- Plan 16-05 (CI documentation) will reference this plan's D-03 fold — the lint step already enforces it; no new ci.yml job step needed for the security lint
---
*Phase: 16-ci-dependency-audit-and-security-checks*
*Completed: 2026-06-13*
@@ -0,0 +1,163 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: 04
type: execute
wave: 1
depends_on: []
files_modified:
- .gitleaks.toml
- scripts/gitleaks-baseline.json
- .dockerignore
autonomous: false
requirements: [SEC-01, IMG-02]
must_haves:
truths:
- "A gitleaks config exists that inherits the default ruleset and allowlists the known test-fixture VAPID keys + .env.example/.env.spike so they do not trip the gate"
- "A full-history/full-tree gitleaks baseline scan has been run and committed, suppressing any pre-existing findings so the first PR-diff scan starts from a clean known state"
- "A .dockerignore exists that keeps secrets, dev affordances, tests, and bulk out of the Docker build context WITHOUT excluding apps/api/src (the builder stage needs it)"
artifacts:
- path: ".gitleaks.toml"
provides: "gitleaks config: useDefault + allowlists for VAPID fixture / env templates"
contains: "useDefault"
- path: "scripts/gitleaks-baseline.json"
provides: "Committed full-history baseline scan output"
- path: ".dockerignore"
provides: "Build-context filter (secrets/dev/bulk), preserving apps/api/src + manifests"
contains: ".env"
key_links:
- from: ".gitleaks.toml"
to: "apps/api/tests/fixtures/vapid.ts"
via: "[[allowlists]] paths regex"
pattern: "vapid"
---
<objective>
Author the gitleaks configuration and the committed full-history baseline (D-02) and create the full `.dockerignore` (D-09 / IMG-02) — the static security-scan and image-hygiene artifacts the CI jobs in Wave 2 consume. This delivers the secret-scanning half of the D-01 security-check baseline (secret scanning + static security lint; Trivy/image CVE scanning is dropped per D-01).
Purpose: The app holds real family credentials (encryption key, OIDC secret, Fastmail app passwords), so secret scanning is core. A per-PR diff scan (wired in 16-05) needs a config that allowlists the known test-fixture VAPID keypair (`apps/api/tests/fixtures/vapid.ts`) and env templates, plus a one-time baseline so pre-existing findings do not block every future PR. Separately, today the entire repo root is sent to the Docker daemon as build context (no `.dockerignore` exists), so `.env`, dev seed scripts, tests, and `.planning/` are all shipped to the builder. The `.dockerignore` must exclude secrets/dev/bulk while preserving `apps/api/src` (the builder stage's `COPY apps/api ./apps/api` needs it) and the workspace manifests.
Output: `.gitleaks.toml`, `scripts/gitleaks-baseline.json`, `.dockerignore`. Consumed by 16-05 (gitleaks PR scan references the config + baseline) and 16-06 (static assertion greps the .dockerignore). The baseline-scan step is a human-verify checkpoint because it requires running gitleaks against the real repo history and confirming the only findings are the known test fixtures.
</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-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@.gitignore
</context>
<tasks>
<task type="auto">
<name>Task 1: Author .gitleaks.toml with default ruleset + fixture/env allowlists</name>
<read_first>
- .gitleaks.toml (file being created)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (.gitleaks.toml Config section — exact content: title, [extend] useDefault=true, [[allowlists]] blocks with paths regex for apps/api/tests/fixtures/vapid.ts, .env.example, apps/api/.env.spike; the baseline caveat about the VAPID fixture)
- apps/api/tests/fixtures/vapid.ts (the real-looking VAPID keypair that WILL be flagged unless allowlisted)
- .gitignore (env-handling conventions: .env / .env.* ignored, .env.example kept)
</read_first>
<action>
Create .gitleaks.toml at repo root with: a `title`, an `[extend]` section with `useDefault = true` (inherit the built-in secret ruleset), and three `[[allowlists]]` blocks each with a `description` and a `paths` regex array allowlisting (1) apps/api/tests/fixtures/vapid.ts (documented test-only VAPID values), (2) `.env.example` (intentional placeholder template), and (3) apps/api/.env.spike (dev/spike values). Use the exact structure from RESEARCH.md. Do NOT add custom detection rules — only the default set plus allowlists. Commit: `chore(16-04): add gitleaks config with fixture + env allowlists`.
</action>
<verify>
<automated>grep -q "useDefault" .gitleaks.toml && grep -q "vapid" .gitleaks.toml && grep -q "env.example" .gitleaks.toml && echo OK</automated>
</verify>
<acceptance_criteria>
- .gitleaks.toml exists with `[extend] useDefault = true`
- Three `[[allowlists]]` blocks cover the VAPID fixture, .env.example, and .env.spike, each with a description
- No custom `[[rules]]` were added (default ruleset only)
</acceptance_criteria>
<done>The gitleaks config inherits the default ruleset and allowlists the three known-safe paths.</done>
</task>
<task type="auto">
<name>Task 2: Create the full .dockerignore (secrets/dev/bulk, preserving builder inputs)</name>
<read_first>
- .dockerignore (file being created)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Exact .dockerignore Line List section — the full recommended content; the critical insight that .dockerignore filters the build CONTEXT only, not COPY --from=stage; the "Items NOT excluded" list: apps/api/src, apps/pwa/src, pnpm-workspace.yaml, pnpm-lock.yaml, package.json, package.jsons, tsconfig.jsons)
- apps/api/Dockerfile (builder stage `COPY apps/api ./apps/api` at line 16 — proves apps/api/src MUST stay in context; pwa-builder `COPY apps/pwa` at line 32)
- .gitignore (section-header comment style to mirror)
</read_first>
<action>
Create .dockerignore at repo root mirroring the RESEARCH.md "Recommended .dockerignore" content with section-header comments (Secrets, VCS, Build artifacts, Dependencies, Tests, Playwright artifacts, Planning/docs, Editor/OS, CI config, SQL dumps). MUST exclude: .env, .env.* (with `!.env.example` un-ignore), apps/api/scripts/seed-credential.mjs, .git, **/dist/, **/node_modules/, apps/api/tests/, apps/api/test/, apps/pwa/e2e/, Playwright artifact dirs, .planning/, docs/, editor/OS files, .gitea/, and *.sql dumps. MUST NOT exclude apps/api/src, apps/pwa/src, pnpm-workspace.yaml, pnpm-lock.yaml, the package.json files, or the tsconfig.json files (the builder/pwa-builder stages need them). Add the explanatory NOTE comment from RESEARCH about migration .sql files traveling only in the builder stage. Commit: `chore(16-04): add .dockerignore (secrets/dev/bulk, preserve builder inputs)`.
</action>
<verify>
<automated>set -e; for p in ".env" "node_modules" "apps/api/scripts" ".git" ".planning" "apps/api/tests" "apps/pwa/e2e"; do grep -q "$p" .dockerignore || { echo "MISSING $p"; exit 1; }; done; grep -Eq '(^|/)apps/api/src( |/|$)' .dockerignore && { echo "ERROR: apps/api/src is excluded"; exit 1; }; echo OK</automated>
</verify>
<acceptance_criteria>
- .dockerignore exists and contains all forbidden patterns the 16-06 static assertion greps for (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests, apps/pwa/e2e)
- .dockerignore does NOT exclude apps/api/src (verify grep finds no such line)
- `!.env.example` un-ignore is present so the template survives
</acceptance_criteria>
<done>The .dockerignore excludes secrets/dev/bulk from the build context while preserving builder-stage inputs.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 3: Run + commit the gitleaks full-history baseline; confirm only known fixtures are flagged</name>
<read_first>
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Full-History Baseline Scan section — exact command `gitleaks git --config .gitleaks.toml --report-path scripts/gitleaks-baseline.json`; the VAPID fixture caveat; --baseline-path semantics)
- .gitleaks.toml (config authored in Task 1)
</read_first>
<what-built>
Tasks 1-2 created .gitleaks.toml and .dockerignore. This checkpoint runs the one-time full-history/full-tree gitleaks baseline scan locally and commits the result, so the PR-diff scan wired in 16-05 starts from a clean, reviewed known state. This must be human-verified because the scan reads the real repo history and the operator must confirm that the ONLY findings are the documented test fixtures (VAPID keys) — a real leaked credential surfacing here is a genuine security event, not noise.
Automated steps the executor performs first:
1. Install gitleaks v8.30.1 locally (single binary): download `gitleaks_8.30.1_linux_x64.tar.gz` from github.com/gitleaks/gitleaks releases, extract, chmod +x.
2. Run `gitleaks git --config .gitleaks.toml --report-path scripts/gitleaks-baseline.json` from repo root.
3. Inspect scripts/gitleaks-baseline.json — list every finding's file + rule.
</what-built>
<how-to-verify>
1. Review the executor's listing of baseline findings.
2. Confirm EVERY finding is one of: the test-fixture VAPID keys (apps/api/tests/fixtures/vapid.ts), .env.example placeholders, or .env.spike dev values — all of which Task 1 allowlisted (so ideally the baseline is empty/near-empty after allowlisting).
3. If ANY finding is a real credential (an actual OIDC secret, encryption key, or Fastmail app password committed to history) → STOP. Do not approve. This is a genuine leak requiring rotation + history rewrite, out of scope for this plan — flag it to the operator.
4. If all findings are the known fixtures (or none), approve. The executor then commits scripts/gitleaks-baseline.json with message `chore(16-04): commit gitleaks full-history baseline`.
</how-to-verify>
<verify>
<automated>test -f scripts/gitleaks-baseline.json && node -e "JSON.parse(require('fs').readFileSync('scripts/gitleaks-baseline.json','utf8')); console.log('valid JSON baseline')"</automated>
</verify>
<resume-signal>Type "approved" once you confirm the baseline contains only known test fixtures (or is empty), or describe any real credential found.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer commit → git history | A secret committed to any branch enters history; gitleaks scans the git object, not just the working tree |
| repo working tree → Docker build context | Everything in the context is sent to the daemon and reachable by COPY; secrets/dev files must be filtered out |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-11 | Information Disclosure | A real OIDC secret / encryption key / Fastmail app password committed to git history | mitigate | D-02: gitleaks default ruleset + full-history baseline (Tasks 1, 3) surface any pre-existing leak; the human checkpoint blocks approval if a real credential is found |
| T-16-12 | Information Disclosure | Test-fixture keys mis-flagged, masking real findings in noise | mitigate | .gitleaks.toml allowlists the known fixture/template paths (Task 1) so the scan signal is real leaks only |
| T-16-13 | Information Disclosure | `.env`, seed-credential.mjs, .planning, or family data shipped in the Docker image | mitigate | D-09: .dockerignore (Task 2) filters secrets/dev/bulk from the build context; verified by 16-06 static assertion |
| T-16-14 | Tampering | .dockerignore accidentally excludes apps/api/src, breaking the build | accept | Task 2 verify explicitly asserts apps/api/src is NOT excluded; build failure is loud and caught at publish, residual risk nil |
</threat_model>
<verification>
- `.gitleaks.toml` has `useDefault = true` + the three fixture/env allowlists
- `scripts/gitleaks-baseline.json` exists, is valid JSON, and was human-confirmed to contain only known fixtures
- `.dockerignore` contains every forbidden pattern the 16-06 assertion checks AND does not exclude apps/api/src
</verification>
<success_criteria>
- gitleaks config inherits the default ruleset and allowlists known-safe paths
- Full-history baseline committed and confirmed free of real credentials (human checkpoint)
- .dockerignore excludes secrets/dev/bulk while preserving builder-stage inputs
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-04-SUMMARY.md` when done.
</output>
@@ -0,0 +1,108 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "04"
subsystem: infra
tags: [gitleaks, secret-scanning, dockerignore, image-hygiene, security, ci]
requires:
- phase: 16-ci-dependency-audit-and-security-checks
provides: Phase context, CI workflow, security check baseline strategy
provides:
- .gitleaks.toml — default ruleset + 4 allowlists (VAPID fixture, .env.example, .env.spike, crypto.test.ts AES fixture)
- scripts/gitleaks-baseline.json — committed empty-array full-history baseline (613 commits, 23 MB, zero findings)
- .dockerignore — excludes secrets/dev/bulk from Docker build context while preserving apps/api/src and workspace manifests
affects:
- 16-05 (gitleaks PR-diff scan CI job — consumes .gitleaks.toml + --baseline-path scripts/gitleaks-baseline.json)
- 16-06 (static .dockerignore assertion — greps the exclusion patterns added here)
tech-stack:
added:
- gitleaks v8.30.1 (secret scanner — used locally to generate baseline; CI binary installed in 16-05)
patterns:
- gitleaks allowlist-by-path pattern for known test fixtures (paths regex array in [[allowlists]] blocks)
- Full-history baseline committed as empty JSON; PR-diff scan uses --baseline-path to ignore pre-existing known-safe history
key-files:
created:
- .gitleaks.toml
- scripts/gitleaks-baseline.json
- .dockerignore
modified: []
key-decisions:
- "D-04-ALLOWLIST: crypto.test.ts TEST_KEY allowlisted by path — human-verified Vitest beforeAll synthetic AES-256-GCM fixture, not a real credential; 4th [[allowlists]] block added after human approval at the Task 3 checkpoint"
- "D-04-BASELINE: baseline is empty JSON array after allowlisting; all 613 commits scanned clean; PR-diff scans in 16-05 start from provably clean history"
patterns-established:
- "gitleaks allowlist block structure: [[allowlists]] with description + paths (raw TOML string regex) — match existing block style when adding future fixture paths"
requirements-completed: [SEC-01, IMG-02]
duration: 45min
completed: 2026-06-13
---
# Phase 16 Plan 04: Gitleaks Config, Full-History Baseline, and .dockerignore Summary
**gitleaks config (4 path allowlists) + committed empty baseline (613 commits clean) + .dockerignore keeping secrets/dev/bulk out of Docker build context**
## Performance
- **Duration:** ~45 min
- **Started:** 2026-06-13
- **Completed:** 2026-06-13
- **Tasks:** 3 (Tasks 1-2 by prior executor; Task 3 checkpoint + continuation by this executor)
- **Files modified:** 3 created + 1 extended (.gitleaks.toml 4th allowlist)
## Accomplishments
- `.gitleaks.toml` authored with `[extend] useDefault = true` inheriting the full default ruleset, plus 4 `[[allowlists]]` blocks covering the VAPID test fixture, .env.example, .env.spike, and the synthetic AES-256-GCM key in crypto.test.ts
- `scripts/gitleaks-baseline.json` regenerated after allowlisting the crypto.test.ts fixture — 613 commits scanned, ~23 MB of git history, zero findings; baseline is an empty JSON array `[]`, giving 16-05's PR-diff scan a provably clean starting state
- `.dockerignore` created, excluding `.env`, `node_modules`, `.git`, `.planning/`, `apps/api/tests/`, `apps/pwa/e2e/`, seed scripts, and bulk artifacts while preserving `apps/api/src` (required by the builder stage's `COPY apps/api ./apps/api`), `apps/pwa/src`, workspace manifests, and all `package.json`/`tsconfig.json` files
## Task Commits
1. **Task 1: .gitleaks.toml with default ruleset + fixture/env allowlists** - `2f1592c` (chore)
2. **Task 2: .dockerignore (secrets/dev/bulk, preserve builder inputs)** - `5819247` (chore)
3. **Task 3 (post-checkpoint): allowlist crypto.test.ts in .gitleaks.toml** - `fba22b4` (chore)
4. **Task 3 (post-checkpoint): regenerate clean full-history baseline** - `bc83495` (chore)
## Files Created/Modified
- `.gitleaks.toml` — gitleaks config: useDefault=true + 4 path-based allowlists (VAPID fixture, .env.example, .env.spike, crypto.test.ts AES fixture)
- `scripts/gitleaks-baseline.json` — committed full-history baseline: empty `[]` (613 commits clean)
- `.dockerignore` — Docker build context filter: excludes secrets/dev/bulk, preserves builder-stage inputs
## Decisions Made
- **D-04-ALLOWLIST:** The Task 3 human-verify checkpoint surfaced one baseline finding: `TEST_KEY` at `apps/api/tests/broker/crypto.test.ts:15`, a synthetic AES-256-GCM key assigned to `process.env.APP_PASSWORD_ENCRYPTION_KEY` in a Vitest `beforeAll`. Human verified it is a test fixture. Operator approved adding a 4th `[[allowlists]]` block for `apps/api/tests/broker/crypto\.test\.ts` so future PR-diff scans also suppress it by path. Allowlist added, baseline regenerated — result is zero findings.
- **D-04-BASELINE:** Empty baseline `[]` is the correct output when all known fixtures are properly allowlisted. The 16-05 gitleaks workflow will pass `--baseline-path scripts/gitleaks-baseline.json` so PR-diff scans only alert on new findings introduced in the PR, not pre-existing allowlisted history.
## Deviations from Plan
The original plan had Tasks 1-2 as `type="auto"` and Task 3 as a `type="checkpoint:human-verify"`. The continuation task (adding the 4th allowlist and regenerating the baseline) was triggered by the human-verified finding at the checkpoint — this is expected flow, not a deviation. The 4th allowlist block was added per the operator's "Approve + allowlist it" decision.
None - plan executed exactly as specified; the checkpoint and human-directed allowlist addition are the intended workflow.
## Issues Encountered
None — gitleaks scan completed cleanly in 3 seconds; zero unexpected findings after allowlisting the known test fixture.
## Threat Surface Scan
No new network endpoints, auth paths, file access patterns, or schema changes introduced by this plan. All changes are static config files (`.gitleaks.toml`, `.dockerignore`) and a JSON report artifact (`scripts/gitleaks-baseline.json`).
## User Setup Required
None — no external service configuration required. The gitleaks binary is installed in CI via the 16-05 workflow step, not checked in.
## Next Phase Readiness
- `16-05` (gitleaks PR-diff scan CI job): `.gitleaks.toml` and `scripts/gitleaks-baseline.json` are in place — 16-05 can wire the `gitleaks git --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json` CI step immediately
- `16-06` (static .dockerignore assertion): `.dockerignore` contains all patterns the static assertion greps for; `apps/api/src` exclusion is verified absent
---
*Phase: 16-ci-dependency-audit-and-security-checks*
*Completed: 2026-06-13*
@@ -0,0 +1,139 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: 05
type: execute
wave: 2
depends_on: ["16-02", "16-03", "16-04"]
files_modified:
- .gitea/workflows/ci.yml
autonomous: true
requirements: [CI-03, SEC-01, DEP-01, DEP-02]
must_haves:
truths:
- "A new security job runs in parallel with fast-checks: gitleaks scans every PR (including doc-only), while pnpm audit + pnpm outdated run only when changes.outputs.code is true"
- "The gitleaks PR-diff scan is blocking and uses the committed config + baseline; the base.sha availability assumption is probed before the scan relies on it, with a merge-base fallback"
- "The security job is wired into the gate aggregator with an individual needs.security.result check that requires success (not success-or-skipped), since gitleaks always runs"
artifacts:
- path: ".gitea/workflows/ci.yml"
provides: "security job (gitleaks + check-audit.mjs + check-outdated.mjs) + updated gate"
contains: "security:"
key_links:
- from: ".gitea/workflows/ci.yml"
to: "scripts/check-audit.mjs"
via: "node scripts/check-audit.mjs step (code-gated)"
pattern: "check-audit"
- from: ".gitea/workflows/ci.yml gate"
to: "security job"
via: "needs.security.result == success check"
pattern: "needs.security.result"
---
<objective>
Add a dedicated `security` job to the existing PR workflow (`.gitea/workflows/ci.yml`) — parallel to `fast-checks` — that runs gitleaks on every PR (blocking, D-12) and runs `check-audit.mjs` (blocking on unwaived High+Critical, D-04) and `check-outdated.mjs` (advisory-only, D-06) only on code/lockfile-changing PRs. Then wire `security` into the `gate` aggregator with an individual `needs.security.result` check (D-14 / D-15). This realizes the D-11 gating posture: gitleaks and pnpm audit High+Critical are blocking; pnpm outdated is advisory and never gates.
Purpose: Centralizes the new PR-time security/dependency checks into one isolated, parallel job so a secret-leak or unwaived advisory is clearly attributable and does not pollute fast-checks. This is ADDITIVE — it does not restructure the existing changes/fast-checks/api/harness/gate topology. eslint-plugin-security is NOT a step here (it already runs inside the existing fast-checks `pnpm lint` via 16-03 — this plan only relies on that).
Output: The modified `ci.yml`. Consumes the scripts/config from 16-02 (check-audit.mjs, check-outdated.mjs), 16-03 (eslint-plugin-security already in the lint step), and 16-04 (.gitleaks.toml, gitleaks-baseline.json). Honors all Gitea runner constraints: no actions/cache, ubuntu-latest, set -euo pipefail, REGISTRY_PAT naming (n/a here), individual needs.X.result (Gitea #31007).
</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-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@.gitea/workflows/ci.yml
</context>
<tasks>
<task type="auto">
<name>Task 1: Add the security job (gitleaks always; audit/outdated code-gated) with a base.sha probe</name>
<read_first>
- .gitea/workflows/ci.yml (existing job skeletons: fast-checks lines 33-66, api conditional pattern lines 68-72, the `changes`/paths-filter job lines 8-31 — needs.changes.outputs.code)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (the full `security` job skeleton; set -euo pipefail convention; no actions/cache rule; node: import convention)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Secret Scanning section — gitleaks v8.30.1 install via curl|tar; `gitleaks git --log-opts="--no-merges BASE..HEAD" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1`; fetch-depth:0 requirement; Pitfall 3; Assumption A2 + Open Question 1 — base.sha may be empty on Gitea, fallback `git merge-base $(git rev-parse origin/${{ github.base_ref }}) HEAD`; the security job needs:[changes], if pull_request)
- scripts/check-audit.mjs and scripts/check-outdated.mjs (created in 16-02 — invoked here)
- .gitleaks.toml and scripts/gitleaks-baseline.json (created in 16-04 — referenced here)
</read_first>
<action>
In ci.yml add a new `security` job (placed after `harness`, before `gate`) with `runs-on: ubuntu-latest`, `needs: [changes]`, `if: github.event_name == 'pull_request'`. Steps in order: (1) actions/checkout@v4 with `fetch-depth: 0` (Pitfall 3 — base.sha must be local). (2) A "Probe PR base/head SHA" step (always runs) that echoes `github.event.pull_request.base.sha` and `head.sha`, computes `BASE_SHA` from the event context and, if empty, falls back to `git merge-base "$(git rev-parse origin/${{ github.base_ref }})" HEAD`, exporting BASE_SHA and HEAD_SHA to $GITHUB_ENV (Assumption A2 / OQ-1). (3) Install gitleaks: `set -euo pipefail`, pin VERSION=8.30.1, curl the linux_x64 tarball, tar -xz gitleaks, chmod +x, mv to /usr/local/bin. (4) "Secret scan (PR diff, blocking)" always-runs: `set -euo pipefail`, run `gitleaks git --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --report-path /tmp/gitleaks-pr-report.json --exit-code 1`. (5) actions/setup-node@v4 node 22, (6) corepack enable pnpm, (7) pnpm install --frozen-lockfile, (8) `node scripts/check-audit.mjs`, (9) `node scripts/check-outdated.mjs` — steps 5-9 EACH carry `if: needs.changes.outputs.code == 'true'` (step-level, NOT job-level — D-12 so gitleaks still runs on doc-only PRs). Do NOT add actions/cache. Every multi-line run block starts with `set -euo pipefail`. Commit: `ci(16-05): add security job (gitleaks always; audit/outdated code-gated)`.
</action>
<verify>
<automated>command -v yq >/dev/null 2>&1 && yq '.jobs.security' .gitea/workflows/ci.yml >/dev/null || python3 -c "import yaml,sys; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); j=d['jobs']['security']; assert j['needs']==['changes']; print('security job parses OK')"</automated>
</verify>
<acceptance_criteria>
- ci.yml has a `security` job with `needs: [changes]`, `if: github.event_name == 'pull_request'`, and `fetch-depth: 0` checkout
- A base/head SHA probe step computes BASE_SHA with a `git merge-base` fallback when the event context is empty
- gitleaks install + scan steps have NO `if:` (always run, D-12); the gitleaks scan references --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1
- The pnpm/setup-node/check-audit/check-outdated steps each carry `if: needs.changes.outputs.code == 'true'`
- No `actions/cache` in the security job; every `run: |` block starts with `set -euo pipefail`
</acceptance_criteria>
<done>The security job runs gitleaks unconditionally and the dependency checks behind the code filter, with a base.sha probe + fallback.</done>
</task>
<task type="auto">
<name>Task 2: Wire the security job into the gate aggregator (individual needs.security.result check)</name>
<read_first>
- .gitea/workflows/ci.yml (the gate job lines 345-367 — `needs: [fast-checks, changes, api, harness]`, the individual needs.X.result checks, the #31007 wildcard-bug comment)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Updated gate needs list + security check; the rule that security must require SUCCESS, not "success OR skipped", because gitleaks always runs)
</read_first>
<action>
In ci.yml update the `gate` job: add `security` to its `needs:` list (so it becomes `needs: [fast-checks, changes, api, harness, security]`). In the gate shell script, add a NEW individual check after the existing fast-checks check and BEFORE the `for result in ... api ... harness` loop: if `needs.security.result != success` then echo the result and `exit 1`. Do NOT add security to the success-OR-skipped loop (the api/harness loop) — security always runs (gitleaks is unconditional), so it must strictly require success per Gitea #31007 individual-check convention. Leave the api/harness loop unchanged. Commit: `ci(16-05): wire security job into gate aggregator`.
</action>
<verify>
<automated>python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/ci.yml')); g=d['jobs']['gate']; assert 'security' in g['needs'], 'security not in gate needs'; print('gate needs OK')" && grep -q "needs.security.result" .gitea/workflows/ci.yml && echo CHECK-OK</automated>
</verify>
<acceptance_criteria>
- gate `needs:` includes `security`
- The gate script has an individual `needs.security.result` check that exits 1 unless it equals `success`
- security is NOT folded into the api/harness success-or-skipped loop
- The existing fast-checks / api / harness gate logic is unchanged
</acceptance_criteria>
<done>The gate requires the security job to succeed via an individual result check, consistent with the #31007 workaround.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| PR author → main branch | The PR workflow is the enforcement point before code reaches the trusted main branch |
| CI runner network → external download | gitleaks binary is fetched from GitHub releases at a pinned version |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-15 | Information Disclosure | A secret introduced in a PR diff (including a doc/config-only PR) reaches main | mitigate | D-02/D-12: gitleaks runs unconditionally in the security job on every PR with --exit-code 1 (Task 1); gate requires security success (Task 2) |
| T-16-16 | Tampering | Unwaived High/Critical dependency advisory merges to main | mitigate | D-04: check-audit.mjs runs code-gated and exits 1 on unwaived High+Critical (Task 1); gate blocks |
| T-16-17 | Repudiation | gitleaks silently scans nothing (fetch-depth:1 → empty base.sha → no commits in range → exit 0) | mitigate | Pitfall 3 + A2: fetch-depth:0 + a base.sha probe with merge-base fallback (Task 1) ensures the diff range is real |
| T-16-SC | Tampering | gitleaks binary download from GitHub releases could be substituted | mitigate | Version pinned to 8.30.1; download from the official gitleaks/gitleaks releases path (Task 1). Note: no checksum verification this phase — accepted residual for a pinned tag from the canonical source |
</threat_model>
<verification>
- ci.yml parses as valid YAML; `jobs.security` exists with needs:[changes]
- gitleaks steps have no `if:`; audit/outdated steps are code-gated
- gate `needs:` includes security and the script has an individual needs.security.result==success check
- No actions/cache; set -euo pipefail on every new multi-line run block
- Live proof (a deliberate-secret PR failing the gate, and a doc-only PR still running gitleaks) is a phase-verification manual check, not a unit test
</verification>
<success_criteria>
- security job added parallel to fast-checks; gitleaks always, audit/outdated code-gated
- base.sha probe + merge-base fallback present
- gate requires security success via an individual result check
- Fully additive — existing topology untouched; all runner constraints honored
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-05-SUMMARY.md` when done.
</output>
@@ -0,0 +1,82 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "05"
subsystem: ci
tags: [gitea-ci, gitleaks, security, pnpm-audit, dependency-audit, gate]
dependency_graph:
requires: ["16-02", "16-03", "16-04"]
provides: ["security job in ci.yml", "gate wired with security check"]
affects: [".gitea/workflows/ci.yml"]
tech_stack:
added: []
patterns: ["security CI job parallel to fast-checks", "individual needs.X.result check (Gitea #31007)", "base.sha probe with git merge-base fallback"]
key_files:
modified:
- path: .gitea/workflows/ci.yml
role: CI workflow — security job added; gate aggregator updated
decisions:
- "D-12: gitleaks always runs on every PR via unconditional steps (no job-level if:); only pnpm audit/outdated are code-gated at step level"
- "D-14/D-15: security wired into gate with individual needs.security.result check — must be 'success', not 'success-or-skipped', because gitleaks always runs"
- "A2/OQ-1: base.sha probe step with git merge-base fallback guards against empty base.sha on some Gitea versions"
metrics:
duration: 7
completed: "2026-06-13T12:23:36Z"
tasks_completed: 2
files_modified: 1
---
# Phase 16 Plan 05: CI Security Job — Summary
**One-liner:** Dedicated `security` CI job (gitleaks always + audit/outdated code-gated) wired into the `gate` aggregator with an individual `needs.security.result` success check.
## What Was Built
A new `security` job was added to `.gitea/workflows/ci.yml`, placed between `harness` and `gate` in the file order (runs in parallel with `fast-checks`). The job:
- Runs on every PR (`if: github.event_name == 'pull_request'`), `needs: [changes]`
- Uses `actions/checkout@v4` with `fetch-depth: 0` (Pitfall 3 — base.sha must be locally present)
- Has a "Probe PR base/head SHA" step that reads `github.event.pull_request.base.sha` and falls back to `git merge-base origin/${{ github.base_ref }} HEAD` if empty (Assumption A2 / OQ-1), exporting `BASE_SHA` and `HEAD_SHA` to `$GITHUB_ENV`
- Installs gitleaks v8.30.1 from GitHub releases (pinned, no `actions/cache` per D-PROBE-04), then runs `gitleaks git --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json --exit-code 1` — both gitleaks steps have **no `if:`** (D-12: blocking on every PR)
- The pnpm setup-node / corepack / install / check-audit / check-outdated steps each carry `if: needs.changes.outputs.code == 'true'` (step-level, so gitleaks still runs on doc-only PRs)
The `gate` aggregator was updated:
- `needs:` expanded to `[fast-checks, changes, api, harness, security]`
- An individual `if [ "${{ needs.security.result }}" != "success" ]` check was inserted between the fast-checks check and the api/harness for loop
- Security is **not** folded into the for loop — it must always succeed (cannot be skipped)
## Commits
| Hash | Message | Files |
|------|---------|-------|
| 61b7586 | ci(16-05): add security job (gitleaks always; audit/outdated code-gated) | .gitea/workflows/ci.yml |
| f0f7d8a | ci(16-05): wire security job into gate aggregator | .gitea/workflows/ci.yml |
## Deviations from Plan
None — plan executed exactly as written.
The task described a "Probe PR base/head SHA" step as a separate explicit step (per PLAN.md acceptance criteria A2/OQ-1). This matches the PLAN.md requirement and was implemented accordingly. The PATTERNS.md skeleton showed a simpler inline version; the PLAN.md explicitly required the probe step with fallback, so the PLAN.md was authoritative.
## Threat Coverage
| Threat | Mitigation | Status |
|--------|-----------|--------|
| T-16-15 — secret introduced in PR diff | gitleaks runs unconditionally, gate blocks on non-success | Mitigated |
| T-16-16 — unwaived High/Critical advisory merges to main | check-audit.mjs code-gated, gate blocks | Mitigated |
| T-16-17 — gitleaks scans nothing (empty range) | fetch-depth:0 + base.sha probe + merge-base fallback | Mitigated |
| T-16-SC — gitleaks binary substitution | version pinned to 8.30.1 from gitleaks/gitleaks official releases | Accepted residual (no checksum) |
## Known Stubs
None.
## Threat Flags
None — this plan adds only CI workflow steps and does not introduce new network endpoints, auth paths, or schema changes.
## Self-Check: PASSED
- `.gitea/workflows/ci.yml` — FOUND
- Commit 61b7586 (add security job) — FOUND
- Commit f0f7d8a (wire gate) — FOUND
- `16-05-SUMMARY.md` — FOUND
@@ -0,0 +1,134 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: 06
type: execute
wave: 2
depends_on: ["16-01", "16-04"]
files_modified:
- .gitea/workflows/publish.yml
autonomous: true
requirements: [IMG-03]
must_haves:
truths:
- "On push-to-main publish, a static assertion fails the publish unless .dockerignore exists, covers the forbidden patterns, and publish.yml still pins --target production"
- "A boot-smoke runs the freshly-built production image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails the publish unless the image refuses to boot (non-zero exit, neither 0 nor a 124 timeout)"
- "Both assertions run AFTER docker build but BEFORE docker push, so a hygiene failure can never publish the image"
artifacts:
- path: ".gitea/workflows/publish.yml"
provides: "static image-hygiene assertion + boot-smoke steps, ordered before docker push"
contains: "boot-smoke"
key_links:
- from: ".gitea/workflows/publish.yml boot-smoke"
to: "apps/api/src/lib/bootGuards.ts (via the built image)"
via: "docker run prod image with forbidden env, assert non-zero exit"
pattern: "DEV_AUTH_BYPASS=true"
---
<objective>
Add the publish-time image-hygiene CI assertions (D-10 / IMG-03) to `.gitea/workflows/publish.yml`: a static assertion (`.dockerignore` exists + covers forbidden patterns + `--target production` still pinned) and a boot-smoke that runs the freshly-built production image with the forbidden `NODE_ENV=production DEV_AUTH_BYPASS=true` combo and asserts it refuses to boot — proving the D-08 guard fires in the ACTUAL shipped image.
Purpose: The runtime guard (16-01) and the `.dockerignore` (16-04) are only as good as their enforcement at the boundary where the image is actually published. These assertions are the CI-level proof. Critically, they must run after `docker build` (so the image exists and the smoke can run it) but BEFORE `docker push` (so a hygiene regression cannot publish a broken image). The image build only happens at publish (push-to-main), so this attaches to publish.yml, not to every PR.
Output: The modified `publish.yml`. Depends on 16-01 (the boot guard + ENV NODE_ENV=production must be in the image for the smoke to pass) and 16-04 (the .dockerignore the static assertion greps for). Does not touch ci.yml.
</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-RESEARCH.md
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
@.gitea/workflows/publish.yml
</context>
<tasks>
<task type="auto">
<name>Task 1: Restructure the Build/push step so build, assertions, and push are separable</name>
<read_first>
- .gitea/workflows/publish.yml (the "Build and push" step lines 79-92 — currently builds then pushes in one run block; the tags step lines 43-55 outputs sha_tag/latest; the Docker logout step lines 95-97)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — assertions placed BEFORE the docker push lines; set -euo pipefail; if: always() cleanup pattern)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — ordering rule: static assertions then boot-smoke BEFORE push; the existing structure runs steps sequentially)
</read_first>
<action>
In publish.yml, split the current "Build and push" step (lines 79-92) so the `docker build --target production ...` invocation is its own step ("Build production image") that builds + tags the image but does NOT push, and the two `docker push` lines move into a separate later "Push image" step (`set -euo pipefail`; push the immutable sha_tag FIRST, then latest — preserve the existing WR-04 ordering comment). Keep the `--target production`, `-f apps/api/Dockerfile`, both `-t` tags, and the root `.` context identical. Leave the Compute-tags step, Docker login, and Docker logout steps unchanged. This creates the seam where Task 2's assertions insert between build and push. Commit: `ci(16-06): split publish build and push into separate steps`.
</action>
<verify>
<automated>python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); steps=[s.get('name','') for s in d['jobs']['publish']['steps']]; assert any('Build' in n for n in steps) and any('Push' in n for n in steps), steps; print('build/push split OK:',steps)"</automated>
</verify>
<acceptance_criteria>
- publish.yml has a distinct build step (docker build, no push) and a distinct push step (docker push sha_tag then latest)
- --target production, the Dockerfile path, both tags, and the `.` context are unchanged
- The immutable-tag-first push ordering (WR-04) is preserved in the push step
</acceptance_criteria>
<done>Build and push are separate steps, creating an insertion point for the hygiene assertions.</done>
</task>
<task type="auto">
<name>Task 2: Insert static image-hygiene assertion + boot-smoke between build and push</name>
<read_first>
- .gitea/workflows/publish.yml (the build step and push step from Task 1; the tags step outputs steps.tags.outputs.sha_tag)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (publish.yml section — exact static-assertion grep loop over patterns; the boot-smoke `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE"` block with the EXIT==0 fail, EXIT==124 timeout fail, otherwise PASS logic)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (D-10 section — full static + boot-smoke step bodies; Pitfall 5: guard fires before DB/OIDC/VAPID so no env beyond the forbidden combo is needed)
- .dockerignore (16-04 — the forbidden patterns the static assertion greps for must match)
- apps/api/src/lib/bootGuards.ts (16-01 — the guard the boot-smoke proves fires in the image)
</read_first>
<action>
Insert two new steps in publish.yml AFTER the "Build production image" step and BEFORE the "Push image" step. Step A "Image hygiene — static assertions" (`set -euo pipefail`): fail if .dockerignore is absent; loop over the forbidden patterns (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests, apps/pwa/e2e) and fail if any is missing from .dockerignore; fail if `--target production` is no longer grep-able in .gitea/workflows/publish.yml; echo a PASS line. Step B "Image hygiene — boot-smoke (must refuse dev-bypass in production)" (`set -euo pipefail`): set IMAGE to ${{ steps.tags.outputs.sha_tag }}; `set +e`; `timeout 15 docker run --rm --env NODE_ENV=production --env DEV_AUTH_BYPASS=true "$IMAGE" 2>&1 | head -20`; capture EXIT; `set -e`; fail with a clear message if EXIT==0 (image started — guard not working) or EXIT==124 (timeout — guard not firing); otherwise echo PASS (image refused to start). Because both steps precede the push step and `set -euo pipefail` / non-zero exits stop the job, a failure blocks the push. Commit: `ci(16-06): add static image-hygiene assertion + boot-smoke before push`.
</action>
<verify>
<automated>python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/publish.yml')); names=[s.get('name','') for s in d['jobs']['publish']['steps']]; bi=next(i for i,n in enumerate(names) if 'Build' in n); si=next(i for i,n in enumerate(names) if 'Push' in n); seg=names[bi+1:si]; assert any('static' in n.lower() for n in seg) and any('boot-smoke' in n.lower() for n in seg), names; print('assertions between build and push OK')"</automated>
</verify>
<acceptance_criteria>
- A static-assertions step and a boot-smoke step both appear strictly between the build step and the push step
- The static assertion greps for all forbidden .dockerignore patterns AND the `--target production` pin
- The boot-smoke runs the sha_tag image with NODE_ENV=production + DEV_AUTH_BYPASS=true and fails on EXIT 0 or 124, passes otherwise
- publish.yml parses as valid YAML; the push step still runs last (after the assertions)
</acceptance_criteria>
<done>The publish job builds, then asserts hygiene + boot-smoke, then pushes — a hygiene failure blocks publish.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| built image → container registry | The push is the point of no return; once published, the image is pullable/deployable |
| Dockerfile/config drift → shipped image | A future change could re-introduce dev-bypass tolerance or strip the .dockerignore; the assertions catch that at publish |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-18 | Elevation of Privilege | A regressed production image that tolerates DEV_AUTH_BYPASS=true is published | mitigate | D-10: boot-smoke runs the built image with the forbidden combo and fails the publish unless it exits non-zero (Task 2), proving the D-08 guard fires in the shipped artifact before push |
| T-16-19 | Information Disclosure | A future change removes/weakens .dockerignore or drops --target production, shipping secrets/dev files | mitigate | D-10 static assertion fails the publish if .dockerignore is missing/incomplete or --target production is unpinned (Task 2) |
| T-16-20 | Tampering | Assertions run after push, allowing a bad image to publish before the check fails | mitigate | Ordering enforced: build → assertions → push (Tasks 1-2); push is a separate later step, so any assertion failure stops the job before push |
| T-16-21 | Denial of Service | Boot-smoke hangs if the guard does not fire, wedging the publish job | mitigate | `timeout 15` caps the smoke; EXIT==124 is treated as a guard-not-firing failure (Task 2) |
</threat_model>
<verification>
- publish.yml parses as valid YAML
- Step order: Build production image → static assertions → boot-smoke → Push image
- Static assertion patterns match the .dockerignore authored in 16-04
- Boot-smoke uses sha_tag, the forbidden env combo, timeout 15, and the EXIT 0/124 fail logic
- Live proof (an actual publish run showing the smoke PASS) is the phase-verification check after merge
</verification>
<success_criteria>
- Static + boot-smoke assertions inserted between build and push
- A hygiene/boot regression blocks the push
- Boot-smoke proves the D-08 guard fires in the real production image
- ci.yml untouched; publish topology otherwise unchanged
</success_criteria>
<output>
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-06-SUMMARY.md` when done.
</output>
@@ -0,0 +1,105 @@
---
phase: 16-ci-dependency-audit-and-security-checks
plan: "06"
subsystem: ci
tags: [ci, docker, image-hygiene, boot-smoke, security, publish]
dependency_graph:
requires: ["16-01", "16-04"]
provides: ["IMG-03"]
affects: [".gitea/workflows/publish.yml"]
tech_stack:
added: []
patterns:
- "Static assertion step: grep-based structural checks in shell before docker push"
- "Boot-smoke: docker run with forbidden env combo + timeout + exit-code semantics"
key_files:
created: []
modified:
- .gitea/workflows/publish.yml
decisions:
- "D-10 (16-06): Static assertions grep for 7 forbidden .dockerignore patterns + --target production pin; grep is substring-safe (apps/api/scripts matches apps/api/scripts/seed-credential.mjs)"
- "D-10 (16-06): Boot-smoke treats EXIT==0 and EXIT==124 as failures; any other non-zero is a PASS — covers the D-08 guard's process.exit(1) path"
- "D-10 (16-06): Both hygiene steps ordered strictly after build and before push; set -euo pipefail ensures any failure stops the job before push runs"
metrics:
duration: "1 minute"
completed: "2026-06-13"
tasks_completed: 2
files_modified: 1
requirements: [IMG-03]
---
# Phase 16 Plan 06: Image Hygiene CI Assertions Summary
**One-liner:** Publish-time CI assertions that block docker push when .dockerignore is incomplete, --target production is dropped, or the production image tolerates DEV_AUTH_BYPASS=true (D-10 / IMG-03).
## What Was Built
Two CI assertion steps added to `.gitea/workflows/publish.yml`, inserted strictly between the `Build production image` step and the `Push image` step:
**Step 1 — "Image hygiene — static assertions"** (`set -euo pipefail`):
- Fails if `.dockerignore` is absent
- Loops over 7 forbidden patterns (`.env`, `node_modules`, `apps/api/scripts`, `.git`, `.planning`, `apps/api/tests`, `apps/pwa/e2e`) and fails if any is missing from `.dockerignore`
- Fails if `--target production` is no longer grep-able in `publish.yml` itself
- Catches config drift that would ship secrets or dev files (T-16-19)
**Step 2 — "Image hygiene — boot-smoke (must refuse dev-bypass in production)"** (`set -euo pipefail`):
- Runs the freshly-built `sha_tag` image with `NODE_ENV=production DEV_AUTH_BYPASS=true`
- `timeout 15` caps the run (T-16-21: prevents the job hanging if the guard does not fire)
- EXIT==0 → image started → guard NOT working → FAIL
- EXIT==124 → timeout → guard not firing → FAIL
- Any other non-zero exit → image refused boot → PASS
- Proves `assertNotDevBypassInProduction()` (16-01 / D-08) fires in the actual shipped image (T-16-18)
The step order in the publish job is now:
1. Checkout
2. Compute image tags
3. Docker login
4. Build production image
5. Image hygiene — static assertions ← new
6. Image hygiene — boot-smoke ← new
7. Push image
8. Docker logout (if: always())
## Task Commits
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Split publish build and push into separate steps | bc00f3e | .gitea/workflows/publish.yml |
| 2 | Add static image-hygiene assertion + boot-smoke before push | 27046db | .gitea/workflows/publish.yml |
## Deviations from Plan
None — plan executed exactly as written.
## Verification
- YAML parses cleanly (python3 yaml.safe_load confirmed)
- Step ordering verified: assertions at indices 4-5, push at index 6 (between Build at 3 and Push at 6)
- All 7 forbidden .dockerignore patterns confirmed present in .dockerignore
- `--target production` grep confirmed to match within publish.yml
- Dockerfile `ENV NODE_ENV=production` confirmed in production stage (line 45) — precondition for the smoke to be meaningful
- `bootGuards.ts` `process.exit(1)` confirmed fires when `NODE_ENV=production AND DEV_AUTH_BYPASS=true` — that is what the smoke exercises
## Threat Coverage
| Threat ID | Description | Mitigated By |
|-----------|-------------|--------------|
| T-16-18 | Regressed image that tolerates DEV_AUTH_BYPASS=true published | Boot-smoke step: fails push if image starts under forbidden combo |
| T-16-19 | Future change removes .dockerignore or drops --target production | Static assertions step: fails push on any missing pattern or pin |
| T-16-20 | Assertions run after push (bad image already published) | Ordering: both steps are before Push image step |
| T-16-21 | Boot-smoke hangs if guard does not fire | `timeout 15`; EXIT==124 treated as failure |
## Known Stubs
None.
## Self-Check
Files modified:
- `.gitea/workflows/publish.yml` — modified (confirmed by git log)
Commits:
- `bc00f3e` — ci(16-06): split publish build and push into separate steps
- `27046db` — ci(16-06): add static image-hygiene assertion + boot-smoke before push
## Self-Check: PASSED
@@ -0,0 +1,124 @@
# Phase 16: CI Dependency Audit, Security Checks & Image Hygiene - Context
**Gathered:** 2026-06-12
**Status:** Ready for planning
<domain>
## Phase Boundary
Extend the **existing** Gitea CI with three families of gates — implemented as additions to the current `.gitea/workflows/ci.yml` (PR workflow: `changes → fast-checks / api / harness → gate`) and `.gitea/workflows/publish.yml` (push-to-main publish, builds `--target production`). **Not** a new pipeline, **not** a new runtime dependency, **not** a new external service.
1. **Dependency audit**`pnpm audit` against the lockfile (fail on High+Critical) + a `pnpm outdated` advisory report.
2. **Security checks baseline** — secret scanning (gitleaks) + a static security lint (eslint-plugin-security folded into the existing ESLint gate).
3. **Dev↔prod image hygiene (absorbs backlog 999.17)** — provably confine `DEV_AUTH_BYPASS` and any dev affordance/secret/seed data to local dev; the published production image must never carry them and must refuse to run with dev-bypass enabled.
**Explicitly out of scope:** automated dependency *upgrade* bots (Renovate/Dependabot), Trivy/image CVE scanning, removing dev-bypass (still needed for local + Phase 7/8 harness).
</domain>
<decisions>
## Implementation Decisions
### Security-Check Baseline
- **D-01:** Baseline = **secret scanning + static security lint**. Trivy/image CVE scanning is **dropped** — not even backlogged; revisit only if a future need arises.
- **D-02:** **Secret scanning via gitleaks** (tool choice researcher may confirm vs trufflehog). Scope = **per-PR diff (blocking) + a one-time full-history/full-tree baseline scan** to catch anything already committed. The app handles real family credentials (encryption key, OIDC secret, Fastmail app passwords), so secret scanning is core.
- **D-03:** **eslint-plugin-security folded into the existing Phase 13 ESLint gate, as blocking errors** (not warnings). Accepted consequence: the plugin is heuristic/noisy (e.g. `detect-object-injection`); the executor must triage existing code — add targeted `eslint-disable` with justification or rule-tune — to get the gate green. This is expected work, not a blocker.
### Dependency Audit & Outdated
- **D-04:** `pnpm audit` **fails the build on High + Critical**; moderate/low are advisory only.
- **D-05:** Unfixable/transitive advisories are waived via a **committed allowlist file in the repo** — advisory IDs (CVE/GHSA) each with a reason + reviewer, reviewed through PR. A wrapper filters `pnpm audit` output against it (or pnpm's native `auditConfig.ignore*` if the researcher finds it cleaner — but keep it auditable and self-documenting, not silent).
- **D-06:** Outdated reporting **runs and never gates** (respects the deliberate exact-version pins in CLAUDE.md). Exact balance is an **open research question** — see OQ-01.
### Dev/Prod Image Hygiene (999.17)
- **D-07:** **Bake `ENV NODE_ENV=production` into the production Dockerfile stage.** Today the `production` stage sets no `NODE_ENV` and `CMD` runs `node dist/index.js` with no env baked in, so `devBypass.ts`'s "hard guard" (`NODE_ENV==='production'` checked first) is **not actually engaged** in the shipped image — it's only safe because the second check (`DEV_AUTH_BYPASS !== 'true'`) passes through when unset. Baking `NODE_ENV=production` engages the hard guard.
- **D-08:** Add a **boot-time refuse-to-boot guard**: on startup, if `NODE_ENV==='production'` AND `DEV_AUTH_BYPASS==='true'`, **throw and exit non-zero** instead of silently no-op'ing. Unit-tested. (This is defense-in-depth on top of D-07 — turns a silent misconfig into a loud failure.)
- **D-09:** Create a **full `.dockerignore`** (none exists today — the whole repo root is currently sent to the Docker daemon as build context). Scope = **secrets + dev + bulk**: `.env` / `.env.*` (incl. `apps/api/.env.spike`), `apps/api/scripts/seed-credential.mjs`, `.git`, `node_modules`, `dist`, `test`/`tests`, `e2e`, `.planning`, `*.sql`/dumps, Playwright artifacts (researcher enumerates the exact list against the current tree).
- **D-10:** **CI assertion = static + boot-smoke.** Static: assert `.dockerignore` exists & covers the forbidden patterns, and `publish.yml` still pins `--target production`. Boot-smoke: start the built production image with `NODE_ENV=production` + `DEV_AUTH_BYPASS=true` and assert it **refuses to boot** (exits non-zero, proving D-08 in the actual image). Full filesystem forensics deemed unnecessary — the `production` stage already copies only `apps/api/dist` + `apps/pwa/dist`.
### Gating & Noise Posture
- **D-11:** Blocking vs advisory split — **block:** gitleaks (secret found), eslint-plugin-security, `pnpm audit` High+Critical, image-hygiene boot-smoke + static checks. **Advisory (never gates):** `pnpm outdated`.
- **D-12:** **Doc-only PR behavior:** gitleaks **runs on every PR** including doc-only (a secret can land in a doc/config). `pnpm audit` + `pnpm outdated` are **gated behind the code/lockfile `changes` filter** like `api`/`harness` (mirrors Phase 15's doc-only-skip model).
- **D-13:** **Advisory results surface in the job log only** — no PR comment / Gitea API wiring. Gitea doesn't render GitHub-style annotations (see `ci.yml` Pitfall 5 / D-06: reporter `github` is overridden). Blocking checks surface via failed status + the `gate` aggregate.
- **D-14:** Any new **blocking** job must be wired into the `gate` aggregator (`if: always()`, individual `needs.X.result` checks per the Gitea 1.26.2 wildcard bug #31007) and, if it becomes a required context, into branch protection on `main`.
### Claude's Discretion / Researcher Decides
- **Job decomposition** (D-15): how the new PR-time checks (secret scan, audit, outdated) are laid out in `ci.yml` — a dedicated parallel `security` job vs folding into `fast-checks` — is **left to the researcher/planner** against runner constraints (no `actions/cache` — times out; ~30s install per job). Recommendation surfaced in discussion: a new `security` job parallel to `fast-checks` keeps the critical path fast and isolates advisory churn; eslint-plugin-security folds into the existing lint step regardless.
- Exact secret-scan tool (gitleaks vs trufflehog) and exact `.dockerignore` line list — researcher confirms.
### Folded Todos
None folded. (See Deferred — the one matched todo was already delivered in Phase 8.)
</decisions>
<open_questions>
## Open Research Questions
- **OQ-01 (outdated-vs-pins balance):** Design a pragmatic `pnpm outdated` reporting policy that **respects the intentional exact-version pins** in CLAUDE.md but still **surfaces when a pin is a liability** — e.g. the pinned version is multiple major versions behind latest, or the pinned version itself carries a known advisory. The user's words: "Version pins are fine but if there's an issue with them or if they are too far behind there should be a balance here." Output should be a concrete, advisory-only mechanism (what's reported, how a "dangerously behind" pin is flagged distinctly from routine drift). Never gates the build (D-06).
</open_questions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Existing CI to extend
- `.gitea/workflows/ci.yml` — PR workflow being extended. Jobs: `changes` (dorny/paths-filter), `fast-checks` (lint/format/md-lint/typecheck/pwa-unit), `api` (DB-backed, MariaDB service container), `harness` (Playwright iphone+pixel+desktop), `gate` (`if: always()` aggregator). Documents runner pitfalls: no `actions/cache`, no `mysql` CLI, `localhost``::1` vs IPv4, Gitea annotation non-rendering, wildcard-needs bug #31007.
- `.gitea/workflows/publish.yml` — push-to-main publish. Builds `docker build --target production -f apps/api/Dockerfile .`; tags `:latest` + `:<MILESTONE>-<shortsha>`; `REGISTRY_PAT` secret (Gitea forbids `GITEA_` prefix). This is where the image-hygiene boot-smoke + static assertion attach.
### Image hygiene (999.17)
- `apps/api/Dockerfile` — multi-stage: `base`/`builder`/`dev`/`pwa-builder`/`production`. `production` copies only `apps/api/dist` + `apps/pwa/dist`; **no `ENV NODE_ENV`** (the gap D-07 fixes). `dev` target shares the file.
- `apps/api/src/auth/devBypass.ts` — the dev-bypass middleware + `DEV_USER` (id=1). Hard guard checks `NODE_ENV==='production'` first; boot-time refuse-to-boot (D-08) extends this.
- (no `.dockerignore` exists yet — D-09 creates it)
### Constraints / precedent
- `CLAUDE.md` — pins exact dependency versions intentionally (drives D-06 / OQ-01); Technology Stack + Version Compatibility tables.
- `.planning/phases/15-doc-only-ci-skip-and-md-lint/15-CONTEXT.md` — gate-aggregation + doc-only-skip noise-control precedent (D-12, D-14 mirror it).
- `.planning/phases/08-gitea-ci/08-CONTEXT.md` — original CI design decisions + runner-constraint probes (D-PROBE-*).
### Memory (operator-confirmed gotchas)
- Gitea CI runner gotchas: `ubuntu-latest` label, no `actions/cache`, no `mysql` CLI, `GITEA_` secret prefix forbidden (use `REGISTRY_PAT`), `act` reaps backgrounded procs at step boundary.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- **`gate` job pattern** (`ci.yml`) — new blocking jobs plug into its per-`needs.X.result` aggregation; copy the success/skipped tolerance logic.
- **`changes`/paths-filter** (`ci.yml`) — reuse `needs.changes.outputs.code` to gate audit/outdated on code/lockfile changes (D-12).
- **Existing ESLint gate** (Phase 13, run in `fast-checks` `pnpm lint`) — eslint-plugin-security plugs into the same config/step (D-03).
- **Phase 8 inline-Node-via-mysql2 pattern** — precedent for runner steps without extra CLIs (no `mysql`, no extra binaries assumed available).
### Established Patterns
- **Image build only at publish** (`publish.yml`, push-to-main). The boot-smoke (D-10) builds/runs the image where it's already built — at publish — rather than adding a full image build to every PR.
- **Throwaway CI creds** scoped to ephemeral service containers — never reuse for any new secret-handling step.
- **Job-log-only result surfacing** — Gitea annotation non-rendering already forced `--reporter=list,html` over `github`; advisory output follows the same constraint (D-13).
### Integration Points
- New `security` (or folded) checks → `ci.yml` jobs + `gate` aggregator + possibly branch-protection required contexts.
- Boot-time guard → `apps/api` startup path (alongside/within `devBypass.ts` usage in `index.ts`) + a unit test.
- `.dockerignore` → repo root; `ENV NODE_ENV=production``production` stage of `apps/api/Dockerfile`.
- Boot-smoke + static assertion → `publish.yml` (post-build, pre/around push).
</code_context>
<specifics>
## Specific Ideas
- gitleaks preferred for secret scanning (single binary, easy on a self-hosted runner); per-PR diff + one-time full-history baseline.
- eslint-plugin-security must be **blocking** even though it's noisy — the user explicitly chose `error` over `warn`.
- The `NODE_ENV` gap in the production image was the concrete "aha" of this discussion — fixing it (D-07) is the highest-leverage, lowest-cost hardening.
- Keep the whole phase additive to existing CI — no rewrite of `ci.yml`/`publish.yml` structure.
</specifics>
<deferred>
## Deferred Ideas
- **Renovate / Dependabot automated dependency upgrades** — out of scope; detection/enforcement only this phase. Self-hosted Renovate on Gitea is its own setup + interplay with the pin strategy. Candidate for a future phase/backlog.
- **Trivy / image CVE scanning** — dropped, not backlogged per the user; reconsider only if a concrete need arises (base-image `node:22-alpine` CVE exposure).
- **PR-comment surfacing of advisory results** (Gitea API) — deferred in favor of job-log-only (D-13); revisit if visibility proves insufficient.
### Reviewed Todos (not folded)
- `2026-06-10-gitea-ci-regression-and-docker-publish.md` ("Gitea CI — full regression on PR to main + build/publish Docker image") — matched on keywords but **already delivered in Phase 8** (CI-01/CI-02). Not in Phase 16 scope; this is a stale pending-todo that should be archived.
</deferred>
---
*Phase: 16-ci-dependency-audit-and-security-checks*
*Context gathered: 2026-06-12*
@@ -0,0 +1,160 @@
# Phase 16: CI Dependency Audit, Security Checks & Image Hygiene - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-06-12
**Phase:** 16-ci-dependency-audit-and-security-checks
**Areas discussed:** Security-check baseline, Audit + outdated policy, Image-hygiene enforcement (999.17), Gating & noise posture
---
## Security-Check Baseline
### Which checks form the baseline (multiSelect)
| Option | Description | Selected |
|--------|-------------|----------|
| Secret scan on diff | gitleaks/trufflehog scans the PR diff for committed secrets | ✓ |
| Static security lint | eslint-plugin-security or CodeQL | ✓ |
| Trivy image scan | scan built production image for OS/package CVEs | ✗ (later dropped) |
| Dependency-review action | GitHub's PR action — depends on GitHub API, not on Gitea | |
**User's choice:** Secret scan + static security lint + (initially) trivy — then dropped trivy in the follow-up.
### Secret-scan scope
| Option | Description | Selected |
|--------|-------------|----------|
| Diff + one full-history scan | per-PR diff + a one-time full-history baseline | ✓ |
| PR diff only | only changed commits | |
| Full tree every run | whole repo every PR | |
### Trivy gate
| Option | Description | Selected |
|--------|-------------|----------|
| Publish-time, advisory | scan image, never block | |
| Publish-time, block on CRITICAL | fail publish on CRITICAL | |
| Per-PR (build image in PR) | earliest feedback | |
**User's choice:** "Drop Trivy for now. I don't want it in the backlog, but it can be some future thing if we need it." → Trivy removed from scope entirely.
### Static security lint severity
| Option | Description | Selected |
|--------|-------------|----------|
| Warn (advisory) | surfaced but non-blocking | |
| Error (blocking) | fails the lint gate | ✓ |
**Notes:** User accepts that blocking eslint-plugin-security will require triaging/disabling existing heuristic findings to reach green.
---
## Audit + Outdated Policy
### pnpm audit severity threshold
| Option | Description | Selected |
|--------|-------------|----------|
| High + Critical | fail on high/critical | ✓ |
| Critical only | fail only on critical | |
| Moderate+ | fail on moderate and above | |
### Waiver mechanism
| Option | Description | Selected |
|--------|-------------|----------|
| Allowlist file in repo | committed advisory-ID list + reason + reviewer | ✓ |
| pnpm overrides / config | auditConfig.ignore* in package.json | |
| No waiver mechanism yet | deal with it if/when it blocks | |
### Outdated reporting vs intentional pins
| Option | Description | Selected |
|--------|-------------|----------|
| Advisory PR comment, never gates | pnpm outdated -r as PR comment | |
| Advisory, job-log only | print to job log | |
| Skip outdated entirely | rely on audit only | |
**User's choice:** Deferred to researcher (OQ-01). "Version pins are fine but if there's an issue with them or if they are too far behind there should be a balance here." Outcome locked: advisory, never gates; researcher designs the "dangerously behind / pinned-version-has-advisory" flagging.
---
## Image-Hygiene Enforcement (999.17)
### Enforcement mechanism (multiSelect)
| Option | Description | Selected |
|--------|-------------|----------|
| Bake NODE_ENV=production into image | engages devBypass hard guard in shipped image | ✓ |
| Boot-time refuse-to-boot | throw + non-zero exit on prod + dev-bypass | ✓ |
| Build-time abort | fail build/publish on dev target/arg | |
**Notes:** publish.yml already pins `--target production`; the static CI assertion covers "stays that way."
### CI assertion depth
| Option | Description | Selected |
|--------|-------------|----------|
| Static + boot smoke | .dockerignore + --target assertion + run image with dangerous combo, assert refuses to boot | ✓ |
| Full filesystem forensics | export image fs, grep for secrets/seed/.git | |
| Static checks only | no container built/run | |
### .dockerignore scope
| Option | Description | Selected |
|--------|-------------|----------|
| Secrets + dev + bulk | .env*, seed-credential.mjs, .git, node_modules, dist, tests, e2e, .planning, *.sql, playwright artifacts | ✓ |
| Secrets-only minimal | only secret/seed/data files | |
| Researcher proposes the list | capture intent, enumerate later | |
---
## Gating & Noise Posture
### Job layout
| Option | Description | Selected |
|--------|-------------|----------|
| New 'security' job, parallel | gitleaks+audit+outdated parallel to fast-checks | |
| Fold into fast-checks | steps in existing job | |
| Researcher decides layout | pick against runner constraints | ✓ |
**Notes:** Recommendation surfaced (dedicated parallel `security` job) but final decomposition left to researcher/planner.
### Doc-only PR behavior
| Option | Description | Selected |
|--------|-------------|----------|
| Secret scan always; audit/outdated code-only | gitleaks universal, audit/outdated behind changes filter | ✓ |
| All new checks code-only | whole security job skips doc-only | |
| All new checks always run | run on every PR | |
### Result surfacing
| Option | Description | Selected |
|--------|-------------|----------|
| Job-log summary only | advisory output to job log | ✓ |
| PR comment via Gitea API | step posts/updates a PR comment | |
### Renovate / Dependabot
| Option | Description | Selected |
|--------|-------------|----------|
| Defer | out of scope; capture as deferred | ✓ |
| In scope | add upgrade-bot config this phase | |
---
## Claude's Discretion
- Job decomposition for the new PR-time checks (D-15) — researcher/planner.
- Exact secret-scan tool (gitleaks vs trufflehog) and exact `.dockerignore` line list — researcher confirms.
## Deferred Ideas
- Renovate / Dependabot automated dependency upgrades — future phase/backlog.
- Trivy / image CVE scanning — dropped, not backlogged (revisit only if needed).
- PR-comment surfacing of advisory results — deferred in favor of job-log-only.
- Stale pending todo `2026-06-10-gitea-ci-regression-and-docker-publish.md` — already delivered in Phase 8; should be archived.
@@ -0,0 +1,569 @@
# Phase 16: CI Dependency Audit, Security Checks & Image Hygiene — Pattern Map
**Mapped:** 2026-06-13
**Files analyzed:** 11
**Analogs found:** 10 / 11
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `.gitea/workflows/ci.yml` | CI workflow | event-driven | self (existing jobs in same file) | exact |
| `.gitea/workflows/publish.yml` | CI workflow | event-driven | self (existing build/push steps) | exact |
| `apps/api/Dockerfile` | config | build-time | self (existing `base`/`dev` stage ENV/WORKDIR lines) | exact |
| `apps/api/src/index.ts` | startup / guard | request-response | `apps/api/src/auth/devBypass.ts` (existing hard guard) | exact |
| `apps/api/src/lib/bootGuards.ts` | utility | — | `apps/api/src/auth/devBypass.ts` | role-match |
| `apps/api/tests/lib/bootGuards.test.ts` | test | — | `apps/api/tests/auth/devBypass.test.ts` | exact |
| `.dockerignore` | config | build-time | `.gitignore` (root) | role-match |
| `.gitleaks.toml` | config | — | root config files (`.prettierrc`, `.markdownlint-cli2.jsonc`) | partial |
| `scripts/check-audit.mjs` | utility script | batch | none in repo | no analog |
| `scripts/check-outdated.mjs` | utility script | batch | none in repo | no analog |
| `scripts/audit-allowlist.json` | config | — | none in repo | no analog |
| `scripts/outdated-pins.json` | config | — | none in repo | no analog |
| `eslint.config.js` | config | — | self (existing flat config) | exact |
---
## Pattern Assignments
### `.gitea/workflows/ci.yml` — add `security` job + update `gate`
**Analog:** The existing jobs in the same file.
**Job skeleton pattern** — how every job starts (lines 3354, `fast-checks`):
```yaml
fast-checks:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Enable pnpm
run: corepack enable pnpm
# actions/cache@v4 is intentionally omitted — probe (D-PROBE-04) showed it
# times out on this runner.
- name: Install dependencies
run: pnpm install --frozen-lockfile
```
**Conditional job pattern**`needs: [changes]` + `if:` code-gated (lines 6872, `api`):
```yaml
api:
runs-on: ubuntu-latest
needs: [changes]
if: github.event_name == 'pull_request' && needs.changes.outputs.code == 'true'
```
**`gate` aggregator pattern — individual `needs.X.result` checks** (lines 345367):
```yaml
gate:
runs-on: ubuntu-latest
needs: [fast-checks, changes, api, harness]
if: always()
steps:
- name: Check all required jobs passed or were skipped
run: |
# fast-checks always runs — must be success
if [ "${{ needs.fast-checks.result }}" != "success" ]; then
echo "fast-checks: ${{ needs.fast-checks.result }}"
exit 1
fi
# api and harness are conditionally skipped — success OR skipped are both acceptable
# NOTE: uses individual needs.X.result checks (not the wildcard aggregate) due to
# Gitea 1.26.2 bug #31007 where the wildcard expression returns false even when jobs succeed.
for result in "${{ needs.api.result }}" "${{ needs.harness.result }}"; do
if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then
echo "Heavy job failed or was cancelled: $result"
exit 1
fi
done
echo "Gate passed."
```
**New `security` job pattern** — parallel to `fast-checks`, always runs gitleaks, conditionally runs pnpm steps:
```yaml
security:
runs-on: ubuntu-latest
needs: [changes]
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for gitleaks git log-opts range — base.sha must be local
# ── Gitleaks (always runs per D-12) ─────────────────────────
- name: Install gitleaks
run: |
set -euo pipefail
VERSION=8.30.1
curl -sL \
"https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
| tar -xz gitleaks
chmod +x gitleaks
mv gitleaks /usr/local/bin/gitleaks
- name: Secret scan (PR diff, blocking)
run: |
set -euo pipefail
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
gitleaks git \
--log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" \
--config .gitleaks.toml \
--baseline-path scripts/gitleaks-baseline.json \
--report-path /tmp/gitleaks-pr-report.json \
--exit-code 1
# ── pnpm audit + outdated (code-change PRs only per D-12) ───
- uses: actions/setup-node@v4
if: needs.changes.outputs.code == 'true'
with:
node-version: '22'
- name: Enable pnpm
if: needs.changes.outputs.code == 'true'
run: corepack enable pnpm
- name: Install dependencies
if: needs.changes.outputs.code == 'true'
run: pnpm install --frozen-lockfile
- name: Dependency audit (blocking on High+Critical)
if: needs.changes.outputs.code == 'true'
run: node scripts/check-audit.mjs
- name: Dependency outdated report (advisory only)
if: needs.changes.outputs.code == 'true'
run: node scripts/check-outdated.mjs
# Always exits 0 — log output only, never gates (D-06)
```
**Updated `gate` needs list and security check to add:**
```yaml
gate:
needs: [fast-checks, changes, api, harness, security] # security added
...
# security always runs — must be success (gitleaks always fires)
if [ "${{ needs.security.result }}" != "success" ]; then
echo "security: ${{ needs.security.result }}"
exit 1
fi
```
**Step-level `set -euo pipefail` pattern** — all multi-line `run:` blocks in the file use this as the first line. Follow the same convention for all new steps.
---
### `.gitea/workflows/publish.yml` — add static assertions + boot-smoke
**Analog:** Existing steps in the same file.
**Inline shell step with `set -euo pipefail`** (lines 8093):
```yaml
- name: Build and push
run: |
set -euo pipefail
docker build --target production \
-f apps/api/Dockerfile \
-t ${{ steps.tags.outputs.latest }} \
-t ${{ steps.tags.outputs.sha_tag }} \
.
docker push ${{ steps.tags.outputs.sha_tag }}
docker push ${{ steps.tags.outputs.latest }}
```
**`if: always()` pattern for cleanup** (lines 9597):
```yaml
- name: Docker logout
if: always()
run: docker logout git.bergerhouse.net || true
```
**New assertions placed BEFORE the `docker push` lines** (placement rule from RESEARCH D-10):
```yaml
- name: Image hygiene — static assertions
run: |
set -euo pipefail
if [ ! -f ".dockerignore" ]; then
echo "FAIL: .dockerignore does not exist"
exit 1
fi
for pattern in ".env" "node_modules" "apps/api/scripts" ".git" ".planning" "apps/api/tests" "apps/pwa/e2e"; do
if ! grep -q "$pattern" .dockerignore; then
echo "FAIL: .dockerignore missing pattern: $pattern"
exit 1
fi
done
if ! grep -q "\-\-target production" .gitea/workflows/publish.yml; then
echo "FAIL: publish.yml does not build --target production"
exit 1
fi
echo "Static image hygiene assertions PASSED."
- name: Image hygiene — boot-smoke (must refuse dev-bypass in production)
run: |
set -euo pipefail
IMAGE="${{ steps.tags.outputs.sha_tag }}"
set +e
timeout 15 docker run --rm \
--env NODE_ENV=production \
--env DEV_AUTH_BYPASS=true \
"$IMAGE" \
2>&1 | head -20
EXIT=$?
set -e
if [ "$EXIT" -eq 0 ]; then
echo "FAIL: Production image started successfully with DEV_AUTH_BYPASS=true — guard not working"
exit 1
fi
if [ "$EXIT" -eq 124 ]; then
echo "FAIL: Production image did not exit within 15s — guard not firing"
exit 1
fi
echo "PASS: Production image refused to start with DEV_AUTH_BYPASS=true (exit $EXIT)"
```
---
### `apps/api/Dockerfile` — add `ENV NODE_ENV=production` in production stage
**Analog:** Existing ENV/CMD/WORKDIR conventions within the same file.
**Existing `dev` stage pattern** (lines 1922) — shows WORKDIR + CMD:
```dockerfile
FROM base AS dev
WORKDIR /app/apps/api
COPY --from=builder /app /app
CMD ["node", "--watch", "dist/index.js"]
```
**Existing `production` stage** (lines 3546) — the gap to fix:
```dockerfile
FROM base AS production
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/pwa/package.json ./apps/pwa/
RUN pnpm install --frozen-lockfile --prod --filter @familysync/api...
COPY --from=builder /app/apps/api/dist ./apps/api/dist
WORKDIR /app/apps/api
COPY --from=pwa-builder /app/apps/pwa/dist ./public
CMD ["node", "dist/index.js"]
```
**Add after `WORKDIR /app/apps/api`, before `COPY --from=pwa-builder`:**
```dockerfile
# Enforce production identity — engages the NODE_ENV=production hard guard
# in devBypass.ts, preventing dev-bypass activation even if DEV_AUTH_BYPASS
# is accidentally set in the container environment. (D-07)
ENV NODE_ENV=production
```
---
### `apps/api/src/lib/bootGuards.ts` — exported guard function
**Analog:** `apps/api/src/auth/devBypass.ts` — same pattern of evaluating env vars at call time, exporting a pure function with a JSDoc comment block.
**Function export pattern** (devBypass.ts lines 5876):
```typescript
/**
* Returns a Hono MiddlewareHandler ...
*
* The function evaluates env vars at call time (when the app starts), not at request time.
*/
export function devAuthBypass(): MiddlewareHandler {
// Hard production guard — FIRST check, before reading any other env var.
if (process.env.NODE_ENV === 'production') {
return async (_c, next) => next();
}
...
}
```
**New `bootGuards.ts` pattern to follow:**
```typescript
/**
* Boot-time production safety guards (D-08).
*
* Exported as a standalone function so it can be unit-tested without
* forking a process or importing the full app module graph.
*
* Call assertNotDevBypassInProduction() as the FIRST statement inside
* the isMainModule() block in index.ts, before VAPID config, workers,
* or serve().
*/
export function assertNotDevBypassInProduction(): void {
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);
}
}
```
---
### `apps/api/src/index.ts` — add boot guard call
**Analog:** Existing `isMainModule()` guard block (lines 112147) and devBypassActive comment pattern (lines 2329).
**Placement rule** — first statement inside `if (isMainModule())` before any other startup code:
```typescript
if (isMainModule()) {
// D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve().
assertNotDevBypassInProduction();
// Configure VAPID credentials for web-push before starting background workers.
const vapidSubject = process.env.VAPID_SUBJECT ?? '';
// ...existing startup code unchanged...
}
```
**Import to add** (follows existing import block pattern, lines 118):
```typescript
import { assertNotDevBypassInProduction } from './lib/bootGuards.js';
```
---
### `apps/api/tests/lib/bootGuards.test.ts` — unit test
**Analog:** `apps/api/tests/auth/devBypass.test.ts` — exact same role, same test framework, same env manipulation pattern.
**Test file structure pattern** (devBypass.test.ts lines 130):
```typescript
/**
* [description of what is tested] — unit tests.
*
* Tests the [N] behavioral cases:
* 1. ...
*/
import { describe, it, expect, afterEach } from 'vitest';
// Import the module under test (not Hono app — pure function test)
describe('[function name]', () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalBypassFlag = process.env.DEV_AUTH_BYPASS;
afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
if (originalBypassFlag === undefined) {
delete process.env.DEV_AUTH_BYPASS;
} else {
process.env.DEV_AUTH_BYPASS = originalBypassFlag;
}
});
it('...description...', async () => {
process.env.NODE_ENV = 'production';
process.env.DEV_AUTH_BYPASS = 'true';
// ...
});
});
```
**Key difference for bootGuards test:** Use `vi.spyOn(process, 'exit').mockImplementation(...)` and `vi.stubEnv` from vitest instead of manual env manipulation, since `assertNotDevBypassInProduction()` calls `process.exit(1)` directly. Import `vi` from vitest.
**Test cases required:**
1. `NODE_ENV=production` + `DEV_AUTH_BYPASS=true` → calls `process.exit(1)`
2. `NODE_ENV=development` + `DEV_AUTH_BYPASS=true` → does NOT call `process.exit`
3. `NODE_ENV=production` + `DEV_AUTH_BYPASS` unset → does NOT call `process.exit`
---
### `.dockerignore` — new root-level file
**Analog:** `.gitignore` at repo root for pattern style and comment conventions.
**`.gitignore` comment/section style** (lines 130):
```gitignore
# Dependencies
node_modules/
# Build output
dist/
.dist/
# Environment — NEVER commit secrets at rest ...
.env
.env.*
!.env.example
```
**Follow the same section-header comment style.** Refer to the full recommended content in RESEARCH.md (the `.dockerignore` section) — it is already fully specified there. Key sections: Secrets, VCS, Build artifacts, Dependencies, Tests, Playwright artifacts, Planning/docs, Editor/OS, CI config files, SQL dumps.
---
### `.gitleaks.toml` — new root-level config file
**Analog:** No close analog in the repo. Root-level TOML config files follow a "title + sections" structure. The repo has `.markdownlint-cli2.jsonc` as a comparable root config (different format).
**Pattern:** Follow the content exactly as specified in RESEARCH.md — the full `.gitleaks.toml` content is pre-authored there. Key structural rules:
- `title = "..."` at the top
- `[extend] useDefault = true` to inherit built-in ruleset
- `[[allowlists]]` blocks with `description` + `paths` fields for known-safe false-positive files
---
### `eslint.config.js` — add `eslint-plugin-security`
**Analog:** Itself — the existing flat config is the pattern to extend.
**Existing plugin registration pattern** (lines 712, imports + `tseslint.config()` wrapper):
```javascript
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactPlugin from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier/flat';
export default tseslint.config(
```
**Existing config block with `files:` + `extends:` + `rules:` pattern** (lines 2743):
```javascript
{
files: ['apps/**/*.{ts,tsx}'],
extends: [js.configs.recommended, tseslint.configs.recommendedTypeChecked],
languageOptions: { ... },
rules: {
'@typescript-eslint/no-unused-vars': ['error', { ... }],
},
},
```
**New block to insert BEFORE `prettierConfig` (section 5, which MUST remain last):**
```javascript
import pluginSecurity from 'eslint-plugin-security';
// ... inside tseslint.config(...):
// ── N. eslint-plugin-security: blocking errors per D-03 ──────────────────
// Applied to all TS/TSX in both apps. detect-object-injection disabled globally
// due to very high false-positive rate on Drizzle ORM bracket access patterns;
// real risk sites carry inline eslint-disable with justification comment.
{
files: ['apps/**/*.{ts,tsx}'],
...pluginSecurity.configs.recommended,
rules: {
...pluginSecurity.configs.recommended.rules,
'security/detect-object-injection': 'off', // High FP rate; Drizzle + TS generics — see triage notes
},
},
prettierConfig, // MUST remain last
```
---
### `scripts/check-audit.mjs` — new Node.js wrapper script
**Analog:** No existing script analog. Pattern is a standalone ESM Node.js script using `node:child_process` and `node:fs` built-ins.
**Conventions to follow from RESEARCH.md:**
- Use `import { execSync } from 'node:child_process'` and `import { readFileSync } from 'node:fs'` (node: prefix protocol)
- Run `pnpm audit --json` without `--audit-level` (captures all severities in JSON)
- Filter `audit.advisories` by `severity` in code
- Cross-check against `scripts/audit-allowlist.json` by `github_advisory_id`
- Exit 1 on unwaived High+Critical; exit 0 on all waived or no findings
- Print advisory-only findings (moderate/low) to stdout before exiting 0
---
### `scripts/check-outdated.mjs` — new Node.js wrapper script
**Analog:** No existing script analog.
**Conventions to follow from RESEARCH.md:**
- Run `pnpm outdated --format json -r` and parse JSON
- Read `scripts/outdated-pins.json` for known-intentional pin explanations
- Classify each entry: AUDIT-ADVISORY / MAJOR-BEHIND / INTENTIONAL-PIN / ROUTINE-DRIFT
- Always exits 0 — advisory-only (D-06)
- Cross-check `pnpm audit --json` output to flag pinned versions with active advisories
---
### `scripts/audit-allowlist.json` and `scripts/outdated-pins.json` — new JSON config files
**Analog:** No existing analog.
**`audit-allowlist.json` format:**
```json
{
"GHSA-xxxx-xxxx-xxxx": {
"reason": "...",
"reviewer": "luc",
"expires": "YYYY-MM-DD"
}
}
```
Must include the pre-existing `GHSA-gv7w-rqvm-qjhr` (esbuild High, transitive through drizzle-kit/vitest/vite — dev-only) as the initial entry.
**`outdated-pins.json` format:**
```json
{
"package-name": "Human-readable reason for the intentional pin."
}
```
Initial entries: `eslint`, `@eslint/js`, `zod`, `@types/node` (all with reasons matching RESEARCH.md).
---
## Shared Patterns
### `set -euo pipefail` in all shell steps
**Source:** `.gitea/workflows/ci.yml` — every multi-line `run:` block starts with this.
**Apply to:** Every new `run: |` block in both `ci.yml` and `publish.yml`.
### No `actions/cache`
**Source:** `.gitea/workflows/ci.yml` line 4648 comment.
**Apply to:** The new `security` job — do NOT add `actions/cache@v4`. The ~30s pnpm install + ~5s gitleaks download are acceptable per D-PROBE-04.
### Individual `needs.X.result` checks in `gate` (not wildcards)
**Source:** `.gitea/workflows/ci.yml` lines 358365, comment referencing Gitea bug #31007.
**Apply to:** The updated `gate` aggregator — add `needs.security.result` as a separate individual check, not folded into the `for result in ...` loop (security must always succeed, not "success OR skipped").
### `node:` prefix for built-in imports in scripts
**Source:** `apps/api/src/index.ts` lines 12: `import { fileURLToPath } from 'node:url'`, `import { realpathSync } from 'node:fs'`.
**Apply to:** `scripts/check-audit.mjs` and `scripts/check-outdated.mjs`.
### JSDoc comment block on exported functions
**Source:** `apps/api/src/auth/devBypass.ts` lines 125 (file-level) and 4957 (function-level).
**Apply to:** `apps/api/src/lib/bootGuards.ts` — the exported `assertNotDevBypassInProduction()` function must have a JSDoc block explaining its purpose, placement requirement (first in `isMainModule()`), and the D-08 reference.
### afterEach env restoration in unit tests
**Source:** `apps/api/tests/auth/devBypass.test.ts` lines 1929.
**Apply to:** `apps/api/tests/lib/bootGuards.test.ts` — restore `process.env.NODE_ENV` and `process.env.DEV_AUTH_BYPASS` in `afterEach`.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| `scripts/check-audit.mjs` | utility script | batch | No audit/wrapper scripts exist in the repo |
| `scripts/check-outdated.mjs` | utility script | batch | No outdated/wrapper scripts exist in the repo |
| `scripts/audit-allowlist.json` | config data | — | No allowlist/waiver JSON pattern exists in the repo |
| `scripts/outdated-pins.json` | config data | — | No pin-reason config pattern exists in the repo |
| `.gitleaks.toml` | tool config | — | No TOML configs exist in the repo; RESEARCH.md content is the full spec |
| `scripts/gitleaks-baseline.json` | generated artifact | — | Generated by running gitleaks locally; not handwritten |
---
## Metadata
**Analog search scope:** `.gitea/workflows/`, `apps/api/src/`, `apps/api/tests/`, `eslint.config.js`, `apps/api/Dockerfile`, `.gitignore`
**Files scanned:** 9 source files read directly
**Pattern extraction date:** 2026-06-13
@@ -0,0 +1,160 @@
---
phase: 16-ci-dependency-audit-and-security-checks
fixed_at: 2026-06-13T00:00:00Z
review_path: .planning/phases/16-ci-dependency-audit-and-security-checks/16-REVIEW.md
iteration: 1
findings_in_scope: 9
fixed: 6
skipped: 3
status: partial
---
# Phase 16: Code Review Fix Report
**Fixed at:** 2026-06-13
**Source review:** .planning/phases/16-ci-dependency-audit-and-security-checks/16-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 9 (CR-01; WR-01..WR-05; IN-01..IN-04 — IN-04 carries no fix)
- Fixed: 6 (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05, plus IN-01)
- Skipped: 3 (IN-02, IN-03, IN-04 — all optional/no-action)
IN-01 was applied inside the CR-01 commit (same file, `scripts/check-audit.mjs`),
so it does not get a standalone commit line below but is counted as fixed.
## Validation
All validation run inside the isolated review-fix worktree:
- `pnpm lint` — PASS (apps/api + apps/pwa, `--max-warnings 0`). Note: the project
lint scope does not include `scripts/*.mjs`, so the edited `.mjs` scripts are
syntax-checked via `node -c` instead (all pass).
- `pnpm typecheck` — PASS (apps/api + apps/pwa `tsc --noEmit`, PWA e2e tsconfig).
- `node --test scripts/__tests__/check-audit.test.mjs` — 8 pass / 0 fail
(includes 3 new expired-waiver / isWaived assertions for CR-01).
- `python3 -c 'yaml.safe_load(...)'` for `.gitea/workflows/ci.yml` and
`.gitea/workflows/publish.yml` — both parse OK after edits.
- Working tree clean after all commits (no stray/uncommitted changes).
The API DB-integration tests (`tests/routes/lists.test.ts`,
`tests/lib/listAccess.test.ts`, etc.) fail with `ER_ACCESS_DENIED` in this
environment because no dev MariaDB is reachable. That is pre-existing/environmental
and unrelated to these fixes; no new test failures were introduced.
## Fixed Issues
### CR-01: Audit-waiver `expires` field is decorative — expired waivers never re-block
**Files modified:** `scripts/check-audit.mjs`, `scripts/__tests__/check-audit.test.mjs`
**Commit:** 4bb205f
**Applied fix:** Added an exported `isWaived(adv, allowlist)` predicate. An allowlist
entry with no `expires`, or a future `expires`, waives the advisory; an entry whose
`expires` is in the past (`<= Date.now()`) is treated as absent so the High/Critical
advisory re-blocks. Applied `isWaived` in BOTH `selectBlocking` and
`partitionAdvisories` (replacing the bare `!allowlist[...]` checks). Added three
unit tests: expired waiver re-blocks via `selectBlocking`, expired waiver re-blocks
via `partitionAdvisories`, and a direct `isWaived` truth-table test (future expiry →
waived, past expiry → not waived, no-expiry → waived, missing → not waived). All 8
tests pass.
### WR-01: `github.base_ref` interpolated into a shell command (script-injection vector)
**Files modified:** `.gitea/workflows/ci.yml`
**Commit:** 26a6b2e
**Applied fix:** Bound `github.event.pull_request.base.sha`, `head.sha`, and
`github.base_ref` through an `env:` block (`PR_BASE_SHA`, `PR_HEAD_SHA`,
`PR_BASE_REF`) on the "Probe PR base/head SHA" step. The `run:` body now references
only the already-quoted shell variables — no `${{ ... }}` context interpolation
inside the script. The merge-base fallback uses `git rev-parse "origin/$PR_BASE_REF"`.
### WR-03: `HEAD_SHA` has no fallback while `BASE_SHA` does — asymmetric defense
**Files modified:** `.gitea/workflows/ci.yml`
**Commit:** 26a6b2e (committed together with WR-01 — same step in the same file)
**Applied fix:** Added a symmetric head fallback (`if [ -z "$HEAD_SHA" ]; then
HEAD_SHA=$(git rev-parse HEAD); fi`) and an `echo "Secret-scan range:
${BASE_SHA}..${HEAD_SHA}"` line before the gitleaks invocation so the scanned range
is logged rather than relying on git's `A..``A..HEAD` default.
### WR-02: Boot-smoke false-PASS if a regressed image emits ≥20 lines before binding
**Files modified:** `.gitea/workflows/publish.yml`
**Commit:** 3daa351
**Applied fix:** Captured `docker run` output into `OUT=$(...)` and read `EXIT=$?`
from the docker command directly (no `| head -20` in the exit-bearing command), so a
chatty-but-booting image can no longer SIGPIPE docker to exit 141 and false-PASS.
`head -20` is now applied only to the printed `echo "$OUT"`. Treat both `0` and `124`
as FAIL ("did not refuse boot"). Added a positive belt-and-suspenders assertion:
the output must contain `DEV_AUTH_BYPASS=true is set in a production environment`
(the exact D-08 guard marker from `bootGuards.ts`), so a refusal for an unrelated
reason cannot masquerade as the guard working.
> Logic note: this change alters the PASS/FAIL decision logic of a security smoke
> test. The shell logic was reviewed against the guard marker string, but the actual
> container behavior under the forbidden env is not exercisable in this environment
> (no docker daemon / built image). Recommend a human confirm the smoke step on a
> real publish run.
### WR-05: Static `.dockerignore` assertions use unquoted-regex `grep` (false-positive prone)
**Files modified:** `.gitea/workflows/publish.yml`
**Commit:** 3daa351 (committed together with WR-02 — same file)
**Applied fix:** Switched the per-pattern assertion to comment-stripped, fixed-string
matching: `grep -v '^[[:space:]]*#' .dockerignore | grep -qF "$pattern"`. Patterns
are no longer treated as regexes (`.env` can't match `denv`) and a commented-out
rule (`# .env was here`) no longer satisfies the check. Failure message updated to
"missing active rule".
### WR-04: AUDIT-ADVISORY tier in the outdated report is effectively dead code
**Files modified:** `scripts/check-outdated.mjs`
**Commit:** 3e609b2
**Applied fix:** Took the SAFE relabel option (no risky full-tree rewrite). Renamed
the tier from `AUDIT-ADVISORY` to `OUTDATED-WITH-ADVISORY` and documented, in the
file header, the inline classification comment, and the printed header text, that it
only matches outdated *direct* deps against advisory `module_name`s (most advisories
are on transitive deps, so it rarely fires) and that the authoritative advisory gate
is `check-audit.mjs`. The report still always exits 0 (advisory-only). The
audit-parse-failure warning message was updated to the new tier name.
## Skipped Issues
### IN-02: `pnpm audit --json` is run twice per CI security job
**File:** `scripts/check-audit.mjs:84`, `scripts/check-outdated.mjs:65`
**Reason:** Skipped — explicitly optional and out of v1 performance scope per REVIEW.md
("Out of v1 performance scope and harmless"). The suggested fix (pipe one audit pass
to both scripts via stdin/arg, or merge the two scripts) is a structural change to
script interfaces and CI invocation with no correctness benefit; applying it here
would be speculative scope creep.
**Original issue:** Both scripts independently spawn `pnpm audit --json`, doubling
the audit work in the security job.
### IN-03: `outdated-pins.json` reasons are not cross-checked against the audit allowlist
**File:** `scripts/outdated-pins.json` / `scripts/audit-allowlist.json`
**Reason:** Skipped — explicitly optional ("Documentation-level coupling only").
Both JSON files are flat maps that the consuming scripts iterate directly:
`check-outdated.mjs` reads `pins[pkgName]` as a pin reason, and `check-audit.mjs`
reads `allowlist[github_advisory_id]`. Injecting a meta `__note`/cross-reference key
risks the consumers misreading it as real data (a `__note` pin would be treated as a
pin reason if a package were ever named `__note`). The safer choice is to leave the
data files as pure data rather than add inert-but-fragile meta keys. The suggested
lint-step variant is net-new tooling, out of scope for a review fix.
**Original issue:** Two independent suppression lists with no linkage between a
pinned package and an audit waiver for the same package.
### IN-04: `expand.test.ts` is in scope but unrelated to this CI/security phase
**File:** `apps/api/tests/broker/expand.test.ts`
**Reason:** Skipped — no action required. REVIEW.md states "**Fix:** None." The
reviewer found no defects; the file appears in the review set only because it was
touched/moved and is orthogonal to this phase.
**Original issue:** Well-constructed test noted for completeness; no defect.
---
_Fixed: 2026-06-13_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,121 @@
---
phase: 16-ci-dependency-audit-and-security-checks
reviewed: 2026-06-13T00:00:00Z
depth: standard
files_reviewed: 5
files_reviewed_list:
- .gitea/workflows/ci.yml
- .gitea/workflows/publish.yml
- scripts/check-audit.mjs
- scripts/check-outdated.mjs
- scripts/__tests__/check-audit.test.mjs
findings:
critical: 0
warning: 0
info: 1
total: 1
status: clean
---
# Phase 16: Code Review Report
**Reviewed:** 2026-06-13
**Depth:** standard
**Files Reviewed:** 5
**Status:** clean
## Summary
Iteration-2 re-review of the fixer's changes to the 5 in-scope files. Every prior
finding that targeted these files (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05,
IN-01) is genuinely resolved, and the fixes introduced no Critical/Warning
regression. The previously-accepted skipped INFO items (IN-02, IN-03, IN-04) are
not re-litigated.
Verification performed:
- `node --test scripts/__tests__/check-audit.test.mjs` → 8 pass / 0 fail
(includes the 3 new expired-waiver / `isWaived` truth-table assertions).
- Both workflow files parse under `yaml.safe_load`.
- `node -c` syntax-clean on both `.mjs` scripts.
- Guard marker string asserted by the boot-smoke
(`DEV_AUTH_BYPASS=true is set in a production environment`) confirmed to match
the literal in `apps/api/src/lib/bootGuards.ts:29`.
I was not given a `<structural_findings>` block, so there is no fallow substrate
section.
### Prior-finding verification
- **CR-01 (resolved).** `isWaived(adv, allowlist)` (`scripts/check-audit.mjs:37-43`)
returns false for a missing entry and for a past/equal `expires`
(`Date.parse(w.expires) <= Date.now()`), true for future/no `expires`. Wired into
BOTH `selectBlocking` (line 55) and `partitionAdvisories` (line 72). Tests
exercise expired-waiver re-block via both functions plus a direct `isWaived`
truth-table. The time-boxed waiver is now actually time-boxed.
- **IN-01 (resolved).** `isMainModule()` (`scripts/check-audit.mjs:86-93`) now
compares `realpathSync(process.argv[1])` to the resolved `__filename`, mirroring
`index.ts`. A symlinked/non-canonical entrypoint no longer silently skips the gate.
- **WR-01 (resolved).** `github.event.pull_request.base.sha`, `head.sha`, and
`github.base_ref` are bound through `env:` (`ci.yml:365-368`) and referenced only
as already-quoted shell variables (`$PR_BASE_SHA`, `$PR_HEAD_SHA`, `$PR_BASE_REF`).
No `${{ ... }}` context value is interpolated into the rendered `run:` body. The
merge-base fallback uses `git rev-parse "origin/$PR_BASE_REF"` (quoted). The
script-injection vector is closed.
- **WR-03 (resolved).** Symmetric head fallback added (`ci.yml:382-386`):
`if [ -z "$HEAD_SHA" ]; then HEAD_SHA=$(git rev-parse HEAD); fi`, plus an explicit
`echo "Secret-scan range: ${BASE_SHA}..${HEAD_SHA}"` (line 387). The gitleaks step
consumes both via `$GITHUB_ENV` under `set -u`, so a missing range fails loudly
rather than scanning a silently-wrong range.
- **WR-02 (resolved).** Boot-smoke now captures `OUT=$(timeout 15 docker run ...)`
and `EXIT=$?` directly (`publish.yml:132-137`), with `head -20` applied only to the
display `echo` (line 139). A chatty-but-booting regressed image can no longer
SIGPIPE docker to exit 141 and false-PASS. 0/124 → FAIL, and a positive
belt-and-suspenders grep requires the exact D-08 guard marker (line 147).
- **WR-04 (resolved, relabel option).** The tier is relabeled `OUTDATED-WITH-ADVISORY`
with an honest sub-line and header doc (`check-outdated.mjs:4-11, 144-145`)
stating it only cross-checks outdated *direct* deps against advisory
`module_name`s, rarely fires, and that `check-audit.mjs` is the authoritative gate.
The misleading implied check is gone.
- **WR-05 (resolved).** Per-pattern `.dockerignore` assertion now strips comment
lines and fixed-string matches (`publish.yml:103-109`):
`grep -v '^[[:space:]]*#' .dockerignore | grep -qF "$pattern"`. `.env` no longer
regex-matches `denv`, and a commented-out rule no longer satisfies the check.
### Regression check on the fixes
- `ci.yml` env-binding: with `set -u`, an empty `PR_BASE_REF` + empty `base.sha`
makes `git rev-parse "origin/"` fail and the step fails closed — acceptable.
- `publish.yml` boot-smoke: the only residual edge is that under `pipefail` a very
large `OUT` could SIGPIPE the display `echo "$OUT" | head -20` and fail the step.
That direction is fail-safe (blocks a good image, never false-PASSes a bad one)
and strictly more conservative than the original bug — not a defect.
- `check-audit.mjs` `isWaived`: logic-traced for missing / past / equal / future /
absent-expires cases — all correct. One latent gap noted as IN-01 below.
- `check-outdated.mjs` relabel: classification logic unchanged; only strings moved.
## Info
### IN-01: Unparseable `expires` in the audit allowlist waives indefinitely
**File:** `scripts/check-audit.mjs:41`
**Issue:** `isWaived` guards expiry with `if (w.expires && Date.parse(w.expires) <= Date.now())`.
If `expires` is a non-empty but unparseable string (e.g. `"soon"`, `"2026-13-40"`),
`Date.parse` returns `NaN`, `NaN <= Date.now()` is `false`, and the entry waives the
advisory indefinitely — the same failure mode CR-01 fixed, reachable via a typo in
committed allowlist data. Low severity: the allowlist is reviewed, committed,
non-attacker data, and CR-01's primary case (a real past date) is handled. Flagged
for completeness only; not a regression introduced by the fix.
**Fix:** Treat an unparseable `expires` as expired (fail-closed):
```js
if (w.expires) {
const t = Date.parse(w.expires);
if (Number.isNaN(t) || t <= Date.now()) return false;
}
return true;
```
---
_Reviewed: 2026-06-13_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,108 @@
---
phase: 16
slug: ci-dependency-audit-and-security-checks
status: draft
nyquist_compliant: true
wave_0_complete: false
created: 2026-06-12
---
# Phase 16 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
This phase is mostly CI/Docker/security wiring. Only two artifacts carry unit-testable
pure logic — the boot guard (`assertNotDevBypassInProduction()`) and the audit-wrapper
filter (`check-audit.mjs`). Everything else is verified by file-assertion, `pnpm lint`,
or a CI-run / boot-smoke that is exercised after merge.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Vitest (apps/api) for the boot guard; `node --test` for the root-level audit wrapper |
| **Config file** | `apps/api/vitest.config.ts`; root scripts use no config (`node --test`) |
| **Quick run command** | `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` |
| **Full suite command** | `pnpm --filter @familysync/api test && node --test scripts/__tests__/check-audit.test.mjs` |
| **Estimated runtime** | ~15 seconds |
---
## Sampling Rate
- **After every task commit:** Run the relevant quick command (boot guard unit test, or `node --test` for the audit wrapper, or `pnpm lint` for the eslint fold)
- **After every plan wave:** Run the full suite command
- **Before `/gsd-verify-work`:** Full API suite green + `pnpm lint` green + (post-merge) publish boot-smoke PASS
- **Max feedback latency:** 60 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 16-01-01 | 01 | 1 | IMG-01 | T-16-02 | Failing test pins guard exit(1) on prod+bypass | unit | `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` | ❌ W0 | ⬜ pending |
| 16-01-02 | 01 | 1 | IMG-01 | T-16-02 | Guard exits 1 on NODE_ENV=production + DEV_AUTH_BYPASS=true; inert otherwise | unit | `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` | ❌ W0 | ⬜ pending |
| 16-01-03 | 01 | 1 | IMG-01 | T-16-01 | Production image bakes NODE_ENV=production | file-assert | `grep -c "ENV NODE_ENV=production" apps/api/Dockerfile` (==1, in production stage) | ✅ | ⬜ pending |
| 16-02-01 | 02 | 1 | DEP-01 | T-16-04/T-16-05 | esbuild High advisory waived with reason+reviewer before gate goes live | file-assert | `node -e "require('./scripts/audit-allowlist.json')['GHSA-gv7w-rqvm-qjhr']"` | ❌ W0 | ⬜ pending |
| 16-02-02 | 02 | 1 | DEP-01 | T-16-04 | Wrapper blocks unwaived High+Critical, honors allowlist | unit | `node --test scripts/__tests__/check-audit.test.mjs` | ❌ W0 | ⬜ pending |
| 16-02-03 | 02 | 1 | DEP-02 | T-16-06 | Outdated report tiered, pin-aware, always exit 0 | behavior | `node scripts/check-outdated.mjs; test $? -eq 0` | ❌ W0 | ⬜ pending |
| 16-03-01 | 03 | 1 | SEC-02 | T-16-08 | eslint-plugin-security registered as blocking errors | file-assert | `grep -q pluginSecurity eslint.config.js` | ✅ | ⬜ pending |
| 16-03-02 | 03 | 1 | SEC-02 | T-16-08/T-16-09 | Lint green with security rules active; suppressions justified | lint | `pnpm lint` | ✅ | ⬜ pending |
| 16-04-01 | 04 | 1 | SEC-01 | T-16-12 | gitleaks config inherits default ruleset + fixture/env allowlists | file-assert | `grep -q useDefault .gitleaks.toml && grep -q vapid .gitleaks.toml` | ✅ | ⬜ pending |
| 16-04-02 | 04 | 1 | IMG-02 | T-16-13/T-16-14 | .dockerignore excludes secrets/dev/bulk, preserves apps/api/src | file-assert | `grep -q "apps/api/tests" .dockerignore` and apps/api/src NOT excluded | ✅ | ⬜ pending |
| 16-04-03 | 04 | 1 | SEC-01 | T-16-11 | Full-history baseline committed, only known fixtures flagged | human-verify + file-assert | `test -f scripts/gitleaks-baseline.json` + operator confirms findings | ✅ | ⬜ pending |
| 16-05-01 | 05 | 2 | CI-03/SEC-01/DEP-01/DEP-02 | T-16-15/T-16-16/T-16-17 | security job: gitleaks always, audit/outdated code-gated, base.sha probed | yaml-parse | `python3 -c "import yaml;yaml.safe_load(open('.gitea/workflows/ci.yml'))['jobs']['security']"` | ✅ | ⬜ pending |
| 16-05-02 | 05 | 2 | CI-03 | T-16-15 | gate requires security success (individual result check) | yaml-parse | `grep -q needs.security.result .gitea/workflows/ci.yml` | ✅ | ⬜ pending |
| 16-06-01 | 06 | 2 | IMG-03 | T-16-20 | build and push are separate steps (assertion seam) | yaml-parse | `python3` build/push split assertion | ✅ | ⬜ pending |
| 16-06-02 | 06 | 2 | IMG-03 | T-16-18/T-16-19/T-16-21 | static + boot-smoke assertions between build and push | yaml-parse | `python3` assertions-between-build-and-push assertion | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
These are the test/scaffold assets that do not yet exist and must be created by their owning
task as the FIRST step (RED) before implementation:
- [ ] `apps/api/tests/lib/bootGuards.test.ts` — unit tests for `assertNotDevBypassInProduction()` (created by 16-01 Task 1, RED)
- [ ] `apps/api/src/lib/bootGuards.ts` — exported guard function (created by 16-01 Task 2, GREEN)
- [ ] `scripts/__tests__/check-audit.test.mjs` — unit tests for the audit-wrapper filter logic (created by 16-02 Task 2)
- [ ] `scripts/check-audit.mjs` — audit wrapper (16-02 Task 2)
- [ ] `scripts/check-outdated.mjs` — outdated wrapper (16-02 Task 3)
- [ ] `scripts/audit-allowlist.json` — seeded with `GHSA-gv7w-rqvm-qjhr` (16-02 Task 1)
- [ ] `scripts/outdated-pins.json` — intentional-pin reasons (16-02 Task 1)
- [ ] `.gitleaks.toml` — config + allowlists (16-04 Task 1)
- [ ] `scripts/gitleaks-baseline.json` — full-history scan output, committed (16-04 Task 3)
- [ ] `.dockerignore` — root-level build-context filter (16-04 Task 2)
The boot-guard unit test (`bootGuards.test.ts`) is the primary Wave 0 test asset. The audit-wrapper
test is the secondary. All other artifacts are config/wiring verified by file-assertion, lint, or CI-run.
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Full-history gitleaks baseline contains only known test fixtures (no real leaked credential) | SEC-01 | Reading repo history for real secrets is a judgment call; a real finding is a security event needing rotation, not auto-approval | 16-04 Task 3 checkpoint: review baseline findings; approve only if every finding is the VAPID fixture / env template |
| A PR with a deliberately-planted fake secret in the diff fails `CI / gate` via the security job | SEC-01 | Requires opening a throwaway PR against the live Gitea runner | After merge: push a throwaway branch adding a fake AWS-key-shaped string to a tracked file; open PR; confirm gate fails on the security job; close PR |
| A doc-only PR still runs gitleaks but skips audit/outdated | SEC-01 / DEP-01 | Requires a live runner PR to observe step skip behavior | After merge: open a doc-only PR; confirm the security job runs gitleaks (visible in log) and the audit/outdated steps are skipped |
| The published production image refuses to boot with DEV_AUTH_BYPASS=true (boot-smoke PASS in a real publish run) | IMG-03 | Only runs on push-to-main publish against the built image | After this branch merges, watch the Publish workflow run; confirm the boot-smoke step prints PASS and the image publishes |
| base.sha is available on the Gitea runner (Assumption A2 / OQ-1) | SEC-01 | Gitea event-context parity is not probe-confirmed for this field | The 16-05 probe step echoes base.sha/head.sha in the first PR's security-job log; confirm BASE_SHA resolves (event context or merge-base fallback) |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags (uses `--run` / `node --test`, never `vitest` watch)
- [x] Feedback latency < 60s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** approved 2026-06-13
@@ -0,0 +1,131 @@
---
phase: 16-ci-dependency-audit-and-security-checks
verified: 2026-06-13T12:56:26Z
status: passed
score: 8/8
overrides_applied: 0
---
# Phase 16: CI Dependency Audit, Security Checks & Image Hygiene — Verification Report
**Phase Goal:** Extend Gitea CI with outdated-dependency reporting + vulnerability audit + a baseline of additional security checks, and enforce the dev/prod image boundary so no dev-bypass, secret, or family data ships in published images.
**Verified:** 2026-06-13T12:56:26Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | A production image with DEV_AUTH_BYPASS=true refuses to boot (process exits non-zero) | VERIFIED | `assertNotDevBypassInProduction()` in `bootGuards.ts` calls `process.exit(1)` when `NODE_ENV==='production' && DEV_AUTH_BYPASS==='true'`; 3/3 unit tests pass |
| 2 | The production Docker stage bakes NODE_ENV=production so the devBypass hard guard is engaged | VERIFIED | `ENV NODE_ENV=production` at line 45 of `apps/api/Dockerfile`, inside `FROM base AS production` stage only |
| 3 | The boot guard is a unit-tested exported function, called first in isMainModule() | VERIFIED | `bootGuards.ts` exports `assertNotDevBypassInProduction`; called at `index.ts:116` as the first statement inside `if (isMainModule()) {`, before VAPID config (line 121), workers (line 141), and serve (line 149) |
| 4 | The audit wrapper blocks unwaived High+Critical advisories; esbuild GHSA waived with expiry | VERIFIED | `check-audit.mjs` exports `selectBlocking`/`partitionAdvisories`/`isWaived`; no `--audit-level` flag; esbuild `GHSA-gv7w-rqvm-qjhr` waived in `audit-allowlist.json` with reviewer, reason, and future expiry `2026-09-01`; 9/9 unit tests pass (including expiry + fail-closed on malformed date) |
| 5 | The outdated wrapper is advisory-only (always exits 0), tiered, and pin-aware | VERIFIED | `check-outdated.mjs` unconditionally calls `process.exit(0)` at line 189; no reachable `process.exit(1)`; four tiers (OUTDATED-WITH-ADVISORY / MAJOR-BEHIND-INTENTIONAL / MAJOR-BEHIND-UNPINNED / ROUTINE-DRIFT); reads `outdated-pins.json` with four pin reasons (eslint, @eslint/js, zod, @types/node) |
| 6 | eslint-plugin-security runs as blocking errors in pnpm lint, baseline is green | VERIFIED | `eslint-plugin-security@3.0.1` in root devDependencies; folded into `eslint.config.js` section 5 before `prettierConfig`; `detect-object-injection` disabled globally with inline justification comment; `pnpm lint` exits 0 with `--max-warnings 0`; `pnpm typecheck` passes |
| 7 | A gitleaks config with useDefault + fixture allowlists exists; clean baseline committed | VERIFIED | `.gitleaks.toml` has `[extend] useDefault = true` and 4 `[[allowlists]]` blocks (VAPID fixture, .env.example, .env.spike, crypto test); `scripts/gitleaks-baseline.json` is valid JSON `[]` (empty — no pre-existing findings); `.dockerignore` covers all 7 forbidden patterns and does NOT exclude `apps/api/src` |
| 8 | security job (gitleaks always; audit/outdated code-gated) wired into gate as strict success | VERIFIED | `ci.yml` has `security:` job with `needs: [changes]`, `if: pull_request`; checkout has `fetch-depth: 0`; gitleaks steps have no `if:`; audit/outdated steps have `if: needs.changes.outputs.code == 'true'`; gate `needs:` includes `security`; gate script checks `needs.security.result != 'success'` as an individual non-skippable check (not in the success-or-skipped loop) |
**Score:** 8/8 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/lib/bootGuards.ts` | Exports `assertNotDevBypassInProduction()` | VERIFIED | Exists, exports function, correct logic |
| `apps/api/tests/lib/bootGuards.test.ts` | 3 unit test cases | VERIFIED | 3 cases present; 3/3 pass via vitest |
| `apps/api/Dockerfile` | `ENV NODE_ENV=production` in production stage | VERIFIED | Line 45, inside `FROM base AS production` only |
| `apps/api/src/index.ts` | Imports and calls guard first in isMainModule | VERIFIED | Import at line 18; call at line 116, first statement in block |
| `scripts/check-audit.mjs` | Blocking wrapper with pure filter exports | VERIFIED | Exports `selectBlocking`, `partitionAdvisories`, `isWaived`; no `--audit-level` |
| `scripts/audit-allowlist.json` | GHSA-gv7w-rqvm-qjhr waiver with reason+reviewer+expires | VERIFIED | Valid JSON; all fields present; expiry 2026-09-01 (future) |
| `scripts/check-outdated.mjs` | Advisory-only tiered report; always exits 0 | VERIFIED | `process.exit(0)` at end; no reachable exit(1) on report path |
| `scripts/outdated-pins.json` | 4 pin reasons (eslint, @eslint/js, zod, @types/node) | VERIFIED | All 4 present with justification text |
| `scripts/__tests__/check-audit.test.mjs` | 9 test cases (4 plan-required + 5 expiry/edge cases) | VERIFIED | 9/9 pass via `node --test` |
| `eslint.config.js` | eslint-plugin-security before prettierConfig; detect-object-injection off with justification | VERIFIED | Section 5; inline comment on disabled rule |
| `package.json` | eslint-plugin-security in devDependencies | VERIFIED | `3.0.1` |
| `.gitleaks.toml` | useDefault + 3 allowlists (VAPID, .env.example, .env.spike) | VERIFIED | Present; 4 allowlists (plan called for 3; crypto.test.ts is a bonus) |
| `scripts/gitleaks-baseline.json` | Valid JSON, confirmed clean | VERIFIED | `[]` — no findings |
| `.dockerignore` | Forbidden patterns present; apps/api/src NOT excluded | VERIFIED | All 7 required patterns found; apps/api/src does not appear as an exclusion |
| `.gitea/workflows/ci.yml` | security job + updated gate | VERIFIED | Job present with correct conditional structure and gate wiring |
| `.gitea/workflows/publish.yml` | Static assertion + boot-smoke between build and push | VERIFIED | Step order: Build → static assertions → boot-smoke → Push |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `apps/api/src/index.ts` | `apps/api/src/lib/bootGuards.ts` | `import { assertNotDevBypassInProduction }` + call as first statement in `isMainModule()` | WIRED | Line 18 import; line 116 call; verified order before VAPID/workers/serve |
| `apps/api/Dockerfile` production stage | `apps/api/src/lib/bootGuards.ts` (via baked env) | `ENV NODE_ENV=production` engages NODE_ENV check in guard | WIRED | Line 45 in production stage only |
| `scripts/check-audit.mjs` | `scripts/audit-allowlist.json` | `readFileSync` + filter by `github_advisory_id` | WIRED | `allowlistPath = resolve(__dirname, 'audit-allowlist.json')` at line 103 |
| `scripts/check-outdated.mjs` | `scripts/outdated-pins.json` | `readFileSync` + pin-reason lookup | WIRED | `pinsPath = resolve(__dirname, 'outdated-pins.json')` at line 59 |
| `eslint.config.js` | `eslint-plugin-security` | `import pluginSecurity` + spread `configs.recommended` | WIRED | Lines 11, 117-121 |
| `.gitleaks.toml` | `apps/api/tests/fixtures/vapid.ts` | `[[allowlists]] paths` regex | WIRED | Path regex `apps/api/tests/fixtures/vapid\.ts` in first allowlist block |
| `.gitea/workflows/ci.yml` security job | `scripts/check-audit.mjs` | `node scripts/check-audit.mjs` step (code-gated) | WIRED | Line 430 |
| `.gitea/workflows/ci.yml` gate | security job | `needs.security.result == 'success'` individual check | WIRED | Gate needs `[fast-checks, changes, api, harness, security]`; individual check at line 452 |
| `.gitea/workflows/publish.yml` boot-smoke | `apps/api/src/lib/bootGuards.ts` (via built image) | `docker run --env NODE_ENV=production --env DEV_AUTH_BYPASS=true`; assert non-zero exit + guard message | WIRED | Step 5 ("Image hygiene — boot-smoke"); greps for `DEV_AUTH_BYPASS=true is set in a production environment` |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| boot guard exits 1 when NODE_ENV=production and DEV_AUTH_BYPASS=true | `vitest run tests/lib/bootGuards.test.ts` | 3/3 tests pass | PASS |
| check-audit unit tests — blocking/waiving/expiry logic | `node --test scripts/__tests__/check-audit.test.mjs` | 9/9 pass | PASS |
| pnpm lint green with eslint-plugin-security active | `pnpm lint` | exits 0 | PASS |
| pnpm typecheck clean | `pnpm typecheck` | exits 0 (both apps) | PASS |
| ENV NODE_ENV=production in production Dockerfile stage | `awk` + grep on Dockerfile | Found at line 45 in production stage | PASS |
| .dockerignore covers all 7 forbidden patterns; does not exclude apps/api/src | grep loop | All 7 OK; apps/api/src absent | PASS |
| gitleaks baseline is valid JSON | `node -e JSON.parse(...)` | `[]` — 0 findings, valid JSON | PASS |
| security job parses, has correct structure | python3 yaml parse | security needs:[changes], gitleaks steps always, audit/outdated code-gated | PASS |
| publish.yml step order: build → assertions → smoke → push | python3 yaml parse | Steps [3]=Build, [4]=static, [5]=smoke, [6]=Push | PASS |
| check-outdated always exits 0 (no process.exit(1) on report path) | grep | Only `process.exit(0)` at line 189 | PASS |
### Probe Execution
Step 7c skipped — no probe scripts declared or expected for this phase (CI workflow verification; no `probe-*.sh` files present).
### Requirements Coverage
Phase 16 requirement IDs are defined in PLAN frontmatter and ROADMAP.md; they do not appear in `REQUIREMENTS.md` (which tracks only v1.1 functional requirements up to CI-01/CI-02). This is expected — REQUIREMENTS.md ends its traceability table at CI-02 and notes that CI, TEST, ADMIN, SETUP categories are tracked there. The Phase 16 operational/security requirement IDs (SEC-*, DEP-*, IMG-*, CI-03) are roadmap-internal tracking identifiers, not v1.1 product requirements.
| REQ-ID | Plan | What was verified | Status |
|--------|------|-------------------|--------|
| IMG-01 | 16-01 | `bootGuards.ts` exported guard; `index.ts` wiring as first call in isMainModule; Dockerfile `ENV NODE_ENV=production` in production stage only | SATISFIED |
| DEP-01 | 16-02 | `check-audit.mjs` blocks unwaived High+Critical; `audit-allowlist.json` with esbuild GHSA waiver pre-seeded; time-boxed expiry enforced | SATISFIED |
| DEP-02 | 16-02 | `check-outdated.mjs` always exits 0; four tiers including intentional-pin; `outdated-pins.json` with 4 reasons | SATISFIED |
| SEC-02 | 16-03 | `eslint-plugin-security@3.0.1` in root devDeps; folded into flat config before prettierConfig; `pnpm lint` exits 0 | SATISFIED |
| SEC-01 | 16-04 | `.gitleaks.toml` with `useDefault=true` + fixture/env allowlists; `gitleaks-baseline.json` = `[]`; human checkpoint completed (baseline clean) | SATISFIED |
| IMG-02 | 16-04 | `.dockerignore` covers all 7 required forbidden patterns; does NOT exclude `apps/api/src` | SATISFIED |
| CI-03 | 16-05 | `security` job in ci.yml; gitleaks always-runs; audit/outdated code-gated; gate wires security via individual strict success check | SATISFIED |
| IMG-03 | 16-06 | publish.yml: Build → static assertions (grep .dockerignore + `--target production`) → boot-smoke (assert non-zero exit + guard message) → Push | SATISFIED |
### Anti-Patterns Found
None. Scan of all 15 phase-16-modified files found no TBD/FIXME/XXX markers, no placeholder returns, no blanket `/* eslint-disable */` headers, no hardcoded empty data structures in rendering paths.
Notable good patterns observed:
- `detect-object-injection` disabled globally has inline justification comment (not silent `off`)
- Audit wrapper expiry check fails **closed** on unparseable date strings (malformed → not waived)
- Boot-smoke matches guard output text as belt-and-suspenders (non-zero exit alone is insufficient)
### Human Verification Required
Both items were CONFIRMED on 2026-06-13 against the live Gitea Actions logs for PR #15 (merge commit `06238a9`). The Gitea runner could not be reached during planning; it was verified after merge.
1. **Gitleaks PR diff scan + gate wiring** — ✅ **CONFIRMED** (CI run #51, `security` job 148)
- Log evidence: `gitleaks git --config .gitleaks.toml --baseline-path scripts/gitleaks-baseline.json` ran → `40 commits scanned``no leaks found`. `check-audit.mjs``Audit PASS — no unwaived High/Critical advisories` (esbuild GHSA waived); `check-outdated.mjs` advisory-only (`OUTDATED-WITH-ADVISORY` / `ROUTINE-DRIFT`), did not gate.
- Blocking chain: gitleaks exits non-zero on a finding → `security` fails → `gate` checks `needs.security.result == success`. The gate-fails-on-a-red-job behavior is independently demonstrated by run #49, where `fast-checks: failure` produced `gate: failure`. Not separately re-tested with a planted secret (the PR carried none), but the enforcement path is proven end-to-end.
2. **Boot-smoke PASS on a freshly-built production image** — ✅ **CONFIRMED** (publish run #52, `publish` job 150, post-merge)
- Log evidence: `docker build --target production``Static image hygiene assertions PASSED.` → boot-smoke ran the image with `NODE_ENV=production DEV_AUTH_BYPASS=true` → image logged `[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. ... Refusing to start.``PASS: Production image refused to start with DEV_AUTH_BYPASS=true (exit 1)``docker push` (`v1.1-06238a9` + `latest`, digest `sha256:aa6f845…`). The D-08 guard fired in the actual shipped image, matched via the FATAL marker (WR-02 fix, not a false-pass), and the image published only after the gates passed.
---
## Gaps Summary
No gaps. All 8 observable truths are verified against the codebase, all 16 required artifacts exist and are substantive, all key links are wired. Behavioral spot-checks pass (9/9 unit tests, lint, typecheck, structural YAML parsing). The two live-CI items were CONFIRMED post-merge against the Gitea Actions logs (PR #15 run #51 `security` + publish run #52 boot-smoke) — see "Human Verification Required" above. Phase fully verified.
---
_Verified: 2026-06-13T12:56:26Z_
_Verifier: Claude (gsd-verifier)_