Skip to content

fix(overrides): emit >= security floors instead of exact pins - #135

Merged
alamb-hex merged 5 commits into
mainfrom
fix/override-writer-floors
Aug 11, 2026
Merged

fix(overrides): emit >= security floors instead of exact pins#135
alamb-hex merged 5 commits into
mainfrom
fix/override-writer-floors

Conversation

@alamb-hex

Copy link
Copy Markdown
Collaborator

A fleet audit found 29 of 32 projects carrying exact postcss pins like "postcss": "8.5.15". Eight were stuck on versions vulnerable to GHSA-r28c-9q8g-f849 — and an override forces a version, so no routine update could move them. The override was what held them there.

This file is where those pins came from: it wrote pkg.targetVersion verbatim, and its sibling logic assumed exact pins throughout.

What changed

  • applyOverrides emits a >= floor for concrete versions, matching the convention already used by this repo's own esbuild/qs/hono/ws overrides. Dist-tags, git URLs, workspace:* and npm aliases pass through untouched — a caret or floor can't be prefixed to those.
  • removeOverrideConflicts uses semver satisfaction instead of string equality. Without this the tool would have deleted the floors it had just written ("^8.5.23" !== "8.5.26"), logged as override_conflict_removed.
  • cleanStaleOverrides still skips range-valued overrides — deliberately, now documented. A working floor looks "stale" under an installed-is-newer rule, and removing it would strip security protection. Its exact-pin comparison moved from hand-rolled parseInt to semver.
  • Verification that actually runs. The post-install check gated on root node_modules/<pkg>. These are transitive deps; under pnpm's default isolated linker they live in .pnpm/, so the check was a silent no-op on most of the fleet. It now scans the pnpm store, collects every copy, and flags a violation if any copy falls below the floor — the issue-False positive: fixViaOverride patches report success when nested copy is still vulnerable #80 nested-copy class.
  • Honest reporting. A >= floor resolves to maxSatisfying, so it can install a major beyond the target. resolvedVersion now records what actually landed alongside what was requested, and a major jump gets its own warning.

Why >= and not a caret

Caret is inert below 1.0.0: ^0.0.3 expands to >=0.0.3 <0.0.4-0 — one version, identical in effect to the exact pin this change exists to remove. ^0.5.1 caps at <0.6.0, and in 0.x the minor is the breaking axis.

The trade is real: >= has no ceiling, so an override can admit a major. That's why the verification and resolved-version reporting above aren't optional extras — they're what makes an unbounded floor safe to run unattended.

Review history

Four review rounds. Three findings are worth calling out because they'd each have been shipped silently:

  1. The verify check compared installed >= target rather than "satisfies the range we wrote" — so a resolution outside the written range read as success.
  2. A semver throw on an unparseable version was swallowed by a catch-all, producing success: true with no warning and no log, while the violating versions sat in a local variable. Strictly worse than the no-op it replaced.
  3. The store-aware lookup regressed the downgrade guard. It returned the highest copy, so a vulnerable 8.4.31 beside a clean 8.5.30 read as "already past this fix" and the update was refused — leaving the vulnerable copy in place, on exactly the multi-copy isolated layouts this work targets. A downgrade guard needs the lowest copy. Both behaviours are now pinned by tests.

Verification

336 → 382 tests, 46 files. tsc --noEmit clean, pnpm build exit 0. src/lib/updaters/ had no test file before this; it now has two. semver was previously only a transitive dependency — it's now properly declared, so this doesn't introduce the phantom-dep class HexOps itself detects.

Follow-ups, deliberately not included

overridePkgs.some(...) batch semantics (one package resolving marks the batch recovered), escalate/route.ts's duplicate hand-rolled version comparison, and wiring resolvedVersion into the patch-history UI and CSV export.

applyOverrides was writing exact version pins into pnpm.overrides /
overrides / resolutions (and the npm direct-dependency path), which
permanently blocks routine updates past a vulnerable version — 29/32
fleet projects were stuck this way on GHSA-r28c-9q8g-f849 via postcss.

