Skip to content

feat(supply-chain): resolve npm dependencies through the lockfile - #344

Open
Mark2Mac wants to merge 1 commit into
NVIDIA:mainfrom
Mark2Mac:feat/npm-lockfile-resolution
Open

feat(supply-chain): resolve npm dependencies through the lockfile#344
Mark2Mac wants to merge 1 commit into
NVIDIA:mainfrom
Mark2Mac:feat/npm-lockfile-resolution

Conversation

@Mark2Mac

@Mark2Mac Mark2Mac commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

The gap

SC4 reads package.json and stops there. For npm that is the smaller half of the problem:

Python does not have this gap: #263 already reads uv.lock and poetry.lock and prefers those versions. npm had no lockfile support at all.

The change

Read npm lockfiles — package-lock.json and npm-shrinkwrap.json — and use them the same way:

  1. Resolve manifest ranges. "commander": "^11.0.0" with a lockfile is 11.1.0. Note it is not 11.0.0, which is what the old caret-stripping produced and what a reader would guess.
  2. Scan the lockfile itself, so transitive dependencies are covered — the treatment uv.lock already gets.

Both layouts are handled: lockfileVersion 2/3 (keyed by install path, including nested node_modules/a/node_modules/b) and version 1 (nested dependencies). The root entry is the project itself and is skipped; aliased installs use the declared name.

Three details that are load-bearing

Each of these is a silent-wrong-answer trap, so each has a test that fails without it.

Deduplication is by name and version. npm installs the same package at several versions routinely, nesting the copies it cannot hoist. Keying on the name alone keeps whichever entry came first and drops the rest — and the dropped copy is on disk, so a vulnerable one would simply go unreported. On the corpus below, 12 packages are installed at two versions each; none of those 24 versions happens to carry an advisory, so this changed no finding here. It removes a class of miss, not a count.

The ecosystem maps are kept separate. semver, packaging and requests all exist in both PyPI and npm. One shared map would answer a Python question with an npm version. Name normalization differs for the same reason: _normalize_package_name folds _ into -, which is right for PyPI and wrong for npm, where string_decoder and string-decoder are two different real packages.

Manifest ranges resolve to the direct install. A range in package.json names a direct dependency, so it resolves to the top-level copy — not to a nested one that exists only to satisfy some other package's constraint.

Cost

Looking a line number up per package is quadratic: both the search and the offset-to-line conversion restart at the top of the file each time. One indexing pass instead:

  packages     per-package search     one pass
       500                  0.166s      0.014s
      2000                  0.724s      0.048s
      5000                  6.263s      0.180s
     20000                        —      0.439s

5000-entry lockfiles are ordinary, so the quadratic version was not a theoretical concern. A regression test asserts 2000 packages parse in under a second.

Measured

5 lockfiles found in Claude Code plugins on a real machine, 458 installed packages:

   3 packages    0 SC4   polito-batch
  47 packages    1 SC4   superpowers-chrome
 123 packages    5 SC4   superpowers-chrome/mcp
   1 package     1 SC4   superpowers/tests/brainstorm-server
 287 packages    4 SC4   cli-anything/sketch/agent-harness

TOTAL            11 SC4 findings, 7 HIGH — none of them visible before

Each was re-verified by querying OSV directly for that exact version, not just trusting the analyzer:

package advisories where
@modelcontextprotocol/sdk==1.20.1 3 (CVE-2026-25536, CVE-2026-0621, CVE-2025-66414) inside an MCP server plugin
undici==7.25.0 12 transitive
ws==8.19.0 2 transitive
path-to-regexp==8.3.0 2 transitive
js-yaml==3.14.2, picomatch==2.3.1, brace-expansion==1.1.12 2, 2, 4 transitive

None of these appear in any package.json. They are exactly the dependencies a manifest-only scan cannot see, and the first row is the kind an agent-security scanner should not be missing.

Volume stays sane: 458 packages produced 11 findings, not hundreds — the exact-version match is what keeps it quiet.

Scope

yarn.lock and pnpm-lock.yaml are deliberately not here. They are different formats with their own parsing problems, and bundling them would make this change harder to review than it needs to be. The seam they would plug into — _npm_lock_entries returning (name, version, line, depth) — is the same one this PR adds.

Tests

Both lockfile layouts, aliased installs, the skipped root entry, malformed JSON, ecosystem separation, npm name normalization, exact-pin-beats-lockfile precedence, multi-version installs, direct-vs-nested resolution, and the parse-cost regression. Plus two analyzer-level ones:

Full suite green (1751 passed), ruff check and ruff format --check clean at 0.15.19.

Relationship to #323

Independent, and measured rather than assumed: merging #323 into this branch is a clean merge, and the combined tree passes. Either can land first.

