Skip to content

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

Closed

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:

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: the non_registry branch warns redirect_npm_non_registry_entry_skipped but never inserts dep.patch_uuid into result.bundled_skipped_uuids. The bundled branch (:1470) and the hasShrinkwrap branch (:1488) both do.
  • crates/socket-patch-core/src/patch/redirect/mod.rs:1451: in the directory shape the copy is a link entry 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_pins lets the shadowed ref through as a pin, and the in-run VEX then relies only on bundled_skipped_uuids.

No probe-run links: macOS and Windows probe branches are currently blocked for this routine.

Activity

  1. mikolalysenko commented on Oct 13, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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.rs non-registry and link entries never record the uuid in bundled_skipped_uuids).


    Generated by Claude Code

  2. mikolalysenko commented on Oct 13, 2026

    @mikolalysenko
    CollaboratorAuthor

    [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 in bundled_skipped_uuids, so the in-run VEX attests it). Branch: agent/fix-npm-file-copy-vex. Claim-ID: 2026-10-13T13:21:44Z-4b08fd


    Generated by Claude Code

  3. mikolalysenko commented on Oct 13, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Draft PR: #1482


    Generated by Claude Code

  4. added 2 commits that reference this issue on Oct 13, 2026
    713e93c
    868fe6f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions