Files

533 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate JSRPC injection code from analysis_result.json.
Supports parameter-based and transforms-based configuration.
Includes evidence gating: unverified candidates return __JSRPC_ERROR__:EvidenceMissing.
Usage:
python3 scripts/emit_jsrpc_stub.py --analysis analysis_result.json --output generated/jsrpc_inject.js
python3 scripts/emit_jsrpc_stub.py --analysis analysis_result.json --candidates artifacts/encryption_candidates.json --output generated/jsrpc_inject.js
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
from common import load_json
def js_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def parameter_name(transform: dict) -> str:
path = str(transform.get("path") or "$.value")
value = path.removeprefix("$").strip(".")
return value.split(".", 1)[0] or str(transform.get("id") or "value")
def normalize_analysis(analysis: dict) -> dict:
"""Normalize analysis to a common format."""
transforms = list(analysis.get("transforms") or [])
raw_parameters = analysis.get("parameters") or {}
parameters: dict[str, dict] = {str(k): dict(v) for k, v in raw_parameters.items() if isinstance(v, dict)}
# Build parameters from transforms if present
for transform in transforms:
name = parameter_name(transform)
config = parameters.setdefault(name, {})
config.setdefault("entrypoint", {
"type": transform.get("entrypoint_type", "global"),
"path": transform.get("candidate_path", ""),
})
config.setdefault("runtime", {"bind_this_mode": "none"})
config.setdefault("call_signature", {
"arguments": ["value"], "returns": "unknown", "async": False
})
config["jsra_transform"] = transform
for key in ("capture", "dom_bindings", "delivery_mode"):
if key in transform:
config.setdefault(key, transform[key])
# Ensure all parameters have required fields
for name, config in parameters.items():
config.setdefault("entrypoint", {})
config.setdefault("runtime", {"bind_this_mode": "none"})
config.setdefault("call_signature", {})
config["call_signature"].setdefault("async", False)
config.setdefault("jsra_transform", {
"id": name, "action": name, "safe_to_invoke": False
})
jsrpc = dict(analysis.get("jsrpc") or {})
transport = jsrpc.get("transport") if isinstance(jsrpc.get("transport"), dict) else {}
action = jsrpc.get("action_name") or jsrpc.get("action")
if not action:
action = next((t.get("action") for t in transforms if t.get("action")), "jsra_transform")
ws_url = transport.get("ws_url") or jsrpc.get("ws_url")
group = jsrpc.get("group", "jsra")
if not ws_url:
ws_url = f"ws://127.0.0.1:12080/ws?group={group}&name=jsra"
return {"action_name": action, "ws_url": ws_url, "parameters": parameters}
def candidate_verified(candidate: dict | None) -> tuple[bool, str]:
"""Check if a candidate has sufficient verification evidence."""
if not candidate:
return False, "candidate evidence record is missing"
if candidate.get("source") == "hint" or candidate.get("type") == "hint":
return False, "manual entrypoint hints are not runtime evidence"
if candidate.get("verified") is not True:
return False, "candidate is not verified by runtime evidence"
verification = candidate.get("verification")
if not isinstance(verification, list) or not verification:
return False, "candidate has no verification records"
matched = False
for record in verification:
if not isinstance(record, dict):
continue
if record.get("matched") is True or record.get("verified") is True:
matched = True
for test in record.get("tests", []) if isinstance(record.get("tests"), list) else []:
if isinstance(test, dict) and test.get("matched") is True:
matched = True
if not matched:
return False, "candidate has no successful runtime/request match"
return True, "verified runtime entrypoint"
def find_candidate_file(analysis_path: Path) -> Path | None:
"""Find candidate files in the artifacts directory."""
roots = (analysis_path.parent / "artifacts", analysis_path.parent)
for root in roots:
# Prefer the post-verification artifact whenever both files exist.
for name in ("encryption_candidates.verified.json", "encryption_candidates.json"):
path = root / name
if path.exists():
return path
return None
def build_script(config: dict, gate_errors: dict[str, str]) -> str:
"""Build the JSRPC injection script with evidence gating."""
serialized = json.dumps(config, ensure_ascii=False, indent=2)
errors = json.dumps(gate_errors, ensure_ascii=False, indent=2)
return f'''// Generated by emit_jsrpc_stub.py. This file only invokes page runtime functions.
(() => {{
"use strict";
const CONFIG = {serialized};
const EVIDENCE_ERRORS = {errors};
function errorValue(parameter, name, message) {{
return "__JSRPC_ERROR__:" + parameter + ":" + name + ":" + message;
}}
function getByPath(root, path) {{
if (!root || !path) return null;
return String(path).replace(/^window\\./, "").split(".").reduce((current, key) =>
current == null ? null : current[key], root);
}}
function parentPath(path) {{
const parts = String(path || "").split(".");
parts.pop();
return parts.join(".");
}}
function resolveEntrypoint(parameterConfig) {{
const entrypoint = parameterConfig.entrypoint || {{}};
if (entrypoint.type === "resolver") {{
const resolver = getByPath(window, entrypoint.resolver_path || entrypoint.resolver_name);
if (typeof resolver !== "function") return null;
const resolved = resolver();
return typeof resolved === "string" ? getByPath(window, resolved) : resolved;
}}
if (entrypoint.type === "webpack_export") {{
const requireFn = window.__JSRA_require || window.__JSRA_WEBPACK_REQUIRE__;
if (typeof requireFn !== "function") throw new Error("Webpack require was not captured");
const moduleValue = requireFn(entrypoint.module_id);
if (!moduleValue) throw new Error("Webpack module was not found: " + entrypoint.module_id);
return getByPath(moduleValue, entrypoint.export_path || "default");
}}
return getByPath(window, entrypoint.path || "");
}}
function resolveThis(parameterConfig) {{
const runtime = parameterConfig.runtime || {{}};
if (runtime.bind_this_path) return getByPath(window, runtime.bind_this_path);
if (runtime.bind_this_mode === "window" || runtime.bind_this_mode === "global") return window;
if (runtime.bind_this_mode === "entrypoint_parent")
return getByPath(window, parentPath((parameterConfig.entrypoint || {{}}).path));
return null;
}}
function normalizeInput(payload) {{
if (typeof payload === "string") {{
try {{
const parsed = JSON.parse(payload);
if (parsed && typeof parsed === "object") return parsed;
}} catch (_ ) {{}}
return {{ value: payload }};
}}
if (payload && typeof payload === "object") {{
if (Object.prototype.hasOwnProperty.call(payload, "value") || Array.isArray(payload.args))
return payload;
if (Object.prototype.hasOwnProperty.call(payload, "data"))
return {{ value: payload.data, parameter: payload.parameter }};
return {{ value: payload }};
}}
return {{ value: payload }};
}}
function coerce(value) {{
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return value;
}}
function bindInputFields(payload, parameterConfig, raw) {{
const bindings = parameterConfig.dom_bindings || {{}};
const fields = payload.fields || (payload.context && payload.context.fields) || payload;
for (const [source, elementId] of Object.entries(bindings)) {{
const element = document.getElementById(elementId);
if (!element) continue;
const value = source === "value" ? raw : fields[source];
if (value !== undefined) element.value = String(value);
}}
}}
function resultOrError(parameter, input, result) {{
if (result === undefined || result === null)
return errorValue(parameter, "InvalidResult", "page function returned no value");
if (typeof result === "string" && result.length === 0)
return errorValue(parameter, "InvalidResult", "page function returned an empty value");
if (typeof result === "string" && result.indexOf("__JSRPC_ERROR__:") === 0) return result;
if (result instanceof ArrayBuffer) return Array.from(new Uint8Array(result));
return result;
}}
function serializeRequestBody(body) {{
if (body == null) return null;
if (typeof body === "string") return body;
if (body instanceof URLSearchParams) return body.toString();
if (body instanceof FormData) {{
const values = {{}};
for (const [key, value] of body.entries())
values[key] = typeof value === "string" ? value : `[${{value.constructor.name}}]`;
return values;
}}
if (body instanceof ArrayBuffer) return `[ArrayBuffer:${{body.byteLength}}]`;
if (ArrayBuffer.isView(body)) return `[${{body.constructor.name}}:${{body.byteLength}}]`;
try {{ return JSON.stringify(body); }} catch (_) {{ return String(body); }}
}}
function headerObject(headers) {{
if (!headers) return {{}};
try {{ return Object.fromEntries(new Headers(headers).entries()); }}
catch (_) {{ return {{}}; }}
}}
function matchesRoute(url, expected) {{
if (!expected) return true;
try {{
const actualPath = new URL(url, window.location.href).pathname.replace(/\\/+$/, "");
const expectedPath = new URL(expected, window.location.href).pathname.replace(/\\/+$/, "");
return actualPath === expectedPath;
}} catch (_) {{
return String(url).split("?", 1)[0] === String(expected).split("?", 1)[0];
}}
}}
async function invokeWithNetworkCapture(fn, thisArg, args, captureConfig = {{}}) {{
const originalFetch = window.fetch;
const originalAlert = window.alert;
const xhrPrototype = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
const originalXhrOpen = xhrPrototype && xhrPrototype.open;
const originalXhrSend = xhrPrototype && xhrPrototype.send;
const originalXhrSetRequestHeader = xhrPrototype && xhrPrototype.setRequestHeader;
const timeoutMs = 10000;
let captured = null;
const requests = [];
let resolveNetwork;
const networkSeen = new Promise(resolve => {{ resolveNetwork = resolve; }});
let restored = false;
const suppressPageUi = captureConfig.suppress_page_success === true;
const recordNetwork = record => {{
requests.push(record);
const expected = String(captureConfig.url_contains || captureConfig.route || "");
if (matchesRoute(record.url, expected)) {{
captured = record;
resolveNetwork(captured);
return true;
}}
return false;
}};
const parseXhrResponse = xhr => {{
try {{
const value = xhr.responseType === "" || xhr.responseType === "text"
? xhr.responseText : xhr.response;
if (typeof value === "string") {{
try {{ return JSON.parse(value); }} catch (_) {{ return value; }}
}}
return serializeRequestBody(value);
}} catch (_) {{ return null; }}
}};
const withTimeout = (promise, ms, fallback) => {{
let timer;
const timeout = new Promise(resolve => {{
timer = setTimeout(() => resolve(fallback), ms);
}});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}};
const restore = () => {{
if (restored) return;
restored = true;
if (suppressPageUi) {{
try {{ window.alert = originalAlert; }} catch (_) {{}}
}}
try {{
Object.defineProperty(window, "fetch", {{
configurable: true, writable: true, value: originalFetch
}});
}} catch (_) {{ try {{ window.fetch = originalFetch; }} catch (__) {{}} }}
if (xhrPrototype) {{
for (const [key, value] of [["open", originalXhrOpen], ["send", originalXhrSend], ["setRequestHeader", originalXhrSetRequestHeader]]) {{
if (typeof value !== "function") continue;
try {{ Object.defineProperty(xhrPrototype, key, {{ configurable: true, writable: true, value }}); }}
catch (_) {{ try {{ xhrPrototype[key] = value; }} catch (__) {{}} }}
}}
}}
}};
// Challenge pages commonly use alert() before redirecting on either
// success or failure. A synthetic response used only to keep the page
// in place must not leave an external JSRPC caller blocked by a modal.
// Keep the suppression scoped to this invocation and restore the native
// function in every exit path.
if (suppressPageUi && typeof originalAlert === "function") {{
try {{ window.alert = () => {{}}; }} catch (_) {{}}
}}
if (typeof originalFetch === "function") {{
const captureFetch = async function (input, init = {{}}) {{
const url = typeof input === "string" ? input : (input && input.url) || "";
const method = (init && init.method) || (input && input.method) || "GET";
const body = init && Object.prototype.hasOwnProperty.call(init, "body")
? init.body : (input && input.body);
const response = await Reflect.apply(originalFetch, this, arguments);
let responseBody = null;
try {{ responseBody = await response.clone().json(); }} catch (_) {{
try {{ responseBody = await response.clone().text(); }} catch (__) {{}}
}}
const record = {{
url,
method,
headers: headerObject(init && init.headers),
requestBody: serializeRequestBody(body),
status: response.status,
response: responseBody
}};
if (recordNetwork(record)) {{
if (suppressPageUi) {{
try {{
return new Response(JSON.stringify({{ success: false }}), {{
status: response.status,
headers: {{ "Content-Type": "application/json" }}
}});
}} catch (_) {{}}
}}
}}
return response;
}};
try {{
Object.defineProperty(window, "fetch", {{
configurable: true, writable: true, value: captureFetch
}});
}} catch (_) {{
try {{ window.fetch = captureFetch; }} catch (__) {{}}
}}
}}
if (xhrPrototype && typeof originalXhrOpen === "function" && typeof originalXhrSend === "function") {{
try {{
Object.defineProperty(xhrPrototype, "open", {{
configurable: true, writable: true,
value: function(method, url) {{
this.__jsraCapture = {{ method: String(method || "GET"), url: String(url || ""), headers: {{}} }};
return Reflect.apply(originalXhrOpen, this, arguments);
}}
}});
if (typeof originalXhrSetRequestHeader === "function") {{
Object.defineProperty(xhrPrototype, "setRequestHeader", {{
configurable: true, writable: true,
value: function(name, value) {{
const capture = this.__jsraCapture || (this.__jsraCapture = {{ method: "GET", url: "", headers: {{}} }});
capture.headers[String(name)] = String(value);
return Reflect.apply(originalXhrSetRequestHeader, this, arguments);
}}
}});
}}
Object.defineProperty(xhrPrototype, "send", {{
configurable: true, writable: true,
value: function(body) {{
const xhr = this;
const capture = xhr.__jsraCapture || {{ method: "GET", url: "", headers: {{}} }};
const onLoadEnd = () => {{
const record = {{
transport: "xhr",
url: capture.url,
method: capture.method,
headers: capture.headers,
requestBody: serializeRequestBody(body),
status: xhr.status,
response: parseXhrResponse(xhr)
}};
recordNetwork(record);
try {{ xhr.removeEventListener("loadend", onLoadEnd); }} catch (_) {{}}
}};
try {{ xhr.addEventListener("loadend", onLoadEnd, {{ once: true }}); }} catch (_) {{}}
return Reflect.apply(originalXhrSend, this, arguments);
}}
}});
}} catch (_) {{
// Some pages make XHR methods non-configurable; fetch capture remains available.
}}
}}
let resultState;
try {{
const result = fn.apply(thisArg, args);
resultState = await withTimeout(
Promise.resolve(result).then(value => ({{ value }}), error => ({{ error }})),
timeoutMs, {{ timedOut: true }}
);
}} catch (error) {{
resultState = {{ error }};
}}
const network = await withTimeout(networkSeen, timeoutMs, null);
// The page function may return void while its fetch().then(...) chain is
// still pending. Keep alert() muted through that microtask/macrotask
// boundary so the synthetic response cannot open a modal after capture.
if (suppressPageUi && network) {{
await new Promise(resolve => setTimeout(resolve, 50));
}}
restore();
return {{ resultState, network: network || captured, requests }};
}}
if (typeof Hlclient === "undefined")
throw new Error("Hlclient is unavailable; load JsEnv_Dev.js first");
const client = new Hlclient(CONFIG.ws_url);
client.regAction(CONFIG.action_name, function(resolve, rawPayload) {{
const payload = normalizeInput(rawPayload);
const parameter = payload.parameter || Object.keys(CONFIG.parameters)[0];
const parameterConfig = CONFIG.parameters[parameter];
if (!parameterConfig) {{
resolve(errorValue(parameter, "UnknownParameter", "no parameter contract"));
return;
}}
// Evidence gate: refuse to call unverified candidates
const evidenceError = EVIDENCE_ERRORS[parameter];
if (evidenceError) {{
resolve(errorValue(parameter, "EvidenceMissing", evidenceError));
return;
}}
try {{
const fn = resolveEntrypoint(parameterConfig);
if (typeof fn !== "function")
throw new Error("verified page entrypoint is not callable");
const raw = payload.value;
bindInputFields(payload, parameterConfig, raw);
let args = Array.isArray(payload.args) ? payload.args.map(coerce) : [coerce(raw)];
const transform = parameterConfig.jsra_transform || {{}};
if (Array.isArray(transform.arguments)) {{
args = transform.arguments.map((arg) =>
arg && arg.source === "constant" ? arg.value : coerce(raw));
}}
const captureConfig = parameterConfig.capture || transform.capture || {{}};
invokeWithNetworkCapture(fn, resolveThis(parameterConfig), args, captureConfig)
.then((invocation) => {{
const resultState = invocation.resultState || {{}};
if (resultState.error) throw resultState.error;
if (invocation.network) {{
resolve({{
success: true,
parameter,
plaintext: raw,
result: resultState.value === undefined ? null : resultState.value,
request: invocation.network,
requestBody: invocation.network.requestBody,
response: invocation.network.response,
status: invocation.network.status
}});
return;
}}
resolve(resultOrError(parameter, raw, resultState.value));
}})
.catch((error) => resolve(errorValue(parameter, error.name || "Error", error.message || String(error))));
}} catch (error) {{
resolve(errorValue(parameter, error.name || "Error", error.message || String(error)));
}}
}});
window.__JSRA_RPC_CLIENT__ = client;
console.info("[JSRA] registered evidence-gated action", CONFIG.action_name);
}})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Generate evidence-gated JSRPC registration code.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--candidates", help="Path to encryption_candidates.json.")
parser.add_argument("--output", required=True, help="Output JS file path.")
args = parser.parse_args()
analysis_path = Path(args.analysis)
analysis = load_json(analysis_path)
config = normalize_analysis(analysis)
# Load candidates for evidence gating
candidates_data: dict = {}
candidate_path = Path(args.candidates) if args.candidates else find_candidate_file(analysis_path)
if candidate_path and candidate_path.exists():
try:
candidates_data = load_json(candidate_path)
except (OSError, ValueError):
candidates_data = {}
by_path = {
c.get("path"): c
for c in candidates_data.get("candidates", [])
if isinstance(c, dict) and c.get("path")
}
# Build evidence gate errors
gate_errors: dict[str, str] = {}
for name, parameter in config["parameters"].items():
transform = parameter.get("jsra_transform") or {}
path = (parameter.get("entrypoint") or {}).get("path") or transform.get("candidate_path")
candidate = by_path.get(path)
ok, reason = candidate_verified(candidate)
if not ok:
gate_errors[name] = f"{reason}; path={path or '<missing>'}"
if transform.get("safe_to_invoke") is True and not ok:
gate_errors[name] = f"safe_to_invoke requires verified runtime evidence: {gate_errors[name]}"
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_script(config, gate_errors), encoding="utf-8")
status = {
"status": "ok" if not gate_errors else "quarantined",
"output": str(output),
"evidence_errors": gate_errors
}
print(json.dumps(status, ensure_ascii=False))
return 0 if not gate_errors else 2
if __name__ == "__main__":
raise SystemExit(main())