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
208 lines
8.4 KiB
Python
208 lines
8.4 KiB
Python
#!/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()
|