Phase 16: CI dependency audit & security checks #15

Merged
luckberg merged 40 commits from gsd/phase-16-ci-dependency-audit-and-security-checks into main 2026-06-13 10:01:01 -04:00
Owner

Phase 16 — CI Dependency Audit & Security Checks

Adds the security/dependency CI baseline. All 6 plans executed; phase verified 8/8 must-haves against the codebase. Code review + --auto fix loop ran and is clean (1 BLOCKER + 5 WARNINGs fixed, see 16-REVIEW.md / 16-REVIEW-FIX.md).

What this delivers

  • IMG-01 boot guard: assertNotDevBypassInProduction() refuses to boot when NODE_ENV=production + DEV_AUTH_BYPASS=true; called first in index.ts; ENV NODE_ENV=production baked into the prod Dockerfile stage.
  • DEP-01/02 scripts/check-audit.mjs (blocking on unwaived High/Critical; time-boxed and fail-closed waiver expiry) + scripts/check-outdated.mjs (advisory-only).
  • SEC-02 eslint-plugin-security folded into the flat config as errors; pnpm lint green.
  • SEC-01 .gitleaks.toml (default ruleset + fixture/env allowlists) + clean full-history baseline ([]).
  • IMG-02 .dockerignore filtering secrets/dev/bulk while preserving builder inputs.
  • CI-03 security job in ci.yml (gitleaks blocking every PR; audit/outdated code-gated) wired into the gate aggregator.
  • IMG-03 publish.yml static .dockerignore assertion + boot-smoke (run before push) proving the guard fires in the shipped image.

⚠ Live-CI verification (the two items that need this PR / a merge)

  1. Gitleaks PR scan blocks a real secret — the security job on this PR should run gitleaks; confirm it fails the gate if a secret is introduced.
  2. Boot-smoke PASS — after merge to main, confirm publish.yml logs PASS: Production image refused to start with DEV_AUTH_BYPASS=true before the push step.

🤖 Generated with Claude Code

## Phase 16 — CI Dependency Audit & Security Checks Adds the security/dependency CI baseline. All 6 plans executed; phase verified 8/8 must-haves against the codebase. Code review + `--auto` fix loop ran and is **clean** (1 BLOCKER + 5 WARNINGs fixed, see `16-REVIEW.md` / `16-REVIEW-FIX.md`). ### What this delivers - **IMG-01** boot guard: `assertNotDevBypassInProduction()` refuses to boot when `NODE_ENV=production` + `DEV_AUTH_BYPASS=true`; called first in `index.ts`; `ENV NODE_ENV=production` baked into the prod Dockerfile stage. - **DEP-01/02** `scripts/check-audit.mjs` (blocking on unwaived High/Critical; time-boxed **and** fail-closed waiver expiry) + `scripts/check-outdated.mjs` (advisory-only). - **SEC-02** `eslint-plugin-security` folded into the flat config as errors; `pnpm lint` green. - **SEC-01** `.gitleaks.toml` (default ruleset + fixture/env allowlists) + clean full-history baseline (`[]`). - **IMG-02** `.dockerignore` filtering secrets/dev/bulk while preserving builder inputs. - **CI-03** `security` job in `ci.yml` (gitleaks blocking every PR; audit/outdated code-gated) wired into the `gate` aggregator. - **IMG-03** `publish.yml` static `.dockerignore` assertion + boot-smoke (run before push) proving the guard fires in the shipped image. ### ⚠ Live-CI verification (the two items that need this PR / a merge) 1. **Gitleaks PR scan blocks a real secret** — the `security` job on *this PR* should run gitleaks; confirm it fails the gate if a secret is introduced. 2. **Boot-smoke PASS** — after merge to `main`, confirm `publish.yml` logs `PASS: Production image refused to start with DEV_AUTH_BYPASS=true` before the push step. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
luckberg added 37 commits 2026-06-13 09:01:10 -04:00
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.
- Three test cases: prod+bypass=exit(1), dev+bypass=no-exit, prod+unset=no-exit
- Fails with Cannot find module (src/lib/bootGuards.ts absent) — RED confirmed
- Create apps/api/src/lib/bootGuards.ts with assertNotDevBypassInProduction()
- Guard exits non-zero when NODE_ENV=production AND DEV_AUTH_BYPASS=true (D-08)
- Wire import + call as first statement in isMainModule() block in index.ts
- 3/3 unit tests pass, typecheck green
- Add ENV NODE_ENV=production in production stage after WORKDIR /app/apps/api
- Engages the devBypass.ts hard guard so DEV_AUTH_BYPASS can never inject
  DEV_USER in the shipped image, even if accidentally set (D-07)
