Skip to content

fix(npm): honor consuming repo overrides in closure probe (v2.12.0) - #19

Merged
aldegad merged 7 commits into
mainfrom
fix/npm-closure-honors-repo-overrides
Jul 24, 2026
Merged

fix(npm): honor consuming repo overrides in closure probe (v2.12.0)#19
aldegad merged 7 commits into
mainfrom
fix/npm-closure-honors-repo-overrides

Conversation

@aldegad

@aldegad aldegad commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Revived from a stale local branch (originally authored 2026-06-29, rebased onto main today). It was sitting unmerged with a version collision; nothing in main supersedes it.

Problem

safedeps check resolves a package's published dependency closure through a throwaway probe with an empty package.json, which ignores the consuming repo's npm overrides. A transitive that the repo had already pinned to a patched version -- the canonical remediation -- was still false-flagged.

Concrete case: exceljs -> uuid@8.3.2 (GHSA-w5hq-g745-h8pq) stayed blocked even with overrides: { "uuid@8.3.2": "^11.1.1" } in place.

Approach

safedeps_npm_repo_overrides_json discovers the consuming repo's overrides and injects them into the closure probe, so npm resolves transitives the way the real install will.

  • Source precedence: SAFEDEPS_NPM_OVERRIDES_JSON env (explicit JSON, for tests and non-cwd callers), else the nearest package.json walking up from $PWD, bounded by the git root.
  • Only concrete version pins are honored. $-ref strings ("$react") and objects mentioning $ are dropped -- they have no meaning in a standalone probe and would break resolution.

This cannot hide a real vulnerability. The probe still resolves each override to a concrete version and OSV is queried for that exact version -- the version that will actually be installed. An override pointing at a still-vulnerable version is flagged like any other.

Relationship to v2.10/v2.11

This is the npm-overrides analogue of the Yarn-resolutions work, and the two do not interact. safedeps_npm_resolve_spec_closure takes the Yarn project path first; when a Yarn context exists it approves, materializes, or fails closed and returns -- it never falls through to the published probe. Overrides injection lives only on the published-probe path, reached when there is no Yarn project context. The v2.11 no-published-fallback invariant is untouched.

Rebase notes

  • Version collision resolved: the branch originally claimed 2.10.0, which main has since used for Yarn resolution-aware check. Rebased to 2.12.0 (package.json == bin/safedeps SAFEDEPS_VERSION).
  • lib/npm/closure.sh conflicted because both branches added functions at the same insertion point. Both sides kept; safedeps_npm_write_source and safedeps_npm_repo_overrides_json each retain their own closing brace.
  • Also carries a one-line assets/demo.tape fix for the alex-extensions -> agent-extensions rename, which main still lacks.

