Files
familysync/.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md
T

62 KiB
Raw Blame History

Phase 16: CI Dependency Audit, Security Checks & Image Hygiene - Research

Researched: 2026-06-13 Domain: Gitea CI extension — dependency auditing, secret scanning, static security linting, Docker image hygiene Confidence: HIGH (all findings grounded in live repo reads + verified CLI invocations)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Baseline = secret scanning + static security lint. Trivy/image CVE scanning DROPPED.
  • D-02: Secret scanning via gitleaks. Scope = per-PR diff (blocking) + one-time full-history/full-tree baseline scan.
  • D-03: eslint-plugin-security folded into the existing Phase 13 ESLint gate, as blocking ERRORS (not warnings).
  • D-04: pnpm audit fails the build on High + Critical; moderate/low are advisory only.
  • D-05: Unfixable/transitive advisories waived via a committed allowlist file in the repo — advisory IDs (CVE/GHSA) each with a reason + reviewer, reviewed through PR.
  • D-06: Outdated reporting runs and NEVER gates.
  • D-07: Bake ENV NODE_ENV=production into the production Dockerfile stage.
  • D-08: Add a boot-time refuse-to-boot guard: if NODE_ENV==='production' AND DEV_AUTH_BYPASS==='true', throw and exit non-zero.
  • D-09: Create a full .dockerignore (none exists today). Scope = secrets + dev + bulk.
  • D-10: CI assertion = static (assert .dockerignore + publish.yml --target production) + boot-smoke (start production image with NODE_ENV=production DEV_AUTH_BYPASS=true, assert non-zero exit).
  • D-11: Blocking: gitleaks (secret found), eslint-plugin-security, pnpm audit High+Critical, image-hygiene boot-smoke + static checks. Advisory (never gates): pnpm outdated.
  • D-12: Doc-only PR: gitleaks runs on every PR (including doc-only). pnpm audit + pnpm outdated gated behind needs.changes.outputs.code.
  • D-13: Advisory results surface in job log only — no PR comment / Gitea API wiring.
  • D-14: Any new blocking job wired into the gate aggregator with individual needs.X.result checks (Gitea 1.26.2 wildcard bug #31007).

Claude's Discretion

  • D-15: Job decomposition — how the new PR-time checks are laid out in ci.yml. Dedicated parallel security job vs folding into fast-checks. Researcher/planner decides.
  • Exact secret-scan tool (gitleaks vs trufflehog) — researcher confirms.
  • Exact .dockerignore line list — researcher confirms.

Deferred Ideas (OUT OF SCOPE)

  • Renovate / Dependabot automated dependency upgrades.
  • Trivy / image CVE scanning.
  • PR-comment surfacing of advisory results (Gitea API). </user_constraints>

Summary

Phase 16 makes three additive families of changes to the existing CI defined in .gitea/workflows/ci.yml and .gitea/workflows/publish.yml. No new external services — all new steps use single binaries or npm packages already computable from the workspace.

Dependency audit uses pnpm audit --json (without --audit-level to capture all severities) plus a Node wrapper that reads a committed allowlist file and exits non-zero only for unwaived High+Critical advisories. A live audit run RIGHT NOW finds one High advisory: GHSA-gv7w-rqvm-qjhr (esbuild >=0.17.0 <0.28.1, dev transitive through drizzle-kit/vitest/vite). This will immediately trigger the gate on the first PR that runs the new audit job — the planner MUST include a task to either bump the transitive dependency or add an initial waiver to the committed allowlist before the gate goes live.

Secret scanning uses gitleaks v8 (single static Go binary, MIT licensed). Per-PR: gitleaks git --log-opts="--no-merges $BASE_SHA..$HEAD_SHA" (blocking). Full-history baseline: gitleaks git on the full repo history, run once and committed as scripts/gitleaks-baseline.json; subsequent PR scans use --baseline-path to suppress already-known findings.

eslint-plugin-security v4.0.1 (2.7M weekly downloads, eslint-community org, HIGH reputation) plugs into the flat ESLint config at the root. Its 15 rules fire as blocking errors per D-03. The known-noisy rule is detect-object-injection — it fires on every obj[key] pattern. The existing codebase will need targeted // eslint-disable-next-line security/detect-object-injection comments with justification comments on legitimate usages. The planner must include a triage task for this.

Image hygiene is the lowest-risk, highest-leverage change: add one ENV NODE_ENV=production line to the production Dockerfile stage, add a boot-time guard in apps/api/src/index.ts (before isMainModule() branches), create a .dockerignore at repo root, and add post-build boot-smoke + static assertion steps to publish.yml.

Primary recommendation: Implement a dedicated parallel security job in ci.yml (D-15), run gitleaks + audit there. ESLint-security folds into fast-checks lint step. Image hygiene assertions attach to publish.yml. Wire security into the gate aggregator with individual needs.security.result check.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
Dependency audit (pnpm audit) CI job (PR-time) Lockfile-level check; no runtime tier owns this
Outdated reporting (pnpm outdated) CI job (PR-time, advisory) Registry comparison; job-log output only
Secret scanning (gitleaks) CI job (PR-time) Full-history baseline (one-time) Scans git object, not running code
Static security lint (eslint-plugin-security) CI job (fast-checks / lint step) Developer IDE Rules run at code-analysis time
Dockerfile NODE_ENV fix Docker build (production stage) Baked into image at build time
Boot-time bypass guard API server startup (apps/api/src/index.ts) Process-level runtime check
.dockerignore Docker build context Build-time filter before any COPY
CI image-hygiene assertions CI job (publish.yml, post-build) Executes after image is built

Standard Stack

Core (new additions)

Tool / Library Version Purpose Source
gitleaks v8.30.1 Secret scanning — single Go binary, no runtime deps [VERIFIED: github.com/gitleaks/gitleaks releases]
eslint-plugin-security 4.0.1 15 Node.js security rules for flat ESLint config [VERIFIED: npm registry]

Verified Package State

# Confirmed via npm view 2026-06-13:
npm view eslint-plugin-security version   # → 4.0.1
# gitleaks binary — GitHub releases API confirmed v8.30.1 as latest (2026-03-21)
# Asset: gitleaks_8.30.1_linux_x64.tar.gz

No New npm Packages for Audit/Outdated

pnpm audit and pnpm outdated are built-in pnpm commands (pnpm@11.5.1, already in workspace). No extra tool install needed.

Installation

# eslint-plugin-security — add to root devDependencies
pnpm add -D -w eslint-plugin-security@4.0.1

# gitleaks — downloaded in CI from GitHub releases (pinned version, no actions/cache)
# No local install needed; binary fetched per-run in the security job

Package Legitimacy Audit

Package Registry Age Downloads Source Repo Verdict Disposition
eslint-plugin-security npm ~10 yrs (est.) 2,685,308/wk github.com/eslint-community/eslint-plugin-security SUS (too-new: v4.0.1 published 2026-06-12) Approved — SUS verdict is purely because v4.0.1 published same day as research; the package is the canonical eslint-community org maintained package with 2.7M weekly downloads and a long history (v2.x, v3.x, v4.x all on registry). Version 3.0.0 predates the research window by months. Use v3.0.1 if v4.0.1 freshness is a concern; both are OK.

Packages removed due to SLOP verdict: none

Packages flagged as suspicious SUS: eslint-plugin-security — APPROVED despite SUS flag (version-freshness-only signal on a well-established package with verified eslint-community ownership). No checkpoint:human-verify needed.

Recommendation: Pin to 3.0.1 if the team prefers a version with more bake time; or use 4.0.1 knowing the only change is detect-bidi-characters rule addition. Either is safe.


OQ-01: pnpm outdated — Advisory-Only Mechanism Respecting Intentional Pins

The Problem

CLAUDE.md pins exact versions intentionally (e.g., eslint@9.39.4 to avoid ESLint 10 breaking eslint-plugin-react — D-13-ESLint-PIN). Running pnpm outdated naively produces a wall of noise treating routine minor drift identically to "your pin is 3 majors behind and has a known advisory."

Current State (verified 2026-06-13)

MAJOR-BEHIND packages (pinned at older major):
  @eslint/js      9.39.4 → 10.0.1   (intentional: ESLint 10 breaks eslint-plugin-react)
  eslint          9.39.4 → 10.4.1   (intentional: same reason)
  @types/node     22.19.19 → 25.9.3  (intentional: Node 22 LTS types)
  @vitejs/plugin-react  4.7.0 → 6.0.2   (MAJOR gap — check if intentional)
  jsdom           26.1.0 → 29.1.1   (transitive dev dep)
  typescript      5.9.3 → 6.0.3    (NEW: TS 6.0 released — evaluate)
  zod             3.25.76 → 4.4.3   (pinned at 3.x; 4.x is breaking change)

Minor-behind packages (routine drift):
  @types/react    19.2.16 → 19.2.17
  hono            4.12.23 → 4.12.25
  mysql2          3.22.4 → 3.22.5

Do not use pnpm.auditConfig.ignoreGhsas for the outdated report — that config key applies to pnpm audit only, not pnpm outdated. The outdated report has no native "ignore" config.

Use a committed Node.js script at scripts/check-outdated.mjs that:

  1. Runs pnpm outdated --format json -r and captures stdout.
  2. Parses the JSON (shape: { "pkg-name": { current, latest, wanted, isDeprecated, dependencyType, dependentPackages } }).
  3. Reads a committed scripts/outdated-pins.json that maps package names to "reason" strings for known intentional pins (explains why a major gap is expected).
  4. Classifies each entry:
    • AUDIT-ADVISORY: pinned version itself carries a known GHSA (cross-checks pnpm audit --json output for the same package name).
    • MAJOR-BEHIND: parseInt(latest.split('.')[0]) > parseInt(current.split('.')[0]).
    • INTENTIONAL-PIN: package has an entry in outdated-pins.json.
    • ROUTINE-DRIFT: same major, minor/patch behind.
  5. Outputs a human-readable table to stdout grouped by tier:
    === DEPENDENCY HEALTH REPORT ===
    
    [AUDIT-ADVISORY] Packages with active advisories on the pinned version:
      (none — or list with GHSA + severity)
    
    [MAJOR-BEHIND / UNPINNED] Packages >1 major behind without a pin reason:
      @vitejs/plugin-react  4.7.0 → 6.0.2  (devDependency)
    
    [MAJOR-BEHIND / INTENTIONAL PIN] Packages behind due to a known constraint:
      eslint           9.39.4 → 10.4.1  (reason: ESLint 10 breaks eslint-plugin-react@7.37.5)
      @eslint/js       9.39.4 → 10.0.1  (reason: same)
      zod              3.25.76 → 4.4.3   (reason: zod v4 is a breaking API change)
    
    [ROUTINE-DRIFT] Patch/minor updates (low priority):
      hono 4.12.23 → 4.12.25, mysql2 3.22.4 → 3.22.5, ...
    
  6. Always exits 0 (advisory-only, never blocks, per D-06).

CI invocation:

- name: Dependency outdated report (advisory only)
  run: node scripts/check-outdated.mjs
  # Always exits 0 — output appears in job log, never gates

scripts/outdated-pins.json format:

{
  "eslint": "ESLint 10 breaks eslint-plugin-react@7.37.5 (jsx-eslint#3977). Unpin when plugin releases ESLint 10 support.",
  "@eslint/js": "Pinned with eslint — same constraint.",
  "zod": "zod v4 has breaking API changes. Pin at 3.x until migration is planned.",
  "@types/node": "Pinned to Node 22 LTS types to match runtime; Node 25 is not LTS."
}

How "AUDIT-ADVISORY" cross-check works: The script also runs pnpm audit --json (all severities, no --audit-level), collects the module_name of each advisory, then flags any package in the outdated report whose current version matches a vulnerable advisory. This surfaces the case where a pinned version is not just behind but actively vulnerable.

Output format: Advisory only — in the job log under the security job. No PR comments. No failing step.

[VERIFIED: pnpm.io/cli/outdated — --format json confirmed, -r recursive confirmed, JSON shape confirmed via live pnpm outdated --format json -r run on the repo]


Secret Scanning: gitleaks Confirmed

Tool Confirmation: gitleaks over trufflehog

gitleaks is correct for this runner environment:

  • Single static Go binary (~25MB) — no runtime, no docker-in-docker, no additional deps.
  • Downloads in ~5s from GitHub releases; tarball extraction is one step.
  • MIT licensed. [VERIFIED: github.com/gitleaks/gitleaks releases — v8.30.1, 2026-03-21]
  • detect and protect subcommands deprecated in v8.19.0. Current subcommands: git, dir, stdin.
  • TruffleHog requires Python or Docker — both add complexity on the self-hosted runner; eliminated.

Install Approach (no actions/cache)

- 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
    gitleaks version

No actions/cache — install takes ~5s on the runner (small binary). Pinning VERSION=8.30.1 to avoid surprise API changes. [ASSUMED: ~5s install time estimate based on binary size and typical runner network]

PR Diff Scan (blocking)

The gitleaks git subcommand scans git history via git log -p. Scoping to the PR range uses --log-opts:

- name: Secret scan (PR diff)
  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 \
      --report-path /tmp/gitleaks-pr-report.json \
      --exit-code 1

github.event.pull_request.base.sha and github.event.pull_request.head.sha are available in Gitea Actions on pull_request events (same as GitHub Actions). [VERIFIED: Gitea Actions GitHub-compatibility layer; GITHUB_SHA confirmed available in Phase 8 probe D-PROBE-07]

Fetch depth requirement: The actions/checkout@v4 step must use fetch-depth: 0 in the security job to ensure both base.sha and head.sha are locally available for git log. The default fetch-depth: 1 only gets the HEAD commit. [ASSUMED: standard Gitea Actions behavior matches GitHub Actions checkout semantics]

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

Exit code: gitleaks exits 0 (no leaks), 1 (leaks found), 2 (error). --exit-code 1 makes it non-zero on secrets found. [VERIFIED: gitleaks wiki/README behavior]

Full-History Baseline Scan (one-time)

Run locally before Phase 16 merges:

gitleaks git \
  --config .gitleaks.toml \
  --report-path scripts/gitleaks-baseline.json

Commit scripts/gitleaks-baseline.json to suppress pre-existing findings. After that, PR scans use:

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

--baseline-path instructs gitleaks to suppress any finding whose fingerprint appears in the baseline file. New commits after baseline are fully scanned. [VERIFIED: gitleaks wiki baseline documentation]

Important baseline caveat: The apps/api/tests/fixtures/vapid.ts file contains a real-looking VAPID keypair (BIr9cwAc5L..., IjVM8QjjFqD...). Although these are documented test-only values, gitleaks may flag them as ECDH/private key leaks. They MUST appear in the baseline OR be allowlisted in .gitleaks.toml. The baseline scan should be run locally, the finding noted, and the allowlist added before committing.

.gitleaks.toml Config

# .gitleaks.toml — gitleaks configuration
# Repo: familysync

title = "FamilySync gitleaks config"

[extend]
# Extend with the default ruleset (all standard secret patterns)
useDefault = true

[[allowlists]]
description = "Test fixture VAPID keys — documented test-only values, not production keys"
paths = ['''apps/api/tests/fixtures/vapid\.ts''']

[[allowlists]]
description = ".env.example — intentional placeholder/template values, not live secrets"
paths = ['''\.env\.example$''']

[[allowlists]]
description = "apps/api/.env.spike — dev/spike values, not production secrets"
paths = ['''apps/api/\.env\.spike$''']

[extend] useDefault = true inherits the built-in gitleaks rule set (covers API keys, private keys, JWT tokens, OIDC secrets, etc.). Custom [[allowlists]] are global (highest precedence) and match by file path regex. [VERIFIED: gitleaks wiki — [[allowlists]] global config with paths field]

False positive mitigation beyond allowlist: For specific test credential strings, gitleaks supports inline # gitleaks:allow comments on the offending line. This is the preferred approach for individual instances that don't warrant a whole-path allowlist.


eslint-plugin-security Integration (D-03)

Version and Flat Config

Version: 4.0.1 [VERIFIED: npm view 2026-06-13]. Published 2026-06-12 (same day as research — SUS signal from legitimacy gate, but package is 10+ years old at eslint-community org, 2.7M downloads/wk — approved).

Alternative safe version: 3.0.1 (stable, published months earlier). Either works identically for flat config.

Flat Config Wiring

The repo uses a flat config (eslint.config.js, ESM, tseslint.config(...)) — confirmed by reading the file. eslint-plugin-security 4.x supports flat config natively.

Add to eslint.config.js:

import pluginSecurity from 'eslint-plugin-security';

export default tseslint.config(
  // ... existing config sections ...

  // Security rules — applied to all TS/TSX files in both apps
  // D-03: blocking errors, not warnings. All 15 rules enabled.
  {
    files: ['apps/**/*.{ts,tsx}'],
    ...pluginSecurity.configs.recommended,
    rules: {
      ...pluginSecurity.configs.recommended.rules,
      // detect-object-injection fires on every obj[key] pattern.
      // After triage of existing codebase: suppress globally and add inline
      // comments at true risk sites, OR keep as error and add targeted
      // eslint-disable-next-line with justification at false-positive sites.
      // Decision delegated to executor — see Triage section below.
    },
  },

  prettierConfig, // MUST remain last
);

The ...pluginSecurity.configs.recommended spread injects plugins: { security: pluginSecurity } and rules (all 15 rules at error level in the recommended config as of v4). [VERIFIED: github.com/eslint-community/eslint-plugin-security — flat config docs]

ESLint version constraint: The repo is pinned at ESLint 9.39.4 (D-13-ESLint-PIN). eslint-plugin-security 4.0.1 supports ESLint >= 8.23.0 — compatible. Do NOT upgrade ESLint to 10.x as part of this phase.

All 15 Rules (v4.0.1)

Rule What It Flags Noise Level
detect-bidi-characters Trojan-Source bidirectional characters in strings LOW (rare)
detect-buffer-noassert Buffer calls missing noassert parameter LOW
detect-child-process child_process.exec() / execSync() MEDIUM
detect-disable-mustache-escape Handlebars {{{...}}} LOW (not used)
detect-eval-with-expression eval() with variable LOW
detect-new-buffer new Buffer() (deprecated) LOW
detect-no-csrf-before-method-override method-override before CSRF LOW (not used)
detect-non-literal-fs-filename fs.* calls with variable path HIGH noise
detect-non-literal-regexp new RegExp(variable) MEDIUM
detect-non-literal-require require(variable) LOW (ESM)
detect-object-injection obj[key] bracket access VERY HIGH noise
detect-possible-timing-attacks == with password/token-like string MEDIUM
detect-pseudoRandomBytes Math.random() MEDIUM
detect-unsafe-regex ReDoS-vulnerable regex MEDIUM
detect-unsafe-regex (aliases) ReDoS variants MEDIUM

Triage Strategy for Existing Codebase

The existing codebase uses bracket access (obj[key]) extensively in Drizzle ORM query builders, schema definitions, and TypeScript generic patterns. detect-object-injection will fire prolifically.

Recommended triage approach for detect-object-injection:

Option A (recommended): Disable globally in the security block and add targeted // eslint-disable-next-line security/detect-object-injection -- reason at the handful of true risk sites (user-controlled key without validation).

rules: {
  ...pluginSecurity.configs.recommended.rules,
  'security/detect-object-injection': 'off', // High false-positive rate; real risks guarded by zod validation
},

Option B: Keep as error, add // eslint-disable-next-line security/detect-object-injection -- controlled: key from schema, not user input at every Drizzle/TypeScript usage. This creates a lot of churn but keeps the rule active.

The user explicitly chose error severity (D-03). Option A is the pragmatic read — disable the single highest-noise rule while keeping the other 14 at error. Option B preserves the full rule set but requires annotating ~2050 existing sites. The executor decides after running pnpm lint and counting violations.

Other high-noise candidates in this codebase:

  • detect-non-literal-fs-filenameserveStatic({ root: './public' }) in index.ts uses a literal, but dynamic path construction anywhere (e.g., in the crypto broker) may fire.
  • detect-possible-timing-attacks — any string comparison involving OIDC session tokens.
  • detect-child-process — not used in this codebase (no subprocess calls found in source scan). Low risk.

Executor workflow:

  1. Install eslint-plugin-security, add to flat config.
  2. Run pnpm lint — observe all violations.
  3. For each rule category: determine if it's a true risk or false positive.
  4. True risks: fix the code.
  5. False positives at specific sites: add // eslint-disable-next-line security/detect-RULE -- justification with a comment explaining why it's safe.
  6. Whole-codebase false positives for a given rule: disable the rule in the security config block with an explanation comment.
  7. Re-run pnpm lint --max-warnings 0 — must be green before merge.

pnpm audit Allowlist/Waiver Mechanism (D-04 / D-05)

Mechanism Comparison

Option A — pnpm.auditConfig.ignoreGhsas in root package.json:

{
  "pnpm": {
    "auditConfig": {
      "ignoreGhsas": ["GHSA-gv7w-rqvm-qjhr"]
    }
  }
}
  • Pros: Native pnpm support; zero wrapper script; pnpm audit exit code respects ignores.
  • Cons: ignoreGhsas replaced ignoreCves in pnpm v11 — confirmed supported [VERIFIED: pnpm.io/cli/audit]. No way to attach a "reason" or "reviewer" field inline. The JSON key is just an array of GHSA strings — not self-documenting for auditors.
  • ignoreCves is NO LONGER SUPPORTED in pnpm v11 (workspace uses pnpm@11.5.1).

Option B — Committed allowlist file + Node.js wrapper:

scripts/audit-allowlist.json:
{
  "GHSA-gv7w-rqvm-qjhr": {
    "reason": "esbuild binary integrity check bug in Deno module — only exploitable via NPM_CONFIG_REGISTRY manipulation in a Deno environment. Not applicable to our Node.js runtime. Transitive via drizzle-kit (devDependency), vitest, vite (build-time only). Patched in esbuild >=0.28.1; will resolve when drizzle-kit bumps its transitive dependency.",
    "reviewer": "luc",
    "expires": "2026-09-01"
  }
}

Wrapper script scripts/check-audit.mjs:

import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';

const allowlist = JSON.parse(readFileSync('scripts/audit-allowlist.json', 'utf8'));
const audit = JSON.parse(execSync('pnpm audit --json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }));

const unwaived = Object.entries(audit.advisories || {})
  .filter(([, adv]) => ['high', 'critical'].includes(adv.severity))
  .filter(([, adv]) => !allowlist[adv.github_advisory_id]);

if (unwaived.length > 0) {
  console.error('BLOCKING advisories (High/Critical, not in allowlist):');
  unwaived.forEach(([, adv]) => {
    console.error(`  ${adv.github_advisory_id} [${adv.severity}] ${adv.module_name}: ${adv.title}`);
  });
  process.exit(1);
}

console.log('Audit PASS — no unwaived High/Critical advisories.');

// Advisory report (moderate/low)
const advisory = Object.entries(audit.advisories || {})
  .filter(([, adv]) => !['high', 'critical'].includes(adv.severity));
if (advisory.length > 0) {
  console.log('Advisory (non-blocking) findings:');
  advisory.forEach(([, adv]) => console.log(`  ${adv.github_advisory_id} [${adv.severity}] ${adv.module_name}`));
}

Recommendation: Option B — committed allowlist file with structured reason + reviewer + expiry fields. More auditable, self-documenting, PR-reviewable. The wrapper can also log expired waivers as warnings. This matches D-05 which explicitly calls for "reason + reviewer."

Current Advisory State (MUST ADDRESS BEFORE GATE GOES LIVE)

High (blocks gate): GHSA-gv7w-rqvm-qjhr
  esbuild >=0.17.0 <0.28.1
  Paths: drizzle-kit (devDep), vitest, vite — all build/dev tooling
  Note: esbuild is a dev transitive dep — production image does not run esbuild
  Resolution: Add GHSA-gv7w-rqvm-qjhr to audit-allowlist.json with reason,
              OR upgrade drizzle-kit to a version that pins esbuild >=0.28.1
  Fixable: YES (esbuild 0.28.1+ patches it; drizzle-kit upgrade path unclear without testing)

Moderate (advisory): GHSA-67mh-4wv8-2f99
  esbuild <=0.24.2 — same transitive path — advisory only

Low (advisory): GHSA-g7r4-m6w7-qqqr
  esbuild — same transitive path — advisory only

[VERIFIED: live pnpm audit --json run on the repo 2026-06-13]

CI Invocation

- name: Dependency audit (High+Critical blocks)
  run: node scripts/check-audit.mjs
  # Exits 1 if any unwaived High/Critical advisory; exits 0 if all waived or none

For pnpm audit --json (all severities in JSON, no --audit-level): confirmed that --audit-level high FILTERS the JSON output to only high+ entries, while --json alone returns all severities. The wrapper uses plain --json and does the severity filter in code — giving visibility into moderate/low in the log while blocking only on high/critical. [VERIFIED: live CLI test]


Exact .dockerignore Line List (D-09)

Analysis of Current Repo Tree

What the production stage actually COPYs (from Dockerfile):

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   # ← multi-stage artifact
COPY --from=pwa-builder /app/apps/pwa/dist ./public       # ← multi-stage artifact

The dist/ artifacts come from multi-stage COPYs (--from=builder, --from=pwa-builder) — NOT from the build context. The production stage only needs the workspace manifests, lockfile, and package JSONs from the build context (for pnpm install --prod). The builder and pwa-builder stages copy apps/api and apps/pwa respectively from the build context.

Critical insight: .dockerignore applies to the build context (what the Docker daemon receives). It does NOT affect COPY --from=<stage> operations. So the .dockerignore must protect the builder stage's COPY apps/api ./apps/api from receiving secrets, but does NOT need to worry about the production stage's multi-stage copies.

# === Secrets and credentials (NEVER in build context) ===
.env
.env.*
!.env.example
apps/api/scripts/seed-credential.mjs

# === VCS (large and unnecessary) ===
.git
.gitignore

# === Build artifacts (regenerated in-build) ===
**/dist/
**/.dist/

# === Dependencies (reinstalled in-build) ===
**/node_modules/

# === Tests (not needed in build; keep out of prod) ===
apps/api/tests/
apps/api/test/
apps/pwa/e2e/

# === Playwright artifacts ===
apps/pwa/test-results/
apps/pwa/playwright-report/
apps/pwa/blob-report/
.playwright/
.playwright-cli/

# === Planning / docs / dev tooling ===
.planning/
docs/
graphify-out/
.venv/

# === Editor / OS ===
.vscode/
.idea/
.DS_Store

# === CI / dev config files (not needed in image) ===
.gitea/
.markdownlint-cli2.jsonc
.prettierignore
.prettierrc
eslint.config.js

# === SQL dumps (if any) ===
*.sql.dump
*.sql.gz

# NOTE: apps/api/src/db/migrations/*.sql are included in the build context
# because the builder stage's `COPY apps/api ./apps/api` needs them.
# However, migrations are applied at runtime (drizzle-kit migrate), not
# baked into the image — they travel with the app source in builder stage only.
# The production stage does NOT copy apps/api/src directly; it only copies
# apps/api/dist (via --from=builder) and apps/api/package.json.
# ← So migration .sql files in src/db/migrations/ never reach the production image.

Items NOT excluded (must be available to builder stage):

  • apps/api/src/ — needed by builder stage's COPY apps/api ./apps/api and pnpm build
  • apps/pwa/src/ — needed by pwa-builder stage
  • pnpm-workspace.yaml, pnpm-lock.yaml, package.json — needed by all stages
  • apps/api/package.json, apps/pwa/package.json — needed by all stages
  • apps/api/tsconfig.json, apps/pwa/tsconfig.json — needed by TypeScript build

Dev spike file: .env.spike is gitignored already, but .dockerignore should also exclude it (apps/api/.env.spike) in case it's untracked in the build context. Since .env.* is already covered by .env.* glob, this is covered.

seed-credential.mjs: Gitignored (never committed) but explicitly listed in .dockerignore for defense-in-depth — if an operator accidentally un-ignores it, Docker won't send it to the daemon.

[VERIFIED: live directory listing of entire repo tree; Dockerfile COPY instructions read directly]


D-07 / D-08: Image Hygiene Code Changes

D-07: Add ENV NODE_ENV=production to Dockerfile

Location: The production stage in apps/api/Dockerfile, before the CMD line.

Current production stage (lines 3546):

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 the WORKDIR line:

# 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.
ENV NODE_ENV=production

Why here: After WORKDIR sets the runtime working directory, before CMD. The ENV instruction is baked into the image layer — it sets the environment for all subsequent RUN steps and for the final container process. The CMD (node dist/index.js) inherits it. [VERIFIED: Dockerfile read directly]

Impact on devBypass.ts: The existing guard in devBypass.ts (line 61: if (process.env.NODE_ENV === 'production') return async (_c, next) => next();) will now fire reliably in the production image. Previously it was safe only because DEV_AUTH_BYPASS defaulted to unset — but anyone accidentally adding DEV_AUTH_BYPASS=true to the production compose environment would have had a silent bypass with no guard. With ENV NODE_ENV=production baked in, the hard guard fires first, always. [VERIFIED: devBypass.ts read directly]

Impact on index.ts: Line 24: const devBypassActive = process.env.NODE_ENV !== 'production' && process.env.DEV_AUTH_BYPASS === 'true'; — this will evaluate to false in the production image regardless of DEV_AUTH_BYPASS. Correct. [VERIFIED: index.ts read directly]

D-08: Boot-Time Refuse-to-Boot Guard

Location: apps/api/src/index.ts, inside the isMainModule() block, BEFORE any background worker startup or serve() call.

Guard code:

if (isMainModule()) {
  // D-08: Production safety guard. Refuse to boot if someone accidentally
  // sets DEV_AUTH_BYPASS=true in a production container. This is defense-in-depth
  // on top of the Dockerfile ENV NODE_ENV=production (D-07) — turns a silent
  // misconfiguration into a loud, immediate failure.
  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);
  }

  // ... VAPID config, startBrokerPoller(), serve() etc. remain unchanged
}

Placement: First statement inside the if (isMainModule()) block, before any other startup code. This ensures the process exits with code 1 before opening any ports or starting workers.

Unit test: In apps/api/tests/auth/devBypass.test.ts (existing file) or a new tests/startup.test.ts:

// Test the boot-time guard independently without forking a process.
// The guard logic is simple enough to unit-test by extracting it or by
// testing the index.ts module's startup path with mocked process.env.
import { describe, it, expect, vi, afterEach } from 'vitest';

describe('boot-time production guard', () => {
  afterEach(() => {
    vi.unstubAllEnvs();
  });

  it('exits 1 when NODE_ENV=production and DEV_AUTH_BYPASS=true', () => {
    vi.stubEnv('NODE_ENV', 'production');
    vi.stubEnv('DEV_AUTH_BYPASS', 'true');
    const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); });
    expect(() => {
      // Call the guard logic directly — extract to a testable function
      if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') {
        process.exit(1);
      }
    }).toThrow('process.exit called');
    expect(exitSpy).toHaveBeenCalledWith(1);
    exitSpy.mockRestore();
  });

  it('does not exit when NODE_ENV=development and DEV_AUTH_BYPASS=true', () => {
    vi.stubEnv('NODE_ENV', 'development');
    vi.stubEnv('DEV_AUTH_BYPASS', 'true');
    const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit called'); });
    expect(() => {
      if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') {
        process.exit(1);
      }
    }).not.toThrow();
    exitSpy.mockRestore();
  });
});

