Skip to content

Agent-mode apply skips a bundled copy inside another vlt/pnpm store entry whenever the package is also installed normally, and VEX attests not_affected #601

Description

[agent] Found by the scheduled vlt bug-hunt routine (ledger #307).

Summary

A package can bundle a vulnerable name@version (bundleDependencies) while the project also installs that same name@version normally. vlt unpacks the bundled copy into the parent's store entry, at node_modules/.vlt/~npm~bundler@1.0.0/node_modules/bundler/node_modules/left-pad. Agent mode (scan --mode agent, apply) patches only the normal store copy (.vlt/~npm~left-pad@1.3.0/...) and leaves the bundled copy pristine. It reports success, and socket-patch vex attests not_affected / inline_mitigations_already_exist. The parent package then loads the unpatched bytes at runtime.

If the bundled copy is the only install (the project doesn't depend on left-pad directly), agent mode patches it. So the crawler can find the copy. It just stops looking once it has found any copy.

The same thing happens on pnpm 10.28.0 (.pnpm/bundler@1.0.0/node_modules/bundler/node_modules/left-pad stays pristine, vex exits 0), since both use the same crawler path. Hosted and vendored are fine since #472: they warn redirect_vlt_bundled_instance_skipped / vendor_bundled_instance_skipped, and vex refuses to attest.

Impact

The VEX statement is false: the shipped tree still contains an unpatched copy of the vulnerable version that a dependency actually requires. apply exits 0 with already_patched on re-runs, so there's nothing that warns the user.

Repro (vlt 1.3.5, Linux, main b1f9818, reproduced twice on each version)

The mock registry serves left-pad@1.3.0 (index.js = module.exports = 'pristine') and bundler@1.0.0, which bundles left-pad@1.3.0 and has main: module.exports = require('left-pad'). The mock patch API's patch changes index.js to 'patched'. This is the same mock the ledger uses.

mkdir app && cd app
echo '{"name":"app","version":"1.0.0","dependencies":{"left-pad":"1.3.0","bundler":"1.0.0"}}' > package.json
echo '{"config":{"registries":{"npm":"http://127.0.0.1:18555/"}}}' > vlt.json
vlt install
socket-patch scan --mode agent --yes --json      # rc 0, applied 1
socket-patch apply --json                         # rc 0, skipped: already_patched
find node_modules -path '*left-pad*' -name index.js | xargs grep -H .
#   node_modules/.vlt/~npm~bundler@1.0.0/node_modules/bundler/node_modules/left-pad/index.js:module.exports = 'pristine'
#   node_modules/.vlt/~npm~left-pad@1.3.0/node_modules/left-pad/index.js:module.exports = 'patched'
node -p "require('bundler')"                      # pristine
socket-patch vex --output vex.json                # rc 0, not_affected pkg:npm/left-pad@1.3.0

Control: drop "left-pad":"1.3.0" from dependencies. After the agent scan, the bundled copy reads 'patched'.

Expected vs actual

  • Expected: agent apply patches every installed physical copy of the purl. The crawler comments say as much ("a target can physically exist ONLY inside another package's store entry … leaving an installed, scan-visible package invisible to apply (fail-open)"), and so does Fix agent vex checking only one installed copy (#516) #517 ("agent vex checking only one installed copy"). If it can't patch a copy, it should at least not attest it. CLI_CONTRACT.md "Contested locks" already says a bundled copy of the same name@version must not be attested.
  • Actual: the bundled copy stays unpatched, apply and scan exit 0, and vex says not_affected.
OS vlt 1.0.10 vlt 1.2.0 vlt 1.3.5 pnpm 10.28.0
Linux reproduces reproduces reproduces reproduces
macOS / Windows untested (probe branches blocked) untested untested untested

The bundled-only control passes on every version above.

Suspect code

crates/socket-patch-core/src/crawlers/npm_crawler.rs:1235: find_by_purls runs the unfiltered pass 2 (which probes every store entry, including a host package's bundled node_modules) only if !pending.is_empty(), meaning only for targets that pass 1 didn't find at all. Pass 1 filters store entries by advertised name (pending_store_entries, and unmatched_names in resolve_pending_targets, around line 1318). So once the normal ~npm~left-pad@1.3.0 entry matches, the ~npm~bundler@1.0.0 entry is never probed, and agent vex sees the same incomplete copy set.

Not bisected: 4.0.0 predates vlt support, and the pass-2 fallback is newer than that.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions