62 KiB
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 auditfails 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=productioninto the production Dockerfile stage. - D-08: Add a boot-time refuse-to-boot guard: if
NODE_ENV==='production'ANDDEV_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 withNODE_ENV=production DEV_AUTH_BYPASS=true, assert non-zero exit). - D-11: Blocking: gitleaks (secret found), eslint-plugin-security,
pnpm auditHigh+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 outdatedgated behindneeds.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
gateaggregator with individualneeds.X.resultchecks (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
securityjob vs folding intofast-checks. Researcher/planner decides. - Exact secret-scan tool (gitleaks vs trufflehog) — researcher confirms.
- Exact
.dockerignoreline 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-charactersrule 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:
- Runs
pnpm outdated --format json -rand captures stdout. - Parses the JSON (shape:
{ "pkg-name": { current, latest, wanted, isDeprecated, dependencyType, dependentPackages } }). - Reads a committed
scripts/outdated-pins.jsonthat maps package names to "reason" strings for known intentional pins (explains why a major gap is expected). - Classifies each entry:
- AUDIT-ADVISORY: pinned version itself carries a known GHSA (cross-checks
pnpm audit --jsonoutput 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.
- AUDIT-ADVISORY: pinned version itself carries a known GHSA (cross-checks
- 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, ... - 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]
detectandprotectsubcommands 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.tsfile 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 ~20–50 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' })inindex.tsuses 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:
- Install
eslint-plugin-security, add to flat config. - Run
pnpm lint— observe all violations. - For each rule category: determine if it's a true risk or false positive.
- True risks: fix the code.
- False positives at specific sites: add
// eslint-disable-next-line security/detect-RULE -- justificationwith a comment explaining why it's safe. - Whole-codebase false positives for a given rule: disable the rule in the security config block with an explanation comment.
- 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 auditexit code respects ignores. - Cons:
ignoreGhsasreplacedignoreCvesin 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. ignoreCvesis 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.
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 bybuilderstage'sCOPY apps/api ./apps/apiandpnpm buildapps/pwa/src/— needed bypwa-builderstagepnpm-workspace.yaml,pnpm-lock.yaml,package.json— needed by all stagesapps/api/package.json,apps/pwa/package.json— needed by all stagesapps/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 35–46):
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
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:
-
ESLint-security folds into
fast-checks(pnpm lintstep) — zero extra install cost, same ~30s pnpm install already paid. The lint step runs ESLint which now includes the security plugin. -
Dedicated
securityjob for gitleaks + audit/outdated — separate fromfast-checksbecause:- gitleaks installs a binary (~5s) — separate step isolation avoids polluting the fast-checks job.
- Advisory churn in
pnpm outdateddoesn't cause fast-checks to appear noisy. - A secret-leak failure should be clearly attributable to the
securityjob, not buried in fast-checks. - Both run in parallel — critical path is:
fast-checks||security→gate. Thesecurityjob is lighter thanapi/harnessand won't be the bottleneck.
-
securityjobneeds: [changes]but runs differently fromapi/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-levelif:), not job-level. This keeps the job always-running (for gitleaks) while skipping the pnpm steps for doc-only PRs. -
gateaggregator update (D-14): Addsecuritytoneeds:and add a per-needs.security.resultcheck 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-qjhrto the initialscripts/audit-allowlist.jsonwith 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 lintgreen + boot-smoke PASS before/gsd-verify-work
Wave 0 Gaps
apps/api/tests/lib/bootGuards.test.ts— unit tests forassertNotDevBypassInProduction()apps/api/src/lib/bootGuards.ts— exported guard function (if extracting from index.ts)scripts/check-audit.mjs— wrapper scriptscripts/check-outdated.mjs— wrapper scriptscripts/audit-allowlist.json— initial entry forGHSA-gv7w-rqvm-qjhrscripts/outdated-pins.json— intentional pin explanations.gitleaks.toml— config with allowlistsscripts/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 — useignoreGhsasor the wrapper approach.gitleaks detect/gitleaks protect: Deprecated since v8.19.0 — usegitleaks 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
-
Does
github.event.pull_request.base.shapopulate in Gitea Actions?- What we know:
GITHUB_SHAis confirmed available (Phase 8 D-PROBE-07). Gitea Actions mirrors GitHub Actions event context. - What's unclear: The
github.event.pull_requestcontext object populates onpull_requestevents — 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.shaandhead.sha— confirm non-empty. If empty, fall back to:git merge-base $(git rev-parse origin/${{ github.base_ref }}) HEADas the base SHA.
- What we know:
-
Can the esbuild High advisory be resolved by upgrading drizzle-kit?
- What we know:
GHSA-gv7w-rqvm-qjhris 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.
- What we know:
-
eslint-plugin-security
detect-object-injectiontriage 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.
- What we know: The rule fires on
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 —
--json,--audit-level,ignoreGhsasdocumentation - pnpm.io/cli/outdated —
--format json,-rflags confirmed - github.com/gitleaks/gitleaks — v8.30.1 latest;
gitsubcommand;--log-opts; baseline mechanism;.gitleaks.toml - 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 transitivepnpm audit --audit-level high --json— confirmed filters JSON to high+ only; exits 1pnpm audit --audit-level high— exits 1 (confirmed)pnpm outdated --format json -r— live run; confirmed JSON shape with current/latest/wanted/isDeprecated/dependencyType/dependentPackagesnpm 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.gzasset confirmed
Tertiary (LOW confidence — training knowledge)
- gitleaks
--exit-codebehavior (exits 0 clean, 1 leak, 2 error) — documented in gitleaks README, training knowledge - eslint-plugin-security rule list and
detect-object-injectionnoise 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)