- applyOverrides now writes a caret floor (^8.5.26) for concrete
  targets via toFloorRange(); dist-tags and already-range values pass
  through unchanged. The npm direct-dependency write and the
  post-install verify/anyResolved checks are updated to match (floor
  satisfaction, not exact equality).
- removeOverrideConflicts now removes an override only when the new
  target doesn't satisfy the existing range (semver.satisfies),
  instead of a plain string comparison that would have deleted the
  floor it just wrote. Floating dist-tags still force removal.
- cleanStaleOverrides keeps skipping range-valued overrides (now
  documented as deliberate, not incidental) and replaces the
  hand-rolled parseInt version comparison with real semver comparison
  for the exact-pin case, fixing mishandling of prereleases and build
  metadata.

Adds semver/@types/semver as real (non-phantom) dependencies, and
src/lib/updaters/override.test.ts covering all three functions plus
package.json indentation preservation.
Follow-up to 5491c8e per independent review:

- Switch floors from caret to >= (owner decision): caret is inert
  below 1.0.0 (^0.0.3 expands to exactly one version, byte-identical
  to the exact pin this change removes) and always caps at the next
  major even above 1.0.0, so it can never admit a fix that ships in a
  later major. >= also matches this repo's own existing override
  convention (esbuild, qs, hono, ws).
- removeOverrideConflicts / applyOverrides's post-install verification
  now compare against the range actually written
  (satisfiesWrittenOverride), not a bare "installed >= target" scalar,
  so a genuine mismatch (keyed override, workspace catalog, parent
  constraint) is still caught instead of rubber-stamped.
- The anyResolved install-failure fallback no longer lets a
  pre-existing copy identical to fromVersion masquerade as a completed
  resolution when the install itself failed.
- Dropped includePrerelease from the conflict-satisfaction check so it
  matches actual npm/pnpm resolver semantics.
- Corrected toFloorRange's JSDoc claim about prerelease matching under
  >= (empirically re-verified rather than reasoned from the old
  caret-era wording).

Adds tests for all of the above plus previously-untested edge cases
(non-semver pinned values, wildcard/empty override values).
Follow-up to 3a604a8 per second review round, two owner decisions plus
one more bug in the fromVersion guard:

- Bare >= floors stay unbounded (owner decision), but the post-install
  verify block now reports what actually resolved, not just the
  target: adds resolvedVersion to UpdateResult/PatchHistoryEntry
  (additive, does not repurpose toVersion) and threads it through the
  output string, override_applied log, and patch-history entry so
  "requested >=8.5.26, resolved 10.1.0" is visible instead of a
  false audit trail recording only the target.
- Adds an override_major_jump warning when the resolved major exceeds
  the targeted major (satisfies the floor, so success stays true).