They do compound, though. The tests here build manifests with json.dumps(..., indent=2) on purpose, because a compact package.json still yields zero dependencies on main — the line-oriented scan never enters the dependency section, which is what #323 fixes. Until that lands, range resolution has nothing to resolve on a one-line manifest. The lockfile scan, which is where all 11 findings above come from, is unaffected either way.

@Mark2Mac
Mark2Mac force-pushed the feat/npm-lockfile-resolution branch 2 times, most recently from 08ab40d to 79771bf Compare August 4, 2026 16:26
rng1995
rng1995 previously approved these changes Aug 5, 2026

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Automated SkillSpector Review]

Approved. The lockfile parser covers npm lockfile v1 and v2/v3 layouts, preserves distinct installed versions, resolves manifest ranges only from the shallowest direct install, keeps npm normalization separate from PyPI, and avoids quadratic line lookup. Exact-head verification passed all 270 supply-chain pattern tests plus Ruff lint and format checks.

SC4 read package.json and stopped there. For npm that is the smaller half: the manifest
lists direct dependencies, usually as a range, and the versions actually installed --
direct and transitive -- are in package-lock.json, which was never read. Python has no
such gap; uv.lock and poetry.lock are already read and preferred.

Read package-lock.json and npm-shrinkwrap.json the same way: resolve manifest ranges to
the version on disk, and scan the lockfile itself so transitive dependencies are covered.
Both layouts are handled -- lockfileVersion 2/3 keyed by install path, and version 1 with
nested dependencies.

Three details are load-bearing:

- Deduplication is by name and version, not by name. npm installs the same package at
  several versions routinely, nesting the ones it cannot hoist; keeping one entry per name
  drops the others silently, and a dropped copy is as installed as the one kept.
- The npm and PyPI version maps are separate. semver, packaging and requests all exist in
  both ecosystems, so one shared map would answer a Python question with an npm version.
  Name normalization differs for the same reason: PyPI folds _ into -, npm does not, and
  string_decoder and string-decoder are two real packages.
- Line numbers come from one indexing pass. Searching per package is quadratic, which cost
  6.3s on a 5000-entry lockfile; lockfiles that size are ordinary.

Signed-off-by: Marco Macrì <Mark2Mac@users.noreply.github.com>
@Mark2Mac

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 2b408ee (2.9.3) to clear the conflict. New head 33447d3.

The conflict was with #357, which added SC8 to the same module. It was confined to the import block — #357 adds os and pathlib.Path, this PR adds json and collections.abc.Callable — and the resolution is the union of the two.

The approved content is unchanged. git range-diff 7e9c19d..79771bf upstream/main..33447d3 reports one hunk, and that hunk is the import block; every other hunk in the commit is identical to the head you approved. The push dismissed the approval automatically, so this is a re-request rather than a new proposal.

Verified after the rebase, on the exact head:

  • pytest -m "not integration and not provider" tests/2051 passed, 13 skipped, 38 deselected, 4 xfailed
  • ruff check and ruff format --check — clean
  • Signed-off-by present

SC8 and this PR do not interact: SC8 flags shipped bytecode during discovery, the lockfile resolution runs over dependency manifests.

@Mark2Mac

Copy link
Copy Markdown
Contributor Author

Field evidence from a package installed today, which sharpens one claim in the PR body.

text-to-cad (10 skills, installed from the official marketplace) ships 9 package-lock.json files. main reports 23 SC4 findings on it, read from the manifests; most are unpinned and therefore unverifiable. With this PR the lockfiles resolve to 862 distinct (name, version) installs, and querying every one of them against OSV returns 18 vulnerable:

package versions advisories
hono 4.12.18 16
next 16.2.6 9
brace-expansion 1.1.14 and 5.0.6 3 each
js-yaml, ip-address, fast-uri, postcss 3 each
nanoid 3.3.11 and 3.3.12 2 each
postcss 8.5.10 and 8.5.14 2–3 each
vite, @babel/core, esbuild, sharp, qs, body-parser, @hono/node-server 1–2 each

The correction. The PR body says the name-and-version deduplication removes a class of miss "without changing any finding count", because on the corpus I had measured, it didn't. On this package it does: brace-expansion, nanoid and postcss each appear at two distinct vulnerable versions, so deduplicating by name alone silently drops 3 of the 18. That claim was accurate when written and is now too weak — the class it removes is not hypothetical.

For completeness on severity: none of the 18 are installed on this machine (no node_modules anywhere under the package, and the viewer the skill actually launches declares no dependencies and serves a prebuilt bundle). 14 are in docs/ (the project website) and 5 in viewer/ (its build tooling). The point is not that this package is dangerous — it is that main reports its entire transitive surface as absent, and 18 vulnerable installs is what "absent" was hiding.

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.

2 participants