|
| 1 | +name: bughunt-pip |
| 2 | +on: |
| 3 | + push: |
| 4 | + branches: ['bughunt/pip/**'] |
| 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 | + combo: |
| 14 | + - { py: '3.8', pip: '20.3.4' } |
| 15 | + - { py: '3.13', pip: '26.2.1' } |
| 16 | + runs-on: ${{ matrix.os }} |
| 17 | + timeout-minutes: 45 |
| 18 | + steps: |
| 19 | + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 20 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 21 | + with: |
| 22 | + python-version: ${{ matrix.combo.py }} |
| 23 | + - run: rustup show |
| 24 | + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 |
| 25 | + - run: cargo build --locked -p socket-patch-cli |
| 26 | + - name: probe |
| 27 | + shell: bash |
| 28 | + run: | |
| 29 | + cat > probe.py <<'PROBE_EOF' |
| 30 | + import base64, hashlib, http.server, io, json, os, shutil, subprocess, sys, threading, zipfile, re, urllib.parse |
| 31 | + BIN = os.path.abspath(sys.argv[1]); PIPV = sys.argv[2] |
| 32 | + W = os.path.abspath("probe-work"); shutil.rmtree(W, ignore_errors=True); os.makedirs(W) |
| 33 | + WIN = os.name == "nt" |
| 34 | + UUID = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1" |
| 35 | + def run(cmd, cwd=None, env=None): |
| 36 | + p = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True) |
| 37 | + return p.returncode, p.stdout + p.stderr |
| 38 | + def vpy(v): return os.path.join(v, "Scripts" if WIN else "bin", "python.exe" if WIN else "python") |
| 39 | + def mkvenv(path, pipv=PIPV): |
| 40 | + rc, out = run([sys.executable, "-m", "venv", path]); assert rc == 0, out |
| 41 | + rc, out = run([vpy(path), "-m", "pip", "install", "-q", f"pip=={pipv}"]); assert rc == 0, out |
| 42 | + return vpy(path) |
| 43 | + # build the patched wheel |
| 44 | + rc, out = run([sys.executable, "-m", "pip", "download", "--no-deps", "six==1.16.0", "-d", W, "-q"]); assert rc == 0, out |
| 45 | + orig = os.path.join(W, "six-1.16.0-py2.py3-none-any.whl") |
| 46 | + zin = zipfile.ZipFile(orig); buf = io.BytesIO(); zout = zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) |
| 47 | + before = None |
| 48 | + for i in zin.infolist(): |
| 49 | + d = zin.read(i.filename) |
| 50 | + if i.filename == "six.py": before = d; d = d + b"# SOCKET-PATCHED\n"; after = d |
| 51 | + zout.writestr(i, d) |
| 52 | + zout.close(); WHL = buf.getvalue() |
| 53 | + SHA = hashlib.sha256(WHL).hexdigest(); SRI = "sha512-" + base64.b64encode(hashlib.sha512(WHL).digest()).decode() |
| 54 | + g = lambda d: hashlib.sha256(b"blob %d\0" % len(d) + d).hexdigest() |
| 55 | + BLOBS = {g(before): before, g(after): after} |
| 56 | + WPATH = f"/patch/pypi/six/1.16.0/tok/{UUID}/six-1.16.0-py2.py3-none-any.whl" |
| 57 | + PORT = 18765; BASE = f"http://127.0.0.1:{PORT}"; URL = BASE + WPATH |
| 58 | + def key(p): |
| 59 | + p = urllib.parse.unquote(p).split("?")[0].lower() |
| 60 | + if "@" not in p: return p |
| 61 | + n, v = p.split("@", 1); return re.sub(r"[_.-]+", "-", n) + "@" + v |
| 62 | + MATCH = "pkg:pypi/six@1.16.0" |
| 63 | + VULN = {"GHSA-test-aaaa-bbbb": {"cves": ["CVE-2024-0001"], "summary": "s", "severity": "high", "description": "d"}} |
| 64 | + class H(http.server.BaseHTTPRequestHandler): |
| 65 | + def log_message(self, *a): pass |
| 66 | + def send(self, b, ct="application/octet-stream", code=200): |
| 67 | + 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) |
| 68 | + def j(self, o, code=200): self.send(json.dumps(o).encode(), "application/json", code) |
| 69 | + def do_POST(self): |
| 70 | + n = int(self.headers.get("content-length") or 0); b = json.loads(self.rfile.read(n) or b"null") |
| 71 | + if self.path.endswith("/patches/batch"): |
| 72 | + pk = [{"purl": c["purl"], "patches": [{"uuid": UUID, "purl": c["purl"], "tier": "free", "cveIds": [], "ghsaIds": ["GHSA-test-aaaa-bbbb"], "severity": "high", "title": "fixture"}]} for c in b.get("components", []) if key(c["purl"]) == MATCH] |
| 73 | + return self.j({"packages": pk, "canAccessPaidPatches": False}) |
| 74 | + if self.path.endswith("/patches/package"): |
| 75 | + r = {u: {"status": "granted", "url": URL, "purl": None, "artifacts": [{"kind": "tarball", "url": URL, "integrity": {"sha256": SHA, "sha512": SRI}}], "registryOverride": None} for u in b.get("uuids", []) if u == UUID} |
| 76 | + return self.j({"results": r}) |
| 77 | + self.j({}, 404) |
| 78 | + def do_GET(self): |
| 79 | + if self.path == WPATH: return self.send(WHL) |
| 80 | + if "/by-package/" in self.path: |
| 81 | + p = urllib.parse.unquote(self.path.split("/by-package/")[1]) |
| 82 | + ps = [{"uuid": UUID, "purl": p, "publishedAt": "2024-01-01T00:00:00Z", "description": "fixture", "license": "MIT", "tier": "free", "vulnerabilities": VULN}] if key(p) == MATCH else [] |
| 83 | + return self.j({"patches": ps, "canAccessPaidPatches": False}) |
| 84 | + if "/view/" in self.path: |
| 85 | + return self.j({"uuid": UUID, "purl": MATCH, "publishedAt": "2024-01-01T00:00:00Z", "files": {"six.py": {"beforeHash": g(before), "afterHash": g(after)}}, "vulnerabilities": VULN, "description": "fixture", "license": "MIT", "tier": "free"}) |
| 86 | + if "/blob/" in self.path: |
| 87 | + h = self.path.rsplit("/", 1)[1] |
| 88 | + if h in BLOBS: return self.send(BLOBS[h]) |
| 89 | + self.j({}, 404) |
| 90 | + srv = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H); threading.Thread(target=srv.serve_forever, daemon=True).start() |
| 91 | + ENV = dict(os.environ, SOCKET_NO_CONFIG="1", SOCKET_TELEMETRY_DISABLED="1", SOCKET_NO_UPDATE_CHECK="1") |
| 92 | + ENV.pop("VIRTUAL_ENV", None) |
| 93 | + A = ["--org", "test-org", "--api-token", "fake-token", "--api-url", BASE, "--patch-server-url", BASE] |
| 94 | + def sp(args, cwd): return run([BIN] + args + ["--cwd", cwd] + A, env=ENV) |
| 95 | + def proj(name, content): |
| 96 | + p = os.path.join(W, name); os.makedirs(os.path.join(p, ".git")); open(os.path.join(p, "requirements.txt"), "w", newline="").write(content); return p |
| 97 | + def patched(py): |
| 98 | + rc, out = run([py, "-c", "import six;print(six.__file__);print('PATCHED' if 'SOCKET-PATCHED' in open(six.__file__).read() else 'UNPATCHED')"]) |
| 99 | + return out.strip().splitlines()[-1] if rc == 0 else "NOTINSTALLED" |
| 100 | + results = [] |
| 101 | + def cell(name, ok, detail=""): |
| 102 | + results.append((name, "pass" if ok else "FAIL", detail)); print(f"== {name}: {'pass' if ok else 'FAIL'} {detail}", flush=True) |
| 103 | + # 1. hosted fragment pin |
| 104 | + p = proj("hosted", "idna==3.7\nsix==1.16.0\n"); o = open(os.path.join(p, "requirements.txt"), "rb").read() |
| 105 | + rc, out = sp(["scan", "--mode", "hosted", "--yes"], p); txt = open(os.path.join(p, "requirements.txt")).read() |
| 106 | + cell("hosted scan writes fragment pin", rc == 0 and f"#sha256={SHA}" in txt and "--hash" not in txt, txt.strip().replace("\n", " | ")[:200]) |
| 107 | + py = mkvenv(os.path.join(W, "venv-h")); rc, out = run([py, "-m", "pip", "install", "-q", "--no-cache-dir", "-r", os.path.join(p, "requirements.txt")]) |
| 108 | + cell("pip install -r hosted file", rc == 0 and patched(py) == "PATCHED", f"rc={rc} {patched(py)} {out.strip()[-300:]}") |
| 109 | + rc, out = sp(["rollback", "--yes"], p) |
| 110 | + cell("hosted rollback byte-exact", open(os.path.join(p, "requirements.txt"), "rb").read() == o, f"rc={rc} {out.strip()[-200:]}") |
| 111 | + # 2. agent mode in .venv (dist-info) |
| 112 | + p = proj("agent", "six==1.16.0\n"); py = mkvenv(os.path.join(p, ".venv")) |
| 113 | + rc, out = run([py, "-m", "pip", "install", "-q", "--no-cache-dir", "six==1.16.0"]); |
| 114 | + rc, out = sp(["scan", "--mode", "agent", "--yes", "--download-mode", "file"], p) |
| 115 | + cell("agent scan .venv dist-info", rc == 0 and patched(py) == "PATCHED", f"rc={rc} {patched(py)} {out.strip()[-300:]}") |
| 116 | + rc, out = sp(["vex", "--product", "pkg:pypi/app@1", "--output", os.path.join(p, "vex.json")], p) |
| 117 | + st = json.load(open(os.path.join(p, "vex.json")))["statements"][0]["status"] if rc == 0 else None |
| 118 | + cell("agent vex not_affected", st == "not_affected", f"rc={rc} {st} {out.strip()[-200:]}") |
| 119 | + rc, out = sp(["rollback", "--yes", "--download-mode", "file"], p) |
| 120 | + cell("agent rollback", rc == 0 and patched(py) == "UNPATCHED", f"rc={rc} {patched(py)} {out.strip()[-200:]}") |
| 121 | + # 3. agent mode on egg-info (pip < 23.1 only) |
| 122 | + if tuple(int(x) for x in PIPV.split(".")[:2]) < (23, 1): |
| 123 | + p = proj("egg", "six==1.16.0\n"); py = mkvenv(os.path.join(p, ".venv")) |
| 124 | + run([py, "-m", "pip", "uninstall", "-y", "-q", "wheel"]) |
| 125 | + rc, out = run([py, "-m", "pip", "install", "-q", "--no-cache-dir", "--no-binary", "six", "six==1.16.0"]) |
| 126 | + sitep = os.path.join(p, ".venv", "Lib", "site-packages") if WIN else [os.path.join(r, d) for r, ds, _ in os.walk(os.path.join(p, ".venv", "lib")) for d in ds if d == "site-packages"][0] |
| 127 | + eggs = [n for n in os.listdir(sitep) if n.lower().startswith("six")] |
| 128 | + rc, out = sp(["scan", "--mode", "agent", "--yes", "--download-mode", "file"], p) |
| 129 | + cell("agent scan egg-info (#447)", rc == 0 and patched(py) == "PATCHED", f"rc={rc} {eggs} {patched(py)} {out.strip()[-300:]}") |
| 130 | + rc, out = sp(["rollback", "--yes", "--download-mode", "file"], p) |
| 131 | + cell("agent rollback egg-info", rc == 0 and patched(py) == "UNPATCHED", f"rc={rc} {patched(py)}") |
| 132 | + # 4. vendored -> hosted takeover (#328) |
| 133 | + p = proj("takeover", "idna==3.7\nsix==1.16.0\n"); rc, out = sp(["scan", "--mode", "vendored", "--yes"], p) |
| 134 | + v = open(os.path.join(p, "requirements.txt")).read() |
| 135 | + py = mkvenv(os.path.join(W, "venv-v")); rc2, out2 = run([py, "-m", "pip", "install", "-q", "--no-cache-dir", "-r", "requirements.txt"], cwd=p) |
| 136 | + cell("vendored install from project root", rc == 0 and rc2 == 0 and patched(py) == "PATCHED", f"rc={rc}/{rc2} {patched(py)} {v.strip()[:160]}") |
| 137 | + rc, out = sp(["scan", "--mode", "hosted", "--yes", "--json"], p) |
| 138 | + cell("vendored->hosted takeover (#328, expect FAIL)", "#sha256=" in open(os.path.join(p, "requirements.txt")).read(), f"rc={rc} {out.strip()[-250:]}") |
| 139 | + print("\n| cell | result | detail |\n|---|---|---|") |
| 140 | + for r in results: print(f"| {r[0]} | {r[1]} | {r[2][:160].replace('|', '/')} |") |
| 141 | + PROBE_EOF |
| 142 | + BIN=target/debug/socket-patch |
| 143 | + if [ "$RUNNER_OS" = Windows ]; then BIN=target/debug/socket-patch.exe; fi |
| 144 | + python probe.py "$BIN" "${{ matrix.combo.pip }}" 2>&1 | tee probe.log |
0 commit comments