- No other stage (base/builder/dev/pwa-builder) is affected
- audit-allowlist.json: GHSA-gv7w-rqvm-qjhr waiver (esbuild dev transitive via drizzle-kit/vitest/vite; not in production runtime)
- outdated-pins.json: intentional pin reasons for eslint, @eslint/js, zod, @types/node
- Tests four behavioral cases: unwaived High → blocking, waived High → not blocking,
  moderate/low only → not blocking, no advisories → not blocking
- Uses node:test + node:assert (no extra dependencies)
- Fails at RED: check-audit.mjs does not yet exist
- Exports selectBlocking() and partitionAdvisories() as pure functions for unit testing
- Main body guarded by import.meta.url check (only runs when invoked directly)
- Uses pnpm audit --json (no --audit-level — Pitfall 1 honored)
- Exits 1 on unwaived High/Critical; exits 0 with advisory report on moderate/low
- All 5 unit tests pass (node --test)
- Classifies outdated packages into four tiers: AUDIT-ADVISORY, MAJOR-BEHIND-INTENTIONAL,
  MAJOR-BEHIND-UNPINNED, ROUTINE-DRIFT
- Reads outdated-pins.json for intentional pin reasons (eslint, @eslint/js, zod, @types/node)
- Cross-checks pnpm audit --json to flag pinned versions with active advisories
- Always exits 0 — never gates the build (D-06)
- Install eslint-plugin-security@3.0.1 to root devDependencies (pinned exact version; 3.0.1 chosen over 4.0.1 for bake time per RESEARCH)
- Add pluginSecurity import to eslint.config.js
- Insert security config block (files: apps/**/*.{ts,tsx}) with configs.recommended spread, placed before prettierConfig which stays last
- ESLint version unchanged at 9.39.4 (D-13-ESLint-PIN)
- Disable detect-object-injection globally in eslint.config.js: all hits were
  numeric loop array indices (ranks[i]) — not user-controlled keys; zod guards
  real API input boundaries; justification comment added (T-16-09)
- Add inline eslint-disable for detect-non-literal-fs-filename at 2 sites:
  - apps/api/src/index.ts: realpathSync(process.argv[1]) — runtime entry path, not user input
  - apps/api/tests/broker/expand.test.ts: readFileSync of test fixture path — test-controlled
- pnpm lint exits 0 across both apps with --max-warnings 0
- 14 of 15 security rules remain active at error; no blanket file disables
- Add .gitleaks.toml inheriting default ruleset via [extend] useDefault = true
- Allowlist apps/api/tests/fixtures/vapid.ts (test-only VAPID keypair)
- Allowlist .env.example (intentional placeholder template)
- Allowlist apps/api/.env.spike (dev/spike values)
- Exclude .env/.env.* with !.env.example un-ignore so template survives
- Exclude apps/api/scripts/seed-credential.mjs (defense-in-depth)
- Exclude .git, dist/, node_modules/, tests/, Playwright artifacts
- Exclude .planning/, docs/, editor/OS, CI config, SQL dumps
- Do NOT exclude apps/api/src or apps/pwa/src (builder stage needs them)
- Add NOTE comment on migration .sql files and multi-stage COPY semantics
- Add 4th [[allowlists]] block for apps/api/tests/broker/crypto.test.ts
- Human-verified: TEST_KEY in Vitest beforeAll is a synthetic AES-256-GCM fixture, not a real credential
- Original 3 allowlists (VAPID fixture, .env.example, .env.spike) intact
- 613 commits scanned, ~23 MB of git history
- Zero findings after allowlisting crypto.test.ts AES test fixture
- Human-verified: single baseline finding (TEST_KEY) was a Vitest fixture, not a real credential
- Baseline is empty JSON array [] — PR-diff scans in 16-05 start from clean known state
- New security job parallel to fast-checks, needs: [changes], if: pull_request
- actions/checkout@v4 with fetch-depth: 0 (Pitfall 3 — base.sha must be local)
- Probe PR base/head SHA step with git merge-base fallback (A2 / OQ-1)
- Install gitleaks v8.30.1 from GitHub releases (pinned, no actions/cache)
- Secret scan step (no if:) — blocking, uses .gitleaks.toml + baseline
- setup-node/pnpm/audit/outdated steps each gated by needs.changes.outputs.code
- gate needs: expanded to include security
- individual needs.security.result check added (requires success, not success-or-skipped)
- security not folded into api/harness for loop — gitleaks always runs so it cannot be skipped
- follows Gitea #31007 individual needs.X.result pattern
- Separate 'Build and push' into 'Build production image' (build only) and 'Push image' steps
- Creates insertion point between build and push for hygiene assertions (Task 2)
- Preserves --target production, Dockerfile path, both tags, and root context
- WR-04 immutable-tag-first push ordering preserved in push step
- Static assertions step: verifies .dockerignore exists, covers all forbidden
  patterns (.env, node_modules, apps/api/scripts, .git, .planning, apps/api/tests,
  apps/pwa/e2e), and --target production is still pinned in publish.yml (T-16-19)
- Boot-smoke step: runs freshly-built sha_tag image with NODE_ENV=production +
  DEV_AUTH_BYPASS=true; fails if image starts (EXIT==0) or times out (EXIT==124),
  passes on any other non-zero exit — proves D-08 guard fires in shipped image (T-16-18)
- Both steps ordered after build and before push; a hygiene failure blocks publish (T-16-20)
Add isWaived() predicate: a waiver with a past 'expires' date is treated
as absent so the High/Critical advisory re-blocks. Applied in both
selectBlocking and partitionAdvisories. Add expired-waiver unit tests.
isMain now compares fully-resolved real paths (mirrors index.ts).
Bind github.base_ref/base.sha/head.sha through env: and reference quoted
shell vars (no inline ${{ }} in run:) to close the script-injection vector.
Add a HEAD_SHA rev-parse fallback mirroring BASE_SHA and echo the final
${BASE_SHA}..${HEAD_SHA} range before gitleaks.
WR-02: capture docker run exit directly (not the piped head exit) so a
chatty booting image can't SIGPIPE to 141 and false-PASS; require the
FATAL guard marker in output as a positive assertion.
WR-05: strip comment lines and use anchored fixed-string (grep -qF)
matching so a commented-out rule can't satisfy the hygiene check and
patterns aren't treated as regexes.
The AUDIT-ADVISORY tier cross-checks pnpm outdated (direct deps) against
advisory module_names (mostly transitive), so it almost never fires.
Relabel to OUTDATED-WITH-ADVISORY and document the limitation in the
header, header text, and inline comment; point to check-audit.mjs as the
authoritative advisory gate. No risky full-tree rewrite.
docs(phase-16): complete phase execution
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Failing after 57s
CI / api (pull_request) Successful in 58s
CI / harness (pull_request) Successful in 3m59s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
c72e013a7b
luckberg added 1 commit 2026-06-13 09:29:05 -04:00
style(16): apply prettier formatting to satisfy CI format:check
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m23s
CI / api (pull_request) Successful in 1m0s
CI / harness (pull_request) Successful in 3m54s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Successful in 1s
8154ba6f35
luckberg added 2 commits 2026-06-13 09:53:29 -04:00
Workflow-only edits shouldn't pay for the multi-minute MariaDB/integration/harness
suite. A negation '- !.gitea/**' (ordered after the yml/yaml globs) makes CI-config-
only PRs resolve code=false so api/harness skip like docs. fast-checks (format:check
still validates the workflow YAML) + gate continue to gate them. PRs that also touch
app code or lockfile still resolve code=true and run the full suite.
docs(quick-260613-dmw): exclude .gitea/** from CI heavy-job paths-filter
CI / changes (pull_request) Successful in 2s
CI / fast-checks (pull_request) Successful in 1m23s
CI / api (pull_request) Successful in 1m1s
CI / harness (pull_request) Successful in 3m51s
CI / security (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 2s
9be3b7ba37
luckberg merged commit 06238a9781 into main 2026-06-13 10:01:01 -04:00
luckberg deleted branch gsd/phase-16-ci-dependency-audit-and-security-checks 2026-06-13 10:01:01 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: luckberg/familysync#15