Hacktoberfest 2026 : les issues que les mainteneurs ont marquées pour octobre, ouvertes et accessibles aux débutants. Parcourir les issues Hacktoberfest

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)

Ouverte
#1,481 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 1 jour

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
45/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
rust
Domaine
cli, security

Piste de recherche

Read crates/socket-patch-core/src/patch/redirect/mod.rs around the npm non_registry and link handling, then trace how bundled_skipped_uuids affects in-run VEX in crates/socket-patch-core/src/hosted/engine.rs. Reproduce both lockfile shapes described in the issue and compare the hosted scan with standalone vex. Done means the in-run VEX withholds the patch for both unredirected local copies, with warnings and attribution consistent with the documented contract.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

bug bughunt pm:npm

[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 [email protected] 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/[email protected], 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 [email protected] --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/[email protected] > 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/[email protected] --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/[email protected]', '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/[email protected] 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 on unattributed_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, and vex attests nothing for that name@version".
  • The npm rewriter already does this for a bundled copy (redirect_npm_bundled_instance_skipped) and a hasShrinkwrap copy (#753). Both insert the uuid into bundled_skipped_uuids "so the in-run --vex verifies 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: 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 #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.

Langage dominant
Rust
Étoiles
8
Forks
1
Merge moyen
18 h 19 min
PR mergées (30 j)
504

Préparer son environnement

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de SocketDev/socket-patch

Toutes les issues de SocketDev/socket-patch

Issues similaires

Plus d'issues Rust

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.