|
| 1 | +name: bughunt-bun probe |
| 2 | +on: |
| 3 | + push: |
| 4 | + branches: ['bughunt/bun/**'] |
| 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] |
| 13 | + runs-on: ${{ matrix.os }} |
| 14 | + timeout-minutes: 45 |
| 15 | + steps: |
| 16 | + - name: Checkout |
| 17 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 18 | + with: |
| 19 | + persist-credentials: false |
| 20 | + - name: Cache cargo |
| 21 | + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 |
| 22 | + with: |
| 23 | + key: bun-native |
| 24 | + save-if: false |
| 25 | + - name: Build CLI |
| 26 | + run: cargo build --locked -p socket-patch-cli |
| 27 | + - name: Probe |
| 28 | + shell: bash |
| 29 | + run: | |
| 30 | + mkdir -p "$RUNNER_TEMP/bh" |
| 31 | + cat > "$RUNNER_TEMP/bh/driver.py" <<'PYEOF' |
| 32 | + import sys, os, json, io, tarfile, gzip, hashlib, base64, urllib.request, re, threading, shutil, subprocess, tempfile, urllib.parse |
| 33 | + from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler |
| 34 | + SP = os.path.abspath(sys.argv[1]); BUN = shutil.which("bun") if len(sys.argv) < 3 else sys.argv[2] |
| 35 | + ROOT = tempfile.mkdtemp(prefix="bh-") |
| 36 | + MARK = b"/* SOCKET-PATCHED */\n"; TOKEN = "22222222-2222-4222-8222-222222222222" |
| 37 | + def gsha(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest() |
| 38 | + SPECS = [] |
| 39 | + def mkpatch(name, ver, uuid): |
| 40 | + raw = urllib.request.urlopen(f"https://registry.npmjs.org/{name}/-/{name}-{ver}.tgz").read() |
| 41 | + tin = tarfile.open(fileobj=io.BytesIO(raw), mode="r:gz"); files = {} |
| 42 | + for m in tin.getmembers(): |
| 43 | + if m.isfile(): files[m.name] = tin.extractfile(m).read() |
| 44 | + top = next(iter(files)).split('/')[0]; target = f"{top}/index.js" |
| 45 | + before = files[target]; after = MARK + before |
| 46 | + buf = io.BytesIO(); tout = tarfile.open(fileobj=buf, mode="w", format=tarfile.USTAR_FORMAT) |
| 47 | + for rel, data in files.items(): |
| 48 | + d = after if rel == target else data |
| 49 | + ti = tarfile.TarInfo(rel); ti.mode = 0o644; ti.mtime = 0; ti.size = len(d); tout.addfile(ti, io.BytesIO(d)) |
| 50 | + tout.close(); gz = io.BytesIO() |
| 51 | + with gzip.GzipFile(fileobj=gz, mode="wb", mtime=0) as g: g.write(buf.getvalue()) |
| 52 | + tgz = gz.getvalue() |
| 53 | + SPECS.append({"uuid": uuid, "purl": f"pkg:npm/{name}@{ver}", "name": name, "version": ver, "leaf": f"{name}-{ver}.tgz", "tgz": tgz, |
| 54 | + "sri": "sha512-" + base64.b64encode(hashlib.sha512(tgz).digest()).decode(), |
| 55 | + "files": {"package/index.js": {"beforeHash": gsha(before), "afterHash": gsha(after), "blobContent": base64.b64encode(after).decode()}}}) |
| 56 | + PORT = [0] |
| 57 | + class H(BaseHTTPRequestHandler): |
| 58 | + def log_message(self, *a): pass |
| 59 | + def j(self, o, code=200): |
| 60 | + 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) |
| 61 | + def hosted(self, s): return f"http://127.0.0.1:{PORT[0]}/patch/npm/{s['name']}/{s['version']}/{TOKEN}/{s['uuid']}/{s['leaf']}" |
| 62 | + def pinfo(self, s): return {"uuid": s["uuid"], "purl": s["purl"], "tier": "free", "cveIds": ["CVE-2026-0001"], "ghsaIds": ["GHSA-bunh-unt0-0001"], "severity": "high", "title": "bughunt fixture"} |
| 63 | + def do_POST(self): |
| 64 | + p = urllib.parse.urlparse(self.path).path; n = int(self.headers.get("content-length") or 0); b = json.loads(self.rfile.read(n) or b"{}") |
| 65 | + if p.endswith("/patches/batch"): |
| 66 | + want = {c.get("purl") for c in b.get("components", [])} |
| 67 | + return self.j({"packages": [{"purl": s["purl"], "patches": [self.pinfo(s)]} for s in SPECS if s["purl"] in want], "canAccessPaidPatches": False}) |
| 68 | + if p.endswith("/patches/package"): |
| 69 | + return self.j({"results": {s["uuid"]: {"status": "granted", "url": self.hosted(s), "purl": s["purl"], "artifacts": [{"kind": "tarball", "url": self.hosted(s), "integrity": {"sha512": s["sri"]}}], "registryOverride": None} for s in SPECS}}) |
| 70 | + self.j({}, 404) |
| 71 | + def do_GET(self): |
| 72 | + p = urllib.parse.unquote(urllib.parse.urlparse(self.path).path) |
| 73 | + m = re.search(r"/patches/view/([0-9a-f-]+)$", p) |
| 74 | + if m: |
| 75 | + for s in SPECS: |
| 76 | + if s["uuid"] == m.group(1): |
| 77 | + return self.j({"uuid": s["uuid"], "purl": s["purl"], "publishedAt": "2026-01-01T00:00:00Z", "files": s["files"], |
| 78 | + "vulnerabilities": {"GHSA-bunh-unt0-0001": {"cves": ["CVE-2026-0001"], "summary": "s", "severity": "high", "description": "d"}}, |
| 79 | + "description": "x", "license": "MIT", "tier": "free"}) |
| 80 | + if "/patches/by-package/" in p: |
| 81 | + pu = p.split("/patches/by-package/", 1)[1] |
| 82 | + return self.j({"patches": [dict(self.pinfo(s), publishedAt="2026-01-01T00:00:00Z", description="x", license="MIT", vulnerabilities={}) for s in SPECS if s["purl"] == pu], "canAccessPaidPatches": False}) |
| 83 | + m = re.match(r"/patch/npm/.+/([0-9a-f-]+)/[^/]+$", p) |
| 84 | + if m: |
| 85 | + for s in SPECS: |
| 86 | + if s["uuid"] == m.group(1): |
| 87 | + self.send_response(200); self.send_header("content-length", str(len(s["tgz"]))); self.end_headers(); self.wfile.write(s["tgz"]); return |
| 88 | + self.j({}, 404) |
| 89 | + def run(cmd, cwd, env=None): |
| 90 | + e = dict(os.environ); e.update(env or {}); e["SOCKET_TELEMETRY_DISABLED"] = "1" |
| 91 | + r = subprocess.run(cmd, cwd=cwd, env=e, capture_output=True, text=True, encoding="utf-8", errors="replace") |
| 92 | + return r.returncode, r.stdout, r.stderr |
| 93 | + def sp(args, cwd): |
| 94 | + return run([SP] + args + ["--api-url", f"http://127.0.0.1:{PORT[0]}", "--org", "test-org", "--api-token", "fake"], cwd) |
| 95 | + def bun(args, cwd): return run([BUN] + args, cwd, {"BUN_INSTALL_CACHE_DIR": os.path.join(cwd, ".bun-cache")}) |
| 96 | + def marker(path): |
| 97 | + try: return open(path, "rb").read().startswith(MARK) |
| 98 | + except Exception as ex: return f"missing:{ex.__class__.__name__}" |
| 99 | + def env_of(out): |
| 100 | + try: return json.loads(out) |
| 101 | + except Exception: return {"_raw": out[:500]} |
| 102 | + RESULTS = [] |
| 103 | + def case_isolated(tag, files): |
| 104 | + d = os.path.join(ROOT, tag); os.makedirs(d) |
| 105 | + for rel, content in files.items(): |
| 106 | + os.makedirs(os.path.dirname(os.path.join(d, rel)) or d, exist_ok=True); open(os.path.join(d, rel), "w").write(content) |
| 107 | + rc, o, e = bun(["install"], d) |
| 108 | + store = os.path.join(d, "node_modules", ".bun") |
| 109 | + layout = sorted(os.listdir(store)) if os.path.isdir(store) else "no .bun store (hoisted)" |
| 110 | + rc2, o2, e2 = sp(["scan", "--mode", "agent", "--json", "--yes"], d); env = env_of(o2) |
| 111 | + acts = [(p.get("purl"), p.get("action"), p.get("errorCode")) for p in env.get("apply", {}).get("patches", [])] |
| 112 | + mm = os.path.join(d, "node_modules", ".bun", "minimist@1.2.8", "node_modules", "minimist", "index.js") |
| 113 | + if not os.path.exists(mm): mm = os.path.join(d, "node_modules", "minimist", "index.js") |
| 114 | + RESULTS.append({"case": tag, "scan_stderr": e2[-400:], "bun_install_rc": rc, "layout": layout, "scan_rc": rc2, "status": env.get("status"), |
| 115 | + "lockfileOnly": env.get("lockfileOnlyPackages"), "apply": acts, "minimist_patched_on_disk": marker(mm)}) |
| 116 | + def case_bunpatch(mode): |
| 117 | + d = os.path.join(ROOT, "bunpatch-" + mode); os.makedirs(d) |
| 118 | + open(os.path.join(d, "package.json"), "w").write('{"name":"app","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}\n') |
| 119 | + bun(["install"], d); bun(["patch", "left-pad"], d) |
| 120 | + open(os.path.join(d, "node_modules", "left-pad", "index.js"), "a").write("// USER-BUN-PATCH\n") |
| 121 | + rcp, op, ep = bun(["patch", "--commit", "node_modules/left-pad"], d) |
| 122 | + has_patch = os.path.isdir(os.path.join(d, "patches")) |
| 123 | + rc2, o2, e2 = sp(["scan", "--mode", mode, "--json", "--yes"], d); env = env_of(o2) |
| 124 | + warns = env.get("redirect", {}).get("warnings") if mode == "hosted" else [x.get("errorCode") for x in env.get("vendor", {}).get("events", []) if x.get("errorCode")] |
| 125 | + f = os.path.join(ROOT, "bunpatch-" + mode + "-fresh"); os.makedirs(f) |
| 126 | + for n in ["package.json", "bun.lock", "bun.lockb", "patches", ".socket"]: |
| 127 | + src = os.path.join(d, n) |
| 128 | + if os.path.isdir(src): shutil.copytree(src, os.path.join(f, n)) |
| 129 | + elif os.path.exists(src): shutil.copy2(src, os.path.join(f, n)) |
| 130 | + rc3, o3, e3 = bun(["install", "--frozen-lockfile"], f) |
| 131 | + idx = os.path.join(f, "node_modules", "left-pad", "index.js") |
| 132 | + try: body = open(idx, "rb").read() |
| 133 | + except Exception: body = b"" |
| 134 | + RESULTS.append({"case": "bunpatch-" + mode, "bun_patch_committed": has_patch and rcp == 0, "scan_rc": rc2, "status": env.get("status"), "warnings": warns, |
| 135 | + "fresh_frozen_rc": rc3, "socket_patch_present": body.startswith(MARK), "user_bun_patch_present": b"USER-BUN-PATCH" in body, |
| 136 | + "frozen_stderr_tail": e3[-300:] if rc3 else ""}) |
| 137 | + if __name__ == "__main__": |
| 138 | + mkpatch("left-pad", "1.3.0", "5a6b7c8d-9e0f-4a1b-8c2d-3e4f5a6b7c8d"); mkpatch("minimist", "1.2.8", "11111111-2222-4333-8444-555555555555") |
| 139 | + srv = ThreadingHTTPServer(("127.0.0.1", 0), H); PORT[0] = srv.server_address[1]; threading.Thread(target=srv.serve_forever, daemon=True).start() |
| 140 | + print("bun", run([BUN, "--version"], ROOT)[1].strip(), "platform", sys.platform) |
| 141 | + case_isolated("isolated-bunfig", {"package.json": '{"name":"app","version":"1.0.0","dependencies":{"mkdirp":"0.5.6","left-pad":"1.3.0"}}\n', "bunfig.toml": '[install]\nlinker = "isolated"\n'}) |
| 142 | + case_isolated("workspace-default-linker", {"package.json": '{"name":"root","private":true,"workspaces":["packages/*"]}\n', "packages/a/package.json": '{"name":"a","version":"1.0.0","dependencies":{"mkdirp":"0.5.6"}}\n'}) |
| 143 | + case_isolated("hoisted-control", {"package.json": '{"name":"app","version":"1.0.0","dependencies":{"mkdirp":"0.5.6","left-pad":"1.3.0"}}\n', "bunfig.toml": '[install]\nlinker = "hoisted"\n'}) |
| 144 | + for m in ("hosted", "vendored"): case_bunpatch(m) |
| 145 | + print("RESULTS-JSON " + json.dumps(RESULTS)) |
| 146 | + for r in RESULTS: print(json.dumps(r)) |
| 147 | + PYEOF |
| 148 | + cat > "$RUNNER_TEMP/bh/fetchbun.py" <<'PYEOF' |
| 149 | + import sys, os, urllib.request, zipfile, io, platform |
| 150 | + v, dest = sys.argv[1], sys.argv[2] |
| 151 | + osn = {"linux": "linux", "darwin": "darwin", "win32": "windows"}[sys.platform] |
| 152 | + arch = "aarch64" if platform.machine().lower() in ("arm64", "aarch64") else "x64" |
| 153 | + name = f"bun-{osn}-{arch}" |
| 154 | + for i in range(4): |
| 155 | + try: |
| 156 | + data = urllib.request.urlopen(f"https://github.com/oven-sh/bun/releases/download/bun-v{v}/{name}.zip").read(); break |
| 157 | + except Exception as e: |
| 158 | + print("retry", e) |
| 159 | + zipfile.ZipFile(io.BytesIO(data)).extractall(dest) |
| 160 | + exe = os.path.join(dest, name, "bun.exe" if osn == "windows" else "bun") |
| 161 | + os.chmod(exe, 0o755); print(exe) |
| 162 | + PYEOF |
| 163 | + SP="$PWD/target/debug/socket-patch"; PY=python3; if [ "$RUNNER_OS" = "Windows" ]; then SP="$SP.exe"; PY=python; fi |
| 164 | + for v in 1.2.23 1.3.14 1.4.2; do |
| 165 | + echo "::group::bun $v" |
| 166 | + BUNEXE=$("$PY" "$RUNNER_TEMP/bh/fetchbun.py" "$v" "$RUNNER_TEMP/bun-$v" | tail -1) |
| 167 | + "$PY" "$RUNNER_TEMP/bh/driver.py" "$SP" "$BUNEXE" 2>&1 | tee "$RUNNER_TEMP/bh/out-$v.txt" || true |
| 168 | + echo "::endgroup::" |
| 169 | + done |
| 170 | + echo "===== SUMMARY =====" |
| 171 | + for v in 1.2.23 1.3.14 1.4.2; do echo "--- bun $v (${{ matrix.os }})"; grep '^{' "$RUNNER_TEMP/bh/out-$v.txt" | sed 's/"scan_stderr": "[^"]*", //' || true; done |
0 commit comments