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