|
| 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.23'] |
| 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 | + python -m pip download -q --no-deps -d wh --only-binary=:all: --python-version 3.11 --platform manylinux_2_17_x86_64 MarkupSafe==2.1.5 |
| 33 | + cat > mock.py <<'MOCKEOF' |
| 34 | + import base64, hashlib, http.server, io, json, os, re, sys, urllib.request, zipfile, ssl |
| 35 | + S = os.path.dirname(os.path.abspath(__file__)) |
| 36 | + PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 |
| 37 | + BASE = f"http://127.0.0.1:{PORT}" |
| 38 | + TOKEN = "11111111-2222-4333-8444-555555555555" |
| 39 | + SUFFIX = b"\n# SOCKET-PATCHED\nSOCKET_PATCHED = 1\n" |
| 40 | + def gsha(b): return hashlib.sha256(b"blob %d\0" % len(b) + b).hexdigest() |
| 41 | + def patch_wheel(src, target): |
| 42 | + zin = zipfile.ZipFile(src); out = io.BytesIO(); members = [] |
| 43 | + rec_name = [n for n in zin.namelist() if n.endswith(".dist-info/RECORD")][0] |
| 44 | + before = after = None |
| 45 | + for n in sorted(zin.namelist()): |
| 46 | + if n == rec_name: continue |
| 47 | + b = zin.read(n) |
| 48 | + if n == target: before = b; b = b + SUFFIX; after = b |
| 49 | + members.append((n, b)) |
| 50 | + rec = "" |
| 51 | + for n, b in members: |
| 52 | + d = base64.urlsafe_b64encode(hashlib.sha256(b).digest()).rstrip(b"=").decode() |
| 53 | + rec += f"{n},sha256={d},{len(b)}\n" |
| 54 | + rec += f"{rec_name},,\n"; members.append((rec_name, rec.encode())) |
| 55 | + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z: |
| 56 | + for n, b in members: |
| 57 | + zi = zipfile.ZipInfo(n, (2020,1,1,0,0,0)); zi.compress_type = zipfile.ZIP_DEFLATED; zi.external_attr = 0o644 << 16 |
| 58 | + z.writestr(zi, b) |
| 59 | + return out.getvalue(), before, after |
| 60 | + PATCHES = {} |
| 61 | + def add(uuid, name, ver, whl, target): |
| 62 | + data, before, after = patch_wheel(os.path.join(S, "wh", whl), target) |
| 63 | + purl = f"pkg:pypi/{name}@{ver}" |
| 64 | + PATCHES[uuid] = dict(uuid=uuid, name=name, ver=ver, purl=purl, whl=whl, data=data, |
| 65 | + url=f"{BASE}/patch/pypi/{name}/{ver}/{TOKEN}/{uuid}/{whl}", |
| 66 | + files={target: {"beforeHash": gsha(before), "afterHash": gsha(after)}}, blobs={gsha(after): after, gsha(before): before}) |
| 67 | + add("aaaaaaaa-0000-4000-8000-000000000001", "six", "1.16.0", "six-1.16.0-py2.py3-none-any.whl", "six.py") |
| 68 | + add("aaaaaaaa-0000-4000-8000-000000000002", "markupsafe", "2.1.5", "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", "markupsafe/__init__.py") |
| 69 | + add("aaaaaaaa-0000-4000-8000-000000000003", "idna", "3.7", "idna-3.7-py3-none-any.whl", "idna/__init__.py") |
| 70 | + ENABLED = set(os.environ.get("MOCK_PATCHES", "six").split(",")) |
| 71 | + def canon(n): return re.sub(r"[-_.]+", "-", n).lower() |
| 72 | + def live(): return [p for p in PATCHES.values() if p["name"] in ENABLED] |
| 73 | + def find_purl(purl): |
| 74 | + m = re.match(r"pkg:pypi/([^@]+)@([^?#]+)", purl) |
| 75 | + if not m: return None |
| 76 | + for p in live(): |
| 77 | + if canon(m.group(1)) == canon(p["name"]) and m.group(2) == p["ver"]: return p |
| 78 | + def view(p): |
| 79 | + return {"uuid": p["uuid"], "purl": p["purl"], "publishedAt": "2026-09-01T00:00:00Z", "files": p["files"], |
| 80 | + "vulnerabilities": {"GHSA-xxxx-yyyy-zzzz": {"cves": ["CVE-2026-0001"], "summary": "s", "severity": "high", "description": "d"}}, |
| 81 | + "description": "bughunt patch", "license": "MIT", "tier": "free"} |
| 82 | + def grant(p): |
| 83 | + sri = "sha512-" + base64.b64encode(hashlib.sha512(p["data"]).digest()).decode() |
| 84 | + return {"status": "granted", "purl": p["purl"], "url": p["url"], "artifacts": [ |
| 85 | + {"kind": "tarball", "url": p["url"], "integrity": {"sha512": sri, "sha256": hashlib.sha256(p["data"]).hexdigest()}}], "registryOverride": None} |
| 86 | + 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() |
| 87 | + class H(http.server.BaseHTTPRequestHandler): |
| 88 | + def log_message(self, *a): |
| 89 | + with open(os.path.join(S, "mock.log"), "a") as f: f.write(f"{self.command} {self.path}\n") |
| 90 | + def send(self, code, body=b"", ctype="application/json", head=False): |
| 91 | + if isinstance(body, (dict, list)): body = json.dumps(body).encode() |
| 92 | + self.send_response(code); self.send_header("Content-Type", ctype); self.send_header("Content-Length", str(len(body))); self.end_headers() |
| 93 | + if not head: self.wfile.write(body) |
| 94 | + def body(self): |
| 95 | + n = int(self.headers.get("Content-Length") or 0); raw = self.rfile.read(n) if n else b"" |
| 96 | + try: return json.loads(raw or b"{}") |
| 97 | + except Exception: return {} |
| 98 | + def do_HEAD(self): self.do_GET(head=True) |
| 99 | + def do_GET(self, head=False): |
| 100 | + path = self.path.split("?")[0] |
| 101 | + for p in PATCHES.values(): |
| 102 | + if path == p["url"][len(BASE):] or path.endswith("/artifacts/" + p["uuid"] + "/" + p["whl"]): |
| 103 | + return self.send(200, p["data"], "application/octet-stream", head) |
| 104 | + m = re.search(r"/view/([0-9a-f-]+)$", path) |
| 105 | + 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) |
| 106 | + m = re.search(r"/by-package/(.+)$", path) |
| 107 | + if m: |
| 108 | + from urllib.parse import unquote |
| 109 | + p = find_purl(unquote(m.group(1))) |
| 110 | + pl = [dict(view(p), vulnerabilities={})] if p else [] |
| 111 | + return self.send(200, {"patches": pl, "canAccessPaidPatches": False}, head=head) |
| 112 | + m = re.search(r"/blob/([0-9a-f]+)$", path) |
| 113 | + if m: |
| 114 | + for p in PATCHES.values(): |
| 115 | + if m.group(1) in p["blobs"]: return self.send(200, p["blobs"][m.group(1)], "application/octet-stream", head) |
| 116 | + if path.startswith("/pypi/"): |
| 117 | + try: |
| 118 | + with urllib.request.urlopen("https://pypi.org" + path, context=CTX, timeout=30) as r: return self.send(200, r.read(), head=head) |
| 119 | + except urllib.error.HTTPError as e: return self.send(e.code, b"", head=head) |
| 120 | + return self.send(404, {"error": "not found"}, head=head) |
| 121 | + def do_POST(self): |
| 122 | + path = self.path.split("?")[0]; b = self.body() |
| 123 | + with open(os.path.join(S, "mock.log"), "a") as f: f.write(f" body {json.dumps(b)[:300]}\n") |
| 124 | + if path.endswith("/patches/batch"): |
| 125 | + pk = [] |
| 126 | + for c in b.get("components", []): |
| 127 | + p = find_purl(c.get("purl", "")) |
| 128 | + 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"}]}) |
| 129 | + return self.send(200, {"packages": pk, "canAccessPaidPatches": False}) |
| 130 | + if path.endswith("/package"): |
| 131 | + res = {} |
| 132 | + ids = set() |
| 133 | + def walk(o): |
| 134 | + if isinstance(o, dict): [walk(v) for v in o.values()] |
| 135 | + elif isinstance(o, list): [walk(v) for v in o] |
| 136 | + elif isinstance(o, str): |
| 137 | + if o in PATCHES: ids.add(o) |
| 138 | + p = find_purl(o) |
| 139 | + if p: ids.add(p["uuid"]) |
| 140 | + walk(b) |
| 141 | + for u in ids: res[u] = grant(PATCHES[u]) |
| 142 | + return self.send(200, {"results": res}) |
| 143 | + return self.send(404, {"error": "not found"}) |
| 144 | + http.server.ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() |
| 145 | + MOCKEOF |
| 146 | + cat > probe.sh <<'PROBEEOF' |
| 147 | + set -u |
| 148 | + M=http://127.0.0.1:8765 |
| 149 | + export SOCKET_TELEMETRY_DISABLED=1 SOCKET_NO_CONFIG=1 SOCKET_PYPI_JSON_API=$M/pypi |
| 150 | + sp() { "$SP" "$@" --api-url $M --api-token fake --org acme --patch-server-url $M; } |
| 151 | + res() { echo "RESULT $1: $2"; } |
| 152 | + tpy() { if [ -x "$1/Scripts/python.exe" ]; then echo "$1/Scripts/python.exe"; else echo "$1/bin/python"; fi; } |
| 153 | + pat() { "$1" -c "import six;print(getattr(six,'SOCKET_PATCHED',0))" 2>&1 | tr -d '\r'; } |
| 154 | + cyc() { # $1 label, $2 python |
| 155 | + local L=$1 P=$2 |
| 156 | + res "$L before" "$(pat "$P")" |
| 157 | + sp scan -g --json > g0.json 2>&1; res "$L scan-report" "exit=$? $(grep -c 'pkg:pypi/six@1.16.0' g0.json)" |
| 158 | + sp scan -g --mode agent --yes --json > g1.json 2>&1; res "$L apply" "exit=$? pat=$(pat "$P")" |
| 159 | + sp vex -g --product pkg:generic/t@1 --output vx.json > v.json 2>&1; res "$L vex" "exit=$? $(grep -o '"not_affected"' vx.json 2>/dev/null | head -1)" |
| 160 | + sp rollback -g --yes --json > r.json 2>&1; res "$L rollback" "exit=$? pat=$(pat "$P")" |
| 161 | + sp get -g pkg:pypi/six@1.16.0 --yes --json > get.json 2>&1; res "$L get" "exit=$? pat=$(pat "$P")" |
| 162 | + sp remove -g pkg:pypi/six@1.16.0 --yes --json > rm.json 2>&1; res "$L remove" "exit=$? pat=$(pat "$P")" |
| 163 | + for f in g1 r get rm; do grep -o '"\(errorCode\|code\|error\)": *"[^"]*"' $f.json | sort -u | head -3 | sed "s/^/ [$L $f] /"; done |
| 164 | + } |
| 165 | + root=$PWD/work; rm -rf "$root"; mkdir -p "$root/cwd"; cd "$root/cwd" |
| 166 | + # 1. default tool dir |
| 167 | + "$UV" tool install -q pycowsay==0.0.0.2 --with six==1.16.0 || res default-tool "install failed" |
| 168 | + TD=$("$UV" tool dir | tr -d '\r'); echo "tool dir: $TD" |
| 169 | + cyc default-tool "$(tpy "$TD/pycowsay")" |
| 170 | + "$UV" tool uninstall -q pycowsay |
| 171 | + # 2. UV_TOOL_DIR with space + unicode |
| 172 | + export UV_TOOL_DIR="$root/tools dir ü" UV_TOOL_BIN_DIR="$root/bin" |
| 173 | + "$UV" tool install -q pycowsay==0.0.0.2 --with six==1.16.0 || res tool-dir "install failed" |
| 174 | + cyc uv-tool-dir "$(tpy "$UV_TOOL_DIR/pycowsay")" |
| 175 | + unset UV_TOOL_DIR UV_TOOL_BIN_DIR |
| 176 | + # 3. UV_PYTHON_INSTALL_DIR with space + unicode |
| 177 | + export UV_PYTHON_INSTALL_DIR="$root/py inst ü" |
| 178 | + "$UV" python install 3.12 > /dev/null 2>&1 || res pyinst "python install failed" |
| 179 | + PY=$("$UV" python find 3.12 --managed-python 2>/dev/null | tr -d '\r'); echo "managed python: $PY" |
| 180 | + "$UV" pip install -q --python "$PY" --system --break-system-packages six==1.16.0 || res pyinst "pip install failed" |
| 181 | + cyc python-install-dir "$PY" |
| 182 | + PROBEEOF |
| 183 | + - name: probe |
| 184 | + run: | |
| 185 | + cd "$RUNNER_TEMP/h" |
| 186 | + (MOCK_PATCHES=six python mock.py 8765 > mock.out 2>&1 &) |
| 187 | + 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 |
| 188 | + export SP="$GITHUB_WORKSPACE/target/release/socket-patch" |
| 189 | + [ -f "$SP.exe" ] && SP="$SP.exe" |
| 190 | + export UV="$(command -v uv)" |
| 191 | + "$UV" --version |
| 192 | + export UV_CACHE_DIR="$RUNNER_TEMP/uvcache" |
| 193 | + bash probe.sh 2>&1 | tee probe.log |
| 194 | + echo '--- RESULTS'; grep '^RESULT\|^ \[' probe.log |
| 195 | + echo '--- mock log'; tail -30 mock.log || true; cat mock.out || true |
0 commit comments