"""ETERNAL Docker Hub hunt: search walk + registry v2 config/layer scan. Endpoint(s): - https://hub.docker.com/v2/search/repositories/?query=... - https://hub.docker.com/v2/repositories///tags - https://auth.docker.io/token?service=registry.docker.io&scope=... - https://registry-1.docker.io/v2///manifests/ - https://registry-1.docker.io/v2///blobs/ Auth: none (anonymous pull via registry token) """ import os as _os BASE = _os.path.dirname(_os.path.abspath(__file__)) import gzip import hashlib import io import json import re import sqlite3 import sys import tarfile import threading import time import requests DB = _os.environ.get("KF_DB") or _os.path.join(BASE, "data", "keys.db") LOG = _os.path.join(BASE, "data", "dockerhub_eternal.log") CURSOR_FILE = _os.path.join(BASE, "data", "dockerhub_cursor.txt") SEEN_FILE = _os.path.join(BASE, "data", "dockerhub_seen.txt") N_THREADS = 4 SLEEP = 120 MAX_ITEMS = 200 MAX_TAGS = 3 # tags per image (--once → 1) from key_patterns import scan_text GARBAGE = ("xxxx", "your", "YOUR", "EXAMPLE", "example", "n0tr3al", "placeholder", "CHANGEME", "changeme", "dummy", "DUMMY", "abcdefghijklmnopqrst", "1234567890abcdef", "00000000", "ffffffff", "deadbeef", "testtest") SCAN_EXT = (".env", ".txt", ".cfg", ".conf", ".yml", ".yaml", ".json", ".xml", ".ini", ".toml", ".properties", ".sh", ".bash", ".ps1", ".py") UA = {"User-Agent": "keyfarm-dockerhub/1.0"} S = requests.Session() S.trust_env = False REGISTRY_UA = { "User-Agent": "keyfarm-dockerhub/1.0", "Accept": "application/vnd.oci.image.index.v1+json," "application/vnd.docker.distribution.manifest.v2+json," "application/vnd.oci.image.manifest.v1+json", } REGISTRY_UA_BLOB = {"User-Agent": "keyfarm-dockerhub/1.0"} # Search queries to find images likely to carry secrets in env / config QUERIES = [ "api", "token", "bot", "config", "secret", "deploy", "credentials", "backend", "openai", "app", ] FILE_CAP = 500_000 # text files inside layer LAYER_CAP = 20 * 1024 * 1024 # 20 MB for layer tarball CONFIG_FILE_SIZES = (1_000, 50_000) # only scan .env between these sizes DOCKER_HUB_RATE = 100 # hub.docker.com SEARCH API /hr budget PULL_QUOTA = 90 # registry-1 anon pull budget per 6h (limit ~100) PULL_WINDOW = 6 * 3600 PULL_STATE = _os.path.join(BASE, "data", "dockerhub_pulls.txt") _pull_lock = threading.Lock() def is_junk(v): if any(g in v for g in GARBAGE): return True if len(set(v)) < 6: return True return False def log(msg): line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n" with open(LOG, "a", encoding="utf-8") as f: f.write(line) conn = sqlite3.connect(DB, timeout=120, check_same_thread=False, isolation_level=None) lock = threading.Lock() total = 0 def db_add(key, prov, repo): global total with lock: conn.execute( "INSERT INTO keys (hash, val, prov, status, found, repo) " "VALUES (?,?,?,?,?,?) " "ON CONFLICT(hash) DO UPDATE SET status='NEW', " "found=excluded.found, repo=excluded.repo " "WHERE keys.status IN ('DEAD','ERR')", (hashlib.sha1(key.encode()).hexdigest()[:16], key, prov, "NEW", "dockerhub", repo[:300])) total += 1 def load_seen(): s = set() try: with open(SEEN_FILE, encoding="utf-8") as f: for line in f: line = line.strip() if line: s.add(line) except FileNotFoundError: pass return s seen = load_seen() def mark_seen(p): seen.add(p) with open(SEEN_FILE, "a", encoding="utf-8") as f: f.write(p + "\n") def _pull_ok(): """Persistent 6h-window counter for registry-1 pulls (state file, shared across processes/restarts). Anonymous Docker Hub pull quota is ~100 per 6 HOURS per IP — not 180/hr (that is the hub search API).""" with _pull_lock: now = int(time.time()) ws, cnt = now, 0 try: parts = open(PULL_STATE, encoding="utf-8").read().split() if len(parts) == 2: ws, cnt = int(parts[0]), int(parts[1]) except Exception: pass if now - ws >= PULL_WINDOW: ws, cnt = now, 0 if cnt >= PULL_QUOTA: return False cnt += 1 with open(PULL_STATE, "w", encoding="utf-8") as f: f.write(str(ws) + " " + str(cnt)) return True def _req(url, headers=None, timeout=15): """Single GET with retry; registry-1 pulls are quota-gated.""" if url.startswith("https://registry-1.docker.io") and not _pull_ok(): log(" pull quota exhausted (6h window) - skipping") return None for attempt in (1, 2, 3): try: r = S.get(url, headers=headers or UA, timeout=timeout) if r.status_code == 429: ra = r.headers.get("Retry-After", "60") s = int(ra) if ra.isdigit() else 60 log(f" 429 on {url[:80]}, sleep {s}s") time.sleep(s) continue return r except requests.RequestException: if attempt == 3: log(f" req-err {url[:80]}") return None time.sleep(4) return None def get_registry_token(repo): """Obtain anonymous bearer token for registry access.""" url = ("https://auth.docker.io/token" f"?service=registry.docker.io&scope=repository:{repo}:pull") r = _req(url, timeout=15) if r and r.status_code == 200: return r.json().get("token") return None def get_manifest(repo, tag, token): """Fetch manifest JSON (resolves OCI image index to amd64/linux).""" url = f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}" r = _req(url, headers={**REGISTRY_UA, "Authorization": f"Bearer {token}"}, timeout=20) if not r or r.status_code != 200: return None man = r.json() mt = man.get("mediaType", "") if "index" in mt or "list" in mt: for m in man.get("manifests", []): plat = m.get("platform", {}) if plat.get("architecture") == "amd64" and plat.get("os") == "linux": return get_manifest(repo, m["digest"], token) # fallback: first manifest entry if man.get("manifests"): return get_manifest(repo, man["manifests"][0]["digest"], token) return None return man def get_config_blob(repo, digest, token): """Fetch image config blob by digest.""" url = f"https://registry-1.docker.io/v2/{repo}/blobs/{digest}" r = _req(url, headers={**REGISTRY_UA_BLOB, "Authorization": f"Bearer {token}"}, timeout=20) if r and r.status_code == 200: return r.json() return None def get_layer_blob(repo, digest, token): """Fetch layer blob (gzipped tarball).""" url = f"https://registry-1.docker.io/v2/{repo}/blobs/{digest}" r = _req(url, headers={**REGISTRY_UA_BLOB, "Authorization": f"Bearer {token}"}, timeout=60) if r and r.status_code == 200 and len(r.content) <= LAYER_CAP: return r.content return None def scan_config_env(cfg, repo_tag): """Scan config's Env entries for keys.""" n = 0 env_list = (cfg.get("config") or {}).get("Env") or [] for entry in env_list: if "=" not in entry: continue val = entry.split("=", 1)[1] if is_junk(val) or len(val) < 30: continue for prov, key in scan_text(val): if is_junk(key) or len(key) < 30: continue db_add(key, prov, repo_tag) n += 1 return n def scan_env_whole_text(cfg, repo_tag): """Join entire Env block and scan as single text for ENV_RE patterns.""" env_list = (cfg.get("config") or {}).get("Env") or [] if not env_list: return 0 text = "\n".join(env_list) n = 0 for prov, key in scan_text(text): if is_junk(key) or len(key) < 30: continue db_add(key, prov, repo_tag) n += 1 return n def scan_layer(layer_data, repo_tag): """Extract .env/config files from layer tarball and scan.""" n = 0 try: with gzip.GzipFile(fileobj=io.BytesIO(layer_data)) as gf: with tarfile.TarFile(fileobj=gf, mode="r") as tf: for member in tf.getmembers(): if member.isdir() or not member.isfile(): continue fn = member.name.lower() if not any(fn.endswith(e) for e in SCAN_EXT): continue if member.size > FILE_CAP or member.size == 0: continue try: data = tf.extractfile(member) if data is None: continue text = data.read().decode("utf-8", errors="replace") except Exception: continue for prov, key in scan_text(text): if is_junk(key) or len(key) < 30: continue db_add(key, prov, repo_tag) n += 1 except Exception: pass return n def search_images(query, page=1): """Search Docker Hub for images matching query.""" url = ("https://hub.docker.com/v2/search/repositories/" f"?query={query}&page_size=30&page={page}") r = _req(url, headers=UA, timeout=20) if r and r.status_code == 200: return r.json().get("results", []) return [] def get_tags(repo): """Get tags (+ digests) for a repo.""" url = f"https://hub.docker.com/v2/repositories/{repo}/tags?page_size=25" r = _req(url, headers=UA, timeout=15) if r and r.status_code == 200: return r.json().get("results", []) return [] def process_image(repo): """Full scan flow for one image: tags → manifest → config env → layer.""" tags = get_tags(repo) if not tags: return 0 n = 0 ttl = 0 for tag_obj in tags: if ttl >= MAX_TAGS: break tag = tag_obj.get("name") if not tag: continue repo_tag = f"docker:{repo}:{tag}" if repo_tag in seen: continue mark_seen(repo_tag) # Skip images without a digest (can't pull from registry) digest = tag_obj.get("digest") if not digest: continue ttl += 1 token = get_registry_token(repo) if not token: continue man = get_manifest(repo, tag, token) if not man: continue cfg_digest = (man.get("config") or {}).get("digest") if not cfg_digest: continue cfg = get_config_blob(repo, cfg_digest, token) if not cfg: continue n += scan_env_whole_text(cfg, repo_tag) # Try first layer if small enough layers = man.get("layers") or [] if layers: first = layers[0] layer_digest = first.get("digest", "") layer_size = first.get("size", 0) if layer_digest and layer_size <= LAYER_CAP and layer_size > 0: layer_data = get_layer_blob(repo, layer_digest, token) if layer_data and len(layer_data) <= LAYER_CAP: n += scan_layer(layer_data, repo_tag) return n def cycle(): global total, seen total = 0 fail = 0 qidx = 0 # Round-robin through queries across cycles using cursor cursor_path = CURSOR_FILE try: with open(cursor_path, encoding="utf-8") as f: raw = f.read().strip() if raw: qidx, page = (int(x) for x in raw.split(":", 1)) else: qidx, page = 0, 1 except (FileNotFoundError, ValueError): qidx, page = 0, 1 query = QUERIES[qidx % len(QUERIES)] log(f"cycle query={query} page={page}") images = search_images(query, page) if not images: # No results or page exhausted → next query, reset page qidx = (qidx + 1) % len(QUERIES) page = 1 images = search_images(QUERIES[qidx], page) for img in images[:MAX_ITEMS]: repo = img.get("repo_name") if not repo: continue try: n = process_image(repo) if n > 0: log(f" {repo} +{n} keys") except Exception: fail += 1 with open(cursor_path, "w", encoding="utf-8") as f: next_page = page + 1 f.write(f"{qidx}:{next_page}") log(f"cycle done query={QUERIES[qidx]} page={page} " f"images={len(images)} fail={fail} +{total} keys") def main(): log("=== dockerhub_eternal START ===") if "--once" in sys.argv: global MAX_ITEMS, MAX_TAGS MAX_ITEMS = min(MAX_ITEMS, 5) MAX_TAGS = 1 while True: cycle() if "--once" in sys.argv: break time.sleep(SLEEP) if __name__ == "__main__": main()