Skip to content

Commit f61597a

Browse files
committed
bughunt probe: v5 agent/vendored/hosted on macOS and Windows
1 parent 61cfb9b commit f61597a

1 file changed

Lines changed: 190 additions & 0 deletions

File tree

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

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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: [ubuntu-latest, macos-latest, windows-latest, windows-2022]
13+
npm: ['10.9.7', '12.2.0']
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/run2.sh" <<'PYEOF'
136+
#!/usr/bin/env bash
137+
# env: SP (socket-patch binary), PY (python), NPMV (label)
138+
set -u
139+
HERE=$(cd "$(dirname "$0")" && pwd); cd "$HERE"
140+
PORT=8765; SPA="--api-url http://127.0.0.1:$PORT --org o --api-token fake --patch-server-url http://127.0.0.1:$PORT"
141+
res(){ echo "RESULT [$NPMV] $1: $2"; }
142+
js(){ $PY -c "import sys,json;d=json.load(sys.stdin);print($1)"; }
143+
for p in "left-pad 1.3.0" "is-number 7.0.0" "is-number 4.0.0" "kind-of 3.2.2"; do $PY mkpatch.py $p >/dev/null || res setup "FAIL mkpatch $p"; done
144+
$PY server.py $PORT > server.out 2>&1 & SRV=$!; sleep 3
145+
PKG='{"name":"t","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0","is-number":"7.0.0","is-odd":"2.0.0"},"devDependencies":{"kind-of":"3.2.2"}}'
146+
marked(){ head -c 21 "$1" 2>/dev/null | grep -c SOCKET; }
147+
vexsum(){ js "sorted((s['status'],x['@id']) for s in d['statements'] for p in s['products'] for x in p.get('subcomponents',[]))" < "$1"; }
148+
# ---------- agent, linked strategy
149+
A="$HERE/agent dir ü/proj"; mkdir -p "$A"; cd "$A"; echo "$PKG" > package.json; printf 'install-strategy=linked\n' > .npmrc
150+
npm install --no-audit --no-fund >/dev/null 2>&1 || res a-install FAIL
151+
"$SP" scan --mode agent $SPA --json > ../scan.json 2>../scan.err; echo "agent scan exit $?"; js "d['status']" < ../scan.json
152+
tot=0; bad=0; while IFS= read -r f; do tot=$((tot+1)); [ "$(marked "$f")" = 1 ] || { bad=$((bad+1)); echo "unpatched $f"; }; done < <(find node_modules/.store -path "*node_modules/is-number/index.js" -o -path "*node_modules/left-pad/index.js" -o -path "*node_modules/kind-of/index.js")
153+
[ $tot -ge 4 ] && [ $bad = 0 ] && res a-linked-apply "PASS ($tot copies)" || res a-linked-apply "FAIL $bad/$tot unpatched"
154+
"$SP" vex $SPA -O ../av.json >../av.out 2>&1; e=$?; s=$(vexsum ../av.json 2>/dev/null); [ $e = 0 ] && echo "$s" | grep -q "is-number@4.0.0" && res a-linked-vex "PASS $s" || { res a-linked-vex "FAIL exit $e $s"; tail -5 ../av.out; }
155+
"$SP" rollback $SPA --json > ../arb.json 2>&1; echo "agent rollback exit $?"
156+
bad=0; while IFS= read -r f; do [ "$(marked "$f")" = 0 ] || { bad=$((bad+1)); echo "still patched $f"; }; done < <(find node_modules/.store -path "*node_modules/is-number/index.js" -o -path "*node_modules/left-pad/index.js")
157+
[ $bad = 0 ] && res a-linked-rollback PASS || res a-linked-rollback "FAIL $bad still patched"
158+
# ---------- vendored, hoisted
159+
V="$HERE/vend dir ü/proj"; mkdir -p "$V"; cd "$V"; echo "$PKG" > package.json
160+
npm install --no-audit --no-fund >/dev/null 2>&1 || res v-install FAIL; cp package-lock.json ../orig.lock
161+
"$SP" scan --mode vendored $SPA --json > ../scan.json 2>../scan.err; echo "vendored scan exit $?"; js "d['status']" < ../scan.json
162+
n=$(grep -c 'file:.socket' package-lock.json); [ "$n" = 4 ] && res v-scan PASS || res v-scan "FAIL file entries=$n"
163+
for omit in "" "--omit=dev"; do
164+
F="$HERE/vend dir ü/fresh$omit"; mkdir -p "$F"; cp -r package.json package-lock.json .socket "$F/"
165+
(cd "$F"; npm ci --no-audit --no-fund $omit > ci.log 2>&1 || { res "v-ci$omit" FAIL; tail -5 ci.log; }
166+
ok=1; for p in node_modules/left-pad node_modules/is-number node_modules/is-odd/node_modules/is-number; do [ "$(marked $p/index.js)" = 1 ] || { ok=0; echo "unpatched $p"; }; done
167+
[ $ok = 1 ] && res "v-ci-patched$omit" PASS || res "v-ci-patched$omit" FAIL
168+
"$SP" vex $SPA -O v.json > vex.out 2>&1; e=$?; s=$(vexsum v.json 2>/dev/null); [ $e = 0 ] && echo "$s" | grep -q "is-number@4.0.0" && res "v-vex$omit" "PASS $s" || { res "v-vex$omit" "FAIL exit $e $s"; tail -5 vex.out; })
169+
done
170+
"$SP" vendor --revert $SPA --json > ../rev.json 2>&1; echo "revert exit $?"
171+
cmp -s package-lock.json ../orig.lock && res v-revert-byte-exact PASS || { res v-revert-byte-exact FAIL; diff ../orig.lock package-lock.json | head -10; }
172+
[ -d .socket/vendor ] && res v-revert-artifacts "FAIL .socket/vendor left: $(find .socket/vendor | head -5)" || res v-revert-artifacts PASS
173+
# ---------- hosted, linked
174+
H="$HERE/host dir ü/proj"; mkdir -p "$H"; cd "$H"; echo "$PKG" > package.json; printf 'install-strategy=linked\n' > .npmrc
175+
npm install --no-audit --no-fund >/dev/null 2>&1 || res h-install FAIL; cp package-lock.json ../orig.lock
176+
"$SP" scan $SPA --json > ../scan.json 2>../scan.err; echo "hosted scan exit $?"
177+
F="$HERE/host dir ü/fresh"; mkdir -p "$F"; cp package.json package-lock.json .npmrc "$F/"
178+
(cd "$F"; npm ci --no-audit --no-fund > ci.log 2>&1 || { res h-ci FAIL; tail -5 ci.log; }
179+
bad=0; tot=0; while IFS= read -r f; do tot=$((tot+1)); [ "$(marked "$f")" = 1 ] || bad=$((bad+1)); done < <(find node_modules/.store -path "*node_modules/is-number/index.js" -o -path "*node_modules/left-pad/index.js")
180+
[ $tot -ge 3 ] && [ $bad = 0 ] && res h-linked-ci "PASS ($tot)" || res h-linked-ci "FAIL $bad/$tot"
181+
"$SP" vex $SPA -O v.json > vex.out 2>&1; e=$?; s=$(vexsum v.json 2>/dev/null); [ $e = 0 ] && echo "$s" | grep -q "is-number@4.0.0" && res h-vex "PASS $s" || { res h-vex "FAIL exit $e $s"; tail -5 vex.out; })
182+
kill $SRV 2>/dev/null; echo DONE
183+
PYEOF
184+
- name: Probe
185+
run: |
186+
export PY=$(command -v python3 || command -v python)
187+
export SP="$GITHUB_WORKSPACE/target/release/socket-patch"
188+
export NPMV="${{ matrix.os }} npm ${{ matrix.npm }}"
189+
bash "$RUNNER_TEMP/probe/run2.sh" 2>&1 | tee "$RUNNER_TEMP/probe.log"
190+
echo "===== SUMMARY"; grep RESULT "$RUNNER_TEMP/probe.log"

0 commit comments

Comments
 (0)