docs(16): create phase plan — 6 plans, 2 waves (dep audit, security checks, image hygiene)

SEC-01/02, DEP-01/02, IMG-01/02/03, CI-03. Wave 1: image-hygiene runtime guard (TDD), audit+outdated wrappers (TDD), eslint-plugin-security fold, gitleaks config+baseline+.dockerignore. Wave 2: ci.yml security job + gate wiring, publish.yml hygiene assertions + boot-smoke. esbuild GHSA-gv7w-rqvm-qjhr waivered in 16-02 before the gate goes live.
This commit is contained in:
Lucas Berger
2026-06-12 23:23:18 -04:00
parent bfc93584d7
commit e039c85a22
9 changed files with 1545 additions and 29 deletions
+9 -4
View File
@@ -311,7 +311,7 @@ Plans:
**Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).** **Goal**: The CI pipeline surfaces outdated and vulnerable dependencies, runs a baseline of additional security checks, and enforces a clean dev↔prod boundary in the images it publishes — so the two-person household app doesn't silently rot on stale/CVE-bearing packages, and no dev-only affordance, secret, or family-specific data ever ships in a production image. Extends the existing Gitea CI (Phase 8) workflow with dependency/security/image-hygiene gates rather than standing up a separate pipeline. **Absorbs backlog 999.17 (dev/prod image boundary).**
**Mode:** standard **Mode:** standard
**Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 1012. **Depends on**: Phase 8 (Gitea CI — adds steps to the existing workflow + publish job; no admin-chain dependency). Independent of Phases 1012.
**Requirements**: TBD (define during discuss/plan — likely new `CI-*` / `SEC-*` IDs) **Requirements**: SEC-01 (secret scanning), SEC-02 (static security lint), DEP-01 (vuln audit gate), DEP-02 (outdated advisory), IMG-01 (NODE_ENV+boot-guard), IMG-02 (.dockerignore), IMG-03 (publish image-hygiene assertions), CI-03 (security job + gate wiring)
**Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):** **Candidate scope (to be sharpened in `/gsd-discuss-phase 16`):**
@@ -324,11 +324,16 @@ Plans:
**Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred. **Boundary:** Extends the existing Gitea CI workflow + publish job; does not remove dev-bypass (still needed for local verification and the Phase 7/8 harness) and does not add a new external service or a runtime dependency to the app. Automated dependency *upgrades* (e.g. Renovate/Dependabot bots) are a separate concern — decide in discuss whether they're in scope or deferred.
**Plans**: 0 plans (run `/gsd-plan-phase 16` to break down) **Plans**: 6 plans in 2 waves
Plans: Plans:
- [ ] TBD (run `/gsd-discuss-phase 16` then `/gsd-plan-phase 16`) - [ ] 16-01-PLAN.md — Image-hygiene runtime: bake NODE_ENV=production + boot-time refuse-to-boot guard (IMG-01)
- [ ] 16-02-PLAN.md — pnpm audit gate + waiver allowlist + advisory-only tiered outdated report (DEP-01, DEP-02)
- [ ] 16-03-PLAN.md — Fold eslint-plugin-security into the lint gate as blocking errors + triage (SEC-02)
- [ ] 16-04-PLAN.md — gitleaks config + full-history baseline + .dockerignore (SEC-01, IMG-02)
- [ ] 16-05-PLAN.md — Add the security job to ci.yml (gitleaks always; audit/outdated code-gated) + gate wiring (CI-03)
- [ ] 16-06-PLAN.md — publish.yml static image-hygiene assertion + boot-smoke before push (IMG-03)
**UI hint**: no **UI hint**: no
@@ -351,7 +356,7 @@ Plans:
| 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 | | 13. Real Lint Gate (ESLint) | v1.1 | 3/3 | Complete | 2026-06-12 |
| 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 | | 14. Desktop E2E Coverage | v1.1 | 1/1 | Complete | 2026-06-12 |
| 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 | | 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 |
| 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 0/? | Not started | - | | 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 0/6 | Not started | - |
## Backlog ## Backlog
@@ -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,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,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,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,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,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,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
@@ -2,7 +2,7 @@
phase: 16 phase: 16
slug: ci-dependency-audit-and-security-checks slug: ci-dependency-audit-and-security-checks
status: draft status: draft
nyquist_compliant: false nyquist_compliant: true
wave_0_complete: false wave_0_complete: false
created: 2026-06-12 created: 2026-06-12
--- ---
@@ -11,26 +11,31 @@ created: 2026-06-12
> Per-phase validation contract for feedback sampling during execution. > 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 ## Test Infrastructure
| Property | Value | | Property | Value |
|----------|-------| |----------|-------|
| **Framework** | {pytest 7.x / jest 29.x / vitest / go test / other} | | **Framework** | Vitest (apps/api) for the boot guard; `node --test` for the root-level audit wrapper |
| **Config file** | {path or "none — Wave 0 installs"} | | **Config file** | `apps/api/vitest.config.ts`; root scripts use no config (`node --test`) |
| **Quick run command** | `{quick command}` | | **Quick run command** | `pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts` |
| **Full suite command** | `{full command}` | | **Full suite command** | `pnpm --filter @familysync/api test && node --test scripts/__tests__/check-audit.test.mjs` |
| **Estimated runtime** | ~{N} seconds | | **Estimated runtime** | ~15 seconds |
--- ---
## Sampling Rate ## Sampling Rate
- **After every task commit:** Run `{quick run command}` - **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 `{full suite command}` - **After every plan wave:** Run the full suite command
- **Before `/gsd-verify-work`:** Full suite must be green - **Before `/gsd-verify-work`:** Full API suite green + `pnpm lint` green + (post-merge) publish boot-smoke PASS
- **Max feedback latency:** {N} seconds - **Max feedback latency:** 60 seconds
--- ---
@@ -38,7 +43,21 @@ created: 2026-06-12
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending | | 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* *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -46,11 +65,22 @@ created: 2026-06-12
## Wave 0 Requirements ## Wave 0 Requirements
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX} These are the test/scaffold assets that do not yet exist and must be created by their owning
- [ ] `{tests/conftest.py}` — shared fixtures task as the FIRST step (RED) before implementation:
- [ ] `{framework install}` — if no framework detected
*If none: "Existing infrastructure covers all phase requirements."* - [ ] `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.
--- ---
@@ -58,19 +88,21 @@ created: 2026-06-12
| Behavior | Requirement | Why Manual | Test Instructions | | Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------| |----------|-------------|------------|-------------------|
| {behavior} | REQ-{XX} | {reason} | {steps} | | 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 |
*If none: "All phase behaviors have automated verification."* | 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 ## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies - [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify - [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references - [x] Wave 0 covers all MISSING references
- [ ] No watch-mode flags - [x] No watch-mode flags (uses `--run` / `node --test`, never `vitest` watch)
- [ ] Feedback latency < {N}s - [x] Feedback latency < 60s
- [ ] `nyquist_compliant: true` set in frontmatter - [x] `nyquist_compliant: true` set in frontmatter
**Approval:** {pending / approved YYYY-MM-DD} **Approval:** approved 2026-06-13