Docs·8777c5dd·Updated Aug 7, 2026·95 ADRs
Back
ADR-059implemented

ADR-059: Dependency Vulnerability Remediation + Blocking CI Security Gate

ADR-059: Dependency Vulnerability Remediation + Blocking CI Security Gate

Status: Implemented Date: 2026-05-30 Sprint: 75


Context

A routine audit surfaced 31 npm audit vulnerabilities (6 high, 25 moderate) across the single root package-lock.json (npm workspaces; no separate mobile lockfile), corresponding to ~13–25 open Dependabot alerts depending on how they dedupe. The vulnerable packages fell into three groups:

  1. Root-tree transitive deps reachable from direct root dependencies — qs (express), ip-address (express-rate-limit), uuid (bull, node-cron), fast-uri.
  2. One direct dependency — axios@1.15.2 (high).
  3. Workspace-nested transitive deps buried in the apps/* trees — tar, @xmldom/xmldom, node-forge, picomatch (all via expo in apps/mobile); postcss/next (build-time CSS in apps/frontend + apps/landing); ws/engine.io (via jsdom, test-only in apps/frontend).

The CI security: job had been capped at --audit-level=critical with an explicit comment that "high vulns in expo@54 are unfixable until SDK upgrade." That cap let dependency debt silently reaccumulate. This sprint's mandate: drive the count to zero and convert the cap into a blocking --audit-level=high gate with an SLA, so debt can never silently return.

Options Considered

  1. Expo SDK upgrade to clear the expo-chain highs at the source — large, risky, out of scope; deferred.
  2. npm audit fix --force — rejected: it installs next@9.3.3 (a catastrophic framework downgrade) and other breaking majors.
  3. Patch-at-the-leaf via root overrides + direct bump for axios — chosen. Force-resolve patched leaf versions so the expo-* depends on a vulnerable … parent alerts auto-clear without touching the expo SDK major.

Decision

1. Remediation: overrides-at-the-leaf + one direct bump

  • axios (direct) bumped 1.15.2 → ^1.16.0.
  • Root overrides extended (keeping the pre-existing tar/minimatch/react/react-dom entries) with patched leaf versions: @xmldom/xmldom ^0.8.13, node-forge >=1.4.0, fast-uri >=3.1.2, qs >=6.15.2, ip-address >=10.1.1, postcss ^8.5.10, plus surgical version-range overrides for packages where the patched version is a major bump beyond the narrow vulnerable range (avoids dragging unrelated lower-major copies up): picomatch@3.0.0 - 3.0.1, ws@8.0.0 - 8.20.0, brace-expansion@5.0.2 - 5.0.5, and a parent-scoped jsdom → ws override.

2. The blocking gate

The CI security: job now runs:

- name: Run npm audit (blocking — no high/critical vulns; see ADR-059)
  run: npm audit --package-lock-only --audit-level=high

No build passes with an unaddressed high or critical dependency vulnerability. --package-lock-only keeps it fast and deterministic.

3. The SLA (standing policy)

  • No high or critical vulnerability (dependency or code-scanning) open longer than 1 week.
  • No vulnerability of any severity open longer than 2 weeks.
  • The gate blocks at high; moderates/lows are tracked to zero under the 2-week clause but do not block a hotfix.

Version

10.3.0 → 10.4.0 (minor — ships a behavioral CI gate).


Implementation Notes (hard-won)

These are recorded because they cost real debugging time and will recur:

  1. npm overrides do not reach apps/* workspace subtrees on an incremental install. npm install against the existing lockfile applies overrides to the root workspace tree (uuid/qs/ip-address/fast-uri cleared) but leaves the expo/next/jsdom subtree leaves untouched (14 residual vulns). Only a from-scratch lockfile regen (rm package-lock.json && rm -rf **/node_modules && npm install) applies every override and reaches zero. The trade-off: a from-scratch regen re-floats every ^/~ dependency to its newest satisfying version (~302 packages changed). This was a deliberate, owner-approved decision for this sprint, not an accident.
  2. uuid must be capped at ^11.1.1, not >=11.1.1. The vulnerability is fixed at exactly 11.1.1, but >= resolves to uuid@14, which is ESM-only for Node (no require export condition) and breaks bull's require('uuid') under Jest (SyntaxError: Unexpected token 'export'). uuid@11.1.1 ships a proper CJS build. Verified: node-cron schedules fire and the full suite passes under 11.1.1.
  3. tar needs an exact-version override ("tar": "7.5.15"), not a range. A range override left apps/mobile's @expo/cli copy at the vulnerable 7.5.7; a parent-scoped nested override caused npm to drop tar entirely. Exact-version forces the hoisted, patched copy everywhere.
  4. @swc/helpers must be pinned. A from-scratch regen under Node 24 silently drops it, breaking next build with Cannot find module '@swc/helpers/_/_interop_require_default'. The pin forces npm to materialize the node. Its value follows next's exact @swc/helpers dependency: it was 0.5.15 under next 15 and is 0.5.23 since Sprint 131 D7. Re-read it from node_modules/next/package.json on every next bump.
  5. ts-jest is pinned to 29.4.6. The re-float bumped it to 29.4.11, which changed how its inline-tsconfig object merges with the project tsconfig — dropping moduleResolution: node16 and breaking @karmyq/shared/schemas/ui subpath resolution in request-service tests (TS2307).
  6. apps/mobile type-check was already red on master (pre-existing FlatList/refreshControl overload errors) and is not in the CI gate. The expo-internal version churn from the re-float lands in that already-broken, non-web-deployed workspace and does not regress any gated check.

