#!/usr/bin/env python3 """Generate a runtime hook probe JS file for tracing fetch/XHR/crypto/serializers. The probe writes evidence to window.__JSRA_TRACE__ using a unified event model with event_id, trace_id, parent_event_id, input_fingerprint, output_fingerprint. Supports SHA-256 fingerprinting and sensitive field redaction. Usage: python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js --max-events 5000 python3 scripts/emit_runtime_hook_probe.py --output generated/runtime_hook_probe.js --capture-raw """ from __future__ import annotations import argparse import json from pathlib import Path HOOK_REGISTRY = (Path(__file__).with_name("hook_registry.js")).read_text(encoding="utf-8") TEMPLATE = r'''(() => { "use strict"; const PROBE_ID = "runtime-hook"; const CONFIG = __CONFIG__; const root = window; __HOOK_REGISTRY__ const registry = getHookRegistry(root); if (window.__JSRA_TRACE__ && window.__JSRA_TRACE__.probe_id === PROBE_ID) { return window.__JSRA_TRACE__; } const sensitive = /pass(word)?|token|secret|authorization|cookie|session|credential/i; const nativeJSONStringify = JSON.stringify.bind(JSON); const nativeDigest = window.crypto && crypto.subtle ? registry.native(crypto.subtle, "digest").bind(crypto.subtle) : null; const nativeTextEncode = window.TextEncoder ? registry.native(TextEncoder.prototype, "encode") : null; const state = { probe_id: PROBE_ID, installedAt: Date.now(), events: [], maxEvents: CONFIG.maxEvents, dropped: 0 }; let eventCounter = 0; let activeTraceId = null; let libraryTimer = null; const runtimeLayers = []; let originalRequest = null; function uuid(prefix) { eventCounter += 1; return `${prefix}-${Date.now().toString(36)}-${eventCounter.toString(36)}`; } function stack() { try { return String(new Error().stack || "").split("\n").slice(2, 14); } catch (_) { return []; } } function stableString(value) { try { if (typeof value === "string") return value; if (value instanceof ArrayBuffer) return Array.from(new Uint8Array(value)).join(","); if (ArrayBuffer.isView(value)) return Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)).join(","); return nativeJSONStringify(value); } catch (_) { return String(value); } } function safeClone(value, depth = 0, seen = new WeakSet()) { if (value === null || value === undefined) return value; if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; if (typeof value === "bigint") return `${value}n`; if (typeof value === "function") return "[function]"; if (value instanceof ArrayBuffer) return `[ArrayBuffer:${value.byteLength}]`; if (ArrayBuffer.isView(value)) return `[${value.constructor.name}:${value.byteLength}]`; if (depth >= 4) return "[MaxDepth]"; if (seen.has(value)) return "[Circular]"; seen.add(value); if (Array.isArray(value)) return value.slice(0, 32).map(item => safeClone(item, depth + 1, seen)); const output = {}; for (const key of Object.keys(value).slice(0, 32)) { if (sensitive.test(key)) output[key] = ""; else { try { output[key] = safeClone(value[key], depth + 1, seen); } catch (_) { output[key] = "[Unreadable]"; } } } return output; } async function sha256(value) { try { if (!nativeDigest || !nativeTextEncode) return null; const data = nativeTextEncode.call(new TextEncoder(), stableString(value)); const digest = await nativeDigest("SHA-256", data); return "sha256:" + Array.from(new Uint8Array(digest)) .map(b => b.toString(16).padStart(2, "0")).join(""); } catch (_) { return null; } } function preview(value, key = "") { if (sensitive.test(key)) return ""; if (CONFIG.captureRaw) return safeClone(value); // JSON.stringify(undefined) returns undefined. Normalise it before // measuring/slicing so an optional crypto or WebSocket argument cannot // abort the entire target page. const text = String(stableString(value) ?? ""); return text.length > 96 ? text.slice(0, 96) + "…" : text; } async function record(type, detail = {}, parentEventId = null) { const event = { event_id: uuid("evt"), trace_id: detail.trace_id || activeTraceId || uuid("trace"), parent_event_id: parentEventId, type, function: detail.function || null, url: detail.url || null, input_preview: preview(detail.input, detail.key || ""), output_preview: preview(detail.output, detail.key || ""), input_fingerprint: detail.hasOwnProperty("input") ? await sha256(detail.input) : null, output_fingerprint: detail.hasOwnProperty("output") ? await sha256(detail.output) : null, stack: detail.stack || stack(), metadata: detail.metadata || {}, timestamp: Date.now() }; state.events.push(event); if (state.events.length > state.maxEvents) { state.events.splice(0, state.events.length - state.maxEvents); state.dropped += 1; } return event; } async function recordFields(traceId, body, parentEventId) { let parsed = null; if (body == null) return; try { if (typeof body === "string") { const trimmed = body.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) parsed = JSON.parse(trimmed); else parsed = Object.fromEntries(new URLSearchParams(body).entries()); } else if (body instanceof URLSearchParams) { parsed = Object.fromEntries(body.entries()); } else if (typeof body === "object" && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body)) { parsed = body; } } catch (_) { parsed = null; } async function walk(value, path, depth) { if (depth > 4) return; if (value && typeof value === "object") { const entries = Array.isArray(value) ? value.entries() : Object.entries(value); for (const [key, child] of entries) await walk(child, path ? `${path}.${key}` : String(key), depth + 1); } else { await record("network.field", { trace_id: traceId, function: "request-field", input: value, key: path, metadata: { path } }, parentEventId); } } if (parsed !== null) await walk(parsed, "$", 0); } function addRuntimeLayer(target, key, id, factory) { const layer = registry.add(target, key, id, factory); if (layer && !layer.duplicate) runtimeLayers.push({ target, key, id }); return layer; } function wrapMethod(target, key, type, name, options = {}) { if (!target || typeof target[key] !== "function") return; addRuntimeLayer(target, key, `runtime:${name}`, original => { const wrapped = function(...args) { if (registry.internalDepth > 0) return original.apply(this, args); if (typeof options.skip === "function" && options.skip.call(this, args)) return original.apply(this, args); const traceId = activeTraceId || uuid("trace"); activeTraceId = traceId; let result; try { result = original.apply(this, args); } catch (error) { record(type + ".error", { trace_id: traceId, function: name, input: args, output: String(error) }); throw error; } if (result && typeof result.then === "function") { return result.then(value => { record(type, { trace_id: traceId, function: name, input: args, output: value }); return value; }); } record(type, { trace_id: traceId, function: name, input: args, output: result }); return result; }; Object.defineProperty(wrapped, "__jsraWrapped", { value: true }); return wrapped; }); } // === Network hooks === if (typeof window.fetch === "function") { addRuntimeLayer(window, "fetch", "runtime:network.fetch", original => async function(input, init = {}) { if (registry.internalDepth > 0) return original.apply(this, arguments); const traceId = uuid("trace"); activeTraceId = traceId; const url = typeof input === "string" ? input : input && input.url; const body = init.body || (input && input.body) || null; const networkEvent = await record("network.fetch", { trace_id: traceId, function: "fetch", url, input: body, metadata: { method: init.method || "GET" } }); await recordFields(traceId, body, networkEvent.event_id); return original.apply(this, arguments); }); } if (window.XMLHttpRequest) { addRuntimeLayer(XMLHttpRequest.prototype, "open", "runtime:network.xhr.open", original => function(method, url) { if (registry.internalDepth > 0) return original.apply(this, arguments); this.__jsra = { method, url }; return original.apply(this, arguments); }); addRuntimeLayer(XMLHttpRequest.prototype, "send", "runtime:network.xhr.send", original => function(body) { if (registry.internalDepth > 0) return original.apply(this, arguments); const traceId = uuid("trace"); activeTraceId = traceId; record("network.xhr", { trace_id: traceId, function: "XMLHttpRequest.send", url: this.__jsra && this.__jsra.url, input: body, metadata: { method: this.__jsra && this.__jsra.method } }).then(event => recordFields(traceId, body, event.event_id)); return original.apply(this, arguments); }); } if (window.Request) { originalRequest = window.Request; window.Request = new Proxy(originalRequest, { construct(target, args, newTarget) { const instance = Reflect.construct(target, args, newTarget); record("network.request.construct", { function: "Request", url: instance.url, input: args[1] && args[1].body }); return instance; } }); } // === Serializer / encoding hooks === if (window.WebSocket) wrapMethod(WebSocket.prototype, "send", "network.websocket", "WebSocket.send", { skip: function() { try { const url = String(this && this.url || ""); return (CONFIG.controlWebSocketUrls || []).some(fragment => url.includes(fragment)); } catch (_) { return false; } } }); if (window.JSON) wrapMethod(JSON, "stringify", "serializer.json", "JSON.stringify"); if (window.URLSearchParams) wrapMethod(URLSearchParams.prototype, "toString", "serializer.urlsearchparams", "URLSearchParams.toString"); if (window.FormData) wrapMethod(FormData.prototype, "append", "serializer.formdata", "FormData.append"); if (window.TextEncoder) wrapMethod(TextEncoder.prototype, "encode", "encoding.text", "TextEncoder.encode"); if (window.TextDecoder) wrapMethod(TextDecoder.prototype, "decode", "encoding.text", "TextDecoder.decode"); wrapMethod(window, "btoa", "encoding.base64", "btoa"); wrapMethod(window, "atob", "encoding.base64", "atob"); // === WebCrypto hooks === if (window.crypto && crypto.subtle) { ["encrypt", "decrypt", "digest", "sign", "verify", "deriveBits", "deriveKey"].forEach(name => { wrapMethod(crypto.subtle, name, "crypto.webcrypto", `crypto.subtle.${name}`); }); } // === Library hooks (CryptoJS, JSEncrypt, sm2/sm3/sm4) === function installLibraryHooks() { try { if (window.CryptoJS) { ["AES", "DES", "TripleDES", "Rabbit", "RC4"].forEach(alg => { const obj = window.CryptoJS[alg]; if (obj) ["encrypt", "decrypt"].forEach(op => wrapMethod(obj, op, "crypto.cryptojs", `CryptoJS.${alg}.${op}`)); }); ["MD5", "SHA1", "SHA256", "SHA512", "HmacSHA256"].forEach(name => wrapMethod(window.CryptoJS, name, "crypto.cryptojs", `CryptoJS.${name}`)); } if (window.JSEncrypt && window.JSEncrypt.prototype) { ["encrypt", "decrypt", "sign", "verify"].forEach(op => wrapMethod(window.JSEncrypt.prototype, op, "crypto.jsencrypt", `JSEncrypt.${op}`)); } if (window.sm2) ["doEncrypt", "doDecrypt", "doSignature", "doVerifySignature"].forEach(op => wrapMethod(window.sm2, op, "crypto.sm", `sm2.${op}`)); if (window.sm3) wrapMethod(window, "sm3", "crypto.sm", "sm3"); if (window.sm4) ["encrypt", "decrypt"].forEach(op => wrapMethod(window.sm4, op, "crypto.sm", `sm4.${op}`)); } catch (_) {} } installLibraryHooks(); // Re-install periodically to catch late-loading libraries libraryTimer = setInterval(installLibraryHooks, 2000); // === Public API === window.__JSRA_TRACE__ = { probe_id: PROBE_ID, state, markTrace(label = "manual") { activeTraceId = uuid(label); return activeTraceId; }, clearTrace() { activeTraceId = null; }, async record(type, detail) { return record(type, detail); }, export() { return { probe_id: PROBE_ID, exportedAt: Date.now(), events: state.events.slice() }; }, dump() { return nativeJSONStringify(this.export()); }, uninstall() { if (libraryTimer) clearInterval(libraryTimer); for (const layer of runtimeLayers.slice().reverse()) registry.remove(layer.target, layer.key, layer.id); if (originalRequest) window.Request = originalRequest; } }; console.info("[JSRA] runtime probe installed"); return window.__JSRA_TRACE__; })(); ''' def main() -> int: parser = argparse.ArgumentParser(description="Emit JSRA runtime hook probe.") parser.add_argument("--output", required=True, help="Output JS file path.") parser.add_argument("--max-events", type=int, default=3000, help="Max events to keep.") parser.add_argument("--capture-raw", action="store_true", help="Store raw values instead of previews.") parser.add_argument("--params", default="", help="Comma-separated target parameter names to watch (for documentation).") parser.add_argument("--control-websocket-url", action="append", default=[], help="URL fragment for a JSRPC/control WebSocket to leave untouched.") args = parser.parse_args() control_urls = args.control_websocket_url or ["127.0.0.1:12080", "localhost:12080"] config = {"maxEvents": max(100, args.max_events), "captureRaw": bool(args.capture_raw), "controlWebSocketUrls": control_urls} # --params is retained for backward compatibility but not used in the probe # The probe watches all crypto-related activity automatically. if args.params: config["targetParams"] = [p.strip() for p in args.params.split(",") if p.strip()] content = TEMPLATE.replace("__CONFIG__", json.dumps(config)) content = content.replace("__HOOK_REGISTRY__", HOOK_REGISTRY) path = Path(args.output) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") print(json.dumps({"status": "ok", "output": str(path)}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())