joy-cli/scripts/manhuang/enrich-open-hits.py
feibisi 2c6281d041 feat(manhuang): supply-chain dashboard, probes, and side-cache data
Expand manhuang collect/UI (tabs, markets, guishi/quote probes), add
configs and launchd helpers, ops handbooks, and scripts for channel
validation and price/threat enrichment. Keys stay in keys.env.example only.
2026-07-13 02:08:29 +08:00

421 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Correlate open feeds against own scope/stack → hits + actions.
Appends to open-summary.json:
hits: { brand_ioc, kev_stack, cc_history, phishing_lookalike }
actions: [{priority, kind, title, detail, next, evidence[]}]
scope / stack meta
"""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
OUT = Path.home() / "data/manhuang/feeds/open"
SUMMARY = Path.home() / "data/manhuang/feeds/open-summary.json"
RECENT_KEV = Path.home() / "data/manhuang/feeds/cisa-kev-recent.json"
# Own surface
DOMAINS = (
"feibisi.com",
"weixiaoduo.com",
"cravatar.com",
"wenpai.org",
"feicode.com",
)
BRAND_TOKENS = (
"feibisi", "weixiaoduo", "cravatar", "wenpai", "feicode",
"菲比斯", "文派", "微晓多",
)
# Stack we actually care about (WP 生态 + 自建)
STACK = (
("wordpress", "WordPress"),
("woocommerce", "WooCommerce"),
("elementor", "Elementor"),
("php", "PHP"),
("nginx", "nginx"),
("apache", "Apache"),
("openssl", "OpenSSL"),
("openssh", "OpenSSH"),
("linux", "Linux kernel"),
("docker", "Docker"),
("joomla", "Joomla"),
("langflow", "Langflow"),
("mysql", "MySQL"),
("mariadb", "MariaDB"),
("redis", "Redis"),
("nodejs", "Node.js"),
("python", "Python"),
("curl", "curl"),
("git", "Git"),
("kubernetes", "Kubernetes"),
("chrome", "Chrome/Chromium"),
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _host(url: str) -> str:
try:
h = (urlparse(url).hostname or "").lower()
return h[4:] if h.startswith("www.") else h
except Exception:
return ""
def _match_domain(text: str) -> list[str]:
t = (text or "").lower()
hit = []
for d in DOMAINS:
if d in t:
hit.append(d)
return hit
def _match_brand_token(text: str) -> list[str]:
t = text or ""
tl = t.lower()
hit = []
for tok in BRAND_TOKENS:
if tok.isascii():
if tok in tl:
hit.append(tok)
else:
if tok in t:
hit.append(tok)
return hit
def scan_url_list(path: Path, source: str, limit_scan: int = 200000) -> list[dict]:
"""Return brand/domain hits from a URL-per-line or CSV file."""
if not path.is_file():
return []
hits = []
seen = set()
n = 0
for raw in path.read_text(errors="ignore").splitlines():
n += 1
if n > limit_scan:
break
line = raw.strip()
if not line or line.startswith("#") or line.startswith(";"):
continue
# urlhaus csv: "id","date","url",...
if "," in line and line[0] == '"':
parts = line.split(",")
url = parts[2].strip().strip('"') if len(parts) >= 3 else line
else:
url = line.split()[0] if line.split() else line
if not url.startswith("http") and "." not in url:
continue
blob = url
doms = _match_domain(blob)
brands = _match_brand_token(blob) if not doms else []
if not doms and not brands:
# lookalike: brand token in host but not exact domain
host = _host(url if url.startswith("http") else "http://" + url)
brands = _match_brand_token(host)
if not brands:
continue
kind = "lookalike"
else:
kind = "brand_ioc" if doms else "lookalike"
key = (source, url[:120])
if key in seen:
continue
seen.add(key)
hits.append({
"kind": kind,
"source": source,
"url": url[:200],
"host": _host(url if url.startswith("http") else "http://" + url),
"domains": doms,
"brands": brands,
"level": "crit" if doms else "warn",
})
if len(hits) >= 40:
break
return hits
def scan_kev_stack(path: Path) -> list[dict]:
if not path.is_file():
return []
try:
data = json.loads(path.read_text())
except Exception:
return []
vulns = data.get("vulnerabilities") or []
# prefer recent 90 days-ish by sorting dateAdded
vulns = sorted(vulns, key=lambda v: v.get("dateAdded") or "", reverse=True)
hits = []
for v in vulns[:400]: # scan recent-ish catalog slice
vendor = (v.get("vendorProject") or "")
product = (v.get("product") or "")
name = (v.get("vulnerabilityName") or "")
blob = f"{vendor} {product} {name}".lower()
matched = []
for key, label in STACK:
# word-ish match
if key == "php" and not re.search(r"\bphp\b", blob):
continue
if key == "git" and not re.search(r"\bgit\b", blob):
continue
if key == "curl" and "curl" not in blob:
continue
if key not in blob and label.lower() not in blob:
continue
matched.append(label)
if not matched:
continue
hits.append({
"kind": "kev_stack",
"source": "cisa-kev",
"cve": v.get("cveID"),
"vendor": vendor,
"product": product,
"name": name[:100],
"dateAdded": v.get("dateAdded"),
"stack": matched,
"level": "warn",
"ransomware": (v.get("knownRansomwareCampaignUse") or "").lower() == "known",
})
if len(hits) >= 25:
break
return hits
def scan_cc() -> list[dict]:
hits = []
for dom in DOMAINS:
p = OUT / f"cc-cdx-{dom}.ndjson"
if not p.is_file() or p.stat().st_size < 5:
hits.append({
"kind": "cc_history",
"source": "commoncrawl",
"domain": dom,
"n": 0,
"level": "info",
"status": "empty_or_fail",
"sample": [],
})
continue
samples = []
n = 0
for line in p.read_text(errors="ignore").splitlines():
line = line.strip()
if not line:
continue
n += 1
if len(samples) < 3:
try:
o = json.loads(line)
samples.append({
"url": (o.get("url") or "")[:100],
"ts": (o.get("timestamp") or "")[:14],
"status": o.get("status"),
})
except Exception:
pass
hits.append({
"kind": "cc_history",
"source": "commoncrawl",
"domain": dom,
"n": n,
"level": "info",
"status": "ok" if n else "empty",
"sample": samples,
})
return hits
def build_actions(brand_hits, kev_hits, cc_hits) -> list[dict]:
actions = []
# brand IoCs first
by_dom: dict[str, list] = {}
for h in brand_hits:
if h.get("kind") != "brand_ioc":
continue
for d in h.get("domains") or []:
by_dom.setdefault(d, []).append(h)
for d, items in by_dom.items():
actions.append({
"priority": 1,
"kind": "brand_ioc",
"level": "crit",
"title": f"品牌域名出现在威胁情报:{d}",
"detail": f"{len(items)} 条来自 " + ", ".join(sorted({i['source'] for i in items})),
"next": f"立刻核验1) 是否钓鱼仿冒 2) 是否自家被挂马 URL 3) Google Safe Browsing / urlscan 查 {d}",
"evidence": [{"source": i["source"], "url": i.get("url"), "host": i.get("host")} for i in items[:5]],
"domain": d,
})
lookalikes = [h for h in brand_hits if h.get("kind") == "lookalike"]
if lookalikes:
actions.append({
"priority": 2,
"kind": "lookalike",
"level": "warn",
"title": f"疑似品牌仿冒域名 {len(lookalikes)}",
"detail": ", ".join(sorted({h.get("host") or "" for h in lookalikes})[:8]),
"next": "登记仿冒域名;视情况投诉注册商 / 加入自有 block 监控",
"evidence": [{"source": h["source"], "url": h.get("url"), "host": h.get("host")} for h in lookalikes[:8]],
})
# KEV stack — group by stack label
if kev_hits:
ransomware = [k for k in kev_hits if k.get("ransomware")]
top = kev_hits[:8]
stacks = sorted({s for k in kev_hits for s in (k.get("stack") or [])})
actions.append({
"priority": 2 if not ransomware else 1,
"kind": "kev_stack",
"level": "crit" if ransomware else "warn",
"title": f"KEV 命中技术栈 {len(kev_hits)} 条({', '.join(stacks[:6])}",
"detail": "; ".join(
f"{k.get('cve')} {k.get('vendor')}/{k.get('product')}" for k in top[:5]
),
"next": "对照自有主机/容器版本:是否在跑对应产品;有则排补丁窗口,无则忽略并标记已评估",
"evidence": [
{
"cve": k.get("cve"),
"product": f"{k.get('vendor')}/{k.get('product')}",
"stack": k.get("stack"),
"dateAdded": k.get("dateAdded"),
"ransomware": k.get("ransomware"),
}
for k in top
],
})
# CC — only action if fail or zero when expected
fails = [c for c in cc_hits if c.get("status") != "ok"]
ok_n = sum(c.get("n") or 0 for c in cc_hits if c.get("status") == "ok")
if fails:
actions.append({
"priority": 3,
"kind": "cc_history",
"level": "info",
"title": f"Common Crawl 部分域名无快照({len(fails)}",
"detail": ", ".join(c["domain"] for c in fails),
"next": "多为索引失败或新域;下轮 cron 重试。无需紧急处理。",
"evidence": fails,
})
elif ok_n:
actions.append({
"priority": 4,
"kind": "cc_history",
"level": "info",
"title": f"自有域名 CC 历史基线 OK{ok_n} 条样本)",
"detail": ", ".join(f"{c['domain']}={c['n']}" for c in cc_hits),
"next": "仅作暴露面基线;突变时再查。无需动作。",
"evidence": [{"domain": c["domain"], "n": c["n"]} for c in cc_hits],
})
# no brand hits = healthy signal
if not by_dom and not lookalikes:
actions.append({
"priority": 5,
"kind": "clear",
"level": "ok",
"title": "公开威胁源未命中自有品牌域名",
"detail": "OpenPhish / URLhaus / DigitalSide 等本轮扫描无 feibisi/wenpai/… 直接命中",
"next": "保持 6h 拉取;有命中再升级处理。",
"evidence": [],
})
actions.sort(key=lambda a: a.get("priority", 99))
return actions
def main() -> int:
brand: list[dict] = []
for fname, src in (
("openphish.txt", "openphish"),
("urlhaus-recent.csv", "urlhaus-csv"),
("digitalside-urls.txt", "digitalside-urls"),
("digitalside-domains.txt", "digitalside-domains"),
):
brand.extend(scan_url_list(OUT / fname, src))
kev_path = OUT / "cisa-kev.json"
kev = scan_kev_stack(kev_path)
cc = scan_cc()
actions = build_actions(brand, kev, cc)
hits = {
"brand_ioc": [h for h in brand if h.get("kind") == "brand_ioc"],
"lookalike": [h for h in brand if h.get("kind") == "lookalike"],
"kev_stack": kev,
"cc_history": cc,
"counts": {
"brand_ioc": sum(1 for h in brand if h.get("kind") == "brand_ioc"),
"lookalike": sum(1 for h in brand if h.get("kind") == "lookalike"),
"kev_stack": len(kev),
"cc_domains_ok": sum(1 for c in cc if c.get("status") == "ok"),
"actions": len(actions),
"actions_actionable": sum(1 for a in actions if a.get("priority", 9) <= 3 and a.get("kind") != "clear"),
},
}
# action-derived signals for live feed (only high priority)
act_signals = []
for a in actions:
if a.get("priority", 9) > 3:
continue
if a.get("kind") in ("clear",):
continue
act_signals.append({
"ts": _now()[:10],
"layer": "威胁" if a.get("kind") in ("brand_ioc", "lookalike", "kev_stack") else "深网",
"source": a.get("kind"),
"level": a.get("level") if a.get("level") in ("crit", "warn", "info") else "warn",
"title": a.get("title"),
"detail": (a.get("next") or "")[:100],
})
summary = {}
if SUMMARY.is_file():
try:
summary = json.loads(SUMMARY.read_text())
except Exception:
summary = {}
if not isinstance(summary, dict):
summary = {}
summary["ts_hits"] = _now()
summary["scope"] = {"domains": list(DOMAINS), "brands": list(BRAND_TOKENS)}
summary["stack"] = [label for _, label in STACK]
summary["hits"] = hits
summary["actions"] = actions
# prepend action signals, keep other signals but cap
old_signals = [s for s in (summary.get("signals") or []) if isinstance(s, dict)]
# drop previous auto action signals
old_signals = [s for s in old_signals if s.get("source") not in ("brand_ioc", "lookalike", "kev_stack", "cc_history")]
summary["signals"] = (act_signals + old_signals)[:40]
summary.setdefault("counts", {})
summary["counts"]["hits_brand"] = hits["counts"]["brand_ioc"]
summary["counts"]["hits_lookalike"] = hits["counts"]["lookalike"]
summary["counts"]["hits_kev_stack"] = hits["counts"]["kev_stack"]
summary["counts"]["actions"] = hits["counts"]["actions"]
summary["counts"]["actions_actionable"] = hits["counts"]["actions_actionable"]
summary["counts"]["signals"] = len(summary["signals"])
SUMMARY.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
print(
"hits ok brand=%s lookalike=%s kev_stack=%s actions=%s actionable=%s"
% (
hits["counts"]["brand_ioc"],
hits["counts"]["lookalike"],
hits["counts"]["kev_stack"],
hits["counts"]["actions"],
hits["counts"]["actions_actionable"],
)
)
for a in actions[:6]:
print(" -", a.get("priority"), a.get("level"), a.get("title"))
return 0
if __name__ == "__main__":
raise SystemExit(main())