2026-07-21 advisory refresh (v11.30.1)

New registry disclosures blocked the standing gate with seven high and one critical finding. The follow-up hotfix retained the leaf-override strategy and again rejected npm audit fix --force:

  • direct Axios consumers now require ^1.18.1;
  • exact/surgical overrides move tar to 7.5.21, brace-expansion to 5.0.7, body-parser to 1.20.6, shell-quote to 1.10.0, js-yaml to 4.3.0, and fast-uri to 4.1.1;
  • Next.js remains on 15.5, while its optional image-processing leaf is overridden to sharp@0.35.3; because that release requires Node 20.9+, only the frontend build/runtime images move from Node 18 Alpine to Node 20 Alpine and declare the matching engine floor.

npm audit --audit-level=high returns zero vulnerabilities after the lockfile refresh. The Sharp override is intentionally compatibility-tested through the frontend production build and Docker build gate rather than accepting npm's suggested breaking Next.js downgrade.


Consequences

Positive

  • Zero high/critical/moderate/low npm audit vulnerabilities at v10.4.0.
  • Dependency debt can no longer silently reaccumulate — the gate fails the build.
  • No expo SDK upgrade required; the web demo's shipped backend + frontend + landing runtimes are unaffected by the leaf overrides.

Negative / costs

  • Override maintenance burden. Each override is a manual pin that must be revisited as the ecosystem moves; a too-low cap (e.g. uuid ^11) blocks legitimate future majors until reviewed.
  • Large lockfile churn. Reaching zero required a from-scratch regen that re-floated ~302 transitive packages. Future remediations should prefer the smallest diff that clears the gate (high) and only re-float when zeroing moderates is explicitly in scope.
  • Emergency escape. If the gate blocks a genuine hotfix, git push --no-verify (local) bypasses it; CI remains the backstop. Use only to unblock, then remediate within the SLA.

Relationship to other gates

This is the dependency half of the standing security posture. Code scanning (CodeQL) is a distinct alert class with its own gate under ADR-060 (Sprint 76). /security-review remains the human-level complement to both automated gates, not a replacement.


Alternatives Rejected

  • Expo SDK 54 → 55/56 upgrade — clears the expo-chain highs at the source but is a large, breaking change; deferred to a dedicated sprint.
  • npm audit fix --force — installs next@9.3.3 and other breaking downgrades.
  • Leaving the gate at critical — the status quo that allowed the debt; rejected.

