|
| 1 | +name: bughunt-uv |
| 2 | +on: |
| 3 | + push: |
| 4 | + branches: ['bughunt/uv/**'] |
| 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 | + uv: ['0.8.17', '0.12.24'] |
| 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 | + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 |
| 22 | + with: |
| 23 | + python-version: '3.11' |
| 24 | + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 |
| 25 | + - run: cargo build --release -p socket-patch-cli |
| 26 | + - name: tools |
| 27 | + run: | |
| 28 | + python -m pip install -q "uv==${{ matrix.uv }}" |
| 29 | + mkdir -p "$RUNNER_TEMP/h/wh" |
| 30 | + cd "$RUNNER_TEMP/h" |
| 31 | + for spec in six==1.16.0 idna==3.7; do python -m pip download -q --no-deps -d wh "$spec"; done |
| 32 | + cat > mock.py <<'MOCKEOF' |
| 33 | + import base64, hashlib, http.server, io, json, os, re, sys, urllib.request, zipfile, ssl |
| 34 | + S = os.path.dirname(os.path.abspath(__file__)) |
| 35 | + PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 |
| 36 | + BASE = f"http://127.0.0.1:{PORT}" |
| 37 | + TOKEN = "11111111-2222-4333-8444-555555555555" |
| 38 | + SUFFIX = b"\n# SOCKET-PATCHED\nSOCKET_PATCHED = 1\n" |
| 39 | + def gsha(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest() |
| 40 | + def patch_wheel(src, target): |
| 41 | + zin = zipfile.ZipFile(src); out = io.BytesIO(); members = [] |
| 42 | + rec_name = [n for n in zin.namelist() if n.endswith(".dist-info/RECORD")][0] |
| 43 | + before = after = None |
| 44 | + for n in sorted(zin.namelist()): |
| 45 | + if n == rec_name: continue |
| 46 | + b = zin.read(n) |
| 47 | + if n == target: before = b; b = b + SUFFIX; after = b |
| 48 | + members.append((n, b)) |
| 49 | + rec = "" |
| 50 | + for n, b in members: |
| 51 | + d = base64.urlsafe_b64encode(hashlib.sha256(b).digest()).rstrip(b"=").decode() |
| 52 | + rec += f"{n},sha256={d},{len(b)}\n" |
| 53 | + rec += f"{rec_name},,\n"; members.append((rec_name, rec.encode())) |
| 54 | + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: |
| 55 | + for n, b in members: |
| 56 | + zi = zipfile.ZipInfo(n, (2020,1,1,0,0,0)); zi.compress_type = zipfile.ZIP_DEFLATED; zi.external_attr = 0o644 << 16 |
| 57 | + z.writestr(zi, b) |
| 58 | + return out.getvalue(), before, after |
| 59 | + PATCHES = {} |
| 60 | + def add(uuid, name, ver, whl, target): |
| 61 | + data, before, after = patch_wheel(os.path.join(S, "wh", whl), target) |
| 62 | + purl = f"pkg:pypi/{name}@{ver}" |
| 63 | + PATCHES[uuid] = dict(uuid=uuid, name=name, ver=ver, purl=purl, whl=whl, data=data, |
| 64 | + url=f"{BASE}/patch/pypi/{name}/{ver}/{TOKEN}/{uuid}/{whl}", |
| 65 | + files={target: {"beforeHash": gsha(before), "afterHash": gsha(after)}}, blobs={gsha(after): after, gsha(before): before}) |
| 66 | + add("aaaaaaaa-0000-4000-8000-000000000001", "six", "1.16.0", "six-1.16.0-py2.py3-none-any.whl", "six.py") |
| 67 | + ENABLED = set(os.environ.get("MOCK_PATCHES", "six").split(",")) |
| 68 | + def canon(n): return re.sub(r"[-_.]+", "-", n).lower() |
| 69 | + def live(): return [p for p in PATCHES.values() if p["name"] in ENABLED] |
| 70 | + def find_purl(purl): |
| 71 | + m = re.match(r"pkg:pypi/([^@]+)@([^?#]+)", purl) |
| 72 | + if not m: return None |
| 73 | + for p in live(): |
| 74 | + if canon(m.group(1)) == canon(p["name"]) and m.group(2) == p["ver"]: return p |
| 75 | + def view(p): |
| 76 | + return {"uuid": p["uuid"], "purl": p["purl"], "publishedAt": "2026-09-01T00:00:00Z", "files": p["files"], |
| 77 | + "vulnerabilities": {"GHSA-xxxx-yyyy-zzzz": {"cves": ["CVE-2026-0001"], "summary": "s", "severity": "high", "description": "d"}}, |
| 78 | + "description": "bughunt patch", "license": "MIT", "tier": "free"} |
| 79 | + def grant(p): |
| 80 | + sri = "sha512-" + base64.b64encode(hashlib.sha512(p["data"]).digest()).decode() |
| 81 | + return {"status": "granted", "purl": p["purl"], "url": p["url"], "artifacts": [ |
| 82 | + {"kind": "tarball", "url": p["url"], "integrity": {"sha512": sri, "sha256": hashlib.sha256(p["data"]).hexdigest()}}], "registryOverride": None} |
| 83 | + CTX = ssl.create_default_context(cafile="/root/.ccr/ca-bundle.crt") if os.path.exists("/root/.ccr/ca-bundle.crt") else ssl.create_default_context() |
| 84 | + class H(http.server.BaseHTTPRequestHandler): |
| 85 | + def log_message(self, *a): |
| 86 | + with open(os.path.join(S, "mock.log"), "a") as f: f.write(f"{self.command} {self.path}\n") |
| 87 | + def send(self, code, body=b"", ctype="application/json", head=False): |
| 88 | + if isinstance(body, (dict, list)): body = json.dumps(body).encode() |
| 89 | + self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(body))); self.end_headers() |
| 90 | + if not head: self.wfile.write(body) |
| 91 | + def body(self): |
| 92 | + n = int(self.headers.get("Content-Length") or 0); raw = self.rfile.read(n) if n else b"" |
| 93 | + try: return json.loads(raw or b"{}") |
| 94 | + except Exception: return {} |
| 95 | + def do_HEAD(self): self.do_GET(head=True) |
| 96 | + def do_GET(self, head=False): |
| 97 | + path = self.path.split("?")[0] |
| 98 | + for p in PATCHES.values(): |
| 99 | + if path.endswith("/" + p["uuid"] + "/" + p["whl"]) or path.endswith("/artifacts/" + p["uuid"] + "/" + p["whl"]): |
| 100 | + return self.send(200, p["data"], "application/octet-stream", head) |
| 101 | + m = re.search(r"/view/([0-9a-f-]+)$", path) |
| 102 | + if m and m.group(1) in PATCHES and PATCHES[m.group(1)]["name"] in ENABLED: return self.send(200, view(PATCHES[m.group(1)]), head=head) |
| 103 | + m = re.search(r"/by-package/(.+)$", path) |
| 104 | + if m: |
| 105 | + from urllib.parse import unquote |
| 106 | + p = find_purl(unquote(m.group(1))) |
| 107 | + pl = [dict(view(p), vulnerabilities={})] if p else [] |
| 108 | + return self.send(200, {"patches": pl, "canAccessPaidPatches": False}, head=head) |
| 109 | + m = re.search(r"/blob/([0-9a-f]+)$", path) |
| 110 | + if m: |
| 111 | + for p in PATCHES.values(): |
| 112 | + if m.group(1) in p["blobs"]: return self.send(200, p["blobs"][m.group(1)], "application/octet-stream", head) |
| 113 | + if path.startswith("/pypi/"): |
| 114 | + try: |
| 115 | + with urllib.request.urlopen("https://pypi.org" + path, context=CTX, timeout=30) as r: return self.send(200, r.read(), head=head) |
| 116 | + except urllib.error.HTTPError as e: return self.send(e.code, b"", head=head) |
| 117 | + return self.send(404, {"error": "not found"}, head=head) |
| 118 | + def do_POST(self): |
| 119 | + path = self.path.split("?")[0]; b = self.body() |
| 120 | + with open(os.path.join(S, "mock.log"), "a") as f: f.write(f" body {json.dumps(b)[:300]}\n") |
| 121 | + if path.endswith("/patches/batch"): |
| 122 | + pk = [] |
| 123 | + for c in b.get("components", []): |
| 124 | + p = find_purl(c.get("purl", "")) |
| 125 | + if p: pk.append({"purl": c["purl"], "patches": [{"uuid": p["uuid"], "purl": p["purl"], "tier": "free", "cveIds": ["CVE-2026-0001"], "ghsaIds": ["GHSA-xxxx-yyyy-zzzz"], "severity": "HIGH", "title": "bughunt"}]}) |
| 126 | + return self.send(200, {"packages": pk, "canAccessPaidPatches": False}) |
| 127 | + if path.endswith("/package"): |
| 128 | + res = {} |
| 129 | + ids = set() |
| 130 | + def walk(o): |
| 131 | + if isinstance(o, dict): [walk(v) for v in o.values()] |
| 132 | + elif isinstance(o, list): [walk(v) for v in o] |
| 133 | + elif isinstance(o, str): |
| 134 | + if o in PATCHES: ids.add(o) |
| 135 | + p = find_purl(o) |
| 136 | + if p: ids.add(p["uuid"]) |
| 137 | + walk(b) |
| 138 | + for u in ids: res[u] = grant(PATCHES[u]) |
| 139 | + return self.send(200, {"results": res}) |
| 140 | + return self.send(404, {"error": "not found"}) |
| 141 | + http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() |
| 142 | + MOCKEOF |
| 143 | + cat > probe.sh <<'PROBEEOF' |
| 144 | + set -u |
| 145 | + M=http://127.0.0.1:8765 |
| 146 | + export SOCKET_TELEMETRY_DISABLED=1 SOCKET_NO_CONFIG=1 SOCKET_PYPI_JSON_API=$M/pypi |
| 147 | + sp() { "$SP" "$@" --api-url $M --api-token fake --org acme --patch-server-url $M --vendor-url $M; } |
| 148 | + py() { if [ -x .venv/Scripts/python.exe ]; then .venv/Scripts/python.exe "$@"; else .venv/bin/python "$@"; fi; } |
| 149 | + flag() { py -c 'import six;print(getattr(six,"SOCKET_PATCHED",0))' 2>&1 | tr -d '\r' | tail -1; } |
| 150 | + for lm in symlink hardlink copy; do |
| 151 | + d=$PWD/../lm-$lm; rm -rf $d; mkdir -p $d; cd $d |
| 152 | + printf '[project]\nname = "demo"\nversion = "0.1.0"\nrequires-python = ">=3.9"\ndependencies = ["six==1.16.0", "attrs>=20"]\n' > pyproject.toml |
| 153 | + "$UV" lock -q; UV_LINK_MODE=$lm "$UV" sync -q 2>sync.err; se=$? |
| 154 | + f=$(py -c 'import six;print(six.__file__)' | tr -d '\r'); if [ -L "$f" ]; then kind=symlink; else kind=regular; fi |
| 155 | + tgt=$(py -c "import os,six;print(os.path.realpath(six.__file__))" | tr -d '\r') |
| 156 | + h0=$(sha256sum "$tgt" | cut -c1-12) |
| 157 | + sp scan --mode agent --yes --json > scan.json 2>scan.err; ae=$? |
| 158 | + p1=$(flag); h1=$(sha256sum "$tgt" | cut -c1-12); if [ -L "$f" ]; then k1=symlink; else k1=regular; fi |
| 159 | + sp vex --output vex.out.json --json > vex.json 2>/dev/null; ve=$?; vs=$(grep -o "\"status\": *\"[a-z_]*\"" vex.out.json 2>/dev/null | sort | uniq -c | tr -s " " | tr "\n" " ") |
| 160 | + d2=$d-other; rm -rf $d2; mkdir -p $d2; cp pyproject.toml uv.lock $d2/ |
| 161 | + other=$(cd $d2 && UV_LINK_MODE=$lm "$UV" sync -q && if [ -x .venv/Scripts/python.exe ]; then .venv/Scripts/python.exe -c 'import six;print(getattr(six,"SOCKET_PATCHED",0))'; else .venv/bin/python -c 'import six;print(getattr(six,"SOCKET_PATCHED",0))'; fi 2>&1 | tr -d '\r' | tail -1) |
| 162 | + sp rollback --yes --json > rb.json 2>rb.err; re=$?; p2=$(flag); h2=$(sha256sum "$tgt" | cut -c1-12) |
| 163 | + UV_LINK_MODE=$lm "$UV" sync -q --reinstall-package six; p3=$(flag) |
| 164 | + echo "RESULT ${UVV} ${RUNNER_OS} lm=$lm sync=$se six=$kind | agent-scan exit=$ae patched=$p1 after=$k1 target-hash $h0->$h1 | vex exit=$ve $vs | other-project patched=$other | rollback exit=$re patched=$p2 hash->$h2 | reinstall patched=$p3" |
| 165 | + cat sync.err scan.err rb.err 2>/dev/null | grep -iv 'api-token\|Got: fake' | head -5 | sed 's/^/ stderr: /' |
| 166 | + cd - >/dev/null |
| 167 | + done |
| 168 | + PROBEEOF |
| 169 | + - name: probe |
| 170 | + run: | |
| 171 | + cd "$RUNNER_TEMP/h" |
| 172 | + (MOCK_PATCHES=six python mock.py 8765 > mock.out 2>&1 &) |
| 173 | + for i in $(seq 1 30); do curl -sf http://127.0.0.1:8765/v0/orgs/acme/patches/view/aaaaaaaa-0000-4000-8000-000000000001 >/dev/null && break; sleep 1; done |
| 174 | + export SP="$GITHUB_WORKSPACE/target/release/socket-patch" |
| 175 | + [ -f "$SP.exe" ] && SP="$SP.exe" |
| 176 | + export UV="$(command -v uv)" |
| 177 | + "$UV" --version |
| 178 | + export UV_CACHE_DIR="$RUNNER_TEMP/uvcache" |
| 179 | + mkdir -p wk && cd wk && UVV="${{ matrix.uv }}" bash ../probe.sh 2>&1 | tee probe.log |
| 180 | + echo '--- RESULTS'; grep '^RESULT' probe.log |
| 181 | + echo '--- mock log'; tail -30 mock.log || true; cat mock.out || true |
0 commit comments