206 lines
8.2 KiB
Python
206 lines
8.2 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Meta Accounts Center change-detection harness.
|
||
|
|
|
||
|
|
Captures a structural snapshot of the accountscenter.meta.com auth flow
|
||
|
|
via CDP inside a NetVM netns, diffs against the stored baseline.
|
||
|
|
|
||
|
|
Outcomes:
|
||
|
|
PASS - matches baseline (or first run establishes it)
|
||
|
|
CHANGED - structural diff detected; needs human review, baseline untouched
|
||
|
|
FAIL - automation itself broke (browser/CDP/network error)
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
meta-ac-snapshot.py [--node NAME] [--promote] [--snapshot-dir DIR]
|
||
|
|
|
||
|
|
--node NetVM node to run in (default: phone)
|
||
|
|
--promote after human review, promote the latest snapshot to baseline
|
||
|
|
--snapshot-dir where snapshots live (default: ~/Projects/NetVM/snapshots/meta-ac)
|
||
|
|
"""
|
||
|
|
import argparse, base64, datetime, json, os, subprocess, sys, time
|
||
|
|
import urllib.parse, urllib.request
|
||
|
|
|
||
|
|
CDP_PORT = 19744
|
||
|
|
|
||
|
|
def log(*a):
|
||
|
|
print(*a, flush=True)
|
||
|
|
|
||
|
|
def ns_exec(node, cmd):
|
||
|
|
return subprocess.run(
|
||
|
|
["sudo", "-n", "ip", "netns", "exec", f"warp-{node}"] + cmd,
|
||
|
|
capture_output=True, text=True)
|
||
|
|
|
||
|
|
def norm_url(u):
|
||
|
|
"""Strip query/fragment — nonces change every visit."""
|
||
|
|
p = urllib.parse.urlparse(u)
|
||
|
|
return f"{p.scheme}://{p.host}{p.path}" if hasattr(p, 'host') else f"{p.scheme}://{p.hostname}{p.path}"
|
||
|
|
|
||
|
|
def main():
|
||
|
|
ap = argparse.ArgumentParser()
|
||
|
|
ap.add_argument("--node", default="phone")
|
||
|
|
ap.add_argument("--promote", action="store_true")
|
||
|
|
ap.add_argument("--snapshot-dir", default=os.path.expanduser(
|
||
|
|
"~/Projects/NetVM/snapshots/meta-ac"))
|
||
|
|
args = ap.parse_args()
|
||
|
|
os.makedirs(args.snapshot_dir, exist_ok=True)
|
||
|
|
baseline_path = os.path.join(args.snapshot_dir, "baseline.json")
|
||
|
|
|
||
|
|
if args.promote:
|
||
|
|
snaps = sorted(f for f in os.listdir(args.snapshot_dir)
|
||
|
|
if f.startswith("snap-") and f.endswith(".json"))
|
||
|
|
if not snaps:
|
||
|
|
log("no snapshots to promote"); return 2
|
||
|
|
latest = os.path.join(args.snapshot_dir, snaps[-1])
|
||
|
|
data = json.load(open(latest))
|
||
|
|
data["promoted_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||
|
|
json.dump(data, open(baseline_path, "w"), indent=2)
|
||
|
|
log(f"promoted {snaps[-1]} -> baseline.json")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
profile_dir = "/tmp/meta-ac-snap-profile"
|
||
|
|
subprocess.run(["rm", "-rf", profile_dir])
|
||
|
|
os.makedirs(profile_dir, exist_ok=True)
|
||
|
|
|
||
|
|
def http(path):
|
||
|
|
with urllib.request.urlopen(
|
||
|
|
f"http://127.0.0.1:{CDP_PORT}{path}", timeout=5) as r:
|
||
|
|
return json.loads(r.read())
|
||
|
|
|
||
|
|
# launch chromium inside the netns via a wrapper script
|
||
|
|
wrapper = "/tmp/meta-ac-snap-run.py"
|
||
|
|
open(wrapper, "w").write(WRAPPER_SRC)
|
||
|
|
log(f"launching chromium in warp-{args.node} (CDP {CDP_PORT})...")
|
||
|
|
proc = subprocess.Popen(
|
||
|
|
["sudo", "-n", "ip", "netns", "exec", f"warp-{args.node}",
|
||
|
|
"python3", wrapper, str(CDP_PORT), profile_dir],
|
||
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||
|
|
try:
|
||
|
|
out, _ = proc.communicate(timeout=120)
|
||
|
|
except subprocess.TimeoutExpired:
|
||
|
|
proc.kill(); log("FAIL: harness timed out"); return 1
|
||
|
|
print(out)
|
||
|
|
# wrapper prints SNAPSHOT_JSON=<json> on success
|
||
|
|
snap = None
|
||
|
|
for line in out.splitlines():
|
||
|
|
if line.startswith("SNAPSHOT_JSON="):
|
||
|
|
snap = json.loads(line[len("SNAPSHOT_JSON="):])
|
||
|
|
if not snap:
|
||
|
|
log("FAIL: no snapshot captured"); return 1
|
||
|
|
|
||
|
|
snap["node"] = args.node
|
||
|
|
snap["captured_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||
|
|
|
||
|
|
# egress ip for context
|
||
|
|
try:
|
||
|
|
r = ns_exec(args.node, ["curl", "-s", "--max-time", "8",
|
||
|
|
"https://api.ipify.org"])
|
||
|
|
snap["egress_ip"] = r.stdout.strip()
|
||
|
|
except Exception:
|
||
|
|
snap["egress_ip"] = "unknown"
|
||
|
|
|
||
|
|
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||
|
|
snap_path = os.path.join(args.snapshot_dir, f"snap-{ts}.json")
|
||
|
|
json.dump(snap, open(snap_path, "w"), indent=2)
|
||
|
|
log(f"snapshot saved: {snap_path}")
|
||
|
|
|
||
|
|
if not os.path.exists(baseline_path):
|
||
|
|
json.dump(snap, open(baseline_path, "w"), indent=2)
|
||
|
|
log("PASS: baseline established (first run)")
|
||
|
|
return 0
|
||
|
|
|
||
|
|
baseline = json.load(open(baseline_path))
|
||
|
|
diffs = diff_snapshots(baseline, snap)
|
||
|
|
if not diffs:
|
||
|
|
log("PASS: matches baseline")
|
||
|
|
return 0
|
||
|
|
log("CHANGED: structural diff detected (baseline untouched):")
|
||
|
|
for d in diffs:
|
||
|
|
log(f" - {d}")
|
||
|
|
log("review with: diff baseline.json snap-<ts>.json")
|
||
|
|
log("promote after review with: --promote")
|
||
|
|
return 3
|
||
|
|
|
||
|
|
def diff_snapshots(base, snap):
|
||
|
|
diffs = []
|
||
|
|
b_chain = [norm_url(u) for u in base.get("redirect_chain", [])]
|
||
|
|
s_chain = [norm_url(u) for u in snap.get("redirect_chain", [])]
|
||
|
|
if b_chain != s_chain:
|
||
|
|
diffs.append(f"redirect_chain changed: {b_chain} -> {s_chain}")
|
||
|
|
for key in ("forms", "inputs", "buttons"):
|
||
|
|
b = sorted(base.get("dom_markers", {}).get(key, []))
|
||
|
|
s = sorted(snap.get("dom_markers", {}).get(key, []))
|
||
|
|
if b != s:
|
||
|
|
added = [x for x in s if x not in b]
|
||
|
|
removed = [x for x in b if x not in s]
|
||
|
|
diffs.append(f"dom_markers.{key}: added={added} removed={removed}")
|
||
|
|
if base.get("final_title") != snap.get("final_title"):
|
||
|
|
diffs.append(f"final_title: {base.get('final_title')!r} -> {snap.get('final_title')!r}")
|
||
|
|
return diffs
|
||
|
|
|
||
|
|
WRAPPER_SRC = '''
|
||
|
|
import json, subprocess, sys, time, os, urllib.request, base64
|
||
|
|
CDP_PORT = int(sys.argv[1])
|
||
|
|
PROFILE_DIR = sys.argv[2]
|
||
|
|
def http(path):
|
||
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{CDP_PORT}{path}", timeout=5) as r:
|
||
|
|
return json.loads(r.read())
|
||
|
|
logf = open("/tmp/meta-ac-snap-chrome.log", "w")
|
||
|
|
proc = subprocess.Popen(["chromium", "--headless=new", "--disable-gpu", "--no-sandbox",
|
||
|
|
"--disable-dev-shm-usage", f"--user-data-dir={PROFILE_DIR}",
|
||
|
|
f"--remote-debugging-port={CDP_PORT}", "--remote-allow-origins=*", "about:blank"],
|
||
|
|
stdout=logf, stderr=subprocess.STDOUT)
|
||
|
|
try:
|
||
|
|
for i in range(30):
|
||
|
|
try:
|
||
|
|
ver = http("/json/version")
|
||
|
|
if "webSocketDebuggerUrl" in ver: break
|
||
|
|
except Exception: pass
|
||
|
|
time.sleep(1)
|
||
|
|
else:
|
||
|
|
print("FAIL: CDP never came up"); sys.exit(1)
|
||
|
|
import websocket
|
||
|
|
bws = websocket.create_connection(ver["webSocketDebuggerUrl"], timeout=20)
|
||
|
|
bws.send(json.dumps({"id": 1, "method": "Target.createTarget",
|
||
|
|
"params": {"url": "https://accountscenter.meta.com"}}))
|
||
|
|
target_id = json.loads(bws.recv())["result"]["targetId"]
|
||
|
|
bws.close()
|
||
|
|
# redirect chain: seed with the navigation target (we always start
|
||
|
|
# there), then poll for where Meta sends us. Seeding fixes the race
|
||
|
|
# where a fast redirect is missed by the poll interval.
|
||
|
|
START_URL = "https://accountscenter.meta.com/"
|
||
|
|
chain, seen = [START_URL], {START_URL}
|
||
|
|
for _ in range(24):
|
||
|
|
time.sleep(2)
|
||
|
|
for t in http("/json/list"):
|
||
|
|
if t.get("id") == target_id or "meta.com" in t.get("url", ""):
|
||
|
|
u = t["url"]
|
||
|
|
if u not in seen:
|
||
|
|
seen.add(u); chain.append(u)
|
||
|
|
title = t.get("title", "")
|
||
|
|
break
|
||
|
|
# dom markers from the final tab
|
||
|
|
tab_ws = None
|
||
|
|
for t in http("/json/list"):
|
||
|
|
if t.get("id") == target_id or "meta.com" in t.get("url", ""):
|
||
|
|
tab_ws = t["webSocketDebuggerUrl"]; final_url = t["url"]; break
|
||
|
|
ws = websocket.create_connection(tab_ws, timeout=20)
|
||
|
|
js = """JSON.stringify({
|
||
|
|
forms: [...document.forms].map(f => f.id || f.name || '(anon)'),
|
||
|
|
inputs: [...document.querySelectorAll('input')].map(i => i.name || i.type || '(anon)'),
|
||
|
|
buttons: [...document.querySelectorAll('button, [role=button]')].map(b => (b.innerText||'').trim()).filter(Boolean)
|
||
|
|
})"""
|
||
|
|
ws.send(json.dumps({"id": 1, "method": "Runtime.evaluate",
|
||
|
|
"params": {"expression": js, "returnByValue": True}}))
|
||
|
|
markers = json.loads(json.loads(ws.recv())["result"]["result"]["value"])
|
||
|
|
# dedupe buttons, keep order
|
||
|
|
markers["buttons"] = list(dict.fromkeys(markers["buttons"]))
|
||
|
|
ws.close()
|
||
|
|
snap = {"redirect_chain": chain, "final_url": final_url,
|
||
|
|
"final_title": title, "dom_markers": markers}
|
||
|
|
print("SNAPSHOT_JSON=" + json.dumps(snap))
|
||
|
|
finally:
|
||
|
|
proc.terminate()
|
||
|
|
'''
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|