Amendment (Sprint 128, 2026-09-08): unavailable evidence is a failure

BUG-038 exposed that a parsed npm error response could be treated as an empty vulnerability map. The audit boundary now rejects missing/malformed maps, npm error objects and inconsistent severity graphs before registry matching. Subprocess spawn errors, signals, timeouts and unsupported exit statuses also fail; stderr is captured and upstream error bodies are not printed. The process has a 120-second timeout. Valid success and finding responses preserve exemption policy, with critical findings always blocking. Unmatched-entry removal advice requires valid evidence.

tests/regression/sprint-128-audit-response-contract.test.ts proves the empty/populated registry cases, CLI behavior and real child-stderr containment. Sprint 75 raw-count reporting uses the same validated acquisition boundary. This changes evidence handling, not the 30-day renewal authority or severity policy.

The SDK-aligned update also removes the need for the two image-size exemptions. Expo 57.0.20 uses @expo/metro 56.0.2, which pins Metro 0.84.5. Its installed src/Assets.js imports the internal ./lib/imageSize parser; its manifest no longer declares image-size. After confirming no remaining dependency edges, PR B removes the orphaned image-size/queue lock entries and their two unmatched GHSA exemptions. Live lockfile audit on September 8 reports zero high/critical findings with an empty registry. The approved conditional renewal was therefore not applied; the existing remediation alternative resolves the deadline without extending an exception.

Release-time advisory refresh

Later on September 8, fresh audit evidence blocked release preparation even though the previous CI head was green. Auditing the unchanged ec4e4af1 lockfile in a temporary manifest snapshot reproduced one critical and three high vulnerable packages. The maintainer authorized remediation in PR B. A green historical run is evidence for its measurement time, not a permanent security verdict.

  • Both web apps raise their Next.js minimum from 15.5.21 to 15.5.24, including matching @next/env and SWC packages. This addresses the Windows-hosted RCE and AVIF image optimization RCE advisories.
  • The existing Sharp override becomes sharp@<0.35.4: 0.35.4; its native packages move to 0.35.4 and libvips packages to 1.3.3, following the published manifests. The Sharp advisory requires this update independently of the Next.js patch.
  • Existing XML/YAML overrides move to @xmldom/xmldom ^0.8.15 and js-yaml 4.3.2, addressing the audit's XML parser/serializer findings and the YAML merge CPU exhaustion advisory.

The lockfile is spliced using published package metadata, preserving unrelated resolutions and all platform variants. These are dependency updates under the existing policy; no exemptions, new production dependencies, image-optimization bypass or severity-policy changes are introduced. The live SDK compatibility check subsequently advanced Expo/Router to 57.0.21/57.0.20; PR B follows those releases' required dependency closure while retaining SDK 57 and React Native 0.86.3. Strict install, live audit, native image processing, both web builds and the required tests must pass on the final tree before publication. Deployment remains pending PR review and authorization.

Amendment (Sprint 123, 2026-08-10): time-boxed exemptions

Why

The gate as written is binary, and that is a real failure mode: an advisory with no published fix blocks every PR indefinitely. Sprint 123 hit it with image-size (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). Verified against the registry, not a changelog:

Escape routeWhy it does not exist
Newer image-sizelatest is 2.0.2; the advisory range is <=2.0.2
Upgrade metrometro@0.87.0 (newest) still declares image-size: ^1.0.2
Override to image-size@2.xmetro/src/Assets.js needs the default export 2.x removed — and 2.0.2 is still affected

Reach is apps/mobile → expo → @expo/metro → metro: a dev-time bundler that ships in no deployed image. The available responses were all bad — leave every PR blocked, drop the gate to critical, or normalise --no-verify. The first two surrender the gate; the third surrenders the habit.

Decision

