1226 lines
62 KiB
Markdown
1226 lines
62 KiB
Markdown
# 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 ~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' })` 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 35–46):
|
||
```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)
|