|
| 1 | +name: bughunt-npm |
| 2 | +on: |
| 3 | + push: |
| 4 | + branches: ['bughunt/npm/**'] |
| 5 | +permissions: |
| 6 | + contents: read |
| 7 | +jobs: |
| 8 | + probe: |
| 9 | + strategy: |
| 10 | + fail-fast: false |
| 11 | + matrix: |
| 12 | + os: [macos-latest, ubuntu-latest] |
| 13 | + npm: ['10.9.7'] |
| 14 | + runs-on: ${{ matrix.os }} |
| 15 | + timeout-minutes: 45 |
| 16 | + defaults: |
| 17 | + run: |
| 18 | + shell: bash |
| 19 | + steps: |
| 20 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 21 | + with: |
| 22 | + persist-credentials: false |
| 23 | + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 |
| 24 | + with: |
| 25 | + node-version: '24.15.0' |
| 26 | + - name: Install npm |
| 27 | + run: npm install -g npm@${{ matrix.npm }} && npm --version |
| 28 | + - name: Build CLI |
| 29 | + run: cargo build --release -p socket-patch-cli |
| 30 | + - name: Write probe files |
| 31 | + run: | |
| 32 | + mkdir -p "$RUNNER_TEMP/probe" |
| 33 | + cat > "$RUNNER_TEMP/probe/mkpatch.py" <<'PYEOF' |
| 34 | + # usage: mkpatch.py name version -> builds /tmp/mock/p/<safe>/ {patched.tgz, meta.json} |
| 35 | + import sys, os, subprocess, tarfile, io, json, hashlib, base64, uuid, gzip |
| 36 | + name, ver = sys.argv[1], sys.argv[2] |
| 37 | + safe = name.replace('/', '__') |
| 38 | + HERE=os.path.dirname(os.path.abspath(__file__)) |
| 39 | + d = os.path.join(HERE,"p",f"{safe}@{ver}"); os.makedirs(d, exist_ok=True) |
| 40 | + subprocess.run(["npm.cmd" if os.name=="nt" else "npm","pack",f"{name}@{ver}","--pack-destination",d,"--silent"],check=True,cwd=d,stdout=subprocess.DEVNULL) |
| 41 | + src = [f for f in os.listdir(d) if f.endswith('.tgz') and f!='patched.tgz'][0] |
| 42 | + MARK=b"/* SOCKET-PATCHED */\n" |
| 43 | + tin = tarfile.open(os.path.join(d,src),'r:gz') |
| 44 | + buf=io.BytesIO(); tout=tarfile.open(fileobj=buf,mode='w',format=tarfile.USTAR_FORMAT) |
| 45 | + orig=None |
| 46 | + for m in tin.getmembers(): |
| 47 | + if not m.isfile(): continue |
| 48 | + data=tin.extractfile(m).read() |
| 49 | + rel = m.name.split('/',1)[1] |
| 50 | + if rel=='index.js' or (orig is None and rel.endswith('.js') and rel.count('/')==0 and rel=='index.js'): |
| 51 | + orig=data; data=MARK+data |
| 52 | + ti=tarfile.TarInfo('package/'+rel); ti.size=len(data); ti.mtime=499162500; ti.mode=0o644 |
| 53 | + tout.addfile(ti,io.BytesIO(data)) |
| 54 | + tout.close() |
| 55 | + assert orig is not None, "no index.js" |
| 56 | + raw=buf.getvalue(); gz=io.BytesIO() |
| 57 | + with gzip.GzipFile(fileobj=gz,mode='wb',mtime=0) as g: g.write(raw) |
| 58 | + tgz=gz.getvalue(); open(os.path.join(d,'patched.tgz'),'wb').write(tgz) |
| 59 | + def gsha(b): return hashlib.sha256(b"blob %d\0"%len(b)+b).hexdigest() |
| 60 | + u=str(uuid.uuid5(uuid.NAMESPACE_URL,f"{name}@{ver}")) |
| 61 | + purl=f"pkg:npm/{name.replace('@','%40')}@{ver}" if name.startswith('@') else f"pkg:npm/{name}@{ver}" |
| 62 | + meta=dict(name=name,version=ver,uuid=u,purl=purl,sri="sha512-"+base64.b64encode(hashlib.sha512(tgz).digest()).decode(), |
| 63 | + before=gsha(orig),after=gsha(MARK+orig)) |
| 64 | + open(os.path.join(d,'before.blob'),'wb').write(orig); open(os.path.join(d,'after.blob'),'wb').write(MARK+orig) |
| 65 | + json.dump(meta,open(os.path.join(d,'meta.json'),'w'),indent=1); print(json.dumps(meta)) |
| 66 | + PYEOF |
| 67 | + cat > "$RUNNER_TEMP/probe/server.py" <<'PYEOF' |
| 68 | + import json, os, re, sys, urllib.parse |
| 69 | + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 70 | + PORT=int(sys.argv[1]) if len(sys.argv)>1 else 8765 |
| 71 | + BASE=f"http://127.0.0.1:{PORT}" |
| 72 | + HERE=os.path.dirname(os.path.abspath(__file__)) |
| 73 | + PD=os.path.join(HERE,'p') |
| 74 | + LOG=open(os.path.join(HERE,'requests.log'),'a') |
| 75 | + def load(): |
| 76 | + out={} |
| 77 | + for d in os.listdir(PD): |
| 78 | + try: m=json.load(open(os.path.join(PD,d,'meta.json'))); m['dir']=os.path.join(PD,d); out[urllib.parse.unquote(m['purl'])]=m |
| 79 | + except Exception: pass |
| 80 | + return out |
| 81 | + def norm(p): return urllib.parse.unquote(p).split('?')[0] |
| 82 | + class H(BaseHTTPRequestHandler): |
| 83 | + def log_message(self,*a): pass |
| 84 | + def send(self,code,obj=None,raw=None,ct='application/json'): |
| 85 | + b = raw if raw is not None else json.dumps(obj).encode() |
| 86 | + self.send_response(code); self.send_header('content-type',ct); self.send_header('content-length',str(len(b))); self.end_headers(); self.wfile.write(b) |
| 87 | + def body(self): |
| 88 | + n=int(self.headers.get('content-length') or 0); return json.loads(self.rfile.read(n) or b'{}') |
| 89 | + def do_POST(self): |
| 90 | + P=load(); b=self.body(); LOG.write(f"POST {self.path} {json.dumps(b)}\n"); LOG.flush() |
| 91 | + if self.path.endswith('/batch'): |
| 92 | + pk=[] |
| 93 | + for c in b.get('components',[]): |
| 94 | + m=P.get(urllib.parse.unquote(c['purl'])) |
| 95 | + if m: pk.append({"purl":c['purl'],"patches":[{"uuid":m['uuid'],"purl":m['purl'],"tier":"free","cveIds":["CVE-2026-1111"],"ghsaIds":["GHSA-aaaa-bbbb-cccc"],"severity":"high","title":"mock"}]}) |
| 96 | + return self.send(200,{"packages":pk,"canAccessPaidPatches":False}) |
| 97 | + if self.path.endswith('/package'): |
| 98 | + res={} |
| 99 | + for u in b.get('uuids',[]): |
| 100 | + for m in P.values(): |
| 101 | + if m['uuid']==u: |
| 102 | + url=f"{BASE}/patch/npm/11111111-1111-4111-8111-111111111111/{m['uuid']}/{m['name'].split('/')[-1]}-{m['version']}.tgz" |
| 103 | + res[u]={"status":"granted","url":url,"purl":m['purl'],"artifacts":[{"kind":"tarball","url":url,"integrity":{"sha512":m['sri']}}],"registryOverride":None} |
| 104 | + return self.send(200,{"results":res}) |
| 105 | + self.send(404,{"error":"nf"}) |
| 106 | + def do_GET(self): |
| 107 | + P=load(); p=norm(self.path); LOG.write(f"GET {self.path}\n"); LOG.flush() |
| 108 | + if self.path.startswith('/reg/'): |
| 109 | + import subprocess |
| 110 | + r=subprocess.run(['curl','-sS','-f','https://registry.npmjs.org/'+self.path[5:]],capture_output=True) |
| 111 | + return self.send(200 if r.returncode==0 else 404, raw=r.stdout or b'{}') |
| 112 | + if p.startswith('/patch/npm/'): |
| 113 | + u=p.split('/')[4] |
| 114 | + for m in P.values(): |
| 115 | + if m['uuid']==u: return self.send(200,raw=open(os.path.join(m['dir'],'patched.tgz'),'rb').read(),ct='application/octet-stream') |
| 116 | + mm=re.search(r'/patches/view/([0-9a-f-]+)$',p) |
| 117 | + if mm: |
| 118 | + for m in P.values(): |
| 119 | + if m['uuid']==mm.group(1): |
| 120 | + return self.send(200,{"uuid":m['uuid'],"purl":m['purl'],"publishedAt":"2026-01-01T00:00:00Z","files":{"package/index.js":{"beforeHash":m['before'],"afterHash":m['after']}}, |
| 121 | + "vulnerabilities":{"GHSA-aaaa-bbbb-cccc":{"cves":["CVE-2026-1111"],"summary":"mock","severity":"high","description":"d"}},"description":"x","license":"MIT","tier":"free"}) |
| 122 | + mm=re.search(r'/patches/by-package/(.+)$',p) |
| 123 | + if mm: |
| 124 | + m=P.get(urllib.parse.unquote(mm.group(1))) |
| 125 | + pats=[{"uuid":m['uuid'],"purl":m['purl'],"publishedAt":"2026-01-01T00:00:00Z","description":"x","license":"MIT","tier":"free","vulnerabilities":{}}] if m else [] |
| 126 | + return self.send(200,{"patches":pats,"canAccessPaidPatches":False}) |
| 127 | + mm=re.search(r'/blob/([0-9a-f]{64})$',p) or re.search(r'/blobs?/([0-9a-f]{64})$',p) |
| 128 | + if mm: |
| 129 | + for m in P.values(): |
| 130 | + for k in ('before','after'): |
| 131 | + if m[k]==mm.group(1): return self.send(200,raw=open(os.path.join(m['dir'],k+'.blob'),'rb').read(),ct='application/octet-stream') |
| 132 | + self.send(404,{"error":"nf"}) |
| 133 | + ThreadingHTTPServer(('127.0.0.1',PORT),H).serve_forever() |
| 134 | + PYEOF |
| 135 | + cat > "$RUNNER_TEMP/probe/repro.sh" <<'PYEOF' |
| 136 | + #!/usr/bin/env bash |
| 137 | + # usage: repro-ws.sh <dir> <npm-bin> <layout: ws|file> |
| 138 | + set -u |
| 139 | + D=$1; NPM=$2; L=$3; |
| 140 | + SPA="--api-url http://127.0.0.1:8765 --org o --api-token fake --patch-server-url http://127.0.0.1:8765" |
| 141 | + rm -rf "$D"; mkdir -p "$D/packages/left-pad"; cd "$D"; git init -q . |
| 142 | + echo '{"name":"left-pad","version":"1.3.0","main":"index.js"}' > packages/left-pad/package.json |
| 143 | + echo 'module.exports = "first-party fork";' > packages/left-pad/index.js |
| 144 | + if [ "$L" = ws ]; then echo '{"name":"root","version":"1.0.0","private":true,"workspaces":["packages/*"]}' > package.json |
| 145 | + else echo '{"name":"root","version":"1.0.0","private":true,"dependencies":{"left-pad":"file:packages/left-pad"}}' > package.json; fi |
| 146 | + $NPM install --no-audit --no-fund >/dev/null 2>&1 || echo "npm install failed" |
| 147 | + git add -A; git -c user.email=a@b -c user.name=a commit -qm init |
| 148 | + ls -l node_modules/left-pad | cut -c1-200 |
| 149 | + $SP scan --mode agent $SPA > scan.out 2>&1; echo "scan exit $?"; grep -iv "api-token\|Got: fake" scan.out | grep -i "warn\|mismatch\|applied\|patched" | head -5 |
| 150 | + echo "--- git diff of first-party source:"; git diff --stat -- packages |
| 151 | + $SP vex $SPA -O v.json > vex.out 2>&1; echo "vex exit $? statuses=$($PY -c "import json;print([s['status'] for s in json.load(open('v.json'))['statements']])" 2>/dev/null)" |
| 152 | + $SP rollback $SPA > rb.out 2>&1; echo "rollback exit $?"; echo "after rollback:"; head -c 60 packages/left-pad/index.js; echo; git diff --stat -- packages |
| 153 | + PYEOF |
| 154 | + - name: Probe |
| 155 | + run: | |
| 156 | + export PY=$(command -v python3 || command -v python) |
| 157 | + export SP="$GITHUB_WORKSPACE/target/release/socket-patch" |
| 158 | + cd "$RUNNER_TEMP/probe" |
| 159 | + $PY mkpatch.py left-pad 1.3.0 >/dev/null |
| 160 | + $PY server.py 8765 > server.out 2>&1 & |
| 161 | + sleep 3 |
| 162 | + for L in ws file; do |
| 163 | + echo "===== [${{ matrix.os }} npm ${{ matrix.npm }}] layout=$L" |
| 164 | + bash repro.sh "$RUNNER_TEMP/w-$L" npm $L 2>&1 | grep -v "^Patched\|^Summary\|Got: fake\|api-token" |
| 165 | + done |
0 commit comments