A finding may be exempted only through security/audit-exemptions.json, evaluated by scripts/audit-exemptions.js. CI and the regression tier call the same evaluator against the same registry, so the two can never drift apart. Sprint 124 moved the registry's common shape, required-field, duplicate, and UTC-date validation into the spec-driven scripts/lib/exemption-registry.js; the audit script still owns every audit-specific rule.

RuleRationale
Exact package + GHSA idNo package-wide wildcard. A second advisory on an exempted package must still block
high onlycritical is never exemptible, whatever the registry says
rationale, decision, owner, created, expires all requiredAn exemption is a decision with a name on it, not a config tweak
expires ≤ 30 days after created, and created not in the future⚠️ This was 7 through Sprint 124; Sprint 125 raised it to 30 — see Amendment (Sprint 125): renewal cadence below for why, and for what now carries the obligation the shorter cap used to force. An exemption buys review time, never permanence. This cap is audit-specific and unchanged by the shared core. The created clause is load-bearing, not paperwork: capping only the span let a forward-dated entry stay inside the cap while suppressing the finding far longer. Sprint 124's /security-review demonstrated a registry that spanned exactly 7 days, validated clean, and suppressed a high for 149. With created ≤ today and span ≤ 7, expires cannot exceed today + 7 — which is the invariant this row always claimed. expires is the first INVALID day, not the last valid one: cross-agent review of Sprint 124 found that treating it as inclusive made a 7-day span live on 8 calendar days, so a "7-day" exemption quietly bought 8. An entry created 08-11 expiring 08-18 is live on the 11th through the 17th
Fails closed on malformed, expired, duplicate, or unmatched entriesAn exemption matching nothing means upstream shipped a fix; it must be deleted, not left to rot
Parent findings clear only when every advisory reachable through npm's via graph is exemptedmetro is high solely because of image-size; the day it gains its own finding it blocks again

Consequences

  • The gate is now stricter in one respect: an expired or stale exemption fails the build, where previously a permanently-unfixable advisory could only be handled by weakening the gate.
  • It is weaker in one respect: a named human can knowingly ship for up to thirty days (seven, before Sprint 125) with a documented high. That is the trade, and it is recorded in the diff rather than in someone's head.
  • Proof obligation. tests/regression/sprint-123-audit-exemption-gate.test.ts asserts the refusals, not the passes — expired, over-long, malformed, wrong-severity, wrong-id, stale, partially-exempted parents, and the CLI's non-zero exit. A gate demonstrated only by a green run cannot be distinguished from an inert one; this repo has shipped that mistake twice (ADR-060's PR path, and the FROM parser in Sprint 122).
  • The shared validator is deliberately independent of npm audit and now lives in scripts/lib/exemption-registry.js (Sprint 124 / ADR-094). Audit and Expo reuse its mechanics, not its policy: the audit spec alone retains the span maximum (now thirty days), exact GHSA matching, and high-only rule. Critical remains never exemptible.

Amendment (Sprint 125, 2026-08-17): renewal cadence

Status: Accepted · Decision: maintainer, 2026-08-17

MAX_EXEMPTION_DAYS moves from 7 to 30.

What the seven-day cap was actually buying

It was never the number that had value. The value was that a renewal forced someone to re-measure upstream — to go and check whether a fix had shipped. Sprints 123, 124 and 125 each paid that cost by hand: npm view, the two GHSA pages, npm ls, every time, and every time the answer was identical. Three cycles of a manual check that has never once changed its answer is not diligence; it is a ritual, and rituals get performed carelessly or skipped.

What replaces it

.github/workflows/image-size-advisory-watch.yml runs scripts/check-image-size-upstream.js weekly against the live arbiters — the npm registry, GitHub's advisory API, and this repo's own resolved tree. It files an issue the moment a patched release appears, an advisory is withdrawn, a third advisory lands, the resolved tree changes, or the horizon comes within a week. Its evaluate() is pure and every signal has a test that drives it to fire, so it is not a monitor that has only ever been observed passing.

The obligation did not weaken; it moved from a human's memory to a scheduled job. The cap is now the backstop, not the trigger.

