Files
familysync/.planning/milestones/v1.1-phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
T
2026-06-18 22:21:38 -04:00

20 KiB
Raw Blame History

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):

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 patternneeds: [changes] + if: code-gated (lines 6872, api):

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):

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:

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:

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):

    - 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):

    - 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):

    - 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:

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:

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:

# 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):

/**
 * 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:

/**
 * 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:

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):

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):

/**
 * [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):

# 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):

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):

  {
    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):

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:

{
  "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:

{
  "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