Skip to content

Commit 9c52fdf

Browse files
committed
bughunt pip probe: fragment pins, egg-info, Windows venv
1 parent 61cfb9b commit 9c52fdf

1 file changed

Lines changed: 144 additions & 0 deletions

File tree

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

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

Comments
 (0)