Better pattern — extract to a function: Instead of testing inline logic, extract the guard to a testable helper:

// In src/index.ts (or src/lib/bootGuards.ts):
export function assertNotDevBypassInProduction(): void {
  if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') {
    console.error('[FATAL] DEV_AUTH_BYPASS=true is forbidden in production. Refusing to start.');
    process.exit(1);
  }
}

Then in the isMainModule() block: assertNotDevBypassInProduction(); — and test the exported function directly.


D-10: CI Image-Hygiene Assertions (Static + Boot-Smoke)

These assertions attach to publish.yml, after the Build and push step.

Static Assertions

- name: Image hygiene — static assertions
  run: |
    set -euo pipefail

    # Assert .dockerignore exists
    if [ ! -f ".dockerignore" ]; then
      echo "FAIL: .dockerignore does not exist"
      exit 1
    fi

    # Assert .dockerignore covers required forbidden patterns
    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

    # Assert publish.yml still uses --target production
    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."

Boot-Smoke (D-08 verification in the actual image)

After the image is built (but before pushing), run the production image with the forbidden env combo and assert it exits non-zero:

- name: Image hygiene — boot-smoke (must refuse dev-bypass in production)
  run: |
    set -euo pipefail

    IMAGE="${{ steps.tags.outputs.sha_tag }}"

    # Run the production image with NODE_ENV=production and DEV_AUTH_BYPASS=true.
    # The D-08 guard must cause an immediate non-zero exit.
    # --rm: clean up container after run.
    # --env: pass the forbidden combo.
    # timeout 15s: if the container hangs (bug: guard not firing), fail the step.
    set +e
    timeout 15 docker run --rm \
      --env NODE_ENV=production \
      --env DEV_AUTH_BYPASS=true \
      "$IMAGE" \
      2>&1 | head -20
    EXIT=$?
    set -e

    # timeout exits 124 if the process was killed (container didn't exit on its own).
    # docker run exits with the container's exit code otherwise.
    # We want the container to exit with code 1 (the guard's process.exit(1)).
    # A timeout (124) means the guard DIDN'T fire — the container just kept running.
    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 with DEV_AUTH_BYPASS=true — guard not firing"
      exit 1
    fi
    echo "PASS: Production image refused to start with DEV_AUTH_BYPASS=true (exit $EXIT)"