- Fixes the verification blind spot: the post-install check only ever
  looked at root node_modules, which is a silent no-op for any project
  using pnpm's default node-linker=isolated (transitive deps live in
  the .pnpm virtual store instead). Adds a store-aware lookup (root,
  then .pnpm store, then a pnpm list fallback for pnpm when both come
  up empty), checks every discovered copy against the floor rather
  than the first one found (catches the issue #80 false-clear class),
  and surfaces "could not verify" as a distinct state instead of
  silently treating an inconclusive probe as success. Extended the
  same store-aware lookup to the anyResolved install-failure fallback,
  leaving its batch-recovery shape untouched.
- Fixes route.ts pushing the raw (often-absent) request fromVersion
  instead of the already-computed effectiveFromVersion, which left
  the previous round's stale-tree guard inert for any request that
  omits fromVersion.

Adds 7 tests covering major-jump warnings, .pnpm-store resolution
(including scoped packages and multiple coexisting versions), the
pnpm list fallback, the inconclusive-verification state, and the
anyResolved store-aware recovery path.
…ute lookup

Follow-up to 3315dfd per round-3 review, F1-F5 plus minors:

- F1/F2: resolvedVersion selection in applyOverrides's verify loop used
  probe.versions.sort(semver.rcompare) directly, which throws on any
  unparseable version field once a second copy exists (Array.sort never
  invokes the comparator for a single element, which is why single-copy
  cases were safe and multi-copy garbage wasn't). The throw landed in
  the loop's catch-all, silently discarding already-computed violations
  and reporting success with no warning at all -- worse than the
  pre-round-3 no-op. Restructured to compute violations first via the
  already-throw-safe findViolatingVersions, then pick resolvedVersion
  via new pickPrimaryVersion/worstVersion helpers that filter to
  parseable semver before sorting. When copies disagree, resolvedVersion
  now reports the worst (most vulnerable) violator, not the best-looking
  copy, so the structured field can't read as clean when a violation
  exists.
- F3: installPackages' version-unchanged guard flagged failure whenever
  installed.version === fromVersion, with no check against the target --
  benign once route.ts started passing a real, node_modules-read
  fromVersion (fixed last round) instead of the usually-absent raw
  request field. Narrowed the guard so "already at target" is not
  reported as a failure, since that's the guard's intended failure
  signal ("install exited 0 but nothing moved"), not a policy change.
- F4: route.ts's effectiveFromVersion read was the same root-only
  node_modules blind spot fixed inside override.ts for the same class
  of (transitive, override-managed) package. Now reuses override.ts's
  exported findAllInstalledVersions + pickPrimaryVersion instead of a
  second, narrower inline lookup.
- F5: added maxBuffer to the pnpm list CLI fallback, matching the
  convention already used elsewhere in this codebase, so large
  monorepos don't silently lose the fallback to the default 1MB cap.
- Minors: prefix-collision test, route.ts results typed as
  UpdateResult[], workspace-root limitation documented on
  findPnpmStoreVersions.

Adds src/lib/updaters/install.test.ts (new) plus more coverage in
override.test.ts for the throw-path and multi-copy resolvedVersion
cases.
…y one

Follow-up to cafb0bd per round-4 review, B1 (a regression) and B2:

- B1: route.ts's pre-install downgrade guard was calling
  pickPrimaryVersion(root, all) to read the currently-installed version,
  and pickPrimaryVersion prefers the HIGHEST copy when there's no root
  entry. On a pnpm isolated-linker project with no root copy and two
  store copies -- a vulnerable one and a clean one -- that fed the
  guard the clean copy, which read as "already past this fix" and
  refused the whole update before applyOverrides ever ran, leaving the
  vulnerable copy untouched. That's worse than before: previously the
  root-only read returned '', the guard was simply inert, and the
  request went through. A downgrade guard has to ask "is anything
  still below target", which needs the WORST case across every
  discovered copy, not a "primary" pick -- those are different
  questions that only shared a call site because pickPrimaryVersion
  was the only exported picker. Added pickLowestVersion, a distinctly
  named and purposed export, and pointed route.ts at it instead.
  pickPrimaryVersion keeps its original job (reporting "what resolved"
  once every copy is already confirmed to satisfy the floor) and is
  unchanged.
- B2: pickPrimaryVersion/worstVersion filtered candidate versions with
  semver.valid(v, {loose:true}) but sorted with the bare
  semver.rcompare/compare function references, which run WITHOUT the
  loose flag when called as a .sort() comparator -- so a version loose
  parsing accepts but strict parsing rejects (e.g. a leading-zero
  segment) would pass the filter and then throw in the sort. Since
  resolvedVersion was being computed before the mismatch warning was
  assigned, that throw discarded an already-detected violation on its
  way to the outer catch-all -- silent success, no warning. Fixed by
  passing {loose:true} through to the sort comparator too, and,
  structurally, by moving the warning assignment and log call before
  version selection runs, so a future throw during selection can only
  ever cost the resolvedVersion field, never the warning itself.

Adds two pinning tests in override.test.ts: one reproducing the exact
multi-copy isolated-layout scenario end to end (proves the old picker
would have refused, the new one doesn't, and the override is actually
written), and one with a loose-valid/strict-invalid version alongside
a normal one, both violating, confirming the mismatch warning still
fires instead of going silent.
@alamb-hex
alamb-hex merged commit 50fc997 into main Aug 11, 2026
1 check passed
@alamb-hex
alamb-hex deleted the fix/override-writer-floors branch August 11, 2026 02:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant