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

1226 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
# 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
```bash
# 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
```
### Recommended Mechanism: Node Wrapper Script
**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:**
```yaml
- 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:**
```json
{
"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)
```yaml
- 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`:
```yaml
- 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]
```yaml
- 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:
```bash
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:
```bash
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
```toml
# .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`:
```javascript
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).
```javascript
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-filename` — `serveStatic({ 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`:**
```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`:
```javascript
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
```yaml
- 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.
### Recommended `.dockerignore`
```
# === 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):
```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 the WORKDIR line:
```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.
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:**
```typescript
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`:
```typescript
// 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:
```typescript
// 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
```yaml
- 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:
```yaml
- 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
### Recommended Layout
**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` || `security` → `gate`. 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:
```yaml
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
```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
# ── 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)
```
### Recommended File Structure
```
. # 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)
- [pnpm.io/cli/audit](https://pnpm.io/cli/audit) — `--json`, `--audit-level`, `ignoreGhsas` documentation
- [pnpm.io/cli/outdated](https://pnpm.io/cli/outdated) — `--format json`, `-r` flags confirmed
- [github.com/gitleaks/gitleaks](https://github.com/gitleaks/gitleaks) — v8.30.1 latest; `git` subcommand; `--log-opts`; baseline mechanism; `.gitleaks.toml`
- [github.com/eslint-community/eslint-plugin-security](https://github.com/eslint-community/eslint-plugin-security) — v4.0.1; 15 rules; flat config support
### 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)