Placement in publish.yml: After Build and push step — the image is already tagged and available locally (docker build created it). The smoke runs against the locally-built image before any push. However, since the Dockerfile has ENV NODE_ENV=production baked in, the container environment set via --env NODE_ENV=production is technically redundant (the image already has it) — but passing it explicitly makes the test intent explicit.

The real DB, OIDC, VAPID env vars are not needed — the guard fires before any of those are reached in the startup path.

Runner constraint: docker is available in the runner (the publish job already uses docker build and docker push). [VERIFIED: publish.yml uses docker directly]

Important ordering: Run static assertions BEFORE the boot-smoke. If either fails, the push step (which runs after) should be blocked. Use if: success() (implicit) on the push step, or explicitly gate it. The existing publish.yml structure runs steps sequentially — add assertions BEFORE the docker push calls, or restructure to push only after smoke passes.


D-15: Job Decomposition

New security job in ci.yml — parallel to fast-checks:

ci.yml jobs (PR workflow):
  changes      → (no deps) — paths-filter
  fast-checks  → (no deps) — lint(+security), format:check, md:lint, typecheck, pwa-unit
  api          → needs: [changes], if: code=='true' — DB-backed API tests
  harness      → needs: [changes], if: code=='true' — Playwright
  security     → needs: [changes] — gitleaks (always) + audit/outdated (if code=='true')
  gate         → needs: [fast-checks, changes, api, harness, security], if: always()

