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/02scripts/check-audit.mjs (blocking on unwaived High/Critical; time-boxed and fail-closed waiver expiry) + scripts/check-outdated.mjs (advisory-only).
SEC-02eslint-plugin-security folded into the flat config as errors; pnpm lint green.
IMG-02.dockerignore filtering secrets/dev/bulk while preserving builder inputs.
CI-03security job in ci.yml (gitleaks blocking every PR; audit/outdated code-gated) wired into the gate aggregator.
IMG-03publish.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)
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.
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.
## 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)
- 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 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
- 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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 +
--autofix loop ran and is clean (1 BLOCKER + 5 WARNINGs fixed, see16-REVIEW.md/16-REVIEW-FIX.md).What this delivers
assertNotDevBypassInProduction()refuses to boot whenNODE_ENV=production+DEV_AUTH_BYPASS=true; called first inindex.ts;ENV NODE_ENV=productionbaked into the prod Dockerfile stage.scripts/check-audit.mjs(blocking on unwaived High/Critical; time-boxed and fail-closed waiver expiry) +scripts/check-outdated.mjs(advisory-only).eslint-plugin-securityfolded into the flat config as errors;pnpm lintgreen..gitleaks.toml(default ruleset + fixture/env allowlists) + clean full-history baseline ([])..dockerignorefiltering secrets/dev/bulk while preserving builder inputs.securityjob inci.yml(gitleaks blocking every PR; audit/outdated code-gated) wired into thegateaggregator.publish.ymlstatic.dockerignoreassertion + 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)
securityjob on this PR should run gitleaks; confirm it fails the gate if a secret is introduced.main, confirmpublish.ymllogsPASS: Production image refused to start with DEV_AUTH_BYPASS=truebefore the push step.🤖 Generated with Claude Code
- 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)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.