SEC-01/02, DEP-01/02, IMG-01/02/03, CI-03. Wave 1: image-hygiene runtime guard (TDD), audit+outdated wrappers (TDD), eslint-plugin-security fold, gitleaks config+baseline+.dockerignore. Wave 2: ci.yml security job + gate wiring, publish.yml hygiene assertions + boot-smoke. esbuild GHSA-gv7w-rqvm-qjhr waivered in 16-02 before the gate goes live.
177 lines
14 KiB
Markdown
177 lines
14 KiB
Markdown
---
|
|
phase: 16-ci-dependency-audit-and-security-checks
|
|
plan: 02
|
|
type: tdd
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- scripts/check-audit.mjs
|
|
- scripts/audit-allowlist.json
|
|
- scripts/check-outdated.mjs
|
|
- scripts/outdated-pins.json
|
|
- scripts/__tests__/check-audit.test.mjs
|
|
autonomous: true
|
|
requirements: [DEP-01, DEP-02]
|
|
must_haves:
|
|
truths:
|
|
- "The audit wrapper exits non-zero when an unwaived High or Critical advisory exists, and exits zero when it is waived in the committed allowlist"
|
|
- "The pre-existing esbuild High advisory GHSA-gv7w-rqvm-qjhr is waived in scripts/audit-allowlist.json with reason + reviewer BEFORE the audit gate goes live, so the first audit-gated PR does not fail immediately"
|
|
- "The outdated wrapper always exits 0, classifies entries into tiers (AUDIT-ADVISORY / MAJOR-BEHIND / INTENTIONAL-PIN / ROUTINE-DRIFT), and respects intentional pins from outdated-pins.json"
|
|
artifacts:
|
|
- path: "scripts/check-audit.mjs"
|
|
provides: "pnpm audit wrapper — blocks unwaived High+Critical, prints moderate/low advisory"
|
|
- path: "scripts/audit-allowlist.json"
|
|
provides: "Committed GHSA waiver list (reason + reviewer + expires), seeded with GHSA-gv7w-rqvm-qjhr"
|
|
contains: "GHSA-gv7w-rqvm-qjhr"
|
|
- path: "scripts/check-outdated.mjs"
|
|
provides: "pnpm outdated wrapper — tiered advisory report, always exits 0"
|
|
- path: "scripts/outdated-pins.json"
|
|
provides: "Intentional-pin reason map (eslint, @eslint/js, zod, @types/node)"
|
|
key_links:
|
|
- from: "scripts/check-audit.mjs"
|
|
to: "scripts/audit-allowlist.json"
|
|
via: "readFileSync + filter by github_advisory_id"
|
|
pattern: "audit-allowlist"
|
|
- from: "scripts/check-outdated.mjs"
|
|
to: "scripts/outdated-pins.json"
|
|
via: "readFileSync + pin-reason lookup"
|
|
pattern: "outdated-pins"
|
|
---
|
|
|
|
<objective>
|
|
Build the dependency-audit gate (D-04/D-05) and the advisory-only outdated report (D-06/OQ-01) as committed Node.js wrapper scripts, plus the two committed JSON config files (the GHSA waiver allowlist and the intentional-pin reason map).
|
|
|
|
Purpose: `pnpm audit` must FAIL the build on unwaived High+Critical advisories while keeping waivers auditable (reason + reviewer in a PR-reviewed file, not a silent ignore). A live audit RIGHT NOW reports a High advisory `GHSA-gv7w-rqvm-qjhr` (esbuild, dev transitive via drizzle-kit/vitest/vite). This advisory must be seeded into the allowlist with justification in THIS plan so that when 16-05 turns the audit gate on, the first gated PR does not fail immediately (Pitfall 4). Separately, `pnpm outdated` must run advisory-only — never gating — and respect the intentional exact-version pins in CLAUDE.md while still distinctly flagging a pin that is dangerously behind or actively vulnerable (OQ-01).
|
|
|
|
Output: Two wrapper scripts + two JSON config files + a unit test for the audit wrapper's blocking/waiving logic. Consumed by 16-05 (the ci.yml security job invokes both scripts).
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-CONTEXT.md
|
|
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md
|
|
@.planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Seed the audit allowlist and the outdated-pins reason map</name>
|
|
<read_first>
|
|
- scripts/audit-allowlist.json (file being created)
|
|
- scripts/outdated-pins.json (file being created)
|
|
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (pnpm audit Allowlist section — exact reason text for GHSA-gv7w-rqvm-qjhr; OQ-01 section — outdated-pins.json format and reasons)
|
|
- CLAUDE.md (the intentional exact-version pins this report must respect)
|
|
</read_first>
|
|
<action>
|
|
Create scripts/audit-allowlist.json as a JSON object keyed by GHSA id. Seed exactly one entry: GHSA-gv7w-rqvm-qjhr with fields reason (esbuild integrity-check advisory; transitive dev-only via drizzle-kit/vitest/vite; not in the production runtime — esbuild never runs in the shipped image; patched in esbuild >=0.28.1, will resolve when drizzle-kit bumps the transitive pin), reviewer set to "luc", and expires set to "2026-09-01". Create scripts/outdated-pins.json as a flat package-to-reason string map with entries for eslint (ESLint 10 breaks eslint-plugin-react@7.37.5, jsx-eslint#3977 — unpin when supported), @eslint/js (pinned with eslint, same constraint), zod (zod v4 is a breaking API change; pin at 3.x until migration planned), and @types/node (pinned to Node 22 LTS types; Node 25 is not LTS). Both files must be valid JSON — no trailing commas, no comments. Commit: `chore(16-02): seed audit allowlist (esbuild GHSA waiver) + outdated pin reasons`.
|
|
</action>
|
|
<verify>
|
|
<automated>node -e "const a=require('./scripts/audit-allowlist.json'); const p=require('./scripts/outdated-pins.json'); if(!a['GHSA-gv7w-rqvm-qjhr']||!a['GHSA-gv7w-rqvm-qjhr'].reason||!a['GHSA-gv7w-rqvm-qjhr'].reviewer) throw new Error('allowlist seed missing fields'); for(const k of ['eslint','@eslint/js','zod','@types/node']) if(!p[k]) throw new Error('missing pin reason: '+k); console.log('OK')"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- scripts/audit-allowlist.json is valid JSON containing GHSA-gv7w-rqvm-qjhr with non-empty reason and reviewer fields
|
|
- scripts/outdated-pins.json is valid JSON containing reason strings for eslint, @eslint/js, zod, @types/node
|
|
- The verify command prints OK
|
|
</acceptance_criteria>
|
|
<done>Both committed config files exist, are valid JSON, and carry the seeded esbuild waiver + the four intentional-pin reasons.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Implement check-audit.mjs (blocking wrapper) with a unit test over its filter logic</name>
|
|
<read_first>
|
|
- scripts/check-audit.mjs (file being created)
|
|
- scripts/__tests__/check-audit.test.mjs (test file being created)
|
|
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (pnpm audit wrapper section — exact JSON shape: audit.advisories keyed object, each adv has .severity, .github_advisory_id, .module_name, .title; Pitfall 1: use `pnpm audit --json` WITHOUT --audit-level so all severities appear; Pitfall 7: ignoreCves removed in pnpm v11)
|
|
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (Shared Patterns — node: prefix for built-in imports)
|
|
- apps/api/src/index.ts (lines 1-2 — node: import-prefix convention)
|
|
</read_first>
|
|
<behavior>
|
|
- Given an audit JSON with a High advisory NOT in the allowlist → the filter returns it as blocking (script would exit 1)
|
|
- Given the same High advisory WITH its GHSA id in the allowlist → the filter returns empty (script would exit 0)
|
|
- Given only moderate/low advisories → the filter returns empty blocking set (script exits 0) and lists them as advisory
|
|
- Given no advisories → exits 0
|
|
</behavior>
|
|
<action>
|
|
Create scripts/check-audit.mjs: import execSync from 'node:child_process' and readFileSync from 'node:fs'. Structure the severity-filter as an EXPORTED pure function (e.g. export function selectBlocking(advisories, allowlist) and export function partitionAdvisories(...)) so it is unit-testable without spawning pnpm; the script's main body (run only when invoked directly) reads scripts/audit-allowlist.json, runs `pnpm audit --json` (no --audit-level — Pitfall 1) capturing stdout with stdio ignore on stderr, JSON-parses it, calls the pure function to find High+Critical advisories whose github_advisory_id is NOT a key in the allowlist, prints any such blocking advisories to stderr and exits 1, otherwise prints a PASS line plus the moderate/low advisory list to stdout and exits 0. Then create scripts/__tests__/check-audit.test.mjs that imports the pure function(s) and asserts the four behavior cases above against hand-built fixture objects (do NOT shell out to pnpm in the test). Use vitest (run via `pnpm --filter @familysync/api test` is NOT correct here — these scripts are root-level; run the test file directly with `node --test` OR with `npx vitest run scripts/__tests__/check-audit.test.mjs`). Prefer `node --test` with node:assert so no extra dependency is needed. Commit: `feat(16-02): add check-audit.mjs blocking wrapper + unit tests`.
|
|
</action>
|
|
<verify>
|
|
<automated>node --test scripts/__tests__/check-audit.test.mjs</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- scripts/check-audit.mjs exports a pure severity/allowlist filter function and only runs pnpm audit when executed directly (guarded by an import.meta check)
|
|
- scripts/__tests__/check-audit.test.mjs covers: unwaived High → blocking; waived High → not blocking; moderate/low only → not blocking; none → not blocking
|
|
- `node --test scripts/__tests__/check-audit.test.mjs` passes
|
|
- The script uses `pnpm audit --json` with NO --audit-level flag (grep confirms `--audit-level` is absent)
|
|
</acceptance_criteria>
|
|
<done>check-audit.mjs blocks unwaived High+Critical, honors the allowlist, and its filter logic is unit-tested and green.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Implement check-outdated.mjs (advisory-only, tiered, pin-aware)</name>
|
|
<read_first>
|
|
- scripts/check-outdated.mjs (file being created)
|
|
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (OQ-01 section — `pnpm outdated --format json -r` JSON shape: keyed by package with current/latest/wanted/isDeprecated/dependencyType/dependentPackages; the four tiers AUDIT-ADVISORY / MAJOR-BEHIND / INTENTIONAL-PIN / ROUTINE-DRIFT; major-behind = parseInt(latest major) > parseInt(current major); AUDIT-ADVISORY cross-checks `pnpm audit --json` module_name against the pinned current version; MUST always exit 0 — D-06)
|
|
- scripts/outdated-pins.json (created in Task 1 — the intentional-pin reason source)
|
|
- scripts/check-audit.mjs (Task 2 — reuse its audit-parsing approach for the cross-check)
|
|
</read_first>
|
|
<action>
|
|
Create scripts/check-outdated.mjs: import execSync from 'node:child_process' and readFileSync from 'node:fs'. Run `pnpm outdated --format json -r` capturing stdout (tolerate non-zero exit from pnpm outdated itself — it exits non-zero when anything is outdated; wrap in try/catch and read the captured output regardless). JSON-parse the output. Read scripts/outdated-pins.json. Also run `pnpm audit --json` (all severities) and collect the set of vulnerable module_names. Classify each outdated entry into exactly one tier in priority order: AUDIT-ADVISORY (the package name appears in the audit vulnerable set), else MAJOR-BEHIND-INTENTIONAL (latest major > current major AND the package has an outdated-pins.json reason — print the reason), else MAJOR-BEHIND-UNPINNED (latest major > current major with NO pin reason — the "dangerously behind" flag), else ROUTINE-DRIFT (same major). Print a grouped human-readable report to stdout (no PR comment / no Gitea API — D-13). The script MUST `process.exit(0)` unconditionally at the end — it never gates (D-06). Commit: `feat(16-02): add check-outdated.mjs advisory-only tiered report`.
|
|
</action>
|
|
<verify>
|
|
<automated>node scripts/check-outdated.mjs; echo "exit=$?" | grep -q "exit=0" && echo OK</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `node scripts/check-outdated.mjs` exits 0 even though the repo currently has outdated packages
|
|
- Output groups packages under AUDIT-ADVISORY / MAJOR-BEHIND-INTENTIONAL / MAJOR-BEHIND-UNPINNED / ROUTINE-DRIFT headings
|
|
- Packages listed in outdated-pins.json appear under the INTENTIONAL tier with their reason, not as a liability
|
|
- `grep -c "process.exit(0)" scripts/check-outdated.mjs` is >= 1 and there is no `process.exit(1)` reachable from the report path
|
|
</acceptance_criteria>
|
|
<done>check-outdated.mjs produces a tiered, pin-aware advisory report and always exits 0.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| npm registry → lockfile → build | A transitive dependency may carry a known CVE; the audit gate is where it surfaces |
|
|
| waiver author → CI gate | A waiver suppresses a real advisory; abuse (silent ignore) would defeat the gate |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-16-04 | Tampering / Information Disclosure | Transitive dependency with a known High/Critical CVE reaches the build | mitigate | D-04: check-audit.mjs exits 1 on any unwaived High+Critical advisory (Task 2); wired blocking by 16-05 |
|
|
| T-16-05 | Repudiation | Waiver/allowlist abuse — an advisory silently ignored with no accountability | mitigate | D-05: audit-allowlist.json requires reason + reviewer per GHSA, is committed and PR-reviewed (Task 1); the wrapper waives ONLY entries present in that file, nothing implicit |
|
|
| T-16-06 | Tampering | A pinned version is itself actively vulnerable but hidden as "intentional pin" noise | mitigate | OQ-01: check-outdated.mjs cross-checks `pnpm audit` module_names and surfaces vulnerable pins under the distinct AUDIT-ADVISORY tier (Task 3), separating real liability from routine drift |
|
|
| T-16-07 | Denial of Service | A stale/expired waiver permanently suppresses an advisory | accept | Waivers carry an `expires` date for human review (Task 1); enforcement of expiry is advisory only this phase — not gating |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `node --test scripts/__tests__/check-audit.test.mjs` → green (blocking/waiving logic proven without network)
|
|
- `node scripts/check-outdated.mjs` → exit 0, tiered report printed
|
|
- `node -e "require('./scripts/audit-allowlist.json')['GHSA-gv7w-rqvm-qjhr']"` → defined (the seed waiver exists before the gate is wired)
|
|
- The audit wrapper uses `pnpm audit --json` with no `--audit-level` (Pitfall 1 honored)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Audit wrapper blocks unwaived High+Critical, honors the committed allowlist, unit-tested
|
|
- The esbuild GHSA-gv7w-rqvm-qjhr High advisory is waived with justification BEFORE 16-05 turns the gate on
|
|
- Outdated wrapper is advisory-only (always exit 0), tiered, pin-aware, and flags vulnerable pins distinctly
|
|
- Both JSON config files are valid and self-documenting (reason + reviewer)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/16-ci-dependency-audit-and-security-checks/16-02-SUMMARY.md` when done.
|
|
</output>
|