Skip to content

Commit fe9f836

Browse files
committed
bughunt yarn-classic probe: offline-mirror resolution across OSes
1 parent ea09714 commit fe9f836

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
name: bughunt yarn-classic probe
2+
on:
3+
push:
4+
branches: ['bughunt/yarn-classic/**']
5+
permissions:
6+
contents: read
7+
jobs:
8+
probe:
9+
runs-on: ${{ matrix.os }}
10+
timeout-minutes: 45
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
os: [ubuntu-latest, macos-latest, windows-latest]
15+
yarn: ['1.10.1', '1.22.22']
16+
defaults:
17+
run:
18+
shell: bash
19+
steps:
20+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
21+
- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
22+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
23+
with:
24+
node-version: '22.x'
25+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
26+
with:
27+
python-version: '3.12'
28+
- name: Build socket-patch
29+
run: cargo build --release -p socket-patch-cli
30+
- name: Install yarn
31+
run: npm install --prefix "$RUNNER_TEMP/y" yarn@${{ matrix.yarn }} && node "$RUNNER_TEMP/y/node_modules/yarn/bin/yarn.js" --version
32+
- name: Write harness
33+
run: |
34+
mkdir -p "$RUNNER_TEMP/h"
35+
cat > "$RUNNER_TEMP/h/mock.py" <<'MOCKEOF'
36+
#!/usr/bin/env python3
37+
"""Minimal Socket patch API mock: left-pad@1.3.0 patch (prepends a marker to index.js)."""
38+
import base64, gzip, hashlib, io, json, os, sys, tarfile, urllib.request
39+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
40+
41+
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
42+
SRC = sys.argv[2] if len(sys.argv) > 2 else None # optional local left-pad-1.3.0.tgz
43+
BASE = f"http://127.0.0.1:{PORT}"
44+
NAME, VER = "left-pad", "1.3.0"
45+
PURL = f"pkg:npm/{NAME}@{VER}"
46+
UUID = "11111111-2222-4333-8444-555555555555"
47+
MARKER = b"/* SOCKET-PATCHED-MOCK */\n"
48+
49+
raw = open(SRC, "rb").read() if SRC else urllib.request.urlopen(
50+
f"https://registry.npmjs.org/{NAME}/-/{NAME}-{VER}.tgz").read()
51+
files = {}
52+
with tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz") as t:
53+
for m in t.getmembers():
54+
if m.isfile():
55+
files[m.name] = t.extractfile(m).read()
56+
orig = files["package/index.js"]
57+
patched = MARKER + orig
58+
files["package/index.js"] = patched
59+
buf = io.BytesIO()
60+
with tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) as t:
61+
for n in sorted(files):
62+
ti = tarfile.TarInfo(n); ti.size = len(files[n]); ti.mtime = 0; ti.mode = 0o644
63+
t.addfile(ti, io.BytesIO(files[n]))
64+
gz = io.BytesIO()
65+
with gzip.GzipFile(fileobj=gz, mode="wb", mtime=0) as g:
66+
g.write(buf.getvalue())
67+
TGZ = gz.getvalue()
68+
69+
def gsha(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest()
70+
BLOBS = {gsha(orig): orig, gsha(patched): patched}
71+
SRI = "sha512-" + base64.b64encode(hashlib.sha512(TGZ).digest()).decode()
72+
SHA1 = hashlib.sha1(TGZ).hexdigest()
73+
ART = f"{BASE}/artifacts/{UUID}/{NAME}-{VER}.tgz"
74+
SUMMARY = {"uuid": UUID, "purl": PURL, "tier": "free", "cveIds": ["CVE-2026-0001"],
75+
"ghsaIds": ["GHSA-aaaa-bbbb-cccc"], "severity": "high", "title": "mock"}
76+
VIEW = {"uuid": UUID, "purl": PURL, "publishedAt": "2026-01-01T00:00:00Z",
77+
"files": {"package/index.js": {"beforeHash": gsha(orig), "afterHash": gsha(patched),
78+
"blobContent": base64.b64encode(patched).decode()}},
79+
"vulnerabilities": {"GHSA-aaaa-bbbb-cccc": {"cves": ["CVE-2026-0001"], "summary": "s",
80+
"severity": "high", "description": "d"}},
81+
"description": "x", "license": "MIT", "tier": "free"}
82+
GRANT = {"status": "granted", "purl": PURL, "url": ART,
83+
"artifacts": [{"kind": "tarball", "url": ART, "integrity": {"sha512": SRI, "sha1": SHA1}}],
84+
"registryOverride": None}
85+
86+
class H(BaseHTTPRequestHandler):
87+
def log_message(self, *a): sys.stderr.write("MOCK %s\n" % (a[0] % a[1:]))
88+
def send(self, code, body, ctype="application/json"):
89+
if not isinstance(body, bytes): body = json.dumps(body).encode()
90+
self.send_response(code); self.send_header("Content-Type", ctype)
91+
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
92+
def do_GET(self):
93+
p = self.path.split("?")[0]
94+
if p.startswith(f"/artifacts/{UUID}/"): return self.send(200, TGZ, "application/octet-stream")
95+
if "/by-package/" in p:
96+
return self.send(200, {"patches": [dict(SUMMARY, publishedAt="2026-01-01T00:00:00Z",
97+
description="x", license="MIT", vulnerabilities={})], "canAccessPaidPatches": False})
98+
if p.endswith(f"/view/{UUID}"): return self.send(200, VIEW)
99+
if "/blob/" in p:
100+
h = p.rsplit("/", 1)[1]
101+
return self.send(200, BLOBS[h], "application/octet-stream") if h in BLOBS else self.send(404, {})
102+
self.send(404, {"error": "nf"})
103+
def do_POST(self):
104+
p = self.path.split("?")[0]
105+
body = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode("utf-8", "replace")
106+
if p.endswith("/patches/batch") or p.endswith("/patch/batch"):
107+
pk = [{"purl": PURL, "patches": [SUMMARY]}] if (NAME + "@" + VER) in body.replace("%40", "@") else []
108+
return self.send(200, {"packages": pk, "canAccessPaidPatches": False})
109+
if p.endswith("/package"):
110+
return self.send(200, {"results": {UUID: GRANT} if UUID in body else {}})
111+
self.send(404, {"error": "nf"})
112+
113+
if __name__ == "__main__":
114+
print(json.dumps({"sri": SRI, "sha1": SHA1, "after": gsha(patched)}), flush=True)
115+
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()
116+
MOCKEOF
117+
cat > "$RUNNER_TEMP/h/probe.sh" <<'PROBEEOF'
118+
#!/usr/bin/env bash
119+
set -u
120+
BIN="$1"; Y="node $2"
121+
T="$(cd "$RUNNER_TEMP" && pwd -W 2>/dev/null || pwd)"; W="$T/w"; rm -rf "$W"; mkdir -p "$W"
122+
API="--api-url http://127.0.0.1:8787 --org o --api-token x"
123+
export SOCKET_PATCH_SERVER_URL=http://127.0.0.1:8787 SOCKET_API_URL=http://127.0.0.1:8787 SOCKET_PROXY_URL=http://127.0.0.1:8787 SOCKET_TELEMETRY_DISABLED=1
124+
NODEBIN="$(node -p 'require("path").dirname(process.execPath)')"
125+
case "$RUNNER_OS" in Windows) PFX="$NODEBIN";; *) PFX="$(dirname "$NODEBIN")";; esac
126+
UH="$(node -p 'require("os").homedir()')"
127+
echo "prefix=$PFX userhome=$UH"
128+
cleanup(){ rm -f "$UH/.yarnrc" "$UH/.npmrc" "$PFX/etc/yarnrc" "$PFX/etc/npmrc"; [ "$(id -u)" = 0 ] && rm -f /usr/local/share/.yarnrc /usr/local/share/.npmrc; unset YARN_YARN_OFFLINE_MIRROR npm_config_yarn_offline_mirror; }
129+
cell(){ local N="$1" SETUP="$2"; cleanup; local D="$W/$N"; local M="$W/$N-mirror"; mkdir -p "$D/p" "$M"; cd "$D/p"
130+
echo '{"name":"p","version":"1.0.0","private":true,"dependencies":{"left-pad":"1.3.0"}}' > package.json
131+
eval "$SETUP"
132+
$Y install --no-progress --cache-folder "$D/c0" > "$D/i0.log" 2>&1 || { echo "RESULT $N BLOCKED initial-install"; tail -5 "$D/i0.log"; cleanup; return; }
133+
local ACTIVE; ACTIVE=$(ls "$M" | wc -l | tr -d ' ')
134+
"$BIN" scan --mode hosted --json --yes $API > "$D/scan.json" 2> "$D/scan.err"; local EX=$?
135+
local PIN; PIN=$(grep -c 8787 yarn.lock)
136+
local CODES; CODES=$(python -c "import json,sys;d=json.load(open(sys.argv[1]));r=d.get('redirect') or {};print(d.get('status'),[w['code'] for w in r.get('warnings',[])])" "$D/scan.json" 2>&1 | tr -d '\r')
137+
rm -rf node_modules; $Y install --frozen-lockfile --no-progress --cache-folder "$D/c1" > "$D/i1.log" 2>&1; local IE=$?
138+
local PT=UNPATCHED; grep -q SOCKET-PATCHED-MOCK node_modules/left-pad/index.js 2>/dev/null && PT=PATCHED
139+
local V="pass"
140+
if [ "$ACTIVE" != 0 ] && [ "$PIN" != 0 ]; then V="FAIL(pinned-under-active-mirror)"; fi
141+
if [ "$ACTIVE" = 0 ] && [ "$PIN" = 0 ]; then V="FAIL(refused-without-mirror)"; fi
142+
[ $IE -ne 0 ] && V="$V+install-failed"
143+
echo "RESULT $N $V mirror_active=$ACTIVE scan_exit=$EX pinned=$PIN [$CODES] frozen=$IE $PT"
144+
[ $IE -ne 0 ] && grep -i error "$D/i1.log" | head -3
145+
cd "$W"; cleanup; }
146+
MM='"$(cygpath -m "$M" 2>/dev/null || echo "$M")"'
147+
cell control ':'
148+
cell user-yarnrc-config-set '$Y config set yarn-offline-mirror "$(cygpath -m "$M" 2>/dev/null || echo "$M")" >/dev/null'
149+
cell user-npmrc 'echo "yarn-offline-mirror=$(cygpath -m "$M" 2>/dev/null || echo "$M")" > "$UH/.npmrc"'
150+
cell prefix-etc-yarnrc 'mkdir -p "$PFX/etc"; echo "yarn-offline-mirror \"$(cygpath -m "$M" 2>/dev/null || echo "$M")\"" > "$PFX/etc/yarnrc"'
151+
cell parent-yarnrc 'echo "yarn-offline-mirror \"$(cygpath -m "$M" 2>/dev/null || echo "$M")\"" > ../.yarnrc'
152+
cell env-yarn 'export YARN_YARN_OFFLINE_MIRROR="$(cygpath -m "$M" 2>/dev/null || echo "$M")"'
153+
cell env-npm 'export npm_config_yarn_offline_mirror="$(cygpath -m "$M" 2>/dev/null || echo "$M")"'
154+
cell bom-project-yarnrc 'printf "\xef\xbb\xbfyarn-offline-mirror \"%s\"\r\n" "$(cygpath -m "$M" 2>/dev/null || echo "$M")" > .yarnrc'
155+
if [ "$RUNNER_OS" = Windows ]; then
156+
cell ps51-bom-project-yarnrc 'powershell -NoProfile -Command "Set-Content -Path .yarnrc -Encoding utf8 -Value (\"yarn-offline-mirror \" + [char]34 + \"$(cygpath -m "$M")\" + [char]34)"; od -c .yarnrc | head -2'
157+
cell ps51-bom-user-yarnrc 'powershell -NoProfile -Command "Set-Content -Path \"$(cygpath -w "$UH")\\.yarnrc\" -Encoding utf8 -Value (\"yarn-offline-mirror \" + [char]34 + \"$(cygpath -m "$M")\" + [char]34)"'
158+
cell backslash-path-yarnrc 'echo "yarn-offline-mirror \"$(cygpath -w "$M" | sed "s/\\\\/\\\\\\\\/g")\"" > .yarnrc; cat .yarnrc'
159+
fi
160+
cell npm-false-yarn-path 'echo "yarn-offline-mirror \"$(cygpath -m "$M" 2>/dev/null || echo "$M")\"" > .yarnrc; echo "yarn-offline-mirror=false" > .npmrc'
161+
echo DONE
162+
PROBEEOF
163+
- name: Probe
164+
run: |
165+
python "$RUNNER_TEMP/h/mock.py" 8787 > "$RUNNER_TEMP/mock.log" 2>&1 &
166+
for i in $(seq 1 60); do curl -s -o /dev/null -XPOST http://127.0.0.1:8787/x/package -d '{}' && break; sleep 1; done
167+
for i in 1 2 3 4 5; do curl -s -o /dev/null -XPOST http://127.0.0.1:8787/x/package -d '{}'; done
168+
BIN="$GITHUB_WORKSPACE/target/release/socket-patch"; [ -f "$BIN.exe" ] && BIN="$BIN.exe"
169+
cd "$RUNNER_TEMP" && bash "$RUNNER_TEMP/h/probe.sh" "$BIN" "$RUNNER_TEMP/y/node_modules/yarn/bin/yarn.js" 2>&1 | grep -v '^Warning: --api-token\|Got: x' | tee "$RUNNER_TEMP/probe.log"
170+
echo "===== SUMMARY ${{ matrix.os }} yarn ${{ matrix.yarn }}"; grep '^RESULT' "$RUNNER_TEMP/probe.log"

0 commit comments

Comments
 (0)