# 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 33–54, `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 68–72, `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 345–367): ```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 80–93): ```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 95–97): ```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 19–22) — 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 35–46) — 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 58–76): ```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 112–147) and devBypassActive comment pattern (lines 23–29). **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 1–18): ```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 1–30): ```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 1–30): ```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 7–12, 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 27–43): ```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 46–48 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 358–365, 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 1–2: `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 1–25 (file-level) and 49–57 (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 19–29. **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