Repository navigation
Hosted npm scan --vex attests not_affected when the lock also installs the same name@version from a file: tarball or directory, which npm installs unpatched (tarball shape regressed in #1008) #1481
Description
[agent] Found by the scheduled npm bug-hunt routine (ledger #302).
Summary
scan --mode hosted --vex attests a package not_affected when the npm lock also installs that same name@version from a file: tarball or a file: directory. npm installs that copy from its spec, so it stays unpatched. A standalone vex run after npm ci refuses the same state correctly.
Two shapes, both in an npm workspace (install-strategy=nested) where member a depends on registry left-pad@1.3.0 and member b depends on a local copy of the same release:
b's spec |
scan warning | in-run --vex |
npm ci: a / b |
standalone vex after npm ci |
|---|---|---|---|---|
file:../../vendor/left-pad-1.3.0.tgz (tarball) |
redirect_npm_non_registry_entry_skipped |
not_affected |
patched / unpatched | exit 2, patched_ref_unattributable |
file:../../lpdir (directory, link: true) |
none | not_affected |
patched / unpatched | exit 1, not_applied (no_applicable_patches) |
This is the npm counterpart of the pnpm defect fixed for hosted pnpm in #1469 (refs #935). That PR touches only the pnpm path, so npm keeps the bug.
Impact
A scan --mode hosted --vex run, which is the documented one-shot CI flow, produces an OpenVEX not_affected statement while part of the app still loads the vulnerable bytes. The tarball shape at least prints a warning. The directory shape prints no warning at all. Both exit 0 with status: success.
Repro (Linux, npm 10.9.4 / Node 22.22.0, main 15c2c83)
You need a mock of the org patch API on 127.0.0.1:18765 serving one free patch for pkg:npm/left-pad@1.3.0, whose tarball prepends /*SOCKET_PATCHED*/ to index.js. The mock is in the details block below. It speaks the same contract as tests/e2e_redirect_npm_build.rs: patches/batch, patches/by-package, patches/package (granted, tarball artifact + sha512), patches/view/<uuid>, and the tarball route.
#!/usr/bin/env bash
# usage: repro.sh <npm-bin> <socket-patch-bin> <workdir> (mock API on :18765)
set -u; NPM=$1; SP=$2; W=$3; rm -rf "$W"; mkdir -p "$W/packages/a" "$W/packages/b" "$W/vendor"; cd "$W"
$NPM pack left-pad@1.3.0 --pack-destination vendor >/dev/null 2>&1
echo '{"name":"root","version":"0.0.0","private":true,"workspaces":["packages/*"]}' > package.json
echo '{"name":"a","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}' > packages/a/package.json
echo '{"name":"b","version":"1.0.0","dependencies":{"left-pad":"file:../../vendor/left-pad-1.3.0.tgz"}}' > packages/b/package.json
# directory shape: unpack the tarball into ./lpdir and use "file:../../lpdir" instead
echo 'install-strategy=nested' > .npmrc
$NPM install --no-audit --no-fund >/dev/null 2>&1
$SP scan --mode hosted --json --yes --api-url http://127.0.0.1:18765 --org test-org --api-token fake \
--vex scan.vex.json --vex-product pkg:npm/root@0.0.0 > scan.json; echo "scan exit=$?"
rm -rf node_modules packages/*/node_modules; $NPM ci --no-audit --no-fund >/dev/null 2>&1
head -c 20 packages/a/node_modules/left-pad/index.js; echo; head -c 20 packages/b/node_modules/left-pad/index.js; echo
$SP vex --json --output post.vex.json --product pkg:npm/root@0.0.0 --patch-server-url http://127.0.0.1:18765 \
--api-url http://127.0.0.1:18765 --org test-org --api-token fake > vex.json; echo "vex exit=$?"Output for the tarball shape, from the same script with a few extra prints of scan.json / scan.vex.json (npm 10.9.4; identical on npm 12.2.0):
scan exit=0
status success warnings ['redirect_npm_non_registry_entry_skipped', 'redirect_npm_allow_remote']
in-run VEX: [('pkg:npm/left-pad@1.3.0', 'not_affected')]
npm ci exit=0
packages/a left-pad: PATCHED
packages/b left-pad: UNPATCHED
standalone vex exit=2
error ['patched_ref_unattributable']
The standalone vex warning reads: "package-lock.json: pkg:npm/left-pad@1.3.0 is wired to a Socket patch but lock entry packages/b/node_modules/left-pad is not installed from the registry (it resolves to "file:vendor/left-pad-1.3.0.tgz"); npm installs it from that spec, so that copy stays UNPATCHED and nothing is attested".
Directory shape: the scan has no redirect_npm_* skip warning, the in-run VEX is not_affected, b's copy is unpatched after npm ci, and the standalone vex omits the patch with not_applied.
mock.py (python3 mock.py cfg.json 18765)
import json, sys, re, hashlib, base64
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# cfg: list of {name, version, uuid, tgz, files:{path:[before,after]}}
cfg = json.load(open(sys.argv[1])); port=int(sys.argv[2])
ORG="test-org"; TOKEN="22222222-2222-4222-8222-222222222222"
def purl(p): return f"pkg:npm/{p['name']}@{p['version']}"
def base(): return f"http://127.0.0.1:{port}"
def turl(p):
last=p['name'].split('/')[-1]
return f"{base()}/patch/npm/{p['name']}/{p['version']}/{TOKEN}/{p['uuid']}/{last}-{p['version']}.tgz"
def sri(p): return "sha512-"+base64.b64encode(hashlib.sha512(open(p['tgz'],'rb').read()).digest()).decode()
def summ(p): return {"uuid":p['uuid'],"purl":purl(p),"publishedAt":"2026-01-01T00:00:00Z","description":"x","license":"MIT","tier":"free","vulnerabilities":{},"cveIds":[],"ghsaIds":[],"severity":"high","title":"t"}
class H(BaseHTTPRequestHandler):
def log_message(self,*a): sys.stderr.write("REQ %s %s\n"%(self.command,self.path))
def j(self,o,code=200):
b=json.dumps(o).encode(); self.send_response(code); self.send_header("content-type","application/json"); self.send_header("content-length",str(len(b))); self.end_headers(); self.wfile.write(b)
def body(self):
n=int(self.headers.get('content-length',0)); return json.loads(self.rfile.read(n) or b'{}')
def do_POST(self):
path=self.path.split('?')[0]; b=self.body()
if path.endswith('/patches/batch'):
comps=[c.get('purl') if isinstance(c,dict) else c for c in b.get('components',b.get('purls',[]))]
pk=[{"purl":purl(p),"patches":[summ(p)]} for p in cfg if purl(p) in comps or not comps]
return self.j({"packages":pk,"canAccessPaidPatches":False})
if path.endswith('/patches/package'):
res={}
for p in cfg:
res[p['uuid']]={"status":"granted","url":turl(p),"purl":purl(p),"artifacts":[{"kind":"tarball","url":turl(p),"integrity":{"sha512":sri(p)}}],"registryOverride":None}
return self.j({"results":res})
self.j({"error":"nf"},404)
def do_GET(self):
path=self.path.split('?')[0]
m=re.search(r'/patches/by-package/(.+)$',path)
if m:
from urllib.parse import unquote
q=unquote(m.group(1))
return self.j({"patches":[summ(p) for p in cfg if purl(p)==q or q.startswith(purl(p).rsplit('@',1)[0])],"canAccessPaidPatches":False})
m=re.search(r'/patches/view/([^/]+)$',path)
if m:
for p in cfg:
if p['uuid']==m.group(1):
d=summ(p); d["files"]={k:{"beforeHash":v[0],"afterHash":v[1]} for k,v in p['files'].items()}
d["vulnerabilities"]={"GHSA-aaaa-bbbb-cccc":{"cves":["CVE-2026-1111"],"summary":"s","severity":"high","description":"d"}}
return self.j(d)
for p in cfg:
if path==turl(p)[len(base()):]:
b=open(p['tgz'],'rb').read(); self.send_response(200); self.send_header("content-length",str(len(b))); self.end_headers(); self.wfile.write(b); return
self.j({"error":"nf"},404)
ThreadingHTTPServer(("127.0.0.1",port),H).serve_forever()cfg.json: [{"name":"left-pad","version":"1.3.0","uuid":"5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d","tgz":"<patched left-pad-1.3.0.tgz>","files":{"package/index.js":["<git-sha256 before>","<git-sha256 after>"]}}]
Expected vs actual
Expected. The in-run VEX withholds a patch whose name@version also installs from a copy the run can't redirect. Three things in the repo say so:
CLI_CONTRACT.md, the attribution gate in hosted mode: a deliberate partial redirect the run already reports, such as a bundled copy left on the registry, is warned about and kept out of the in-run VEX. The engine's doc onunattributed_pins(hosted/engine.rs:1748-1753) states the same rule.- The Bun row for the same case (
redirect_bun_non_registry_entry_skipped, CLI_CONTRACT.md:1550): "the in-run VEX does not assume the patch, andvexattests nothing for thatname@version". - The npm rewriter already does this for a bundled copy (
redirect_npm_bundled_instance_skipped) and ahasShrinkwrapcopy (npm hosted and vendored modes rewrite a lock entry nested under a dependency that ships npm-shrinkwrap.json (hasShrinkwrap), so npm 7–11 install it unpatched while vendored VEX attests not_affected #753). Both insert the uuid intobundled_skipped_uuids"so the in-run--vexverifies instead of assuming the patch applied".
Actual. The tarball copy is warned about but not recorded, so the in-run VEX attests it. The directory copy is neither warned about nor recorded. In both shapes the in-run VEX contradicts what a standalone vex reports for the same tree.
OS × version
| OS | npm / Node | tarball shape | directory shape |
|---|---|---|---|
| Linux | 10.9.4 / 22.22.0 | repro (twice) | repro |
| Linux | 12.2.0 / 22.22.0 | repro | not run |
| Linux | 8.19.4 / 22.22.0 | n/a: npm 8 has no install-strategy, so it dedupes both specs onto the single file: copy and the scan correctly pins nothing |
n/a |
| macOS / Windows | — | not run (probe branches are blocked for this routine) | — |
First bad commit
Tarball shape: f3c6313a (#1008). On f3c6313a^ the same repro skips the patch with redirect_unattributable ("the rewrite would wire patch … but lockfile …"), writes no pin and no VEX statement, and leaves both copies unpatched. Since #1008 the pin is written and the in-run VEX attests it. The pnpm routine bisected #1469 to the same commit.
Directory shape: also attests on f3c6313a^. It isn't a regression.
Suspect code
crates/socket-patch-core/src/patch/redirect/mod.rs:1502-1515: thenon_registrybranch warnsredirect_npm_non_registry_entry_skippedbut never insertsdep.patch_uuidintoresult.bundled_skipped_uuids. The bundled branch (:1470) and thehasShrinkwrapbranch (:1488) both do.crates/socket-patch-core/src/patch/redirect/mod.rs:1451: in the directory shape the copy is alinkentry with a separate target package entry (lpdir,version: 1.3.0). It isn't flagged at all, so no warning and no uuid record.crates/socket-patch-core/src/hosted/engine.rs:1896-1904: since Fix open npm issues #1008,unattributed_pinslets the shadowed ref through as a pin, and the in-run VEX then relies only onbundled_skipped_uuids.
No probe-run links: macOS and Windows probe branches are currently blocked for this routine.
Activity
mikolalysenko commentedon Oct 13, 2026
[agent] Triage: priority:p1 (npm). Related to #935 / #1469, the pnpm counterpart of the same #1008 regression. #1469's engine change only looks at pnpm locks'
file:copies, so the npm path needs its own fix in the npm rewriter (redirect/mod.rsnon-registry andlinkentries never record the uuid inbundled_skipped_uuids).Generated by Claude Code
mikolalysenko commentedon Oct 13, 2026
[agent] Claiming this issue (shared root cause: the npm rewriter warns about or ignores a
file:copy of a pinned name@version without recording the uuid inbundled_skipped_uuids, so the in-run VEX attests it). Branch: agent/fix-npm-file-copy-vex. Claim-ID: 2026-10-13T13:21:44Z-4b08fdGenerated by Claude Code
mikolalysenko commentedon Oct 13, 2026
[agent] Draft PR: #1482
Generated by Claude Code
Stop in-run VEX attesting npm file: copies
Match only file: links, keep scoped folder names