---
phase: 16-ci-dependency-audit-and-security-checks
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- pnpm-lock.yaml
- eslint.config.js
autonomous: true
requirements: [SEC-02]
must_haves:
truths:
- "eslint-plugin-security runs as part of the existing pnpm lint gate, as blocking errors (not warnings)"
- "pnpm lint passes green across both apps with the security plugin active — existing detect-object-injection / fs-filename noise is triaged (rule-tuned or targeted eslint-disable with justification), not left red"
artifacts:
- path: "eslint.config.js"
provides: "eslint-plugin-security recommended config folded in before prettierConfig"
contains: "eslint-plugin-security"
- path: "package.json"
provides: "eslint-plugin-security added to root devDependencies"
contains: "eslint-plugin-security"
key_links:
- from: "eslint.config.js"
to: "eslint-plugin-security"
via: "import pluginSecurity + spread configs.recommended"
pattern: "pluginSecurity"
---
Fold eslint-plugin-security into the existing root flat ESLint config (D-03) so its rules run as blocking ERRORS inside the current `pnpm lint` step, and triage the resulting violations across the existing codebase so the gate goes green.
Purpose: The phase 13 ESLint gate already runs on every PR in fast-checks. Adding a static security lint here costs nothing extra in CI (same install, same step). The plugin is heuristic and noisy — `detect-object-injection` fires on every `obj[key]` (pervasive in Drizzle ORM and TS generics) and `detect-non-literal-fs-filename` can fire on dynamic path construction. The user explicitly chose `error` over `warn`, accepting that triage of existing code is expected work, not a blocker.
Output: eslint-plugin-security in root devDependencies (pinned), the flat-config block, and whatever targeted suppressions / rule-tunes are needed to make `pnpm lint` green. Consumed by 16-05 (no new ci.yml step — the existing lint step now enforces it; 16-05 only documents the fold).
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.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
@eslint.config.js
Task 1: Install eslint-plugin-security and fold it into the flat config
- package.json (root devDependencies block — lines 18-27; existing pinned eslint tooling versions)
- eslint.config.js (the whole file — section 5 prettierConfig MUST remain last; the existing `files: ['apps/**/*.{ts,tsx}']` block pattern at lines 27-43)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-PATTERNS.md (eslint.config.js section — exact import + spread placement before prettierConfig; detect-object-injection guidance)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (eslint-plugin-security Integration section — version 4.0.1 (or 3.0.1), flat-config wiring, ESLint 9.39.4 compatibility, the 15-rule table)
Add eslint-plugin-security to root devDependencies pinned to an EXACT version (4.0.1; or 3.0.1 if the executor prefers more bake time — both are flat-config compatible with the pinned ESLint 9.39.4). Use `pnpm add -D -w eslint-plugin-security@` so pnpm-lock.yaml updates. Do NOT upgrade ESLint. In eslint.config.js: add `import pluginSecurity from 'eslint-plugin-security';` to the import block (lines 7-11), and insert a NEW config block with `files: ['apps/**/*.{ts,tsx}']` that spreads `...pluginSecurity.configs.recommended` and its `...pluginSecurity.configs.recommended.rules` — placed AFTER section 4 (disableTypeChecked) and BEFORE `prettierConfig` (which must stay the last element). Add a section-header comment ("eslint-plugin-security: blocking errors per D-03"). Do not yet add per-rule overrides — Task 2 decides those after measuring noise. Commit: `chore(16-03): add eslint-plugin-security to root flat config (D-03)`.
node -e "const p=require('./package.json'); if(!p.devDependencies['eslint-plugin-security']) throw new Error('not in devDependencies'); console.log('dep OK')" && grep -q "pluginSecurity" eslint.config.js && grep -nq "prettierConfig" eslint.config.js && echo CONFIG-OK
- eslint-plugin-security present in root devDependencies at an exact pinned version; pnpm-lock.yaml updated
- eslint.config.js imports pluginSecurity and spreads configs.recommended in an `apps/**/*.{ts,tsx}` block placed before prettierConfig
- prettierConfig remains the final element of the exported config array
- ESLint version unchanged (still 9.39.4)
The security plugin is installed and registered in the flat config, before prettier, without touching the ESLint pin.
Task 2: Triage security-rule violations until pnpm lint is green
- eslint.config.js (the security block added in Task 1)
- .planning/phases/16-ci-dependency-audit-and-security-checks/16-RESEARCH.md (Triage Strategy section — Option A: disable detect-object-injection globally with a justification comment + inline disable at true risk sites; Option B: keep error + annotate each site; other high-noise candidates: detect-non-literal-fs-filename, detect-possible-timing-attacks)
- apps/api/src (Drizzle ORM bracket-access and generic patterns that trigger detect-object-injection)
- apps/pwa/src (any dynamic bracket access / fs-like patterns)
Run `pnpm lint` and capture every security/* violation grouped by rule. For each rule decide: (a) genuine risk → fix the code; (b) whole-codebase false positive (e.g. security/detect-object-injection on Drizzle/TS-generic bracket access where the key is schema-derived or zod-validated, not user-controlled) → disable that single rule in the eslint.config.js security block with an inline comment justifying why (note that real user-controlled key risks are guarded by zod validation); (c) a small number of site-specific false positives → add `// eslint-disable-next-line security/ -- ` at each site. Prefer the minimal change that keeps the maximum number of rules at error: disable only the rules that are pervasively false-positive (likely just detect-object-injection, possibly detect-non-literal-fs-filename), and annotate individual sites for the rest. Re-run `pnpm lint` until it is green with `--max-warnings 0`. Do NOT introduce blanket `/* eslint-disable */` file headers. Commit: `chore(16-03): triage eslint-plugin-security findings to green`.
pnpm lint
- `pnpm lint` exits 0 across both apps with the security plugin active
- Any globally disabled security rule has an inline justification comment in eslint.config.js (no silent `off`)
- No blanket file-level `/* eslint-disable */` headers were added; suppressions are rule-specific with `-- justification`
- The majority of the 15 security rules remain at error (only pervasively-false-positive rules are disabled)
pnpm lint is green with eslint-plugin-security enforcing as errors; suppressions are minimal and justified.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer source → committed code | Static security lint inspects source at code-analysis time, before it ships |
| user-controlled input → object/property access | detect-object-injection targets this; real risk only when the key is attacker-controlled and unvalidated |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-16-08 | Tampering / Information Disclosure | Insecure code patterns (eval, child_process with variables, unsafe regex/ReDoS, pseudo-random crypto) introduced in source | mitigate | D-03: eslint-plugin-security rules run as blocking errors in the lint gate (Tasks 1-2), failing the PR on flagged patterns |
| T-16-09 | Elevation of Privilege | Prototype-pollution / object-injection via user-controlled bracket keys | mitigate | detect-object-injection findings triaged: real user-controlled sites are zod-validated; the rule is disabled globally ONLY because the remaining hits are schema-derived keys (Task 2 justification), not because the risk is ignored |
| T-16-10 | Repudiation | Suppression comments hide a genuine vulnerability without accountability | accept | Every suppression carries a `-- justification`; no blanket file disables (Task 2); residual risk is the reviewer trusting the justification, accepted for a two-person repo with PR review |
- `pnpm lint` → exit 0 (both apps, --max-warnings 0)
- `grep -n "security/detect-object-injection" eslint.config.js` → if present, an adjacent justification comment exists
- eslint-plugin-security pinned in package.json devDependencies; pnpm-lock.yaml reflects it
- eslint-plugin-security folded into the existing flat config as blocking errors, prettierConfig still last
- pnpm lint green with the plugin active
- Suppressions are rule-specific and justified; the ESLint pin is untouched