Skip to content

Commit c3e529d

Browse files
committed
bughunt probe: macOS ws link detail
1 parent 045d7ec commit c3e529d

1 file changed

Lines changed: 165 additions & 0 deletions

File tree

‎.github/workflows/bughunt-npm.yml‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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

Comments
 (0)