What did NOT change

  • critical is still never exemptible.
  • Exact package + GHSA id; no wildcards. A second advisory still blocks.
  • Fail-closed on malformed, expired, duplicate, or unmatched entries.
  • created may not be in the future, and expires is still the first invalid day.
  • The high-severity remediation SLA is unchanged. The cap and the SLA were numerically equal until now and that equality is what made them easy to conflate — they are separate rules, and this amendment decouples them deliberately.

The risk being accepted

A high-severity finding can now sit suppressed for a month rather than a week. That is a real widening, and it is only defensible while the weekly monitor is alive and its issues are read. If that workflow is ever removed or left failing, this cap must go back to 7 in the same change — otherwise the registry becomes what ADR-059 was written to prevent: a place where findings are parked and forgotten.


Amendment (Sprint 132, 2026-10-01): node-forge exemption

Status: Accepted · Decision: maintainer, 2026-10-01

GHSA-86w9-cpqp-85rv (high: RSA PKCS#1 v1.5 signature verification in node-forge <=1.4.0) has no patched release. npm's only proposed fix is a backwards semver-major move to expo@44.0.6. The package reaches the tree only through apps/mobile → expo → @expo/cli / @expo/code-signing-certificates, which is developer tooling. Unexempted, it blocked every PR and every master deploy, including a caller-scope security fix (Sprint 132 PR A). It is exempted in security/audit-exemptions.json under the Sprint 123 rules. One entry clears all four findings, because the three Expo parents are high only through it. A second node-forge advisory still blocks.

Seven days, not thirty. The Sprint 125 amendment ties the 30-day cap to a live weekly monitor, and says the cap returns to 7 if that monitor is "removed or left failing". The only monitor, image-size-advisory-watch.yml, watches image-size alone. It has been red since 2026-09-14, because image-size left the tree in Sprint 128 (issue #236). So this exemption expires 2026-10-08. Renewing it means re-checking by hand: npm view node-forge version, the GHSA page and npm ls node-forge --all. Dependabot security updates (node-forge is not ignored) open a PR when a patched release appears.

The gate now encodes this rule. tests/regression/sprint-125-image-size-monitor.test.ts previously required every exempted package to be in the monitor's WATCHED_PACKAGES. It now requires a package to be watched or exempted for at most 7 days. Fixtures prove that an unwatched 8-day, 30-day or malformed entry is refused, and that an unwatched 7-day entry is admitted. The live entry stretched to 30 days turns the test red. BUG-058 covers retiring or generalizing the monitor, and what happens to the 30-day cap meanwhile.

Amendment (Sprint 132, 2026-10-02): braces exemption

Status: Accepted · Decision: maintainer, 2026-10-02

GHSA-vfj7-8cjw-p6xm (high: stack exhaustion on deeply nested patterns in braces) was GitHub-reviewed at 2026-10-02 22:36Z and covers every version. braces@3.0.3 is the latest release, so there is no patched release; npm's only proposed fix is a backwards semver-major move to nodemon@1.14.10. It surfaced between two attempts of the master run for Sprint 132 PR A (#290, run 37077914525) and blocked that deploy, along with every other PR and master push.

Reach. A lockfile walk of production dependencies (2026-10-02) finds braces only under apps/mobile → expo → @expo/cli → @expo/metro-file-map → micromatch → braces, the Expo developer CLI. No service and no packages/shared production tree reaches braces or micromatch; every other path (next's eslint plugin, chokidar, nodemon, fast-glob) is a devDependency; and no repo source imports either package, so no request input reaches a brace pattern. One entry in security/audit-exemptions.json clears all 18 findings, because every parent is high only through braces. A second braces advisory still blocks.

Seven days, not thirty, for the same reason as node-forge: no live monitor watches braces (BUG-058). The exemption expires 2026-10-09. Renewing it means re-checking by hand: npm view braces version, the GHSA page and npm ls braces --all. Tracked as BUG-059.