| |
| """Probe whether the Hippius Hub OCI registry lets THIRD PARTIES enumerate repos. |
| |
| Why this matters (docs/MINER.md Β§5a): `cascade deploy` hides the on-chain |
| pointer with a timed timelock reveal, but the generator CONTENT is uploaded to |
| the miner's Hub repo at deploy time β before the reveal. The timed reveal only |
| protects a submission end-to-end if that content is undiscoverable without the |
| (still-hidden) ref. `--hub-namespace` gives each submission a non-guessable |
| repo name, which is sufficient **iff** the registry does not let strangers list |
| a namespace's repos. This script settles that empirically: |
| |
| * ``GET /v2/_catalog`` β the registry-wide repo listing (should be denied) |
| * ``GET /v2/<repo>/tags/list`` β tag listing on someone else's known repo |
| (a digest-pinned push may expose tags; listing them leaks fresh digests) |
| |
| Each is tried anonymously and (if ``HIPPIUS_HUB_TOKEN`` is set) authenticated |
| as a *different* user than the repo owner β the attacker model is "any Hub |
| account watching your namespace", not "no account at all". |
| |
| Usage:: |
| |
| python scripts/probe_hub_enumeration.py # anonymous only |
| python scripts/probe_hub_enumeration.py --repo other-acct/gen-abc123 |
| HIPPIUS_HUB_TOKEN=β¦ python scripts/probe_hub_enumeration.py # + authenticated |
| |
| Exit codes: 0 = not enumerable (random repo names suffice), 1 = ENUMERABLE |
| (escalate to sealed submissions β encrypt the artifact, key rides in the |
| timelocked payload), 2 = inconclusive (endpoints unreachable / all denied |
| ambiguously). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import urllib.error |
| import urllib.request |
|
|
| DEFAULT_REGISTRY = "https://registry.hippius.com" |
|
|
|
|
| def _get(url: str, token: str | None) -> tuple[int, str]: |
| """GET ``url`` β (status, body[:2000]); network errors β (0, reason).""" |
| req = urllib.request.Request(url, headers={"User-Agent": "cascade-hub-probe"}) |
| if token: |
| req.add_header("Authorization", f"Bearer {token}") |
| try: |
| with urllib.request.urlopen(req, timeout=20) as resp: |
| return int(resp.status), resp.read(2000).decode("utf-8", "replace") |
| except urllib.error.HTTPError as e: |
| return int(e.code), e.read(2000).decode("utf-8", "replace") |
| except Exception as e: |
| return 0, f"{type(e).__name__}: {e}" |
|
|
|
|
| def _judge_catalog(status: int, body: str) -> str | None: |
| """Return a leak description if the catalog response exposes repos.""" |
| if status != 200: |
| return None |
| try: |
| repos = json.loads(body).get("repositories", []) |
| except ValueError: |
| return None |
| return f"_catalog returned {len(repos)} repo name(s), e.g. {repos[:3]}" if repos else None |
|
|
|
|
| def probe(registry: str, repo: str | None, token: str | None) -> int: |
| leaks: list[str] = [] |
| unreachable = 0 |
| modes = [("anonymous", None)] + ([("authenticated (other account)", token)] if token else []) |
|
|
| for label, tok in modes: |
| status, body = _get(f"{registry}/v2/_catalog?n=100", tok) |
| print(f"[{label}] GET /v2/_catalog β {status if status else body}") |
| if status == 0: |
| unreachable += 1 |
| leak = _judge_catalog(status, body) |
| if leak: |
| leaks.append(f"{label}: {leak}") |
|
|
| if repo: |
| status, body = _get(f"{registry}/v2/{repo}/tags/list", tok) |
| print(f"[{label}] GET /v2/{repo}/tags/list β {status if status else body}") |
| if status == 200 and '"tags"' in body: |
| leaks.append(f"{label}: tags/list on a third-party repo returned {body[:120]!r}") |
|
|
| print() |
| if leaks: |
| print("ENUMERABLE β the timed reveal is guarding an empty vault:") |
| for line in leaks: |
| print(f" - {line}") |
| print("β escalate to sealed submissions (upload ciphertext; decryption key +\n" |
| " plaintext digest ride in the timelocked payload).") |
| return 1 |
| if unreachable == len(modes): |
| print("INCONCLUSIVE β registry unreachable from this network; rerun where " |
| f"{registry} is routable.") |
| return 2 |
| print("NOT ENUMERABLE via catalog/tag listing β fresh non-guessable repo names " |
| "(`cascade deploy --hub-namespace`) keep pre-reveal content undiscoverable.") |
| return 0 |
|
|
|
|
| def main(argv: list[str] | None = None) -> int: |
| ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| ap.add_argument("--registry", default=DEFAULT_REGISTRY) |
| ap.add_argument("--repo", default=None, |
| help="A known repo you do NOT own (namespace/name), to test tag listing.") |
| args = ap.parse_args(argv) |
| return probe(args.registry.rstrip("/"), args.repo, os.environ.get("HIPPIUS_HUB_TOKEN")) |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|