First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
harvest_static.py <BASE_URL> <OUTDIR>
参考模板 — 非通用成品。执行前须按目标站点调整,常见改动:
- extract_endpoints 正则(endpoint 方言)
- webpack/Vite manifest 解析逻辑
- 微前端 publicPath、重试策略
Static SPA bundle harvester. Framework-agnostic; tuned for webpack + Vite.
1. Fetch entry HTML, collect script/module references.
2. Parse the runtime chunk manifest(s) and download EVERY chunk (not just the
ones in HTML), looping until no new chunk ids appear. Handles multiple
micro-frontend runtimes, each with its own publicPath.
3. Extract API endpoints and route paths from all downloaded JS.
Stdlib only. TLS verification is disabled (recon against self-signed/internal hosts).
"""
import sys, os, re, ssl, json, urllib.request, urllib.parse
from concurrent.futures import ThreadPoolExecutor
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
UA = "Mozilla/5.0 (spa-api-recon)"
def fetch(url, binary=False):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, context=CTX, timeout=25) as r:
data = r.read()
return (data if binary else data.decode("utf-8", "ignore")), r.status
except Exception as e:
code = getattr(e, "code", 0)
return "", code
def origin_of(u):
p = urllib.parse.urlparse(u)
return f"{p.scheme}://{p.netloc}"
# ---- chunk manifest parsing -------------------------------------------------
def matched_block(s, end_brace_idx):
"""Walk back from a '}' index to its matching '{' and return the object body."""
depth = 0; j = end_brace_idx
while j >= 0:
if s[j] == '}': depth += 1
elif s[j] == '{':
depth -= 1
if depth == 0: return s[j:end_brace_idx+1]
j -= 1
return ""
def parse_chunk_maps(js_text):
"""Return list of (publicPath_hint, {id: hash}) for every `}[x]+".js"` map
(webpack __webpack_require__.u) found in the text."""
maps = []
# publicPath hints in this file: .p="..." or publicPath="..."
pubs = re.findall(r'(?:\.p|publicPath)\s*=\s*"([^"]*)"', js_text)
for m in re.finditer(r'\}\[[A-Za-z_$]\]\s*\+\s*"\.js"', js_text):
body = matched_block(js_text, m.start())
pairs = re.findall(r'(\d+):"([0-9a-fA-F]{6,16})"', body)
if pairs:
maps.append((pubs, dict(pairs)))
# Vite style: __vite__mapDeps map of "assets/xx.js"
for fn in re.findall(r'"(assets/[^"]+\.js)"', js_text):
maps.append((["/"], {"__vite__": fn}))
return maps
def chunk_urls(base, js_text):
"""Yield absolute chunk URLs reconstructable from this file's manifest(s)."""
origin = origin_of(base)
out = set()
for pubs, idmap in parse_chunk_maps(js_text):
paths = pubs or ["/assets/", "/"]
for cid, h in idmap.items():
if cid == "__vite__":
fn = h # already "assets/xx.js"
for pp in paths:
out.add(urllib.parse.urljoin(origin + "/", fn))
continue
for pp in paths:
if pp.startswith("http"): bareorigin = ""; prefix = pp
else: bareorigin = origin; prefix = pp if pp.startswith("/") else "/"+pp
if not prefix.endswith("/"): prefix += "/"
out.add(f"{bareorigin}{prefix}{cid}.{h}.js")
return out
# ---- endpoint / route extraction -------------------------------------------
API_HINT = re.compile(r'/(?:api|rest|service|services|gateway|graphql|v\d|web|admin|backend|open)\b', re.I)
ASSET_EXT = re.compile(r'\.(js|css|png|jpe?g|svg|gif|woff2?|ttf|ico|map|json|mp4|webp)(\?|$)', re.I)
def extract_endpoints(js_text):
paths = set()
# quoted ('/...'), double-quoted, and backtick template paths
for m in re.findall(r'''["'`](/[A-Za-z0-9_\-./{}$:]+)["'`]''', js_text):
paths.add(m)
# concatenation heads: "/api/x/" + var
for m in re.findall(r'''["'](/[A-Za-z0-9_\-./]+/)["']\s*\+''', js_text):
paths.add(m)
api, other = set(), set()
for p in paths:
if ASSET_EXT.search(p): continue
if p.count('/') < 2 and not API_HINT.search(p): continue
(api if API_HINT.search(p) else other).add(p)
return api, other
def extract_routes(js_text):
r = set()
for key in ('path', 'to', 'redirect', 'href'):
for m in re.findall(key + r'''\s*:\s*["'](/[A-Za-z0-9_\-/:]*)["']''', js_text):
if not ASSET_EXT.search(m) and not API_HINT.search(m):
r.add(m)
return r
# ---- main -------------------------------------------------------------------
def main():
if len(sys.argv) < 3:
print("usage: harvest_static.py <BASE_URL> <OUTDIR>"); sys.exit(1)
base, outdir = sys.argv[1], sys.argv[2]
if not base.startswith("http"): base = "https://" + base
jsdir = os.path.join(outdir, "js"); os.makedirs(jsdir, exist_ok=True)
print(f"[*] entry: {base}")
html, status = fetch(base)
open(os.path.join(outdir, "index.html"), "w").write(html)
origin = origin_of(base)
# initial scripts from HTML
srcs = set(re.findall(r'<script[^>]+src="([^"]+\.js[^"]*)"', html))
srcs |= set(re.findall(r'(?:src|href)="([^"]*\.js)"', html))
seed = set()
for s in srcs:
seed.add(s if s.startswith("http") else urllib.parse.urljoin(base, s))
print(f"[*] {len(seed)} scripts referenced in HTML")
have = {} # url -> local path
def dl(url):
fn = os.path.basename(urllib.parse.urlparse(url).path)
if not fn.endswith(".js"): return None
lp = os.path.join(jsdir, fn)
if url in have: return have[url]
data, st = fetch(url, binary=True)
if st == 200 and data and not data[:15].lstrip().startswith(b"<"):
open(lp, "wb").write(data); have[url] = lp; return lp
return None
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(dl, seed))
# iteratively expand via chunk manifests (chunks reference more chunks)
seen_urls = set(have.keys()); frontier = list(have.values())
rounds = 0
while frontier and rounds < 6:
rounds += 1
new_urls = set()
for lp in frontier:
try: txt = open(lp, encoding="utf-8", errors="ignore").read()
except: continue
for cu in chunk_urls(base, txt):
if cu not in seen_urls: new_urls.add(cu)
seen_urls |= new_urls
if not new_urls: break
print(f"[*] round {rounds}: {len(new_urls)} new chunk urls from manifest")
before = set(have.values())
with ThreadPoolExecutor(max_workers=24) as ex:
list(ex.map(dl, new_urls))
frontier = [p for p in have.values() if p not in before]
# retry-once any manifest chunk that 404'd (transient failures are real)
all_manifest = set()
for lp in list(have.values()):
try: all_manifest |= chunk_urls(base, open(lp, encoding="utf-8", errors="ignore").read())
except: pass
missing = [u for u in all_manifest if u not in have]
if missing:
with ThreadPoolExecutor(max_workers=24) as ex:
list(ex.map(dl, missing))
still = [u for u in all_manifest if u not in have]
print(f"[*] manifest chunks: {len(all_manifest)} | downloaded {len(have)} | "
f"unreachable {len(still)} (CSS-only / undeployed)")
print(f"[+] total JS downloaded: {len(have)}")
# extract from everything
api, other, routes = set(), set(), set()
for lp in have.values():
try: txt = open(lp, encoding="utf-8", errors="ignore").read()
except: continue
a, o = extract_endpoints(txt); api |= a; other |= o
routes |= extract_routes(txt)
def dump(name, items):
path = os.path.join(outdir, name)
open(path, "w").write("\n".join(sorted(items)))
return path
dump("api_static.txt", api)
dump("paths_other.txt", other)
dump("routes.txt", routes)
dump("chunkmap.txt", sorted(os.path.basename(u) for u in all_manifest))
print(f"[+] api endpoints: {len(api)} (api_static.txt)")
print(f"[+] other paths: {len(other)} (paths_other.txt)")
print(f"[+] route paths: {len(routes)} (routes.txt)")
print(f"[+] outdir: {outdir}")
print("\n[next] reverse the 3 gate facts (see reference.md), fill config.json, "
"then: node runtime_harvest.js config.json")
if __name__ == "__main__":
main()