Files
iimp0ster-detection-chokepo…/scripts/ingest_carson_domains.py
T
imposterandClaude Opus 4.8 66034135c3 feat(clickgrab): re-source ingest + clean Carson three-feed trend model
Re-source ClickGrab ingest off the dead Git-LFS path onto raw GitHub blobs and re-architect the ClickFix trends page around three feeds, each used only for what it is reliable for (DECISIONS #010-012).

Ingest (#010-011): fetch MHaggis ClickGrab as raw blobs (upstream LFS quota exhausted); append-only idempotent volume generator + daily GHA for volume and Carson gist landscape count.

Behaviour (#012): rebuild the per-domain command classification from Carson's ClickFix Hunter export (build_domain_monthly.py) and re-plumb charts/cards to it, separating hex-XOR from base64 (the prior site-crawl source conflated them and measured ~93-99% noise). Trends prose corrected to the honest figures: May base64 69% (316/458), inline 95.2%, Nov msiexec 87% (669/767).

Workstream B: rank MHaggis lure-page HTML keywords (build_lure_keywords.py) into data-driven URLScan OSINT pivots on the clickfix chokepoint and enrich the multilingual IOK matcher.

Validated: scripts/validate_schema.py passes (13 chokepoints, 3 trends files). Deferred: two classifier regex bugs distort Dec-Apr months only; headline figures robust (DECISIONS #013).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 17:59:48 -06:00

139 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
ingest_carson_domains.py
Fetch Carson Williams' (cdup07) ClickFix domain gist and cache the normalised
domain set for the trends generator.
ROLE (see docs/DECISIONS.md #011): Carson's gist is a domains-ONLY feed — bare
hostnames, one per line, refreshed daily (~3.3k domains). It carries no command
lines (his richer ClickGrab fork froze 2025-10-09), so it cannot feed the
behavioural cradle/evasion stats — those are all MHaggis. Carson's value is
breadth: a landscape count (meta.total_domains) and corroboration of staging
domains MHaggis also observed.
HYGIENE: the full domain list stays in gitignored cache only. analyze_clickgrab.py
commits the COUNT and the corroboration count to clickgrab_trends.yml, never the
list itself.
Source: https://api.github.com/gists/9f563dfb78a06fad5db794f33ba93a3f
Output: cache/clickgrab/carson_domains.json {count, updated, source, domains[]}
"""
import ipaddress
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
import requests
REPO_ROOT = Path(__file__).parent.parent
OUT_PATH = REPO_ROOT / "cache" / "clickgrab" / "carson_domains.json"
GIST_ID = "9f563dfb78a06fad5db794f33ba93a3f"
GIST_API = f"https://api.github.com/gists/{GIST_ID}"
GIST_FILE = "clickfix_domains.txt"
HEADERS = {"User-Agent": "detection-chokepoints/clickgrab-ingest",
"Accept": "application/vnd.github+json"}
# A host line: a domain (labels + alpha TLD) OR a bare IPv4. We keep www. and
# IPs so the count stays faithful to "how many hosts Carson tracks" (a landscape
# stat that should trend with his list). www-stripping happens only at join time
# in the generator, where it's needed to match defanged staging domains.
RE_DOMAIN = re.compile(r"^(?:[a-z0-9_-]+\.)+[a-z]{2,}$")
def _is_ipv4(s: str) -> bool:
"""True only for a well-formed IPv4 — rejects 999.999.999.999 etc. that a loose
\\d{1,3} regex would admit, so junk can't inflate the landscape count."""
try:
ipaddress.IPv4Address(s)
return True
except ValueError:
return False
def normalise(line: str) -> str:
"""Lowercase, strip scheme/path/port/comments and a trailing dot. Keep www.
and IPs. Returns '' to drop blanks/comments/invalid hosts."""
s = line.strip().lower()
if not s or s.startswith("#"):
return ""
s = re.sub(r"^https?://", "", s) # tolerate a stray scheme
s = s.split("/", 1)[0].split(":", 1)[0] # drop any path / port
s = s.rstrip(".")
return s if (RE_DOMAIN.match(s) or _is_ipv4(s)) else ""
def main() -> None:
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
try:
resp = requests.get(GIST_API, headers=HEADERS, timeout=30)
resp.raise_for_status()
gist = resp.json()
except requests.RequestException as exc:
print(f"ERROR fetching gist: {exc}", file=sys.stderr)
sys.exit(1)
files = gist.get("files", {})
finfo = files.get(GIST_FILE) or next(iter(files.values()), None)
if not finfo:
print("ERROR: gist has no files", file=sys.stderr)
sys.exit(1)
# The gist API inlines file content unless it exceeds ~1 MB (this file is
# ~64 KB, so 'truncated' is False); fall back to raw_url just in case — with
# the same status/timeout guards, so a 5xx error page can't become the data.
content = finfo.get("content")
if finfo.get("truncated") or content is None:
try:
r = requests.get(finfo["raw_url"], headers=HEADERS, timeout=30)
r.raise_for_status()
content = r.text
except requests.RequestException as exc:
print(f"ERROR fetching gist raw content: {exc}", file=sys.stderr)
sys.exit(1)
raw_lines = content.splitlines()
domains = sorted({d for d in (normalise(ln) for ln in raw_lines) if d})
# Guard against a transient/empty upstream clobbering the cache with count~0,
# which analyze_clickgrab would then publish as total_domains. Refuse to
# overwrite a healthy cache with an implausibly small result; keep the last good.
prior = None
if OUT_PATH.exists():
try:
prior = json.loads(OUT_PATH.read_text(encoding="utf-8")).get("count")
except Exception:
prior = None
if len(domains) == 0 or (isinstance(prior, int) and prior > 0 and len(domains) < prior * 0.5):
print(f"ERROR: refusing to overwrite cache — {len(domains)} domains is implausibly low "
f"vs prior {prior} (likely a transient/empty upstream). Keeping last good cache.",
file=sys.stderr)
sys.exit(1)
# Atomic write: a crash mid-write must not leave truncated JSON the generator
# would then json.load.
tmp = OUT_PATH.with_name(OUT_PATH.name + ".tmp")
tmp.write_text(json.dumps({
"count": len(domains),
"updated": gist.get("updated_at"),
"description": gist.get("description"),
"source": f"https://gist.github.com/cdup07/{GIST_ID}",
"fetched_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"domains": domains,
}, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, OUT_PATH)
dropped = len(raw_lines) - len(domains)
print(f"Carson gist: {len(raw_lines)} lines -> {len(domains)} unique domains "
f"({dropped} blank/dupe/invalid dropped)")
print(f"Updated: {gist.get('updated_at')} | written: {OUT_PATH}")
if __name__ == "__main__":
main()