Verification

  • npm test (smoke + e2e) green, including npm closure honors repo overrides (concrete pins kept, $-refs dropped), npm closure with no repo overrides is unchanged, and npm closure override env source precedence
  • v2.11 Yarn materialization tests still pass -- no regression from the merge resolution
  • green under both a symlinked and a real-path TMPDIR (the cross-platform trap that bit PR feat(check): materialize absent Yarn candidates in an isolated mirror (v2.11.0) #18)
  • bash -n on all touched scripts, git diff --check, version SSoT check

Not done here

Docs are not updated for this feature, and this is hard-floor code that has not been through a validator loop. Review before merge.

aldegad added 7 commits July 24, 2026 18:46
safedeps check resolved a package's *published* dependency closure via a
throwaway probe with an empty package.json, ignoring the consuming repo's
npm `overrides`. A transitive the repo had already pinned to a patched
version (the canonical remediation) was still false-flagged — e.g. exceljs
-> uuid@8.3.2 (GHSA-w5hq-g745-h8pq) stayed blocked even with
`overrides: { "uuid@8.3.2": "^11.1.1" }` in place.

Add safedeps_npm_repo_overrides_json: discover the consuming repo's
overrides (SAFEDEPS_NPM_OVERRIDES_JSON env, else nearest package.json up
from $PWD bounded by the git root) and inject them into the closure probe
so npm resolves transitives the way the real install will. Only concrete
version pins are honored — $-ref strings and objects mentioning $ are
dropped (no meaning in the standalone probe). This cannot hide a real vuln:
the probe still resolves each override to a concrete version and OSV is
queried for that exact version.

Minor bump 2.9.1 -> 2.10.0; smoke covers concrete-pin/$-ref-drop/no-override/env.
… dir

The overrides walk-up broke out on `[[ -d "${dir}/.git" ]]`, but a git
worktree root carries a `.git` FILE, not a directory. Inside any worktree
the walk therefore stepped straight past the project root and picked up an
ancestor's `overrides` -- which are not the overrides the real install in
that worktree will use.

The Yarn project-context walk-up in the same file already got this right
and says so in a comment; this is the same test, so the two boundaries now
agree.

Regression test simulates a worktree root with a `.git` file and an
ancestor carrying `overrides`, and asserts the ancestor's pins do not leak
in. Mutation-verified: reverting to `-d` makes the new test fail with
`got: {"leaked":"9.9.9"}`.
Honoring `overrides` made the probe closure a function of the consuming
project, but the approval key stayed global. An approval earned in a repo
that pinned a vulnerable transitive to a patched version therefore
satisfied the same check in a repo that had not, whose real install
resolves the vulnerable version. Reproduced: repo A with
`overrides: {minimist: 1.2.8}` approves mkdirp@0.5.1, then repo B with no
overrides returns already_approved while resolving minimist@0.0.8.

A published-package approval may be global only because it is
project-independent. Once overrides apply, it is not, so it is now keyed
the way the Yarn project closure already was, reusing that machinery
rather than adding a parallel contract:

- closure.sh gains `safedeps_npm_overrides_context`, emitting a
  `npm-overrides-probe` context with project_root, the override set, its
  canonical (key-sorted) hash, and a context_hash over both.
- ledger.sh requires those fields for the new type and rejects an empty
  override set, matching how it gates the Yarn contexts.
- bin/safedeps wires the context at both approval sites and derives the
  same hash for the cache lookup, so a scoped approval still hits.
- the pre-guard derives the same key, so an approved install is not
  blocked by a key the guard could not reproduce.

Repos with no overrides get no context and keep the ordinary global
approval. Failing to apply overrides to the probe manifest is now logged
instead of silently dropping them; it only makes the check stricter, but
an unexplained denial is not observable.

Tests: approval-key scoping and order-independence, ledger provenance
gating, and a hermetic e2e that stubs npm so the resolved closure depends
on the probe manifest — asserting patched=clean, still-vulnerable=deny,
no-overrides=no leak, same-set=already_approved. Both mutation-verified.

Docs: English SSoT + Korean mirrors describe the behavior, the
concrete-pins-only limit, and the approval scope.
Both mktemp failures in safedeps_npm_resolve_spec_closure returned without
removing what was already allocated: the new overrides-source file dropped
tmp_dir and the project context, and the pre-existing tmp_dir line dropped
the project context. Every other early return in this function already
cleans up; these now match.
The guard derives the approval key independently of the check. If the two
ever disagree, an approved install is denied at the gate — a functional
regression the existing tests would not catch, since they only exercised
the check side.
…t root

The probe resolves from an empty manifest, so its closure depends only on
the override set — the project root is in the key deliberately, not by
accident. Record the tradeoff (a redundant resolve for a second repo with
identical overrides, in exchange for an auditable entry and parity with
the Yarn context key) so it does not read as an oversight.
@aldegad
aldegad merged commit d2be321 into main Jul 24, 2026
2 checks passed
@aldegad
aldegad deleted the fix/npm-closure-honors-repo-overrides branch July 24, 2026 11:22
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