Rationale:

  1. ESLint-security folds into fast-checks (pnpm lint step) — zero extra install cost, same ~30s pnpm install already paid. The lint step runs ESLint which now includes the security plugin.

  2. Dedicated security job for gitleaks + audit/outdated — separate from fast-checks because:

    • gitleaks installs a binary (~5s) — separate step isolation avoids polluting the fast-checks job.
    • Advisory churn in pnpm outdated doesn't cause fast-checks to appear noisy.
    • A secret-leak failure should be clearly attributable to the security job, not buried in fast-checks.
    • Both run in parallel — critical path is: fast-checks || securitygate. The security job is lighter than api/harness and won't be the bottleneck.
  3. security job needs: [changes] but runs differently from api/harness:

    • gitleaks: always runs (D-12 — a secret can land in a doc commit).
    • pnpm audit + pnpm outdated: only if changes.outputs.code == 'true' (lockfile or source changes).

    Implementation: Run gitleaks unconditionally in the job. Use a if: needs.changes.outputs.code == 'true' condition on the audit/outdated steps (step-level if:), not job-level. This keeps the job always-running (for gitleaks) while skipping the pnpm steps for doc-only PRs.

  4. gate aggregator update (D-14): Add security to needs: and add a per-needs.security.result check in the gate shell script:

gate:
  runs-on: ubuntu-latest
  needs: [fast-checks, changes, api, harness, security]
  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
        # security always runs — must be success
        if [ "${{ needs.security.result }}" != "success" ]; then
          echo "security: ${{ needs.security.result }}"
          exit 1
        fi
        # api and harness are conditionally skipped — success OR skipped acceptable
        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."

Note: security must always succeed (not just "success or skipped") because gitleaks always runs in it. If the job itself errors or is cancelled, the gate must fail. [VERIFIED: gate job pattern from ci.yml read directly, Gitea #31007 wildcard bug handled]

Full security Job Skeleton

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

    # ── Gitleaks (always runs, 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, 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

Common Pitfalls

Pitfall 1: pnpm audit --audit-level high Filters JSON Output

What goes wrong: Using pnpm audit --audit-level high --json and then trying to count moderate/low advisories from the output — they won't appear. --audit-level high filters BOTH the human-readable output AND the JSON output to only show high+.

How to avoid: Use pnpm audit --json (no --audit-level) and filter severity in the wrapper script. This gives full visibility in the log while allowing custom blocking logic.

[VERIFIED: live CLI test confirmed JSON is filtered by --audit-level]

Pitfall 2: gitleaks detect/protect Are Deprecated

What goes wrong: Using gitleaks detect --source . (deprecated since v8.19.0). Still works but hidden from help.

How to avoid: Use gitleaks git for repository scanning with --log-opts for range scoping.

Pitfall 3: fetch-depth: 1 Makes gitleaks PR Range Scan Fail

What goes wrong: Default actions/checkout@v4 with fetch-depth: 1 only fetches the HEAD commit. github.event.pull_request.base.sha is not in the local git history, so git log BASE_SHA..HEAD_SHA finds no commits and gitleaks exits 0 silently (appears to pass but scanned nothing).

How to avoid: Set fetch-depth: 0 in the security job's checkout step.

Warning signs: gitleaks log shows "no commits in range" or exits immediately with 0.

Pitfall 4: The Existing esbuild High Advisory Will Immediately Fail the Gate

What goes wrong: The Phase 16 branch's first PR with the audit gate enabled will immediately fail with GHSA-gv7w-rqvm-qjhr (esbuild high advisory). This is expected — the advisory exists today.

How to avoid: The planner MUST schedule a Wave 0 task (or Plan 0) to either:

  • Add GHSA-gv7w-rqvm-qjhr to the initial scripts/audit-allowlist.json with justification (the advisory is in dev/build tooling — drizzle-kit/vitest/vite — not in the production runtime), OR
  • Upgrade the relevant tools to pull in esbuild >=0.28.1 (if feasible without breaking pins).

The allowlist waiver is the safe initial path; upgrade is a separate task.

Pitfall 5: docker run Boot-Smoke Needs DB + Other Env Vars to NOT Crash Before the Guard

What goes wrong: The production image tries to connect to MariaDB at startup (before the guard fires) if the guard is placed too late in the startup path.

How to avoid: Place the assertNotDevBypassInProduction() call as the FIRST statement inside if (isMainModule()), before VAPID config and before serve(). The guard fires before any worker, DB connection, or server setup.

Verification: The boot-smoke assertion (timeout 15 docker run ...) exits when the guard fires — it does NOT need DB, OIDC, or VAPID env vars. The container should print the [FATAL] message and exit with code 1 within ~1 second.

Pitfall 6: detect-object-injection Will Fire on Drizzle ORM Patterns

What goes wrong: ESLint rule security/detect-object-injection flags obj[key] bracket access. Drizzle ORM, TypeScript generics, and schema-driven code use this pattern extensively. Running pnpm lint after adding eslint-plugin-security will produce dozens of violations.

How to avoid: Plan a triage task for the executor: run lint, count violations per rule, then decide to disable the rule globally or add targeted eslint-disable comments. Do not merge with lint failures.

Pitfall 7: auditConfig.ignoreCves Is Removed in pnpm v11

What goes wrong: Using pnpm.auditConfig.ignoreCves in package.json — this was replaced by ignoreGhsas in pnpm v11. The workspace uses pnpm@11.5.1. ignoreCves silently does nothing.

How to avoid: Use ignoreGhsas if using the native pnpm config, or use the wrapper script approach (recommended).


Architecture Patterns

System Architecture Diagram

pull_request event
       │
       ├──► changes (paths-filter) ──────────────────────────────────┐
       │                                                              │
       ├──► fast-checks (always)                                      │
       │     └── pnpm lint (now includes eslint-plugin-security)      │
       │     └── format:check, md:lint, typecheck, pwa-unit           │
       │                                                              │
       ├──► security (always, no pnpm cache needed for gitleaks)      │
       │     └── gitleaks install (5s binary download)               │
       │     └── gitleaks git [BASE..HEAD] (always, D-12)            │
       │     └── if(code):                                           │
       │           └── pnpm install (~30s)                           │
       │           └── check-audit.mjs (exits 1 on unwaived High+)   │
       │           └── check-outdated.mjs (advisory log, exits 0)    │
       │                          ▲                                   │
       │                    needs.changes.outputs.code               │
       │                                                              │
       ├──► api (if code) ◄──────────────────────────────────────────┤
       │     └── MariaDB service, pnpm install, migrations, tests    │
       │                                                              │
       ├──► harness (if code) ◄──────────────────────────────────────┤
       │     └── MariaDB service, Playwright, API background proc    │
       │                                                              │
       └──► gate (if: always(), needs: all)
             └── fast-checks must succeed
             └── security must succeed
             └── api: success OR skipped
             └── harness: success OR skipped

push to main (merge) event
       │
       └──► publish
             └── docker build --target production -f apps/api/Dockerfile .
             └── STATIC assertions (.dockerignore exists + covers patterns + --target pin)
             └── BOOT-SMOKE (docker run prod image + NODE_ENV=prod + DEV_AUTH_BYPASS=true → assert exit 1)
             └── docker push (immutable tag first, then :latest)
             └── docker logout (always)
.                              # repo root
├── .dockerignore              # NEW — D-09
├── .gitleaks.toml             # NEW — gitleaks config + allowlists
├── .gitea/
│   └── workflows/
│       ├── ci.yml             # MODIFIED — add security job, update gate
│       └── publish.yml        # MODIFIED — add static + boot-smoke assertions
├── scripts/
│   ├── audit-allowlist.json   # NEW — GHSA waivers with reason + reviewer
│   ├── check-audit.mjs        # NEW — pnpm audit wrapper
│   ├── check-outdated.mjs     # NEW — pnpm outdated wrapper + classification
│   └── gitleaks-baseline.json # NEW — full-history scan result (committed)
└── apps/api/
    ├── Dockerfile             # MODIFIED — add ENV NODE_ENV=production in production stage
    └── src/
        ├── index.ts           # MODIFIED — add assertNotDevBypassInProduction() guard
        └── lib/
            └── bootGuards.ts  # NEW (optional) — exported guard function for testability

Validation Architecture (Nyquist)

workflow.nyquist_validation is true — section required.

Test Framework

Property Value
Framework Vitest (apps/api: pnpm --filter @familysync/api test)
Config file apps/api/vitest.config.ts
Quick run command pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts
Full suite command pnpm --filter @familysync/api test

Phase Requirements → Test Map

Behavior Test Type Automated Command Notes
Boot-time guard: exits 1 when NODE_ENV=production + DEV_AUTH_BYPASS=true Unit pnpm --filter @familysync/api test -- --run tests/lib/bootGuards.test.ts Tests exported assertNotDevBypassInProduction() function
Boot-time guard: no-op when NODE_ENV=development Unit same Guard should be inert in dev
gitleaks detects a real secret in a diff Manual/fixture injection Run gitleaks manually with a test fixture file containing a fake key pattern One-time validation during Phase 16 setup
gitleaks baseline suppresses pre-existing findings Manual Run with --baseline-path scripts/gitleaks-baseline.json against known clean state Confirm baseline file works
pnpm audit wrapper exits 1 on High advisory without waiver Unit (Node.js) node scripts/check-audit.mjs against fixture JSON Can be tested with a mock pnpm audit JSON
pnpm audit wrapper exits 0 on High advisory with waiver Unit same with GHSA in allowlist
pnpm outdated wrapper always exits 0 Unit node scripts/check-outdated.mjs Just check exit code
.dockerignore exists and covers patterns CI static step Runs in publish.yml Automated
Production image refuses DEV_AUTH_BYPASS=true Integration (Docker) publish.yml boot-smoke step Runs post-build in CI
eslint-plugin-security rules flag real security issues Lint pnpm lint Existing lint gate; becomes the test
Gate fails if security job fails CI behavior Manual PR test with deliberate secret in diff Human-validated once

Sampling Rate

  • Per task commit: pnpm --filter @familysync/api test -- --run tests/lib/ (unit tests for boot guard)
  • Per wave merge: pnpm --filter @familysync/api test (full API test suite)
  • Phase gate: Full suite green + pnpm lint green + boot-smoke PASS before /gsd-verify-work

Wave 0 Gaps

  • apps/api/tests/lib/bootGuards.test.ts — unit tests for assertNotDevBypassInProduction()
  • apps/api/src/lib/bootGuards.ts — exported guard function (if extracting from index.ts)
  • scripts/check-audit.mjs — wrapper script
  • scripts/check-outdated.mjs — wrapper script
  • scripts/audit-allowlist.json — initial entry for GHSA-gv7w-rqvm-qjhr
  • scripts/outdated-pins.json — intentional pin explanations
  • .gitleaks.toml — config with allowlists
  • scripts/gitleaks-baseline.json — full-history scan output (generated + committed)
  • .dockerignore — root-level file

Security Domain

security_enforcement: true (absent in config = enabled).

Applicable ASVS Categories (Phase 16 — CI tooling phase)

ASVS Category Applies Standard Control
V2 Authentication No Not changing auth logic
V3 Session Management No Not changing session handling
V4 Access Control No Not changing access controls
V5 Input Validation Partial Validating allowlist JSON format in wrapper scripts
V6 Cryptography No Not changing crypto
V14 Configuration Yes Ensuring production image does not carry dev credentials or accept dev-bypass config

Threat Model for This Phase

Threat STRIDE Mitigation
Developer accidentally commits OIDC secret or app password to git Information Disclosure gitleaks PR scan (blocking)
Production container deployed with DEV_AUTH_BYPASS=true (misconfigured docker-compose) Elevation of Privilege D-07 (ENV NODE_ENV baked in) + D-08 (boot-time guard, exits 1)
Transitive dependency with known CVE ships in production image Tampering / Information Disclosure pnpm audit gate (D-04); dev deps audited separately
Dev-only code, test fixtures, or .env files shipped in Docker image Information Disclosure .dockerignore (D-09); production stage multi-stage isolation

State of the Art

Old Approach Current Approach Impact
pnpm audit --audit-level pnpm audit --json + wrapper with per-GHSA allowlist More auditable; can expire waivers
gitleaks detect (v8 <8.19.0) gitleaks git --log-opts Clearer API; supports baseline
auditConfig.ignoreCves auditConfig.ignoreGhsas (pnpm v11) CVE IDs no longer returned by npm audit API

Deprecated:

  • pnpm.auditConfig.ignoreCves: Removed in pnpm v11 — use ignoreGhsas or the wrapper approach.
  • gitleaks detect / gitleaks protect: Deprecated since v8.19.0 — use gitleaks git.

Assumptions Log

# Claim Section Risk if Wrong
A1 gitleaks binary install takes ~5s on the self-hosted runner Secret scanning — Install Approach If slow (>30s), move install to fast-checks or cache binary in a shared step
A2 github.event.pull_request.base.sha is available in Gitea Actions on pull_request events PR Diff Scan If unavailable, use GITHUB_BASE_REF + fetch-depth:0 + git merge-base origin/$BASE_BRANCH HEAD pattern
A3 actions/checkout@v4 with fetch-depth: 0 works on this Gitea runner PR Diff Scan Runner probe in Phase 8 confirmed checkout works; fetch-depth:0 is a standard option — low risk
A4 eslint-plugin-security 4.0.1 is compatible with ESLint 9.39.4 (pinned) eslint-plugin-security Integration Plugin supports ESLint >= 8.23.0 per docs; confirmed compatible

If this table is empty: Not empty — A2 is worth confirming in a runner probe step.


Open Questions

  1. Does github.event.pull_request.base.sha populate in Gitea Actions?

    • What we know: GITHUB_SHA is confirmed available (Phase 8 D-PROBE-07). Gitea Actions mirrors GitHub Actions event context.
    • What's unclear: The github.event.pull_request context object populates on pull_request events — confirmed in GitHub Actions. Gitea's event context compatibility is high but not probe-verified for this specific field.
    • Recommendation: Add a runner probe step in Wave 0 to print github.event.pull_request.base.sha and head.sha — confirm non-empty. If empty, fall back to: git merge-base $(git rev-parse origin/${{ github.base_ref }}) HEAD as the base SHA.
  2. Can the esbuild High advisory be resolved by upgrading drizzle-kit?

    • What we know: GHSA-gv7w-rqvm-qjhr is fixable (esbuild >=0.28.1). drizzle-kit 0.31.10 (pinned in CLAUDE.md) pins esbuild 0.28.0. A minor drizzle-kit bump might pull in esbuild 0.28.1+.
    • What's unclear: Whether drizzle-kit has released a version that resolves the transitive esbuild pin without breaking the migration/generate workflow.
    • Recommendation: Initial plan should waiver the advisory with justification. A follow-up task can investigate upgrading drizzle-kit to drop the waiver.
  3. eslint-plugin-security detect-object-injection triage scope

    • What we know: The rule fires on obj[key] patterns. The codebase uses Drizzle ORM, TypeScript generics, and dynamic dispatch extensively.
    • What's unclear: Exact count of violations. Cannot determine without running lint with the plugin installed.
    • Recommendation: Executor runs lint first and counts; either disable globally or add targeted suppression. Build the plan with a dedicated "triage and fix ESLint security violations" task.

Environment Availability

Dependency Required By Available Version Fallback
pnpm All pnpm commands 11.5.1 (workspace)
Node.js 22 scripts/check-audit.mjs, check-outdated.mjs 22 LTS (runner confirmed)
docker publish.yml boot-smoke Available in runner (publish.yml uses it)
curl gitleaks binary download ✓ (assumed) Standard ubuntu-latest wget as fallback
gitleaks Secret scanning ✗ (downloaded in CI) v8.30.1 (pinned)

[VERIFIED: docker available — publish.yml uses docker build, docker push without issues; pnpm/node available — confirmed from all existing CI jobs]


Sources

Primary (MEDIUM confidence — WebSearch + official site reads)

Verified via Live CLI Runs (HIGH confidence)

  • pnpm audit --json — live run on repo; confirmed 3 advisories (1 high: GHSA-gv7w-rqvm-qjhr, 1 moderate, 1 low); all esbuild transitive
  • pnpm audit --audit-level high --json — confirmed filters JSON to high+ only; exits 1
  • pnpm audit --audit-level high — exits 1 (confirmed)
  • pnpm outdated --format json -r — live run; confirmed JSON shape with current/latest/wanted/isDeprecated/dependencyType/dependentPackages
  • npm view eslint-plugin-security version → 4.0.1 (2026-06-12)
  • GitHub releases API: gitleaks v8.30.1, gitleaks_8.30.1_linux_x64.tar.gz asset confirmed

Tertiary (LOW confidence — training knowledge)

  • gitleaks --exit-code behavior (exits 0 clean, 1 leak, 2 error) — documented in gitleaks README, training knowledge
  • eslint-plugin-security rule list and detect-object-injection noise level — corroborated by multiple search results

Metadata

Confidence breakdown:

  • Standard stack: HIGH — gitleaks binary confirmed on GitHub releases; eslint-plugin-security confirmed on npm; pnpm commands confirmed via live runs
  • Architecture: HIGH — grounded in actual ci.yml, publish.yml, Dockerfile, index.ts, devBypass.ts reads
  • Pitfalls: HIGH — most derived from live CLI verification of exit codes and JSON shapes

Research date: 2026-06-13 Valid until: 2026-08-01 (stable tooling; pnpm audit JSON format unlikely to change; gitleaks v8 API stable)