reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,125 @@
var rpc_client_id, Hlclient = function (wsURL) {
this.wsURL = wsURL;
this.handlers = {
_execjs: function (resolve, param) {
var res = eval(param)
if (!res) {
resolve("没有返回值")
} else {
resolve(res)
}
}
};
this.socket = undefined;
if (!wsURL) {
throw new Error('wsURL can not be empty!!')
}
this.connect()
}
Hlclient.prototype.connect = function () {
if (this.wsURL.indexOf("clientId=") === -1 && rpc_client_id) {
this.wsURL += "&clientId=" + rpc_client_id
}
console.log('begin of connect to wsURL: ' + this.wsURL);
var _this = this;
try {
this.socket = new WebSocket(this.wsURL);
this.socket.onmessage = function (e) {
_this.handlerRequest(e.data)
}
} catch (e) {
console.log("connection failed,reconnect after 10s");
setTimeout(function () {
_this.connect()
}, 10000)
}
this.socket.onclose = function () {
console.log('rpc已关闭');
setTimeout(function () {
_this.connect()
}, 10000)
}
this.socket.addEventListener('open', (event) => {
console.log("rpc连接成功");
});
this.socket.addEventListener('error', (event) => {
console.error('rpc连接出错,请检查是否打开服务端:', event.error);
})
};
Hlclient.prototype.send = function (msg) {
this.socket.send(msg)
}
Hlclient.prototype.regAction = function (func_name, func) {
if (typeof func_name !== 'string') {
throw new Error("an func_name must be string");
}
if (typeof func !== 'function') {
throw new Error("must be function");
}
console.log("register func_name: " + func_name);
this.handlers[func_name] = func;
return true
}
Hlclient.prototype.handlerRequest = function (requestJson) {
var _this = this;
try {
var result = JSON.parse(requestJson)
} catch (error) {
console.log("请求信息解析错误", requestJson);
return
}
if (result["registerId"]) {
rpc_client_id = result['registerId']
return
}
if (!result['action'] || !result["message_id"]) {
console.warn('没有方法或者消息id,不处理');
return
}
var action = result["action"], message_id = result["message_id"]
var theHandler = this.handlers[action];
if (!theHandler) {
this.sendResult(action, message_id, 'action没找到');
return
}
try {
if (!result["param"]) {
theHandler(function (response) {
_this.sendResult(action, message_id, response);
})
return
}
var param = result["param"]
try {
param = JSON.parse(param)
} catch (e) {
// Fix: JSRPC may send param without outer braces
try {
param = JSON.parse('{' + param + '}')
} catch (e2) {
param = { value: param }
}
}
theHandler(function (response) {
_this.sendResult(action, message_id, response);
}, param)
} catch (e) {
console.log("error: " + e);
_this.sendResult(action, message_id, e);
}
}
Hlclient.prototype.sendResult = function (action, message_id, e) {
if (typeof e === 'object' && e !== null) {
try {
e = JSON.stringify(e)
} catch (v) {
console.log(v)//不是json无需操作
}
}
this.send(JSON.stringify({"action": action, "message_id": message_id, "response_data": e}));
}
// Explicitly publish the constructor. DevTools evaluate_script may execute
// this file in an isolated lexical scope; the generated JSRPC stub must still
// be able to construct the client in a later evaluation.
if (typeof window !== 'undefined') window.Hlclient = Hlclient;

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Compare baseline and patched adversarial probe exports.
This turns an intervention into evidence: a patch is useful only when it
produces additional target signals or requests without increasing runtime
errors. It does not claim that a page is bypassed; it reports observable
changes for the authorized test target.
"""
from __future__ import annotations
import argparse
from collections import Counter
from pathlib import Path
from common import dump_json, load_json
def events(value: dict) -> list[dict]:
state = value.get("state") if isinstance(value, dict) else {}
raw = state.get("events", []) if isinstance(state, dict) else []
return [item for item in raw if isinstance(item, dict)]
def summarize(items: list[dict]) -> dict:
types = Counter(str(item.get("type", "unknown")) for item in items)
paths = Counter(str(item.get("path")) for item in items if item.get("path"))
return {
"events": len(items),
"types": dict(sorted(types.items())),
"paths": dict(sorted(paths.items())),
"requests": sum(count for name, count in types.items() if name.startswith("network.")),
"errors": sum(count for name, count in types.items() if "error" in name or "reject" in name),
"interventions": sum(count for name, count in types.items() if name.startswith("intervention.")),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Compare JSRA adversarial probe exports.")
parser.add_argument("--baseline", required=True)
parser.add_argument("--patched", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
before = summarize(events(load_json(Path(args.baseline), {})))
after = summarize(events(load_json(Path(args.patched), {})))
new_types = sorted(set(after["types"]) - set(before["types"]))
new_paths = sorted(set(after["paths"]) - set(before["paths"]))
errors_not_increased = after["errors"] <= before["errors"]
evidence_increased = after["events"] > before["events"] or after["requests"] > before["requests"]
result = {
"baseline": before,
"patched": after,
"delta": {
"events": after["events"] - before["events"],
"requests": after["requests"] - before["requests"],
"errors": after["errors"] - before["errors"],
"new_types": new_types,
"new_paths": new_paths,
},
"verdict": "improved" if evidence_increased and errors_not_increased else "inconclusive",
"limitations": [
"This is an observable-difference report, not proof of bypass or protocol success.",
"A real target request and business success condition still require independent verification.",
],
}
dump_json(Path(args.output), result)
print(f"[OK] adversarial diff: {args.output} verdict={result['verdict']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
"use strict";
/**
* Static AST candidate analyzer for JavaScript bundles.
*
* Parses JS files using Babel (with fallback regex parser) to find function
* declarations matching crypto-related keywords.
*
* Usage:
* node scripts/ast_candidate_analyzer.js --input bundle.js --output artifacts/static_candidates.json
*/
const fs = require("fs");
const path = require("path");
let babelParser = null;
try { babelParser = require("@babel/parser"); } catch (_) {}
function args() {
const out = {};
for (let i = 2; i < process.argv.length; i += 2)
out[process.argv[i].replace(/^--/, "")] = process.argv[i + 1];
if (!out.input || !out.output)
throw new Error("Usage: ast_candidate_analyzer.js --input bundle.js --output static_candidates.json");
return out;
}
const KEYWORDS = /encrypt|decrypt|cipher|sign|hmac|digest|hash|rsa|aes|des|sm2|sm3|sm4|md5|sha|base64|crypto/i;
function sourceSnippet(code, start, end) {
return code.slice(start || 0, Math.min(end || code.length, (start || 0) + 1200));
}
function locationFromOffset(code, offset) {
const before = code.slice(0, offset);
const lines = before.split("\n");
return { line: lines.length, column: lines[lines.length - 1].length };
}
function signalsFor(name, snippet) {
const signals = [];
if (KEYWORDS.test(name)) signals.push("name_keyword");
if (KEYWORDS.test(snippet)) signals.push("source_keyword");
if (/crypto\.subtle|CryptoJS|JSEncrypt|sm-crypto|jsrsasign|forge/i.test(snippet))
signals.push("known_library");
if (/publicKey|privateKey|BEGIN PUBLIC KEY|BEGIN RSA/i.test(snippet))
signals.push("key_material");
return signals;
}
function addCandidate(map, code, name, start, end, mode) {
const snippet = sourceSnippet(code, start, end);
const signals = signalsFor(name, snippet);
if (!signals.length) return;
const loc = locationFromOffset(code, start);
const key = `${name}:${loc.line}:${loc.column}`;
map.set(key, {
id: `static:${key}`, name, path: name, type: "function",
location: loc, signals, source_snippet: snippet, parser_mode: mode
});
}
function walk(node, parent, visit) {
if (!node || typeof node !== "object") return;
if (typeof node.type === "string") visit(node, parent);
for (const [key, value] of Object.entries(node)) {
if (["loc", "start", "end", "extra", "tokens", "comments"].includes(key)) continue;
if (Array.isArray(value)) value.forEach(child => walk(child, node, visit));
else if (value && typeof value === "object") walk(value, node, visit);
}
}
function nameOf(node, parent) {
if (node.id && node.id.name) return node.id.name;
if (parent && parent.type === "VariableDeclarator" && parent.id && parent.id.name)
return parent.id.name;
if (parent && /ObjectProperty|ObjectMethod|ClassMethod/.test(parent.type || ""))
return parent.key && (parent.key.name || parent.key.value) || "anonymous";
return "anonymous";
}
function analyzeWithBabel(code, map) {
const ast = babelParser.parse(code, {
sourceType: "unambiguous",
errorRecovery: true,
plugins: ["jsx", "typescript", "classProperties", "optionalChaining", "dynamicImport", "decorators-legacy"]
});
walk(ast, null, (node, parent) => {
if (!["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression",
"ObjectMethod", "ClassMethod"].includes(node.type)) return;
addCandidate(map, code, nameOf(node, parent), node.start, node.end, "babel");
});
}
function analyzeFallback(code, map) {
const patterns = [
/function\s+([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g,
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\s*)?\([^)]*\)\s*=>?\s*\{/g,
/([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?function\s*\([^)]*\)\s*\{/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(code)))
addCandidate(map, code, match[1], match.index, Math.min(code.length, match.index + 3000), "fallback-regex");
}
}
function main() {
const opt = args();
const code = fs.readFileSync(opt.input, "utf8");
const map = new Map();
if (babelParser) analyzeWithBabel(code, map);
else analyzeFallback(code, map);
const result = {
input: path.resolve(opt.input),
parser_mode: babelParser ? "babel" : "fallback-regex",
candidates: Array.from(map.values())
};
fs.mkdirSync(path.dirname(opt.output), { recursive: true });
fs.writeFileSync(opt.output, JSON.stringify(result, null, 2) + "\n");
console.log(JSON.stringify({
status: "ok",
parser_mode: result.parser_mode,
candidates: result.candidates.length,
output: opt.output
}));
}
try { main(); }
catch (error) { console.error(String(error.stack || error)); process.exit(2); }

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Build a lightweight JSRA evidence graph from probe and module dumps.
Creates nodes (events) and edges (parent-child, fingerprint_flow, temporal)
to correlate producers and consumers via matching SHA-256 fingerprints.
Usage:
python3 scripts/build_evidence_graph.py \
--probe artifacts/probe_dump.json \
--modules artifacts/module_dump.json \
--output artifacts/evidence_graph.json
"""
from __future__ import annotations
import argparse
from collections import defaultdict
from common import dump_json, flatten_events, load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Build a lightweight JSRA evidence graph.")
parser.add_argument("--probe", required=True, help="Path to probe_dump.json.")
parser.add_argument("--modules", required=False, help="Path to module_dump.json.")
parser.add_argument("--output", required=True, help="Path to output evidence_graph.json.")
args = parser.parse_args()
probe = load_json(args.probe, {})
modules = load_json(args.modules, {}) if args.modules else {}
events = flatten_events(probe)
nodes = []
edges = []
by_trace = defaultdict(list)
producers = defaultdict(list)
consumers = defaultdict(list)
for event in events:
event_id = str(event.get("event_id"))
trace_id = str(event.get("trace_id") or "unscoped")
nodes.append({"id": event_id, "kind": "event", "data": event})
by_trace[trace_id].append(event)
parent = event.get("parent_event_id")
if parent:
edges.append({"from": str(parent), "to": event_id, "type": "parent"})
if event.get("output_fingerprint"):
producers[event["output_fingerprint"]].append(event_id)
if event.get("input_fingerprint"):
consumers[event["input_fingerprint"]].append(event_id)
# Fingerprint flow edges (producer -> consumer). A runtime hook often
# creates a new trace id for the network call after the crypto call has
# completed, so requiring equal trace ids loses the most useful edges.
# Pair each consumer with the nearest preceding producer instead. Exact
# fingerprints remain the proof signal; the time relation only disambiguates
# repeated values and prevents future-to-past edges.
event_ids = {str(event.get("event_id")) for event in events}
for fp, source_ids in producers.items():
source_events = [
next((event for event in events if str(event.get("event_id")) == source_id), {})
for source_id in source_ids
if source_id in event_ids
]
for target_id in consumers.get(fp, []):
if target_id not in event_ids:
continue
target_event = next((e for e in events if str(e.get("event_id")) == target_id), {})
target_time = target_event.get("timestamp")
preceding = [
event for event in source_events
if str(event.get("event_id")) != target_id
and (
not isinstance(target_time, (int, float))
or not isinstance(event.get("timestamp"), (int, float))
or event.get("timestamp") <= target_time
)
]
if not preceding:
continue
source_event = max(
preceding,
key=lambda event: event.get("timestamp", 0)
if isinstance(event.get("timestamp"), (int, float)) else 0,
)
source_id = str(source_event.get("event_id"))
source_time = source_event.get("timestamp")
delta = None
if isinstance(source_time, (int, float)) and isinstance(target_time, (int, float)):
delta = max(0, target_time - source_time)
edges.append({
"from": source_id, "to": target_id,
"type": "fingerprint_flow",
"fingerprint": fp,
"trace_id": source_event.get("trace_id"),
"target_trace_id": target_event.get("trace_id"),
"same_trace": source_event.get("trace_id") == target_event.get("trace_id"),
"time_delta_ms": delta,
})
# Temporal edges (within same trace)
for trace_id, trace_events in by_trace.items():
ordered = sorted(trace_events, key=lambda e: e.get("timestamp", 0))
for left, right in zip(ordered, ordered[1:]):
edges.append({
"from": str(left.get("event_id")),
"to": str(right.get("event_id")),
"type": "temporal",
"trace_id": trace_id
})
# Module/globals as candidate source nodes
for item in (modules.get("modules") or []) + (modules.get("globals") or []):
node_id = str(item.get("id") or item.get("path") or item.get("name"))
nodes.append({"id": node_id, "kind": "candidate_source", "data": item})
result = {
"nodes": nodes,
"edges": edges,
"stats": {
"events": len(events),
"traces": len(by_trace),
"fingerprint_flows": sum(1 for e in edges if e["type"] == "fingerprint_flow")
},
}
dump_json(args.output, result)
print(f"[OK] evidence graph: {args.output} ({result['stats']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""回归检查生成的 JSRPC/Flask/Burp 交付契约。"""
from __future__ import annotations
import argparse
import importlib.util
import json
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
def load_module(path: Path):
spec = importlib.util.spec_from_file_location("jsra_delivery_contract_proxy", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载代理:{path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def check_endpoint_normalization(module) -> dict[str, Any]:
calls: list[str] = []
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, Any]:
return {"data": {"requestBody": "loginId=admin&password2=ENC"}}
def fake_get(url: str, **_: Any) -> FakeResponse:
calls.append(url)
return FakeResponse()
original_get = module.requests.get
try:
module.requests.get = fake_get
transform = module.CONFIG["transforms"][0]
module.jsrpc_call(transform.get("action", module.CONFIG["jsrpc"]["action"]), "123456", transform)
finally:
module.requests.get = original_get
if len(calls) != 1 or not calls[0].endswith("/go") or calls[0].endswith("/go/go"):
raise AssertionError(f"JSRPC endpoint 归一化失败:{calls}")
return {"ok": True, "called": calls[0]}
def check_packet_modes(module) -> dict[str, Any]:
transform = module.CONFIG["transforms"][0]
path = str(transform.get("path") or transform.get("id") or "password")
field = path.removeprefix("$.").split(".")[-1]
if transform.get("delivery_mode") == "request_body":
fake_result = f"loginId=admin&{field}=ENC(123456)"
expected_body = fake_result
else:
fake_result = "ENC(123456)"
expected_body = f"loginId=admin&{urlencode({field: 'ENC(123456)'})}"
module.jsrpc_call = lambda _action, _value, _transform: fake_result
client = module.app.test_client()
raw_packet = (
"POST /newlogin/login.do?appName=arena&fromSite=77 HTTP/1.1\r\n"
"Host: 127.0.0.1:8123\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Connection: close\r\n\r\n"
f"loginId=admin&{field}=123456"
)
packet_response = client.post("/encode", data=raw_packet, content_type="application/octet-stream")
packet_text = packet_response.get_data(as_text=True)
if packet_response.status_code != 200:
raise AssertionError(f"整包模式失败:HTTP {packet_response.status_code}: {packet_text}")
if expected_body not in packet_text:
raise AssertionError(f"整包模式没有替换 body:{packet_text}")
expected_length = len(expected_body.encode())
if f"Content-Length: {expected_length}" not in packet_text:
raise AssertionError(f"整包模式没有同步 Content-Length:{packet_text}")
wrapper_response = client.post(
"/encode",
data={"dataBody": f"loginId=admin&{field}=123456", "requestorresponse": "request"},
content_type="application/x-www-form-urlencoded",
)
wrapper_text = wrapper_response.get_data(as_text=True)
expected_wrapper = expected_body
if wrapper_response.status_code != 200 or wrapper_text != expected_wrapper:
raise AssertionError(f"dataBody wrapper 失败:HTTP {wrapper_response.status_code}: {wrapper_text}")
return {"ok": True, "packet_status": packet_response.status_code, "wrapper_status": wrapper_response.status_code}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate generated Flask delivery contracts.")
parser.add_argument("--proxy", required=True, help="Generated flask_proxy.py path.")
parser.add_argument("--output", required=True, help="JSON report path.")
args = parser.parse_args()
module = load_module(Path(args.proxy))
result = {
"passed": True,
"proxy": str(Path(args.proxy).resolve()),
"endpoint_normalization": check_endpoint_normalization(module),
"packet_modes": check_packet_modes(module),
"live_action_required": True,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Validate and normalize Phase 0 input for the JS reverse automation skill.
Usage:
python3 scripts/check_inputs.py --input <raw> --output artifacts/phase0_input.json
python3 scripts/check_inputs.py --analysis analysis_result.json --schema schemas/analysis_result.schema.json --output artifacts/input_validation.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from urllib.parse import urlparse
from common import dump_json, load_json
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--input", help="Path to raw input JSON (Phase 0 mode).")
parser.add_argument("--analysis", help="Path to analysis_result.json (schema validation mode).")
parser.add_argument("--schema", default="", help="Path to JSON schema for analysis validation.")
parser.add_argument("--output", required=True, help="Path to output JSON.")
return parser.parse_args()
def ensure_http_url(value: str) -> str:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("target_url must be an absolute http or https URL")
return value
def normalize_parameters(value: object) -> list[str]:
if isinstance(value, str):
items = [part.strip() for part in value.replace("/", ",").split(",")]
elif isinstance(value, list):
items = [str(part).strip() for part in value]
else:
raise ValueError("parameters must be a string or a list of strings")
normalized: list[str] = []
seen: set[str] = set()
for item in items:
if not item:
continue
if item not in seen:
seen.add(item)
normalized.append(item)
if not normalized:
raise ValueError("parameters must not be empty")
return normalized
def validate_phase0(raw: dict) -> dict:
"""Validate and normalize Phase 0 input."""
missing = [field for field in ("target_url", "parameters") if not raw.get(field)]
if missing:
raise ValueError(f"missing required fields: {', '.join(missing)}")
return {
"target_url": ensure_http_url(str(raw["target_url"]).strip()),
"parameters": normalize_parameters(raw["parameters"]),
"fetch_example": str(raw.get("fetch_example", "")).strip(),
"notes": raw.get("notes", []),
}
def validate_analysis_schema(analysis: dict, schema_path: str) -> list[str]:
"""Validate analysis_result.json against JSON schema if jsonschema is available."""
if not schema_path:
return []
try:
from jsonschema import Draft202012Validator
schema = load_json(schema_path)
errors = [e.message for e in Draft202012Validator(schema).iter_errors(analysis)]
return errors
except ImportError:
return ["jsonschema not installed; skipping schema validation"]
except FileNotFoundError:
return [f"schema file not found: {schema_path}"]
def main() -> int:
args = parse_args()
output_path = Path(args.output)
# Phase 0 mode: validate raw input
if args.input:
raw = load_json(Path(args.input))
normalized = validate_phase0(raw)
dump_json(output_path, normalized)
print(json.dumps({"status": "ok", "output": str(output_path)}, ensure_ascii=False))
return 0
# Schema validation mode: validate analysis_result.json
if args.analysis:
analysis = load_json(Path(args.analysis))
# Check authorization.confirmed if present
auth = analysis.get("authorization", {})
if auth and not auth.get("confirmed"):
print("[ERROR] authorization.confirmed must be true")
return 2
schema_path = args.schema or str(Path(__file__).parents[1] / "schemas" / "analysis_result.schema.json")
errors = validate_analysis_schema(analysis, schema_path)
if errors:
for error in errors:
print(f"[ERROR] {error}")
return 2
dump_json(output_path, {"status": "ok", "analysis": analysis})
print(f"[OK] input validated: {output_path}")
return 0
print("[ERROR] either --input or --analysis must be specified")
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Anti-crawl type classifier.
Classifies the target website's anti-crawl mechanism into one of three types:
1. Signature-type: environment IS the signature (e.g., RS/Akamai, 412 challenges)
2. Behavioral-type: parameter signing + interceptors (e.g., TikTok a_bogus)
3. Pure obfuscation: just hard to read, no environment detection
Usage:
python3 scripts/classify_anticrawl.py --probe artifacts/probe_dump.json --analysis analysis_result.json --output artifacts/anticrawl_type.json
"""
from __future__ import annotations
import argparse
from common import dump_json, flatten_events, load_json
def classify(events: list[dict], analysis: dict) -> dict:
"""Classify anti-crawl type based on probe events and analysis."""
result = {
"type": "unknown",
"confidence": "low",
"evidence": [],
"recommended_strategy": "runtime_hook",
"capabilities": [],
"event_ids": []
}
# Adversarial runtime signals. These are evidence of observed
# behavior, not proof that a particular vendor or challenge was identified.
# The probe itself calls Function#toString, descriptor APIs and property
# getters while installing hooks. Those self-generated events must not be
# promoted to a target anti-debug verdict.
self_generated = {
"patch.install", "probe.ready", "probe.restored",
"environment.watch_installed", "patch.reconciled",
}
adversarial = []
explicit_adversarial = []
observation_only = []
for event in events:
etype = str(event.get("type", ""))
path = str(event.get("path", ""))
if etype in self_generated or etype.startswith("integrity."):
continue
if etype.startswith("patch.lost"):
adversarial.append(("hook_integrity_check", event))
explicit_adversarial.append(("hook_integrity_check", event))
if etype.startswith("intervention."):
adversarial.append(("dynamic_code", event))
explicit_adversarial.append(("dynamic_code", event))
elif etype.startswith("dynamic."):
# Dynamic-code hooks are useful observations, but a call to the
# wrapper alone is not evidence that the target is fighting us.
observation_only.append(("dynamic_code_observed", event))
if etype.startswith("environment.property_read"):
adversarial.append(("environment_property", event))
if etype.startswith("realm.") or etype.startswith("loader."):
adversarial.append(("dynamic_realm_or_loader", event))
if "webdriver" in path or "canvas" in path or "webgl" in path:
adversarial.append(("fingerprint_property", event))
# Check for signature-type indicators
signature_indicators = []
for event in events:
etype = event.get("type", "")
# 412 challenge patterns
if "412" in str(event.get("url", "")):
signature_indicators.append("412_challenge")
# Environment fingerprinting
if "navigator" in str(event.get("function", "")).lower():
signature_indicators.append("navigator_access")
if "canvas" in str(event.get("function", "")).lower():
signature_indicators.append("canvas_fingerprint")
if "webgl" in str(event.get("function", "")).lower():
signature_indicators.append("webgl_fingerprint")
# Check for behavioral-type indicators
behavioral_indicators = []
for event in events:
etype = event.get("type", "")
# XHR/fetch with signing
if etype.startswith("network."):
body = str(event.get("input_preview", ""))
if any(kw in body.lower() for kw in ["x-bogus", "a_bogus", "wmsdk", "sign", "signature"]):
behavioral_indicators.append("signing_parameter")
# Crypto library usage
if etype.startswith("crypto."):
behavioral_indicators.append("crypto_operation")
# Check for obfuscation indicators
obfuscation_indicators = []
# Check if there are many nested function calls
if len(events) > 100:
obfuscation_indicators.append("high_event_count")
# Check for eval/Function usage
for event in events:
if "eval" in str(event.get("function", "")).lower():
obfuscation_indicators.append("eval_usage")
adversarial_names = sorted({name for name, _ in adversarial})
result["capabilities"] = adversarial_names
result["event_ids"] = [str(event.get("event_id")) for _, event in adversarial[:100] if event.get("event_id")]
result["observation_only"] = sorted({name for name, _ in observation_only})
result["probe_self_events_excluded"] = True
# Determine type
if signature_indicators:
result["type"] = "signature"
result["confidence"] = "high" if len(signature_indicators) >= 2 else "medium"
result["evidence"] = signature_indicators
result["recommended_strategy"] = "environment_masquerade"
elif behavioral_indicators:
result["type"] = "behavioral"
result["confidence"] = "high" if len(behavioral_indicators) >= 2 else "medium"
result["evidence"] = behavioral_indicators
result["recommended_strategy"] = "algorithm_tracking"
elif obfuscation_indicators:
result["type"] = "obfuscation"
result["confidence"] = "medium"
result["evidence"] = obfuscation_indicators
result["recommended_strategy"] = "runtime_hook"
else:
result["type"] = "simple"
result["confidence"] = "medium"
result["evidence"] = ["no_complex_indicators"]
result["recommended_strategy"] = "runtime_hook"
# Prefer the more specific capability when the adversarial probe produced
# stronger evidence than the legacy keyword classifier.
if explicit_adversarial:
result["type"] = "adversarial_runtime"
result["confidence"] = "high" if len(adversarial_names) >= 2 else "medium"
result["evidence"] = sorted(set(result["evidence"] + adversarial_names))[:50]
result["recommended_strategy"] = (
"source_tap" if "dynamic_code" in adversarial_names else "minimal_intervention"
)
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Anti-crawl type classifier.")
parser.add_argument("--probe", required=True, help="Path to probe_dump.json.")
parser.add_argument("--analysis", help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Path to output JSON.")
args = parser.parse_args()
probe = load_json(args.probe, {})
analysis = load_json(args.analysis, {}) if args.analysis else {}
events = flatten_events(probe)
result = classify(events, analysis)
dump_json(args.output, result)
print(f"[OK] anti-crawl type: {result['type']} (confidence: {result['confidence']})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Shared utilities for JSRA scripts.
Provides JSON I/O with atomic writes, SHA-256 fingerprinting, sensitive data
redaction, probe dump event flattening, keyword scoring, and stack iteration.
"""
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from typing import Any, Iterable
SENSITIVE_KEYS = re.compile(
r"pass(word)?|token|secret|authorization|cookie|session|credential", re.I
)
KEYWORDS_HIGH = ("encrypt", "decrypt", "cipher", "sign", "hmac", "digest", "hash")
KEYWORDS_MEDIUM = ("rsa", "aes", "des", "sm2", "sm3", "sm4", "md5", "sha", "base64", "crypto")
KEYWORDS_LOW = ("encode", "decode", "token", "nonce", "secret")
def load_json(path: str | Path, default: Any = None) -> Any:
"""Load JSON from *path*, returning *default* if the file is missing."""
p = Path(path)
if not p.exists():
if default is not None:
return default
raise FileNotFoundError(p)
with p.open("r", encoding="utf-8") as fh:
return json.load(fh)
def dump_json(path: str | Path, value: Any) -> None:
"""Atomically write *value* as JSON to *path* (via rename)."""
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
temp = p.with_suffix(p.suffix + ".tmp")
with temp.open("w", encoding="utf-8") as fh:
json.dump(value, fh, ensure_ascii=False, indent=2)
fh.write("\n")
temp.replace(p)
def fingerprint(value: Any) -> str:
"""Return ``sha256:<hex>`` for the canonical JSON of *value*."""
if not isinstance(value, str):
value = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()
def redact(value: Any, key: str = "", max_len: int = 96) -> Any:
"""Recursively redact sensitive fields and truncate long strings."""
if SENSITIVE_KEYS.search(key):
return "<redacted>"
if isinstance(value, dict):
return {str(k): redact(v, str(k), max_len) for k, v in value.items()}
if isinstance(value, list):
return [redact(v, key, max_len) for v in value[:20]]
text = str(value)
if len(text) > max_len:
text = text[:max_len] + "…"
return text
def flatten_events(probe: dict) -> list[dict]:
"""Normalise legacy probe dump collections into a flat event list.
Older probes may store events in separate lists (``requests``, ``crypto``,
``serializers``, ``calls``, ``encoders``). Current probes use a single
``events`` list with a ``type`` field. This function merges both formats
so downstream code always sees a flat list.
"""
# Runtime probes store events at the top level, while the adversarial
# probe exports them under ``state.events``. Accept both forms so the
# classifier, graph builder and candidate detector see the same evidence.
events: list[dict] = []
seen_ids: set[str] = set()
def add_events(items: object) -> None:
if not isinstance(items, list):
return
for raw in items:
if not isinstance(raw, dict):
continue
event = dict(raw)
event_id = str(event.get("event_id") or "")
if event_id and event_id in seen_ids:
continue
if event_id:
seen_ids.add(event_id)
events.append(event)
add_events(probe.get("events"))
state = probe.get("state")
if isinstance(state, dict):
add_events(state.get("events"))
mapping = {
"requests": "network.request",
"crypto": "crypto.operation",
"serializers": "serializer.operation",
"calls": "function.call",
"encoders": "encoding.operation",
}
for collection, default_type in mapping.items():
for index, raw in enumerate(probe.get(collection) or []):
event = dict(raw)
event.setdefault("type", default_type)
event.setdefault("event_id", f"legacy-{collection}-{index}")
event.setdefault("timestamp", index)
add_events([event])
return events
def keyword_score(text: str) -> float:
"""Score *text* against crypto-related keyword tiers."""
lower = (text or "").lower()
if any(k in lower for k in KEYWORDS_HIGH):
return 1.0
if any(k in lower for k in KEYWORDS_MEDIUM):
return 0.7
if any(k in lower for k in KEYWORDS_LOW):
return 0.35
return 0.0
def iter_stack_lines(event: dict) -> Iterable[str]:
"""Yield stack lines from an event, normalising dict/str/list formats."""
stack = event.get("stack") or event.get("stack_lines") or []
if isinstance(stack, str):
yield from stack.splitlines()
elif isinstance(stack, list):
for line in stack:
if isinstance(line, dict):
yield " ".join(str(line.get(k, "")) for k in ("function", "url", "line", "column"))
else:
yield str(line)

View File

@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Evidence-driven encryption/signature candidate discovery.
Consumes runtime events, Webpack exports, globals, optional static AST results,
and manual hints. Scores candidates across 8 dimensions and tracks verification
status.
Usage:
python3 scripts/detect_encryption.py \
--probe-artifacts artifacts/probe_dump.json \
--module-artifacts artifacts/module_dump.json \
--evidence-graph artifacts/evidence_graph.json \
--analysis analysis_result.json \
--output artifacts/encryption_candidates.json
"""
from __future__ import annotations
import argparse
import re
from collections import defaultdict
from typing import Any
from common import dump_json, flatten_events, iter_stack_lines, keyword_score, load_json
STACK_FN = re.compile(r"(?:at\s+)?([\w$.[\]<>-]+)\s*(?:\(|@)")
def canonical_id(source: str, name: str, module_id: str = "") -> str:
clean = re.sub(r"[^a-zA-Z0-9_.:$\-]+", "_", name or "anonymous")
return f"{source}:{module_id}:{clean}" if module_id else f"{source}:{clean}"
def ensure_candidate(store: dict[str, dict], cid: str, name: str, source: str, **extra: Any) -> dict:
candidate = store.setdefault(cid, {
"id": cid, "name": name or "anonymous", "source": source, "type": extra.get("type", "function"),
"module_id": extra.get("module_id"), "path": extra.get("path") or name,
"source_snippet": extra.get("source_snippet", ""), "evidence": [], "scores": {},
"verified": False, "verification": [],
})
for key, value in extra.items():
if value not in (None, "", []):
candidate[key] = value
return candidate
def build_candidates(probe: dict, modules: dict, graph: dict, analysis: dict) -> list[dict]:
store: dict[str, dict] = {}
events = flatten_events(probe)
event_by_id = {str(e.get("event_id")): e for e in events}
fingerprint_edges = [e for e in graph.get("edges", []) if e.get("type") == "fingerprint_flow"]
# === Runtime functions and stack frames ===
for event in events:
function = event.get("function") or event.get("name")
event_type = str(event.get("type", ""))
if function and not event_type.startswith("network."):
cid = canonical_id("runtime", str(function))
role = "primitive" if event_type.startswith(("crypto.", "encoding.", "serializer.")) else "entrypoint"
c = ensure_candidate(store, cid, str(function), "runtime", role=role)
c["evidence"].append({
"type": "runtime_event",
"event_id": event.get("event_id"),
"event_type": event.get("type"),
"trace_id": event.get("trace_id")
})
if event.get("output_fingerprint"):
c.setdefault("output_fingerprints", []).append(event["output_fingerprint"])
# Stack frame extraction
for line in iter_stack_lines(event):
match = STACK_FN.search(line)
if match:
name = match.group(1)
if name in ("Error", "record", "wrapped"):
continue
cid = canonical_id("stack", name)
c = ensure_candidate(store, cid, name, "stack")
c["evidence"].append({
"type": "runtime_stack",
"event_id": event.get("event_id"),
"line": line[:300]
})
# === Real Webpack exports ===
for item in modules.get("modules", []) or []:
name = item.get("export_path") or item.get("name") or "anonymous"
cid = canonical_id("webpack", name, str(item.get("module_id", "unknown")))
module_id = str(item.get("module_id", ""))
export_path = item.get("export_path") or name
webpack_source = str(item.get("source") or "default")
runtime_path = f"webpack:{webpack_source}:{module_id}:{export_path}"
ensure_candidate(
store, cid, name, "webpack",
type="webpack_export", module_id=module_id,
path=runtime_path, export_path=export_path,
source_snippet=item.get("source_snippet", "")
)["evidence"].append({"type": "module_export", "source": item.get("source")})
# === Global functions ===
for item in modules.get("globals", []) or []:
name = item.get("name") or item.get("path") or "anonymous"
cid = canonical_id("global", name)
ensure_candidate(
store, cid, name, "global",
path=item.get("path"), source_snippet=item.get("source_snippet", "")
)["evidence"].append({"type": "global_function"})
# === Optional static AST candidates ===
for item in modules.get("static", []) or []:
name = item.get("name") or item.get("path") or "anonymous"
cid = canonical_id("static", name)
ensure_candidate(
store, cid, name, "static",
path=item.get("path") or name,
source_snippet=item.get("source_snippet", ""),
location=item.get("location")
)["evidence"].append({"type": "static_ast", "signals": item.get("signals", [])})
# === Manual hints (backward-compatible, evidence only) ===
for param_name, config in (analysis.get("parameters") or {}).items():
entry = config.get("entrypoint") or {}
name = entry.get("path")
if name:
cid = canonical_id("hint", name)
c = ensure_candidate(
store, cid, name, "hint",
type=entry.get("type", "hint"), path=name,
source_snippet=entry.get("source_hint", "")
)
c["parameter"] = param_name
c["evidence"].append({"type": "manual_hint", "details": entry.get("evidence", [])})
# === Cross-source name correlation ===
normalized = defaultdict(list)
for c in store.values():
normalized[re.sub(r"[^a-z0-9]", "", c["name"].lower())].append(c)
for group in normalized.values():
if len(group) > 1:
sources = sorted({c["source"] for c in group})
for c in group:
c["evidence"].append({"type": "cross_source_name", "sources": sources})
# === Fingerprint flow correlation ===
# The graph builder may correlate across trace ids when the crypto output
# is later serialized into a request field. Promote that relationship to
# candidate evidence so request_correlation is not silently stuck at zero.
graph_flows_by_source: defaultdict[str, list[dict]] = defaultdict(list)
for edge in fingerprint_edges:
source_id = str(edge.get("from") or "")
target_id = str(edge.get("to") or "")
source_event = event_by_id.get(source_id, {})
target_event = event_by_id.get(target_id, {})
target_type = str(target_event.get("type") or "")
if source_id and target_id and target_type.startswith(("network.", "serializer.")):
graph_flows_by_source[source_id].append(edge)
for candidate in store.values():
source_event_ids = {
str(item.get("event_id"))
for item in candidate.get("evidence", [])
if item.get("type") == "runtime_event" and item.get("event_id")
}
flow_edges = [
edge for event_id in source_event_ids
for edge in graph_flows_by_source.get(event_id, [])
]
if flow_edges:
candidate["evidence"].append({
"type": "fingerprint_flow",
"flows": [
{
"from": edge.get("from"),
"to": edge.get("to"),
"fingerprint": edge.get("fingerprint"),
"same_trace": edge.get("same_trace"),
"time_delta_ms": edge.get("time_delta_ms"),
}
for edge in flow_edges[:20]
],
})
candidate["_has_fingerprint_flow"] = bool(flow_edges)
# === Score each candidate ===
for c in store.values():
evidence_types = [e.get("type") for e in c["evidence"]]
runtime_count = evidence_types.count("runtime_event") + evidence_types.count("runtime_stack")
network_correlation = (
1.0 if c["verified"]
else (0.8 if c.get("_has_fingerprint_flow") else (0.5 if runtime_count and any(
str(e.get("event_type", "")).startswith("network") for e in c["evidence"]
) else 0.0))
)
scores = {
"name": keyword_score(c["name"]),
"source_keyword": keyword_score(c.get("source_snippet", "")),
"runtime_stack": min(1.0, runtime_count / 3),
"request_correlation": network_correlation,
"input_output_flow": 1.0 if c["verified"] else (
0.6 if c.get("_has_fingerprint_flow") else (0.3 if c.get("output_fingerprints") else 0.0)
),
"module_export": 1.0 if c["source"] == "webpack" else (0.6 if c["source"] == "global" else 0.0),
"cross_source": 1.0 if "cross_source_name" in evidence_types else 0.0,
"verification": 1.0 if c["verified"] else 0.0,
}
weights = {
"name": .12, "source_keyword": .10, "runtime_stack": .17,
"request_correlation": .16, "input_output_flow": .18,
"module_export": .08, "cross_source": .07, "verification": .12
}
total = round(sum(scores[k] * weights[k] for k in weights), 3)
c["scores"] = scores
c["total_score"] = total
c["confidence"] = (
"high" if c["verified"] and total >= .62
else ("medium" if total >= .38 else "low")
)
c["evidence"] = c["evidence"][:100]
c.pop("_has_fingerprint_flow", None)
return sorted(store.values(), key=lambda x: (x["verified"], x["total_score"]), reverse=True)
def main() -> int:
parser = argparse.ArgumentParser(description="Evidence-driven encryption/signature candidate discovery.")
parser.add_argument("--probe-artifacts", required=True, help="Path to probe_dump.json.")
parser.add_argument("--module-artifacts", help="Path to module_dump.json.")
parser.add_argument("--evidence-graph", help="Path to evidence_graph.json.")
parser.add_argument("--static-artifacts", help="Path to static_candidates.json.")
parser.add_argument("--analysis", help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Path to output candidates JSON.")
args = parser.parse_args()
probe = load_json(args.probe_artifacts, {})
modules = load_json(args.module_artifacts, {}) if args.module_artifacts else {}
graph = load_json(args.evidence_graph, {}) if args.evidence_graph else {}
static_artifacts = load_json(args.static_artifacts, {}) if args.static_artifacts else {}
if static_artifacts:
modules = dict(modules)
modules.setdefault("static", []).extend(static_artifacts.get("candidates", []))
analysis = load_json(args.analysis, {}) if args.analysis else {}
candidates = build_candidates(probe, modules, graph, analysis)
result = {
"candidates": candidates,
"stats": {
"total": len(candidates),
"high": sum(c["confidence"] == "high" for c in candidates),
"medium": sum(c["confidence"] == "medium" for c in candidates),
"low": sum(c["confidence"] == "low" for c in candidates),
"verified": sum(c["verified"] for c in candidates)
}
}
dump_json(args.output, result)
print(f"[OK] candidate discovery: {args.output} {result['stats']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Emit/apply differential verification artifacts.
Two subcommands:
emit — Generate a JS verification script that calls candidate functions with
test samples and compares output fingerprints against captured request
field expectations.
apply — Merge verification results back into candidates, promoting to
verified=true only when actual sample matches succeed.
Usage:
python3 scripts/differential_verifier.py emit \
--analysis analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--output generated/differential_verifier.js \
--plan-output artifacts/differential_plan.json
python3 scripts/differential_verifier.py apply \
--candidates artifacts/encryption_candidates.json \
--results artifacts/differential_verification_results.json \
--output artifacts/encryption_candidates.verified.json \
--minimum-matches 2
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import dump_json, load_json
JS_TEMPLATE = r'''(() => {
"use strict";
const plan = __PLAN__;
const results = [];
function resolvePath(path) {
if (!path) return null;
if (path.startsWith("webpack:")) {
const parts = path.split(":");
let source = "default", moduleId, exportPath;
if (parts.length >= 4) {
source = parts[1]; moduleId = parts[2]; exportPath = parts.slice(3).join(":");
} else {
moduleId = parts[1]; exportPath = parts.slice(2).join(":");
}
const req = (window.__JSRA_WEBPACK_REQUIRE_MAP__ &&
window.__JSRA_WEBPACK_REQUIRE_MAP__[source]) ||
window.__JSRA_WEBPACK_REQUIRE__;
if (!req || !req.c || !req.c[moduleId]) return null;
let value = req.c[moduleId].exports;
for (const part of (exportPath || "default").split(".")) {
if (part === "default" && value && value.default !== undefined) value = value.default;
else if (part) value = value && value[part];
}
return value;
}
const normalized = path.replace(/^window\./, "");
return normalized.split(".").reduce((obj, key) => obj && obj[key], window);
}
async function fp(value) {
const text = typeof value === "string" ? value : JSON.stringify(value);
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return "sha256:" + Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, "0")).join("");
}
async function run() {
for (const item of plan.items) {
if (!item.safe_to_invoke) {
results.push({
candidate_id: item.candidate_id, verified: false,
skipped: true, reason: "safe_to_invoke is false"
});
continue;
}
const fn = resolvePath(item.path);
if (typeof fn !== "function") {
results.push({
candidate_id: item.candidate_id, verified: false,
reason: "candidate not resolvable", tests: []
});
continue;
}
const tests = [];
for (const sample of item.samples) {
try {
const output = await fn(sample);
const output_fingerprint = await fp(output);
const expected = item.expected_fingerprints[String(sample)] || null;
const matched = Boolean(expected && expected === output_fingerprint);
tests.push({
input: sample,
output_fingerprint,
output_preview: String(output).slice(0, 96),
expected_fingerprint: expected,
matched,
expectation_source: expected ? "captured_request_field" : "missing_expectation"
});
} catch (error) {
tests.push({ input: sample, error: String(error), matched: false });
}
}
const matched = tests.filter(test => test.matched === true).length;
results.push({
candidate_id: item.candidate_id, path: item.path,
verified: matched >= item.minimum_matches,
matched, tests
});
}
window.__JSRA_VERIFICATION_RESULTS__ = {
generatedAt: Date.now(), results
};
return window.__JSRA_VERIFICATION_RESULTS__;
}
return run();
})();
'''
def build_plan(analysis: dict, candidates: dict) -> dict:
"""Build a verification plan from analysis transforms and candidates."""
transforms = analysis.get("transforms") or []
by_path = {c.get("path"): c for c in candidates.get("candidates", []) if c.get("path")}
by_name = {c.get("name"): c for c in candidates.get("candidates", []) if c.get("name")}
samples = analysis.get("verification", {}).get("samples") or ["JSRA_TEST_A", "JSRA_TEST_B"]
expected_by_sample = analysis.get("verification", {}).get("expected_fingerprints", {})
items = []
for transform in transforms:
path = transform.get("candidate_path")
candidate = by_path.get(path) or by_name.get(path)
if candidate is None and path:
candidate = {"id": f"configured:{path}", "path": path}
if candidate:
items.append({
"candidate_id": candidate.get("id"),
"transform_id": transform.get("id"),
"path": path or candidate.get("path"),
"safe_to_invoke": bool(transform.get("safe_to_invoke")),
"samples": samples,
"minimum_matches": int(analysis.get("verification", {}).get("minimum_matches", 1)),
"expected_fingerprints": (
transform.get("expected_fingerprints", {}) or expected_by_sample
)
})
return {"items": items}
def emit(args: argparse.Namespace) -> int:
"""Emit a differential verification JS script."""
analysis = load_json(args.analysis)
candidates = load_json(args.candidates)
plan = build_plan(analysis, candidates)
content = JS_TEMPLATE.replace("__PLAN__", json.dumps(plan, ensure_ascii=False))
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
if args.plan_output:
dump_json(args.plan_output, plan)
print(f"[OK] verifier emitted: {path}; items={len(plan['items'])}")
return 0
def apply(args: argparse.Namespace) -> int:
"""Apply verification results back into candidates."""
candidates = load_json(args.candidates)
results = load_json(args.results)
by_id = {r.get("candidate_id"): r for r in results.get("results", [])}
minimum = args.minimum_matches
for candidate in candidates.get("candidates", []):
result = by_id.get(candidate.get("id"))
if not result:
continue
matched = sum(test.get("matched") is True for test in result.get("tests", []))
candidate.setdefault("verification", []).append(result)
candidate.setdefault("evidence", []).append({
"type": "verification", "mode": "active_differential", "matched": matched
})
# Only actual sample matches can promote to verified
if matched >= minimum and result.get("verified") is True:
candidate["verified"] = True
candidate.setdefault("scores", {})["verification"] = 1.0
candidate["total_score"] = round(min(1.0, candidate.get("total_score", 0) + .12), 3)
candidate["confidence"] = "high" if candidate["total_score"] >= .62 else "medium"
dump_json(args.output, candidates)
print(f"[OK] verification results applied: {args.output}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Emit/apply differential verification artifacts.")
sub = parser.add_subparsers(dest="command", required=True)
p_emit = sub.add_parser("emit", help="Emit verification JS script.")
p_emit.add_argument("--analysis", required=True)
p_emit.add_argument("--candidates", required=True)
p_emit.add_argument("--output", required=True)
p_emit.add_argument("--plan-output", help="Optional path to dump the plan JSON.")
p_apply = sub.add_parser("apply", help="Apply verification results to candidates.")
p_apply.add_argument("--candidates", required=True)
p_apply.add_argument("--results", required=True)
p_apply.add_argument("--output", required=True)
p_apply.add_argument("--minimum-matches", type=int, default=2)
args = parser.parse_args()
return emit(args) if args.command == "emit" else apply(args)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Check JSRA runtime dependencies.
Usage:
python3 scripts/doctor.py
python3 scripts/doctor.py --ci
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import platform
import shutil
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Check JSRA runtime dependencies.")
parser.add_argument("--ci", action="store_true")
args = parser.parse_args()
checks = {
"python": {"ok": sys.version_info >= (3, 10), "value": platform.python_version(), "required": True},
"flask": {"ok": importlib.util.find_spec("flask") is not None, "required": True},
"requests": {"ok": importlib.util.find_spec("requests") is not None, "required": True},
"jsonschema": {"ok": importlib.util.find_spec("jsonschema") is not None, "required": True},
"websocket_client": {"ok": importlib.util.find_spec("websocket") is not None, "required": False},
"node": {"ok": shutil.which("node") is not None, "value": shutil.which("node"), "required": False},
"chrome": {"ok": any(shutil.which(x) for x in ("google-chrome", "chromium", "chromium-browser")), "required": False},
}
print(json.dumps(checks, ensure_ascii=False, indent=2))
failed = [name for name, item in checks.items() if item.get("required") and not item.get("ok")]
if failed:
print("Missing required dependencies:", ", ".join(failed))
return 2
if not args.ci and not checks["node"]["ok"]:
print("[WARN] Node.js not found; JavaScript syntax validation will be skipped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,456 @@
#!/usr/bin/env python3
"""Emit a low-side-effect adversarial runtime probe.
The generated probe is deliberately split into observation and intervention:
observation records anti-debug/anti-hook/environment signals, while intervention
only applies the explicitly requested debugger-source filter. Every installed
patch is tracked and can be restored through ``uninstall()``.
Usage:
python3 scripts/emit_adversarial_runtime_probe.py \
--output generated/adversarial_runtime_probe.js
python3 scripts/emit_adversarial_runtime_probe.py \
--mode intervene --output generated/adversarial_runtime_probe.js
"""
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 = "adversarial-runtime";
const CONFIG = __CONFIG__;
const KEYWORDS = /pass(word)?|token|secret|authorization|cookie|session|credential/i;
const root = window;
__HOOK_REGISTRY__
const registry = getHookRegistry(root);
if (root.__JSRA_ADVERSARIAL__ && root.__JSRA_ADVERSARIAL__.probe_id === PROBE_ID) {
return root.__JSRA_ADVERSARIAL__;
}
const native = {
json: JSON.stringify.bind(JSON),
setTimeout: window.setTimeout.bind(window),
setInterval: window.setInterval.bind(window),
clearInterval: window.clearInterval.bind(window),
fetch: window.fetch,
eval: window.eval,
Function: window.Function,
defineProperty: Object.defineProperty,
getOwnPropertyDescriptor: Object.getOwnPropertyDescriptor,
getPrototypeOf: Object.getPrototypeOf,
toString: Function.prototype.toString,
};
const state = {
probe_id: PROBE_ID,
mode: CONFIG.mode,
installedAt: Date.now(),
events: [],
patches: [],
realms: [{ realm_id: "main", kind: "window", url: location.href }],
maxEvents: CONFIG.maxEvents,
dropped: 0,
suppressed: 0,
suppressedByPath: {},
health: { installed: true, lostPatches: 0, externalChanges: 0, reconcileErrors: 0, restored: false }
};
let counter = 0;
let healTimer = null;
let observer = null;
const active = new Map();
function id(prefix) {
counter += 1;
return `${prefix}-${Date.now().toString(36)}-${counter.toString(36)}`;
}
function preview(value, key = "") {
if (KEYWORDS.test(String(key))) return "<redacted>";
if (value === null) return "null";
if (value === undefined) return "undefined";
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 (value && typeof value === "object") return `[${value.constructor && value.constructor.name || "object"}]`;
const text = String(value);
return CONFIG.captureRaw ? text.slice(0, 160) : `${typeof value}:${text.length}`;
}
function emit(type, detail = {}) {
const event = {
event_id: id("adv"),
trace_id: detail.trace_id || id("trace"),
type,
path: detail.path || null,
function: detail.function || null,
input: preview(detail.input, detail.key),
output: preview(detail.output, detail.key),
metadata: detail.metadata || {},
timestamp: Date.now()
};
if (state.events.length >= state.maxEvents) {
state.events.shift();
state.dropped += 1;
}
state.events.push(event);
return event;
}
function descriptor(target, key) {
try {
let current = target;
while (current) {
const desc = native.getOwnPropertyDescriptor(current, key);
if (desc) return { owner: current, desc };
current = native.getPrototypeOf(current);
}
} catch (_) {}
return null;
}
function installPatch(name, target, key, factory, metadata = {}) {
if (!target || typeof target[key] !== "function") return null;
emit("patch.install", { path: name, metadata: { ...metadata, key } });
const layerId = `adversarial:${name}`;
const layer = registry.add(target, key, layerId, original => {
const replacement = factory(original);
try {
if (original && original.prototype && replacement && replacement.prototype)
replacement.prototype = original.prototype;
} catch (_) {}
return replacement;
});
if (!layer) return null;
const patch = { name, target, key, layerId, installed: true };
state.patches.push(patch);
active.set(name, patch);
patch.reconcile = () => {
const result = registry.reconcile(target, key);
if (result.changed) {
emit(result.status === "error" ? "patch.reconcile_error" : "patch.reconciled", {
path: name, metadata: { key, status: result.status, error: result.error || null }
});
if (result.status === "error") state.health.reconcileErrors += 1;
else state.health.externalChanges += 1;
}
return result;
};
return patch;
}
function wrap(target, key, type, path, options = {}) {
const sampleEvery = Math.max(1, Number(options.sampleEvery || 1));
const eventBudget = Number.isFinite(Number(options.eventBudget))
? Math.max(0, Number(options.eventBudget)) : Infinity;
const burst = Math.max(0, Number(options.burst || 0));
let invocationCount = 0;
function shouldRecord() {
invocationCount += 1;
const selected = invocationCount <= eventBudget &&
(invocationCount <= burst || invocationCount % sampleEvery === 0);
if (!selected) {
state.suppressed += 1;
state.suppressedByPath[path] = (state.suppressedByPath[path] || 0) + 1;
}
return selected;
}
return installPatch(path, target, key, original => function(...args) {
if (typeof options.skip === "function" && options.skip.call(this, args))
return original.apply(this, args);
const recordInvocation = shouldRecord();
const traceId = id("trace");
if (recordInvocation)
emit(`${type}.call`, { trace_id: traceId, path, function: path, input: args[0], metadata: { argc: args.length } });
let result;
try {
result = original.apply(this, args);
} catch (error) {
if (recordInvocation)
emit(`${type}.error`, { trace_id: traceId, path, function: path, metadata: { error: String(error) } });
throw error;
}
if (result && typeof result.then === "function") {
return result.then(value => {
if (recordInvocation)
emit(`${type}.return`, { trace_id: traceId, path, function: path, output: value });
return value;
}, error => {
if (recordInvocation)
emit(`${type}.reject`, { trace_id: traceId, path, function: path, metadata: { error: String(error) } });
throw error;
});
}
if (recordInvocation)
emit(`${type}.return`, { trace_id: traceId, path, function: path, output: result });
return result;
}, options);
}
function watchGetter(target, key, path) {
const found = descriptor(target, key);
if (!found || !found.desc || typeof found.desc.get !== "function" || found.desc.configurable === false)
return false;
const original = found.desc;
const replacement = { ...original, get() {
const value = original.get.call(this);
emit("environment.property_read", { path, key, output: value, metadata: { owner: path } });
return value;
}};
try {
native.defineProperty(found.owner, key, replacement);
state.patches.push({ name: `getter:${path}`, target: found.owner, key, descriptor: original, installed: true });
return true;
} catch (error) {
emit("patch.error", { path, metadata: { error: String(error) } });
return false;
}
}
function resolve(path) {
return String(path).split(".").reduce((value, key) => value == null ? null : value[key], root);
}
function watchProperties() {
const paths = CONFIG.properties || [
"navigator.webdriver", "navigator.userAgent", "navigator.platform",
"navigator.languages", "navigator.plugins", "screen.width", "screen.height",
"window.innerWidth", "window.innerHeight", "window.outerWidth", "window.outerHeight",
"document.cookie"
];
for (const path of paths) {
const parts = path.split(".");
const key = parts.pop();
const parent = resolve(parts.join("."));
if (parent && watchGetter(parent, key, path))
emit("environment.watch_installed", { path });
}
}
function observeDynamicCode() {
const stripDebugger = source => typeof source === "string"
? source.replace(/\bdebugger\s*;?/g, "") : source;
installPatch("window.eval", root, "eval", original => function(source) {
const next = CONFIG.mode === "intervene" ? stripDebugger(source) : source;
if (next !== source) emit("intervention.debugger_source", { path: "window.eval", input: source, metadata: { action: "strip" } });
emit("dynamic.eval.call", { path: "window.eval", input: next });
return original.call(this, next);
});
installPatch("window.Function", root, "Function", original => function(...args) {
const last = args.length - 1;
if (CONFIG.mode === "intervene" && last >= 0 && typeof args[last] === "string") {
const next = stripDebugger(args[last]);
if (next !== args[last]) emit("intervention.debugger_source", { path: "window.Function", input: args[last], metadata: { action: "strip" } });
args[last] = next;
}
emit("dynamic.function.call", { path: "window.Function", input: args[last], metadata: { argc: args.length } });
return new.target ? Reflect.construct(original, args, new.target) : original.apply(this, args);
});
root.Function.prototype = native.Function.prototype;
}
function observeNetwork() {
if (typeof root.fetch === "function") {
wrap(root, "fetch", "network.fetch", "window.fetch");
}
if (root.XMLHttpRequest) {
wrap(root.XMLHttpRequest.prototype, "open", "network.xhr_open", "XMLHttpRequest.open");
wrap(root.XMLHttpRequest.prototype, "setRequestHeader", "network.xhr_header", "XMLHttpRequest.setRequestHeader");
wrap(root.XMLHttpRequest.prototype, "send", "network.xhr_send", "XMLHttpRequest.send");
}
if (root.Response) {
["json", "text", "arrayBuffer", "blob"].forEach(key => wrap(root.Response.prototype, key, "network.response", `Response.${key}`));
}
if (root.WebSocket) wrap(root.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; }
}
});
}
function observeCrypto() {
if (!root.crypto || !root.crypto.subtle) return;
["encrypt", "decrypt", "sign", "verify", "digest", "importKey", "exportKey", "deriveKey", "deriveBits", "generateKey"].forEach(key => {
wrap(root.crypto.subtle, key, "crypto.subtle", `crypto.subtle.${key}`);
});
if (typeof root.crypto.getRandomValues === "function") wrap(root.crypto, "getRandomValues", "crypto.random", "crypto.getRandomValues");
}
function observeLoaders() {
if (root.Worker) {
installPatch("window.Worker", root, "Worker", original => function(...args) {
emit("realm.worker_create.call", { path: "window.Worker", input: args[0], metadata: { argc: args.length } });
return new original(...args);
});
}
if (root.SharedWorker) {
installPatch("window.SharedWorker", root, "SharedWorker", original => function(...args) {
emit("realm.shared_worker_create.call", { path: "window.SharedWorker", input: args[0], metadata: { argc: args.length } });
return new original(...args);
});
}
if (root.WebAssembly) {
["instantiate", "instantiateStreaming"].forEach(key => {
if (typeof root.WebAssembly[key] === "function") wrap(root.WebAssembly, key, "loader.wasm", `WebAssembly.${key}`);
});
}
if (root.document && root.MutationObserver) {
observer = new MutationObserver(records => records.forEach(record => record.addedNodes && Array.from(record.addedNodes).forEach(node => {
if (node && node.tagName === "SCRIPT") emit("loader.script", { path: "script.src", input: node.src, metadata: { async: !!node.async, type: node.type || "" } });
if (node && node.tagName === "IFRAME") emit("realm.iframe_create", { path: "iframe.src", input: node.src });
})));
observer.observe(root.document.documentElement || root.document, { childList: true, subtree: true });
}
}
function observeControlFlow() {
if (!CONFIG.controlFlow) return;
if (root.console) {
["clear", "table"].forEach(key => {
if (typeof root.console[key] === "function")
wrap(root.console, key, "control.console", `console.${key}`, { sampleEvery: 1, eventBudget: 64 });
});
}
if (root.history) {
["pushState", "replaceState", "back", "forward", "go"].forEach(key => {
if (typeof root.history[key] !== "function") return;
installPatch(`history.${key}`, root.history, key, original => function(...args) {
const blocked = CONFIG.mode === "intervene" && ["back", "forward", "go"].includes(key);
emit(blocked ? "intervention.history_blocked" : "control.history.call", {
path: `history.${key}`, input: args[0], metadata: { argc: args.length, blocked }
});
if (blocked) return undefined;
return original.apply(this, args);
});
});
}
if (root.Storage && root.Storage.prototype) {
["getItem", "setItem", "removeItem", "clear"].forEach(key => {
if (typeof root.Storage.prototype[key] !== "function") return;
wrap(root.Storage.prototype, key, "control.storage", `Storage.${key}`, {
sampleEvery: 1,
eventBudget: 128,
skip: function(args) {
return key !== "clear" && typeof args[0] === "string" && !KEYWORDS.test(args[0]);
}
});
});
}
if (typeof root.close === "function") {
installPatch("window.close", root, "close", original => function(...args) {
const blocked = CONFIG.mode === "intervene";
emit(blocked ? "intervention.close_blocked" : "control.close.call", {
path: "window.close", metadata: { argc: args.length, blocked }
});
if (blocked) return undefined;
return original.apply(this, args);
});
}
}
function observeIntegrity() {
const options = {
eventBudget: CONFIG.integrityEventBudget,
sampleEvery: CONFIG.integritySampleEvery,
burst: CONFIG.integrityBurst,
};
// Browser libraries call these methods at very high frequency. Capture
// an initial burst plus bounded samples so integrity evidence cannot evict
// crypto/network events from the ring buffer.
wrap(Function.prototype, "toString", "integrity.toString", "Function.prototype.toString", options);
wrap(Object, "getOwnPropertyDescriptor", "integrity.descriptor", "Object.getOwnPropertyDescriptor", options);
wrap(Object, "getPrototypeOf", "integrity.prototype", "Object.getPrototypeOf", options);
}
function install() {
watchProperties();
observeDynamicCode();
observeNetwork();
observeControlFlow();
observeCrypto();
observeLoaders();
observeIntegrity();
if (CONFIG.persistent) {
healTimer = native.setInterval(() => state.patches.slice().forEach(patch => {
try { if (patch.reconcile) patch.reconcile(); } catch (error) {
emit("patch.reconcile_error", { path: patch.name, metadata: { error: String(error) } });
state.health.reconcileErrors += 1;
}
}), CONFIG.healIntervalMs);
}
emit("probe.ready", { metadata: { mode: CONFIG.mode, persistent: CONFIG.persistent } });
}
function restore() {
if (healTimer) native.clearInterval(healTimer);
if (observer) observer.disconnect();
for (const patch of state.patches.slice().reverse()) {
try {
if (patch.descriptor) native.defineProperty(patch.target, patch.key, patch.descriptor);
else if (patch.layerId) registry.remove(patch.target, patch.key, patch.layerId);
} catch (_) {}
}
state.health.restored = true;
state.health.installed = false;
emit("probe.restored");
}
const api = {
probe_id: PROBE_ID,
state,
export() { return { probe_id: PROBE_ID, exportedAt: Date.now(), state: { ...state, events: state.events.slice(), patches: state.patches.map(p => ({ name: p.name, key: p.key, installed: p.installed })) } }; },
clear() { state.events.length = 0; state.dropped = 0; state.suppressed = 0; state.suppressedByPath = {}; },
uninstall: restore,
markTrace(label = "manual") { return id(label); }
};
root.__JSRA_ADVERSARIAL__ = api;
install();
return api;
})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Emit JSRA adversarial runtime probe.")
parser.add_argument("--output", required=True)
parser.add_argument("--mode", choices=("observe", "intervene"), default="observe")
parser.add_argument("--max-events", type=int, default=5000)
parser.add_argument("--integrity-event-budget", type=int, default=256,
help="Maximum integrity-hook invocations recorded per method.")
parser.add_argument("--integrity-sample-every", type=int, default=64,
help="After the initial burst, record every Nth integrity invocation.")
parser.add_argument("--integrity-burst", type=int, default=16,
help="Record the first N integrity invocations in full.")
parser.add_argument("--heal-interval-ms", type=int, default=1000)
parser.add_argument("--properties", default="")
parser.add_argument("--control-websocket-url", action="append", default=[],
help="URL fragment for a JSRPC/control WebSocket to leave untouched.")
parser.add_argument("--no-control-flow", action="store_true",
help="Skip console/history/storage/window.close observation.")
parser.add_argument("--capture-raw", action="store_true")
parser.add_argument("--no-persistent", action="store_true")
args = parser.parse_args()
properties = [item.strip() for item in args.properties.split(",") if item.strip()]
if not properties:
properties = [
"navigator.webdriver", "navigator.userAgent", "navigator.platform",
"navigator.languages", "navigator.plugins", "screen.width", "screen.height",
"window.innerWidth", "window.innerHeight", "window.outerWidth", "window.outerHeight",
"document.cookie"
]
config = {
"mode": args.mode,
"maxEvents": max(100, args.max_events),
"integrityEventBudget": max(1, args.integrity_event_budget),
"integritySampleEvery": max(1, args.integrity_sample_every),
"integrityBurst": max(0, args.integrity_burst),
"healIntervalMs": max(250, args.heal_interval_ms),
"properties": properties,
"captureRaw": bool(args.capture_raw),
"persistent": not args.no_persistent,
"controlWebSocketUrls": args.control_websocket_url or ["127.0.0.1:12080", "localhost:12080"],
"controlFlow": not args.no_control_flow,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
content = TEMPLATE.replace("__CONFIG__", json.dumps(config, ensure_ascii=False))
content = content.replace("__HOOK_REGISTRY__", HOOK_REGISTRY)
output.write_text(content, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(output), "mode": args.mode}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Generate Burp autoDecoder integration documentation from analysis_result.json.
Usage:
python3 scripts/emit_burp_doc.py --analysis analysis_result.json --output generated/burp-autodecoder.md
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from common import load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Generate Burp autoDecoder integration guide.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Output markdown file path.")
parser.add_argument("--status", default="", help="Optional Flask status JSON; uses the actual fallback port when present.")
args = parser.parse_args()
analysis = load_json(args.analysis)
flask = analysis.get("flask", {})
flask_server = analysis.get("flask_server", {})
jsrpc = analysis.get("jsrpc", {})
port = flask.get("port", flask_server.get("port", 5000))
status = load_json(args.status, {}) if args.status else {}
if status.get("status") == "started" and status.get("port"):
port = status["port"]
transforms = analysis.get("transforms") or []
parameters = list(analysis.get("parameters", {}).keys())
proxy_url = f"http://127.0.0.1:{port}{flask.get('route', '/autodecoder')}"
encode_url = status.get("encode_url", f"http://127.0.0.1:{port}/encode")
decode_url = status.get("decode_url", f"http://127.0.0.1:{port}/decode")
jsrpc_port = analysis.get("jsrpc_server", {}).get("port", 12080)
group = jsrpc.get("group", "jsra")
action = jsrpc.get("action_name", "encode_password")
sample_data_body = "&".join(f"{p}=<plaintext>" for p in parameters)
# Build transform table if transforms exist
if transforms:
rows = "\n".join(
f"| {t.get('id')} | {t.get('direction')} | {t.get('location')} | "
f"{t.get('content_type', 'auto')} | `{t.get('path', '')}` | `{t.get('action')}` |"
for t in transforms
)
transform_table = f"""
## 转换项
| ID | 方向 | 位置 | 内容类型 | 路径 | JSRPC Action |
|---|---|---|---|---|---|
{rows}
"""
else:
transform_table = ""
content = f'''# Burp autoDecoder 配置(JSRA)
> 重要:此代理不实现加密/解密算法。它只通过 JSRPC 远程调用当前浏览器页面已验证的真实函数。若真实入口、运行时证据或 JSRPC 不可用,代理会返回 `JSRA_ERROR`,不得继续发送请求。
## 本地服务
- Health:`http://127.0.0.1:{port}/health`
- Healthz:`http://127.0.0.1:{port}/healthz`
- 转换接口:`http://127.0.0.1:{port}/autodecoder?direction=request`
- 响应接口:`http://127.0.0.1:{port}/autodecoder?direction=response`
- 当前服务端口:`{port}`。如果服务启动时发生端口回退,文档优先使用 `artifacts/flask_status.json` 中的实际地址。
{transform_table}
## 配置原则
1. 仅匹配授权目标 URL。
2. 请求方向使用 `direction=request`,响应解密使用 `direction=response`。
3. 在 autoDecoder 页面直接粘贴完整 HTTP 请求/响应时,插件可能以 `application/octet-stream` 调用接口;代理会从内层报文自动识别真实 `Content-Type`,并返回完整改写后的报文。
4. 使用 `dataBody/dataHeaders` wrapper 时,`dataBody` 传 body,`dataHeaders` 传可选头部;JSON 请求也可用 `X-JSRA-Content-Type` 覆盖。
5. 首次联调前访问 `/health`。
6. 发生 `JSRA_ERROR` 时不要继续发送被破坏的请求。
7. 不要把页面抓到的公钥、模数或某次样本结果改写成 Python 加密实现。
## Burp autoDecoder 配置
### 截图所示的“接口加解密设置”页面
| 页面字段 | 配置值 |
|---|---|
| 解密接口 | `{decode_url}`(可与加密接口同时配置) |
| 加密接口 | `{encode_url}` |
| 请求方向 | 当前测试请求时选择“请求数据包” |
| 响应方向 | 当前测试响应时选择“响应数据包”;与“请求数据包”互斥 |
| 处理请求头 | 默认不选;只有签名依赖请求头时才启用 |
| 请求 base64 编码 | 只有原始请求体本身是 Base64 时才选 |
| 请求自动 base64 解码 | 只有服务端要求先解码 Base64 时才选 |
| Proxy、Repeater 等模块真实调试 | 联调时可选,用于观察返回的完整数据包 |
加密接口和解密接口可以同时配置。请求方向调试时,在“原始数据包”区域粘贴完整 HTTP 请求并选择“请求数据包”;响应方向调试时切换为“响应数据包”,再粘贴完整 HTTP 响应,两个方向不能同时选。对于截图中的整包模式,接口收到的是完整 HTTP 报文,外层常见 `Content-Type: application/octet-stream`;代理会解析内层 `Content-Type`、替换 body,并同步 `Content-Length`。对于 wrapper 模式,autoDecoder 通过 `dataBody` 传请求体,勾选“处理请求头”时额外传 `dataHeaders`,并传 `requestorresponse=request` 或 `response`。未处理请求头时整包模式返回完整改写报文,wrapper 模式返回改写后的 body;处理请求头时必须返回 `headers + "\\r\\n\\r\\n\\r\\n\\r\\n" + body`,这是 autoDecoder 的固定返回格式。
响应解密时使用已配置的 `{decode_url}`,切换为“响应数据包”后进行测试;如果当前只验证请求加密,也可以保留解密接口配置不使用。
### 响应体配置
| 页面字段 | 配置值 |
|---|---|
| 响应数据方向 | 选择“响应数据包” |
| 解密接口 | `{decode_url}` |
| 处理响应头 | 只有响应解密依赖响应头时才启用 |
| 响应 base64 编码 | 只有响应体传入接口前需要 Base64 编码时才选 |
| 响应自动 base64 解码 | 只有接口返回 Base64、需要由 autoDecoder 还原二进制响应时才选 |
| 响应请求标识 | `requestorresponse=response` |
响应方向启用请求头处理时,接口接收 `dataBody`、`dataHeaders` 和 `requestorresponse=response`,并返回 `响应头 + \\r\\n\\r\\n\\r\\n\\r\\n + 解密后的响应体`。未启用响应头处理时只返回解密后的响应体。
响应体验证:
```bash
curl --noproxy '*' -sS -X POST {decode_url} \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
--data-urlencode 'dataBody=<原始响应体>' \\
--data-urlencode 'dataHeaders=<可选的原始响应头>' \\
--data-urlencode 'requestorresponse=response'
```
预期结果为解密后的响应体;启用响应头处理时,返回值必须包含四组 CRLF 分隔的响应头和响应体。
### 页面配置验证(标准 autoDecoder 协议)
```bash
curl --noproxy '*' -sS -X POST {encode_url} \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
--data-urlencode 'dataBody=<原始请求体>' \\
--data-urlencode 'requestorresponse=request'
```
勾选“处理请求头”时增加:
```bash
--data-urlencode 'dataHeaders=<原始请求头>'
```
此时预期返回格式为:`原始请求头 + \\r\\n\\r\\n\\r\\n\\r\\n + 改写后的请求体`。
### 完整 HTTP 数据包调试
生成的 `/encode` 和 `/decode` 也支持直接提交完整 HTTP 数据包,便于脱离 Burp 页面调试;该模式会拆分请求头和请求体,并自动更新 `Content-Length`。整包验证示例:
```bash
curl --noproxy '*' -sS -X POST {encode_url} \\
-H 'Content-Type: application/octet-stream' \\
--data-binary $'POST /newlogin/login.do?appName=arena&fromSite=77 HTTP/1.1\\r\\nHost: 127.0.0.1:8123\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n\\r\\nloginId=admin&password2=123456'
```
预期返回完整 HTTP 请求,且只替换目标字段;若返回 `JSRA_ERROR`,先检查 JSRPC action 是否仍注册在当前浏览器页面。
### 兼容方式:dataBody/dataHeaders
Burp autoDecoder 插件使用 `dataBody` 和 `dataHeaders` 表单字段:
```
POST {proxy_url} HTTP/1.1
Content-Type: application/x-www-form-urlencoded
dataBody=<原始请求体>&dataHeaders=<原始请求头>
```
- `dataBody`:原始请求体(JSON 或 form-urlencoded)
- `dataHeaders`:原始请求头(可选,用于传递 Cookie、Authorization 等)
### 方式二:直接 POST
```
POST {proxy_url}?direction=request HTTP/1.1
Content-Type: <原始 Content-Type>
<原始请求体>
```
## 验证步骤
1. 在目标页面加载 `JsEnv_Dev.js` 和生成的 `jsrpc_inject.js`。
2. 确认浏览器 Console 出现 JSRPC connected,以及已注册的 action。
3. 用一个受控样本调用 JSRPC:
```bash
curl 'http://127.0.0.1:{jsrpc_port}/go?group={group}&action={action}&param=test123'
```
4. 验证 Flask 代理:
```bash
curl -X POST {proxy_url} \\
-H "Content-Type: application/x-www-form-urlencoded" \\
--data-urlencode "dataBody={sample_data_body}"
```
5. 确认返回值与 JSRPC 调用结果一致。
## 故障排查
- `JSRA_ERROR: ...:EvidenceMissing:...`:没有真实运行时证据或候选入口不可用;重新注入探针、导出证据并运行差分验证。
- `JSRA_ERROR: ...:RuntimeError:...`:页面真实函数执行失败;检查页面状态、依赖、DOM 和存储前置条件。
- `JSRA_ERROR` / HTTP 502:JSRPC 连接、响应格式或页面入口失败;不要发送该请求,先恢复浏览器和 JSRPC。
'''
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())

View File

@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""根据 analysis_result.json 生成 Flask 代理。
支持 dataBody/dataHeaders 兼容模式和 transforms 驱动模式。
所有转换都通过 JSRPC 完成,本文件不实现密码算法。
Usage:
python3 scripts/emit_flask_proxy.py --analysis analysis_result.json --output generated/flask_proxy.py
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
from common import load_json
def python_literal(value: Any) -> str:
"""Render a Python literal for embedding in generated code."""
if value is True:
return "True"
if value is False:
return "False"
if value is None:
return "None"
if isinstance(value, dict):
return "{" + ", ".join(
f"{json.dumps(str(k), ensure_ascii=False)}: {python_literal(v)}"
for k, v in value.items()
) + "}"
if isinstance(value, list):
return "[" + ", ".join(python_literal(v) for v in value) + "]"
return json.dumps(value, ensure_ascii=False)
def normalize(analysis: dict) -> dict:
"""Normalize analysis to a common config format."""
transforms = list(analysis.get("transforms") or [])
jsrpc_input = dict(analysis.get("jsrpc") or {})
default_action = jsrpc_input.get("action_name") or jsrpc_input.get("action") or "jsra_transform"
if not transforms:
# 兼容模式:根据 parameters 构造 transforms
for name, config in (analysis.get("parameters") or {}).items():
entrypoint = config.get("entrypoint", {}) if isinstance(config, dict) else {}
transforms.append({
"id": name,
"direction": "request",
"location": "body",
"content_type": "auto",
"path": f"$.{name}",
"action": default_action,
"candidate_path": entrypoint.get("path", ""),
"safe_to_invoke": False,
"delivery_mode": config.get("delivery_mode", "result") if isinstance(config, dict) else "result",
"capture": config.get("capture", {}) if isinstance(config, dict) else {},
"dom_bindings": config.get("dom_bindings", {}) if isinstance(config, dict) else {},
})
jsrpc = jsrpc_input
transport = jsrpc.get("transport") if isinstance(jsrpc.get("transport"), dict) else {}
jsrpc_config = {
"base_url": jsrpc.get("base_url") or transport.get("go_url") or "http://127.0.0.1:12080",
"group": jsrpc.get("group", "jsra"),
"action": jsrpc.get("action_name") or jsrpc.get("action") or "",
"transport": transport.get("type") or jsrpc.get("transport_type") or "legacy_get",
"timeout_seconds": float(jsrpc.get("timeout_seconds", transport.get("timeout_seconds", 10))),
}
if not jsrpc_config["action"]:
actions = [t.get("action") for t in transforms if t.get("action")]
jsrpc_config["action"] = actions[0] if actions else "jsra_transform"
flask = dict(analysis.get("flask") or {})
flask_server = dict(analysis.get("flask_server") or {})
return {
"jsrpc": jsrpc_config,
"transforms": transforms,
"flask_port": int(flask_server.get("port", flask.get("port", flask.get("listen_port", 5000)))),
"flask_host": flask_server.get("host", flask.get("listen_host", "127.0.0.1")),
"route": flask.get("route", "/autodecoder"),
}
def build_script(analysis: dict) -> str:
config = normalize(analysis)
return f'''#!/usr/bin/env python3
# Generated by emit_flask_proxy.py. JSRPC is the only transform engine.
from __future__ import annotations
import json
import os
from collections import OrderedDict
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import requests
from flask import Flask, Response, jsonify, request
CONFIG = {python_literal(config)}
CONFIG["flask_port"] = int(os.environ.get("JSRA_FLASK_PORT", CONFIG["flask_port"]))
app = Flask(__name__)
class JSRPCError(Exception):
def __init__(self, message: str, status: str = "error", detail: str = ""):
super().__init__(message)
self.status = status
self.detail = detail
def _error_detail(payload: object) -> str:
try:
return json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
return str(payload)
def _nested_jsrpc_data(payload: object) -> object:
if not isinstance(payload, dict):
return payload
nested = payload.get("data")
if isinstance(nested, str):
try:
return json.loads(nested)
except ValueError:
return nested
return nested if nested is not None else payload
def _extract_result(payload: object, transform: dict | None = None) -> object:
if not isinstance(payload, dict):
raise JSRPCError("JSRPC response must be a JSON object", "unexpected_response", _error_detail(payload))
if payload.get("ok") is False or payload.get("status") in {{"error", "failed", "failure"}}:
raise JSRPCError("JSRPC reported an error", "entrypoint_error", _error_detail(payload))
nested = _nested_jsrpc_data(payload)
if isinstance(transform, dict) and transform.get("delivery_mode") == "request_body":
if isinstance(nested, dict) and nested.get("requestBody") not in (None, ""):
return nested["requestBody"]
raise JSRPCError("JSRPC response has no captured requestBody", "unexpected_response", _error_detail(payload))
for key in ("response_data", "data", "result", "value"):
if key in payload:
value = payload[key]
if isinstance(value, dict) and value.get("ok") is False:
raise JSRPCError("page function reported an error", "entrypoint_error", _error_detail(value))
if isinstance(value, dict) and "result" in value and len(value) <= 3:
value = value["result"]
return value
raise JSRPCError("JSRPC response has no result field", "unexpected_response", _error_detail(payload))
def _validate_result(value: object, input_value: object, transform: dict) -> object:
if value is None or value == "":
raise JSRPCError("page function returned an empty result", "invalid_result")
if isinstance(value, str) and value.startswith("__JSRPC_ERROR__:"):
raise JSRPCError(value, "entrypoint_error", value)
if isinstance(value, dict):
if value.get("error") or value.get("__JSRA_ERROR__"):
raise JSRPCError("page function returned an error object", "entrypoint_error", _error_detail(value))
if transform.get("reject_identity") and value == input_value:
raise JSRPCError("page function returned the input unchanged", "invalid_result")
return value
def jsrpc_call(action: str, value: object, transform: dict) -> object:
cfg = CONFIG["jsrpc"]
base = str(cfg["base_url"]).rstrip("/")
endpoint = base if base.endswith("/go") else base + "/go"
timeout = float(cfg.get("timeout_seconds", 10))
context = {{"transform_id": transform.get("id"), "candidate_path": transform.get("candidate_path")}}
if transform.get("context_fields") is not None:
context["fields"] = transform.get("context_fields")
param = json.dumps({{"parameter": transform.get("id"), "value": value, "context": context}}, ensure_ascii=False)
try:
if cfg.get("transport") == "post_json":
response = requests.post(endpoint, json={{"group": cfg["group"], "action": action, "param": param}}, timeout=timeout)
else:
response = requests.get(endpoint, params={{"group": cfg["group"], "action": action, "param": param}}, timeout=timeout)
response.raise_for_status()
try:
payload = response.json()
except ValueError as error:
raise JSRPCError("JSRPC returned non-JSON data", "unexpected_response", str(error)) from error
return _validate_result(_extract_result(payload, transform), value, transform)
except JSRPCError:
raise
except requests.Timeout as error:
raise JSRPCError("JSRPC request timed out", "timeout", str(error)) from error
except requests.RequestException as error:
raise JSRPCError("JSRPC server is unavailable", "connection_error", str(error)) from error
except Exception as error:
raise JSRPCError("JSRPC call failed", "unknown", str(error)) from error
def json_get(data: object, path: str) -> object:
if path in ("", "$", None):
return data
current = data
for part in str(path).removeprefix("$.").split("."):
if isinstance(current, list):
current = current[int(part)]
elif isinstance(current, dict):
current = current[part]
else:
raise KeyError(path)
return current
def json_set(data: object, path: str, value: object) -> object:
if path in ("", "$", None):
return value
parts = str(path).removeprefix("$.").split(".")
current = data
for part in parts[:-1]:
if isinstance(current, list):
current = current[int(part)]
else:
current = current.setdefault(part, {{}})
if isinstance(current, list):
current[int(parts[-1])] = value
else:
current[parts[-1]] = value
return data
def body_value(data: object, raw: str, transform: dict) -> object:
"""Find the plaintext for request-body transforms without hard-coding a field name."""
path = transform.get("path")
try:
return json_get(data, path)
except (KeyError, IndexError, TypeError, ValueError):
if isinstance(data, dict):
for key in ("password", "value", "data"):
if key in data:
return data[key]
return raw
def transforms_for(direction: str, location: str) -> list[dict]:
return [t for t in CONFIG["transforms"] if t.get("direction") == direction and t.get("location") == location]
def body_transforms(direction: str) -> list[dict]:
return transforms_for(direction, "body") + transforms_for(direction, "response")
def transform_key(transform: dict) -> str:
path = str(transform.get("path") or "")
return path.removeprefix("$.headers.").removeprefix("$.cookies.").removeprefix("$.cookie.").removeprefix("$.").split(".")[-1]
def transform_query_target(target: str, transforms: list[dict]) -> str:
if not transforms or "?" not in target:
return target
parts = urlsplit(target)
pairs = parse_qsl(parts.query, keep_blank_values=True, strict_parsing=False)
keys = {{transform_key(t): t for t in transforms}}
updated = []
for name, value in pairs:
transform = keys.get(name)
if transform:
value = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value, transform))
updated.append((name, value))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(updated, doseq=True), parts.fragment))
def transform_cookie_value(value: str, transforms: list[dict]) -> str:
keys = {{transform_key(t): t for t in transforms}}
pairs = []
for item in value.split(";"):
name, separator, current = item.strip().partition("=")
if not separator:
pairs.append(item.strip())
continue
transform = keys.get(name)
if transform:
current = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), current, transform))
pairs.append(f"{{name}}={{current}}")
return "; ".join(pairs)
def transform_headers(header_block: str, direction: str) -> str:
header_transforms = transforms_for(direction, "header")
cookie_transforms = transforms_for(direction, "cookie")
if not header_transforms and not cookie_transforms:
return header_block
by_name = {{transform_key(t).lower(): t for t in header_transforms}}
lines = []
for line in header_block.splitlines():
if ":" not in line:
lines.append(line)
continue
name, value = line.split(":", 1)
transform = by_name.get(name.strip().lower())
if transform:
value = " " + str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value.strip(), transform))
if name.strip().lower() == "cookie" and cookie_transforms:
value = " " + transform_cookie_value(value.strip(), cookie_transforms)
lines.append(name + ":" + value)
return "\\r\\n".join(lines)
def transform_json(raw: str, transforms: list[dict]) -> str:
data = json.loads(raw or "null", object_pairs_hook=OrderedDict)
body_transform = next((t for t in transforms if t.get("delivery_mode") == "request_body"), None)
if body_transform:
old = body_value(data, raw, body_transform)
call_transform = dict(body_transform)
call_transform["context_fields"] = data
return str(jsrpc_call(call_transform.get("action", CONFIG["jsrpc"]["action"]), old, call_transform))
for transform in transforms:
old = json_get(data, transform.get("path"))
data = json_set(data, transform.get("path"), jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), old, transform))
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def transform_form(raw: str, transforms: list[dict]) -> str:
pairs = parse_qsl(raw, keep_blank_values=True, strict_parsing=False)
body_transform = next((t for t in transforms if t.get("delivery_mode") == "request_body"), None)
if body_transform:
fields = dict(pairs)
key = str(body_transform.get("path", "")).removeprefix("$.")
old = fields.get(key) or fields.get("password") or fields.get("value") or fields.get("data") or raw
call_transform = dict(body_transform)
call_transform["context_fields"] = fields
return str(jsrpc_call(call_transform.get("action", CONFIG["jsrpc"]["action"]), old, call_transform))
for transform in transforms:
key = str(transform.get("path", "")).removeprefix("$.")
pairs = [
(name, jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), value, transform) if name == key else value)
for name, value in pairs
]
return urlencode(pairs, doseq=True)
def apply_body(raw: str, content_type: str, transforms: list[dict]) -> str:
if not transforms:
return raw
# Burp autoDecoder sends a complete packet as application/octet-stream
# when the request/response packet radio is selected. Infer the body
# format when the wrapper omits the original Content-Type.
inferred_content_type = content_type or ""
if "octet-stream" in inferred_content_type.lower():
stripped = raw.lstrip()
if stripped.startswith(("{{", "[")):
inferred_content_type = "application/json"
elif "=" in raw and not stripped.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS ", "HTTP/")):
inferred_content_type = "application/x-www-form-urlencoded"
content_type = inferred_content_type
mode = transforms[0].get("content_type", "auto")
if mode == "json" or (mode == "auto" and "json" in content_type.lower()):
return transform_json(raw, transforms)
if mode == "form" or (mode == "auto" and "application/x-www-form-urlencoded" in content_type.lower()):
return transform_form(raw, transforms)
if mode == "raw":
result = raw
for transform in transforms:
result = str(jsrpc_call(transform.get("action", CONFIG["jsrpc"]["action"]), result, transform))
return result
raise ValueError("Unsupported content type: " + content_type)
def apply_packet(header_block: str, body: str, separator: str, direction: str, content_type: str) -> str:
"""Apply query/header/cookie/body transforms while preserving the packet."""
lines = header_block.splitlines()
if lines:
query_transforms = transforms_for(direction, "query")
if query_transforms:
request_line = lines[0].split(" ", 2)
if len(request_line) >= 2:
request_line[1] = transform_query_target(request_line[1], query_transforms)
lines[0] = " ".join(request_line)
transformed_headers = transform_headers("\\r\\n".join(lines), direction)
transformed_body = apply_body(body, content_type, body_transforms(direction))
return rebuild_http_packet(transformed_headers, separator, transformed_body)
def split_http_packet(raw: str) -> tuple[str, str, str] | None:
"""Split a Burp autoDecoder raw packet into headers and body."""
if not raw:
return None
candidates = [raw.lstrip("\\ufeff")]
if "\\\\r\\\\n" in raw:
candidates.append(raw.replace("\\\\r\\\\n", "\\r\\n").replace("\\\\n", "\\n"))
for candidate in candidates:
for separator in ("\\r\\n\\r\\n\\r\\n\\r\\n", "\\n\\n\\n\\n", "\\r\\n\\r\\n", "\\n\\n"):
if separator not in candidate:
continue
header_block, body = candidate.split(separator, 1)
first_line = header_block.splitlines()[0].strip().upper() if header_block.splitlines() else ""
if first_line.startswith(("GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS ", "HTTP/")):
return header_block, separator, body
return None
def packet_content_type(header_block: str) -> str:
for line in header_block.splitlines():
if line.lower().startswith("content-type:") and ":" in line:
return line.split(":", 1)[1].strip()
return "application/octet-stream"
def rebuild_http_packet(header_block: str, separator: str, body: str) -> str:
"""Return a raw packet with Content-Length synchronized to the new body."""
lines = header_block.splitlines()
body_length = len(body.encode("utf-8"))
updated = []
content_length_seen = False
for line in lines:
if line.lower().startswith("content-length:") and ":" in line:
updated.append("Content-Length: " + str(body_length))
content_length_seen = True
else:
updated.append(line)
if not content_length_seen:
updated.append("Content-Length: " + str(body_length))
return "\\r\\n".join(updated) + separator + body
@app.get("/health")
def health():
return jsonify({{"status": "ok", "service": "jsra-flask", "jsrpc": CONFIG["jsrpc"]["base_url"]}})
@app.get("/healthz")
def healthz():
return health()
@app.post("/transform")
def transform_endpoint():
payload = request.get_json(force=True, silent=False)
direction = payload.get("direction", "request")
try:
transforms = [t for t in CONFIG["transforms"] if t.get("direction") == direction]
return jsonify({{"ok": True, "body": apply_body(payload.get("body", ""), payload.get("content_type", "application/octet-stream"), transforms)}})
except JSRPCError as error:
return jsonify({{"ok": False, "error": str(error), "status": error.status, "detail": error.detail}}), 502
except Exception as error:
return jsonify({{"ok": False, "error": str(error)}}), 400
@app.post(CONFIG["route"])
def autodecoder():
# Accept both Burp autoDecoder's dataBody/dataHeaders wrapper and the
# direct-body form. The wrapper is intentionally unwrapped before the
# content-type dispatch so the same transform path is exercised either way.
wrapped_body = request.form.get("dataBody")
if wrapped_body is not None:
raw = wrapped_body
wrapped_headers = request.form.get("dataHeaders")
content_type = request.headers.get("X-JSRA-Content-Type", "")
if not content_type:
content_type = next((
line.split(":", 1)[1].strip()
for line in (wrapped_headers or "").splitlines()
if line.lower().startswith("content-type:") and ":" in line
), "application/octet-stream")
else:
raw = request.get_data(as_text=True)
content_type = request.headers.get("X-JSRA-Content-Type", request.content_type or "application/octet-stream")
direction = request.form.get("requestorresponse") or request.args.get("direction")
if not direction:
direction = "response" if request.path == "/decode" else "request"
try:
packet = split_http_packet(raw) if wrapped_body is None else None
if packet:
header_block, separator, body = packet
content_type = request.headers.get("X-JSRA-Content-Type", packet_content_type(header_block))
return Response(apply_packet(header_block, body, separator, direction, content_type), content_type="text/plain; charset=utf-8")
transformed_headers = transform_headers(wrapped_headers, direction) if wrapped_headers is not None else None
transformed = apply_body(raw, content_type, body_transforms(direction))
if wrapped_body is not None and wrapped_headers is not None:
# autoDecoder expects this exact separator when header handling is enabled.
return Response(transformed_headers + "\\r\\n\\r\\n\\r\\n\\r\\n" + transformed, content_type="text/plain; charset=utf-8")
return Response(transformed, content_type="text/plain; charset=utf-8")
except JSRPCError as error:
return Response("JSRA_ERROR: " + str(error), status=502, content_type="text/plain; charset=utf-8")
except Exception as error:
return Response("JSRA_ERROR: " + str(error), status=400, content_type="text/plain; charset=utf-8")
# Burp autoDecoder 的“请求数据包”模式使用固定的加密/解密接口。
# 保留 CONFIG["route"]、/autodecoder 和 dataBody/dataHeaders 以兼容旧配置。
for alias, endpoint in (("/encode", "autodecoder_encode"), ("/decode", "autodecoder_decode"), ("/autodecoder", "autodecoder_legacy")):
if CONFIG["route"] != alias:
app.add_url_rule(alias, endpoint=endpoint, view_func=autodecoder, methods=["POST"])
if __name__ == "__main__":
app.run(host=CONFIG["flask_host"], port=CONFIG["flask_port"], debug=False)
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Generate a strict JSRPC-only Flask proxy.")
parser.add_argument("--analysis", required=True, help="Path to analysis_result.json.")
parser.add_argument("--output", required=True, help="Output Python file path.")
args = parser.parse_args()
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(build_script(load_json(args.analysis)), encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(output)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,532 @@
#!/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())

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Generate a Webpack/module runtime discovery probe JS file.
The probe attempts to discover module systems (Webpack 4/5, Vite, Rollup)
and extract candidate exports. Results written to window.__JSRA_MODULES__.
Uses the webpackChunk*.push() trick to capture __webpack_require__ without
batch-executing uninitialized module factories.
Usage:
python3 scripts/emit_module_probe.py --output generated/module_probe.js
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
PROBE = r'''(() => {
"use strict";
const dump = {
timestamp: Date.now(),
bundlers: [],
modules: [],
globals: [],
errors: []
};
const seen = new Set();
function previewSource(fn) {
try { return Function.prototype.toString.call(fn).slice(0, 1200); } catch (_) { return ""; }
}
function addExport(moduleId, path, value, source) {
if (typeof value !== "function") return;
const id = `${source}:${moduleId}:${path}`;
if (seen.has(id)) return;
seen.add(id);
dump.modules.push({
id, source, module_id: String(moduleId),
export_path: path, name: value.name || path,
type: "function", source_snippet: previewSource(value)
});
}
function walkExports(moduleId, value, prefix, source, depth = 0) {
if (depth > 2 || value == null) return;
if (typeof value === "function") addExport(moduleId, prefix || "default", value, source);
if ((typeof value === "object" || typeof value === "function") && value) {
let keys = [];
try { keys = Object.keys(value).slice(0, 100); } catch (_) { return; }
keys.forEach(key => {
let child; try { child = value[key]; } catch (_) { return; }
const next = prefix ? `${prefix}.${key}` : key;
if (typeof child === "function") addExport(moduleId, next, child, source);
else if (depth < 2 && child && typeof child === "object")
walkExports(moduleId, child, next, source, depth + 1);
});
}
}
function inspectRequire(req, source) {
if (!req) return;
try {
window.__JSRA_WEBPACK_REQUIRE_MAP__ = window.__JSRA_WEBPACK_REQUIRE_MAP__ || {};
window.__JSRA_WEBPACK_REQUIRE_MAP__[source] = req;
if (!window.__JSRA_WEBPACK_REQUIRE__) window.__JSRA_WEBPACK_REQUIRE__ = req;
} catch (_) {}
const cache = req.c || {};
Object.keys(cache).forEach(id => {
try { walkExports(id, cache[id] && cache[id].exports, "", source); }
catch (error) { dump.errors.push(String(error)); }
});
dump.bundlers.push({
type: "webpack", source,
cached_modules: Object.keys(cache).length,
factories: req.m ? Object.keys(req.m).length : null
});
}
// === Webpack 5: hook webpackChunk*.push() ===
Object.keys(window).filter(k => /^webpackChunk/.test(k)).forEach(key => {
const chunk = window[key];
if (!Array.isArray(chunk) || typeof chunk.push !== "function") return;
let captured = null;
const token = `jsra_${Date.now()}_${Math.random().toString(16).slice(2)}`;
try {
// Push a dummy chunk to capture __webpack_require__
chunk.push([[token], {}, function(__webpack_require__) {
captured = __webpack_require__;
}]);
if (captured) inspectRequire(captured, key);
} catch (error) {
dump.errors.push(`${key}: ${error}`);
}
});
// === Webpack 3/4: webpackJsonp ===
if (typeof window.webpackJsonp === "function") {
dump.bundlers.push({
type: "webpack-legacy", source: "webpackJsonp",
note: "Detected; use runtime-specific adapter if require is not exposed."
});
}
// === Global exports scan ===
Object.getOwnPropertyNames(window).slice(0, 5000).forEach(key => {
let value; try { value = window[key]; } catch (_) { return; }
if (typeof value === "function") {
const source = previewSource(value);
if (/encrypt|decrypt|sign|hmac|digest|hash|rsa|aes|sm2|sm4|crypto/i.test(key + " " + source)) {
dump.globals.push({
id: `global:${key}`, path: `window[${JSON.stringify(key)}]`,
name: key, type: "function", source_snippet: source
});
}
}
});
window.__JSRA_MODULE_DUMP__ = dump;
console.info("[JSRA] module probe complete", dump);
return dump;
})();
'''
def main() -> int:
parser = argparse.ArgumentParser(description="Emit safe Webpack/global module probe.")
parser.add_argument("--output", required=True, help="Output JS file path.")
args = parser.parse_args()
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(PROBE, encoding="utf-8")
print(json.dumps({"status": "ok", "output": str(path)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,339 @@
#!/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] = "<redacted>";
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 "<redacted>";
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())

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Environment patcher for running obfuscated JS in Node.js VM sandbox.
Diagnoses missing browser APIs and generates patches to make the JS runnable.
Usage:
python3 scripts/env_patcher.py --input bundle.js --output artifacts/env_report.json
"""
from __future__ import annotations
import argparse
import json
import subprocess
import tempfile
from pathlib import Path
from common import dump_json, load_json
DIAGNOSE_SCRIPT = r'''
const vm = require('vm');
const fs = require('fs');
const input = process.argv[2];
const code = fs.readFileSync(input, 'utf8');
// Create sandbox with basic browser APIs
const sandbox = {
window: {}, self: {}, global: {}, globalThis: {},
console: { log: () => {}, warn: () => {}, error: () => {} },
setTimeout: (fn, ms) => setTimeout(fn, ms),
setInterval: (fn, ms) => setInterval(fn, ms),
clearTimeout: (id) => clearTimeout(id),
clearInterval: (id) => clearInterval(id),
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
TextEncoder: TextEncoder,
TextDecoder: TextDecoder,
URL: URL,
URLSearchParams: URLSearchParams,
};
// Make window/self/global/globalThis point to sandbox
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.global = sandbox;
sandbox.globalThis = sandbox;
// Proxy to track undefined access
const undefinedPaths = new Set();
const proxy = new Proxy(sandbox, {
get(target, prop) {
if (prop === Symbol.unscopables) return undefined;
if (prop in target) return target[prop];
if (typeof prop === 'string' && !prop.startsWith('_')) {
undefinedPaths.add(prop);
}
return undefined;
},
set(target, prop, value) {
target[prop] = value;
return true;
}
});
// Run in VM
const context = vm.createContext(proxy);
try {
vm.runInContext(code, context, { timeout: 5000 });
console.log(JSON.stringify({
success: true,
error: null,
undefinedPaths: Array.from(undefinedPaths).sort(),
stats: { events: undefinedPaths.size }
}));
} catch (error) {
console.log(JSON.stringify({
success: false,
error: error.message,
undefinedPaths: Array.from(undefinedPaths).sort(),
stats: { events: undefinedPaths.size }
}));
}
'''
def diagnose(js_path: str) -> dict:
"""Run diagnosis on a JS file to find missing browser APIs."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False) as f:
f.write(DIAGNOSE_SCRIPT)
diag_script = f.name
try:
result = subprocess.run(
['node', diag_script, js_path],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return json.loads(result.stdout)
return {"success": False, "error": result.stderr, "undefinedPaths": []}
except Exception as e:
return {"success": False, "error": str(e), "undefinedPaths": []}
finally:
Path(diag_script).unlink(missing_ok=True)
# Mapping from undefined paths to required modules
MODULE_MAP = {
'navigator': 'bom/navigator',
'location': 'bom/location',
'screen': 'bom/screen',
'localStorage': 'bom/storage',
'sessionStorage': 'bom/storage',
'crypto': 'bom/crypto',
'performance': 'bom/performance',
'history': 'bom/history',
'document': 'dom/document',
'Element': 'dom/elements',
'HTMLElement': 'dom/elements',
'Event': 'dom/event',
'fetch': 'webapi/fetch',
'XMLHttpRequest': 'webapi/xhr',
'Blob': 'webapi/blob',
'FormData': 'webapi/blob',
'File': 'webapi/blob',
'WebSocket': 'webapi/network',
'TextEncoder': 'encoding/textencoder',
'TextDecoder': 'encoding/textencoder',
'atob': 'encoding/atob',
'btoa': 'encoding/atob',
}
def select_modules(undefined_paths: list[str]) -> list[str]:
"""Select required modules based on undefined paths."""
modules = set()
for path in undefined_paths:
for prefix, module in MODULE_MAP.items():
if path.startswith(prefix) or path == prefix:
modules.add(module)
return sorted(modules)
def main() -> int:
parser = argparse.ArgumentParser(description="Environment patcher for JS sandbox.")
parser.add_argument("--input", required=True, help="Path to JS file.")
parser.add_argument("--output", required=True, help="Path to output report.")
args = parser.parse_args()
report = diagnose(args.input)
if report.get("undefinedPaths"):
report["selectedModules"] = select_modules(report["undefinedPaths"])
dump_json(args.output, report)
print(json.dumps({
"success": report.get("success", False),
"undefinedPaths": len(report.get("undefinedPaths", [])),
"selectedModules": len(report.get("selectedModules", [])),
"output": args.output
}, ensure_ascii=False))
return 0 if report.get("success") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,134 @@
// Shared hook composition runtime.
// The function stays local to each generated probe while the registry state is
// shared through window, allowing independently generated probes to compose.
function getHookRegistry(root) {
const registryKey = "__JSRA_HOOK_REGISTRY__";
const existing = root[registryKey];
if (existing && existing.version === 1) return existing;
const targets = new WeakMap();
function targetEntries(target, create = false) {
let entries = targets.get(target);
if (!entries && create) {
entries = new Map();
targets.set(target, entries);
}
return entries;
}
function entryFor(target, key, create = false) {
const entries = targetEntries(target, create);
if (!entries) return null;
let entry = entries.get(key);
if (!entry && create) {
if (!target || typeof target[key] !== "function") return null;
entry = {
target,
key,
base: target[key],
current: target[key],
layers: []
};
entries.set(key, entry);
}
return entry || null;
}
function assign(entry, value) {
try {
entry.target[entry.key] = value;
return entry.target[entry.key] === value;
} catch (_) {
return false;
}
}
function rebuild(entry) {
let current = entry.base;
for (const layer of entry.layers) {
current = layer.factory(current);
if (typeof current !== "function")
throw new TypeError(`Hook factory did not return a function: ${layer.id}`);
}
entry.current = current;
if (!assign(entry, current)) throw new TypeError(`Hook assignment failed: ${entry.key}`);
return current;
}
function adoptExternal(entry) {
const current = entry.target[entry.key];
if (current !== entry.current && typeof current === "function") {
entry.base = current;
return true;
}
return false;
}
function add(target, key, id, factory) {
if (!target || typeof target[key] !== "function") return null;
const entry = entryFor(target, key, true);
const duplicate = entry.layers.find(layer => layer.id === id);
if (duplicate) {
adoptExternal(entry);
try { rebuild(entry); } catch (_) {}
return { entry, id, duplicate: true };
}
adoptExternal(entry);
const layer = { id, factory };
entry.layers.push(layer);
try {
rebuild(entry);
return { entry, id, duplicate: false };
} catch (error) {
entry.layers = entry.layers.filter(item => item !== layer);
try { rebuild(entry); } catch (_) {}
throw error;
}
}
function reconcile(target, key) {
const entry = entryFor(target, key, false);
if (!entry) return { status: "none", changed: false };
const changed = adoptExternal(entry);
if (!changed) return { status: "ok", changed: false };
try {
rebuild(entry);
return { status: "rebuilt", changed: true };
} catch (error) {
return { status: "error", changed: true, error: String(error) };
}
}
function remove(target, key, id) {
const entry = entryFor(target, key, false);
if (!entry) return false;
entry.layers = entry.layers.filter(layer => layer.id !== id);
if (entry.layers.length === 0) {
assign(entry, entry.base);
const entries = targetEntries(target, false);
if (entries) entries.delete(key);
return true;
}
rebuild(entry);
return true;
}
const registry = {
version: 1,
add,
reconcile,
remove,
native(target, key) {
const entry = entryFor(target, key, false);
return entry ? entry.base : target && target[key];
},
internalDepth: 0,
runInternal(callback) {
this.internalDepth += 1;
try { return callback(); } finally { this.internalDepth -= 1; }
}
};
root[registryKey] = registry;
return registry;
}

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Hook template generator.
Generates various hook templates for common browser APIs.
Usage:
python3 scripts/hook_templates.py --type cookie --output generated/hooks/cookie.js
python3 scripts/hook_templates.py --type all --output generated/hooks/
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
TEMPLATES = {
"cookie": r'''
// Cookie Hook - Monitor document.cookie access
(function() {
let _cookie = document.cookie;
const originalDesc = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie');
Object.defineProperty(document, 'cookie', {
get: function() {
console.log('[HOOK] cookie.get:', _cookie.substring(0, 100));
return _cookie;
},
set: function(value) {
console.log('[HOOK] cookie.set:', value.substring(0, 200));
_cookie = value;
if (originalDesc && originalDesc.set) {
originalDesc.set.call(document, value);
}
},
configurable: true
});
console.log('[HOOK] cookie hook installed');
})();
''',
"xhr": r'''
// XMLHttpRequest Hook - Monitor XHR open/send
(function() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this.__hook = { method, url, timestamp: Date.now() };
console.log('[HOOK] xhr.open:', method, url);
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this.__hook) {
this.__hook.body = body;
console.log('[HOOK] xhr.send:', this.__hook.method, this.__hook.url,
body ? body.substring(0, 200) : '(no body)');
}
return originalSend.apply(this, arguments);
};
console.log('[HOOK] xhr hook installed');
})();
''',
"fetch": r'''
// Fetch Hook - Monitor fetch calls
(function() {
const originalFetch = window.fetch;
window.fetch = function(input, init) {
const url = typeof input === 'string' ? input : input && input.url;
const method = (init && init.method) || 'GET';
const body = init && init.body;
console.log('[HOOK] fetch:', method, url, body ? body.substring(0, 200) : '(no body)');
return originalFetch.apply(this, arguments);
};
console.log('[HOOK] fetch hook installed');
})();
''',
"eval": r'''
// Eval Hook - Monitor eval/Function calls
(function() {
const originalEval = window.eval;
const originalFunction = window.Function;
window.eval = function(code) {
console.log('[HOOK] eval:', typeof code === 'string' ? code.substring(0, 200) : typeof code);
return originalEval.apply(this, arguments);
};
window.Function = function(...args) {
console.log('[HOOK] new Function:', args.length, 'args');
return originalFunction.apply(this, args);
};
console.log('[HOOK] eval hook installed');
})();
''',
"json": r'''
// JSON Hook - Monitor JSON.parse/stringify
(function() {
const originalParse = JSON.parse;
const originalStringify = JSON.stringify;
JSON.parse = function(text) {
console.log('[HOOK] JSON.parse:', typeof text === 'string' ? text.substring(0, 200) : typeof text);
return originalParse.apply(this, arguments);
};
JSON.stringify = function(value) {
const result = originalStringify.apply(this, arguments);
console.log('[HOOK] JSON.stringify:', result.substring(0, 200));
return result;
};
console.log('[HOOK] json hook installed');
})();
''',
"base64": r'''
// Base64 Hook - Monitor atob/btoa
(function() {
const originalAtob = window.atob;
const originalBtoa = window.btoa;
window.atob = function(encoded) {
console.log('[HOOK] atob:', encoded.substring(0, 100));
return originalAtob.apply(this, arguments);
};
window.btoa = function(text) {
console.log('[HOOK] btoa:', text.substring(0, 100));
return originalBtoa.apply(this, arguments);
};
console.log('[HOOK] base64 hook installed');
})();
''',
"websocket": r'''
// WebSocket Hook - Monitor WebSocket messages
(function() {
const originalSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
console.log('[HOOK] ws.send:', typeof data === 'string' ? data.substring(0, 200) : typeof data);
return originalSend.apply(this, arguments);
};
console.log('[HOOK] websocket hook installed');
})();
''',
"crypto": r'''
// Crypto Hook - Monitor crypto.subtle operations
(function() {
if (!window.crypto || !window.crypto.subtle) return;
const original = window.crypto.subtle;
const methods = ['encrypt', 'decrypt', 'digest', 'sign', 'verify', 'generateKey', 'importKey', 'exportKey'];
methods.forEach(method => {
if (original[method]) {
const originalMethod = original[method].bind(original);
original[method] = function(...args) {
console.log('[HOOK] crypto.subtle.' + method, args.length, 'args');
return originalMethod.apply(this, args);
};
}
});
console.log('[HOOK] crypto hook installed');
})();
''',
}
def main() -> int:
parser = argparse.ArgumentParser(description="Hook template generator.")
parser.add_argument("--type", required=True, choices=list(TEMPLATES.keys()) + ["all"],
help="Hook type to generate.")
parser.add_argument("--output", required=True, help="Output file or directory.")
args = parser.parse_args()
output = Path(args.output)
if args.type == "all":
output.mkdir(parents=True, exist_ok=True)
for name, template in TEMPLATES.items():
(output / f"{name}.js").write_text(template.strip(), encoding="utf-8")
print(f"[OK] all hooks written to {output}")
else:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(TEMPLATES[args.type].strip(), encoding="utf-8")
print(f"[OK] {args.type} hook written to {output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Crypto algorithm identifier.
Identifies encryption algorithm by output length and character set.
Usage:
python3 scripts/identify_crypto.py --output-sample "5d41402abc4b2a76b9719d911017c592"
"""
from __future__ import annotations
import argparse
import re
import json
# Algorithm signatures by output length
LENGTH_SIGNATURES = {
32: ["MD5", "MD4", "MD2"],
40: ["SHA-1", "RIPEMD-160"],
56: ["SHA-224"],
64: ["SHA-256", "SHA3-256"],
96: ["SHA-384"],
128: ["SHA-512", "SHA3-512"],
172: ["RSA-1024 (Base64)"],
256: ["RSA-1024 (Hex)", "RSA-2048 (Base64 truncated)"],
344: ["RSA-2048 (Base64)"],
512: ["RSA-2048 (Hex)", "RSA-4096 (Base64 truncated)"],
}
# Character set patterns
CHARSET_PATTERNS = [
(r'^[0-9a-f]+$', 'hex'),
(r'^[0-9A-F]+$', 'hex_upper'),
(r'^[A-Za-z0-9+/]+=*$', 'base64'),
(r'^[A-Za-z0-9_-]+$', 'base64url'),
(r'^[0-9]+$', 'decimal'),
]
def identify(sample: str) -> dict:
"""Identify crypto algorithm by output sample."""
result = {
"sample": sample[:100],
"length": len(sample),
"charset": "unknown",
"possible_algorithms": [],
"confidence": "low"
}
# Detect charset
for pattern, charset in CHARSET_PATTERNS:
if re.match(pattern, sample):
result["charset"] = charset
break
# Match by length
length = len(sample)
if length in LENGTH_SIGNATURES:
result["possible_algorithms"] = LENGTH_SIGNATURES[length]
result["confidence"] = "medium"
else:
# Find closest match
for sig_length, algorithms in LENGTH_SIGNATURES.items():
if abs(length - sig_length) <= 4:
result["possible_algorithms"] = [f"{alg} (approximate)" for alg in algorithms]
result["confidence"] = "low"
break
# Special cases
if result["charset"] == "base64" and length % 4 == 0:
decoded_length = length * 3 // 4
if decoded_length in [128, 256, 512]:
result["possible_algorithms"].append(f"RSA-{decoded_length * 8} (Base64 encoded)")
if result["charset"] == "hex" and length in [128, 256, 512, 1024]:
result["possible_algorithms"].append(f"RSA-{length * 4} (Hex encoded)")
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Crypto algorithm identifier.")
parser.add_argument("--output-sample", required=True, help="Sample encrypted output.")
parser.add_argument("--output", help="Path to output JSON.")
args = parser.parse_args()
result = identify(args.output_sample)
if args.output:
dump_json(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""Manage JSRPC server and Flask proxy lifecycle.
Supports the legacy flag interface and the state-file subcommand interface.
Includes PID identity verification to avoid killing wrong processes.
Usage (legacy flag interface):
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action start --force
python3 scripts/manage_services.py --service flask --analysis analysis_result.json --flask-file generated/flask_proxy.py --output artifacts/flask_status.json --action start --force
python3 scripts/manage_services.py --service jsrpc --analysis analysis_result.json --output artifacts/jsrpc_status.json --action stop
Usage (state-file subcommand):
python3 scripts/manage_services.py start --kind jsrpc --state artifacts/jsrpc_state.json
python3 scripts/manage_services.py stop --state artifacts/jsrpc_state.json
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import subprocess
import sys
import time
from pathlib import Path
from shutil import which
from common import dump_json, load_json
# Common JSRPC binary names and paths to search
JSRPC_SEARCH_NAMES = ["jsrpc", "jsrpc-mac", "jsrpc-linux", "mac_m_arm64", "mac_x64", "linux_amd64"]
JSRPC_SEARCH_DIRS = [
Path.home() / "jsrpc",
Path.home() / "jstest",
Path.home() / "hacker" / "jstest",
Path.home() / "tools" / "jsrpc",
Path.home() / ".local" / "bin",
Path("/usr/local/bin"),
Path("/opt/jsrpc"),
]
def find_jsrpc_binary() -> str | None:
"""Auto-discover JSRPC binary from common locations and PATH."""
# 1. Check PATH
for name in JSRPC_SEARCH_NAMES:
found = which(name)
if found:
return found
# 2. Check common directories
for search_dir in JSRPC_SEARCH_DIRS:
if not search_dir.exists():
continue
for name in JSRPC_SEARCH_NAMES:
candidate = search_dir / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
for subdir in search_dir.iterdir():
if subdir.is_dir():
for name in JSRPC_SEARCH_NAMES:
candidate = subdir / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
# 3. Check current directory and parent
cwd = Path.cwd()
for name in JSRPC_SEARCH_NAMES:
candidate = cwd / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
candidate = cwd.parent / name
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
return None
def process_identity(pid: int) -> dict:
"""Get process identity info for PID verification."""
result = {"pid": pid, "alive": False, "exe": None, "started": None, "command": None}
try:
os.kill(pid, 0)
result["alive"] = True
except OSError:
return result
proc = Path(f"/proc/{pid}")
if proc.exists():
try:
result["exe"] = str((proc / "exe").resolve())
except OSError:
pass
try:
result["started"] = (proc / "stat").read_text().split()[21]
except Exception:
pass
try:
result["command"] = (proc / "cmdline").read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip()
except Exception:
pass
else:
try:
result["command"] = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
text=True, capture_output=True, timeout=2
).stdout.strip() or None
result["started"] = subprocess.run(
["ps", "-p", str(pid), "-o", "lstart="],
text=True, capture_output=True, timeout=2
).stdout.strip() or None
except Exception:
pass
return result
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
for family in [socket.AF_INET, socket.AF_INET6]:
try:
with socket.socket(family, socket.SOCK_STREAM) as s:
s.settimeout(1)
h = "127.0.0.1" if family == socket.AF_INET else "::1"
if s.connect_ex((h, port)) == 0:
return True
except Exception:
pass
return False
def get_pid_on_port(port: int) -> int | None:
try:
result = subprocess.run(["lsof", "-i", f":{port}", "-t"], capture_output=True, text=True, timeout=5)
for pid in result.stdout.strip().split("\n"):
pid = pid.strip()
if pid.isdigit():
return int(pid)
except Exception:
pass
return None
def find_available_port(start: int, host: str = "127.0.0.1", attempts: int = 30) -> int | None:
"""Find an available local port without touching an unrelated process."""
for candidate in range(int(start), int(start) + attempts):
if not is_port_in_use(candidate, host):
return candidate
return None
def kill_process(pid: int) -> bool:
try:
os.kill(pid, signal.SIGTERM)
time.sleep(1)
try:
os.kill(pid, 0)
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
return True
except ProcessLookupError:
return True
except Exception:
return False
def check_health(host: str, port: int) -> bool:
try:
import urllib.request
req = urllib.request.urlopen(f"http://{host}:{port}/healthz", timeout=3)
return req.status == 200
except Exception:
return False
# === Legacy flag interface ===
def legacy_jsrpc_start(binary_path: str, port: int) -> dict:
proc = subprocess.Popen([binary_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
for _ in range(10):
time.sleep(0.5)
if is_port_in_use(port):
identity = process_identity(proc.pid)
return {"status": "started", "pid": proc.pid, "binary_path": str(Path(binary_path).resolve()), "port": port,
"started": identity.get("started"), "observed_command": identity.get("command"),
"stop_hint": "使用同一状态文件执行 manage_services.py --action stop"}
return {"status": "start_failed", "pid": proc.pid, "binary_path": binary_path, "port": port}
def legacy_flask_start(flask_file: str, host: str, port: int, route: str = "/autodecoder") -> dict:
requested_port = int(port)
actual_port = requested_port
if is_port_in_use(actual_port, host):
actual_port = find_available_port(actual_port + 1, host)
if actual_port is None:
existing_pid = get_pid_on_port(requested_port)
return {"status": "port_occupied", "port": requested_port, "existing_pid": existing_pid, "hint": f"端口 {requested_port} 已被占用,且没有找到可用备用端口"}
if not os.path.isfile(flask_file):
return {"status": "file_not_found", "flask_file": flask_file, "hint": "Flask 代理文件不存在,先运行代码生成"}
log_file = Path(flask_file).parent / "flask_proxy.log"
environment = os.environ.copy()
environment["JSRA_FLASK_PORT"] = str(actual_port)
with open(log_file, "w") as lf:
proc = subprocess.Popen([sys.executable, flask_file], stdout=lf, stderr=subprocess.STDOUT, start_new_session=True, env=environment)
for _ in range(10):
time.sleep(0.5)
if is_port_in_use(actual_port, host):
healthy = check_health(host, actual_port)
identity = process_identity(proc.pid)
return {
"status": "started", "pid": proc.pid, "host": host, "port": actual_port,
"requested_port": requested_port, "port_fallback": actual_port != requested_port,
"flask_file": str(Path(flask_file).resolve()),
"started": identity.get("started"), "observed_command": identity.get("command"),
"url": f"http://{host}:{actual_port}{route}", "healthz": f"http://{host}:{actual_port}/healthz",
"encode_url": f"http://{host}:{actual_port}/encode", "decode_url": f"http://{host}:{actual_port}/decode",
"healthy": healthy, "log_file": str(log_file),
"stop_hint": "使用同一状态文件执行 manage_services.py --action stop"
}
return {"status": "start_failed", "pid": proc.pid, "port": actual_port, "requested_port": requested_port,
"flask_file": str(Path(flask_file).resolve()), "log_file": str(log_file)}
def legacy_status(path: str) -> dict:
"""Load a previously written legacy status file without guessing a PID."""
try:
return load_json(Path(path), {})
except (OSError, ValueError, TypeError):
return {}
def managed_pid_matches(status: dict, kind: str) -> bool:
"""Verify that a legacy status PID still belongs to this service.
The legacy interface predates state files, so only a recorded PID and a
recorded executable/script identity are accepted. A port occupant alone
is never sufficient evidence for a kill operation.
"""
pid = status.get("pid")
if not pid:
return False
try:
identity = process_identity(int(pid))
except (TypeError, ValueError):
return False
if not identity.get("alive"):
return False
expected = str(status.get("flask_file" if kind == "flask" else "binary_path", ""))
command = str(identity.get("command") or "")
if not expected or not command:
return False
if status.get("started") and identity.get("started") and status["started"] != identity["started"]:
return False
return Path(expected).name in command or expected in command
def stop_legacy_service(kind: str, status_path: str, configured_port: int) -> dict:
"""Stop only a process recorded by this skill's own status artifact."""
status = legacy_status(status_path)
if not status:
return {"status": "not_running", "port": configured_port, "hint": "没有可验证的服务状态文件,未尝试按端口杀进程"}
if not managed_pid_matches(status, kind):
return {"status": "identity_mismatch", "pid": status.get("pid"), "port": status.get("port", configured_port),
"hint": "状态文件中的进程身份无法验证,未执行停止操作"}
pid = int(status["pid"])
if kill_process(pid):
return {"status": "stopped", "pid": pid, "port": status.get("port", configured_port)}
return {"status": "stop_failed", "pid": pid, "port": status.get("port", configured_port)}
def legacy_mode(args: argparse.Namespace) -> dict:
"""Legacy flag-based interface."""
analysis = load_json(Path(args.analysis))
if args.service == "jsrpc":
config = analysis.get("jsrpc_server", {})
port = config.get("port", 12080)
if args.action == "stop":
return stop_legacy_service("jsrpc", args.output, port)
elif args.action == "status":
if not is_port_in_use(port):
return {"status": "not_running", "port": port}
pid = get_pid_on_port(port)
return {"status": "already_running", "pid": pid, "port": port,
"stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop" if pid else None}
else: # start
if is_port_in_use(port):
pid = get_pid_on_port(port)
status = legacy_status(args.output)
if status.get("status") in {"started", "already_running"} and managed_pid_matches(status, "jsrpc"):
return {"status": "already_running", "pid": status.get("pid", pid), "port": port,
"stop_command": f"python3 scripts/manage_services.py --service jsrpc --analysis {args.analysis} --output {args.output} --action stop"}
return {"status": "port_occupied", "pid": pid, "port": port,
"hint": f"端口 {port} 已被其他进程占用,未执行强制终止;请释放端口或更换 JSRPC 端口"}
binary_path = config.get("binary_path", "")
if binary_path == "auto" or not binary_path:
binary_path = find_jsrpc_binary()
if binary_path and os.path.isfile(binary_path):
return legacy_jsrpc_start(binary_path, port)
return {"status": "not_found", "port": port, "hint": "请在 analysis_result.json 的 jsrpc_server.binary_path 中提供 JSRPC 服务器二进制路径,或手动启动后重试"}
else: # flask
config = analysis.get("flask_server", analysis.get("flask", {}))
host = config.get("listen_host", config.get("host", "127.0.0.1"))
port = config.get("listen_port", config.get("port", 5000))
if args.action == "stop":
return stop_legacy_service("flask", args.output, port)
elif args.action == "status":
if not is_port_in_use(port, host):
return {"status": "not_running", "port": port}
pid = get_pid_on_port(port)
healthy = check_health(host, port)
flask_config = analysis.get("flask", {})
route = flask_config.get("route", "/autodecoder")
return {"status": "running", "pid": pid, "port": port, "healthy": healthy, "url": f"http://{host}:{port}{route}",
"stop_command": f"python3 scripts/manage_services.py --service flask --analysis {args.analysis} --output {args.output} --action stop" if pid else None}
else: # start
flask_config = analysis.get("flask", {})
route = flask_config.get("route", "/autodecoder")
# The legacy Flask start deliberately selects a free fallback port. Do
# not pre-emptively kill an arbitrary occupant here; --force is
# retained for CLI compatibility but never authorizes that kill.
return legacy_flask_start(args.flask_file, host, port, route)
return {"status": "error", "hint": "unknown action"}
# === State-file subcommand interface ===
def start_process(command: list[str], state_path: Path, kind: str) -> int:
environment = None
requested_port = None
actual_port = None
if kind == "flask":
requested_port = int(os.environ.get("JSRA_FLASK_PORT", "5000"))
actual_port = find_available_port(requested_port)
if actual_port is None:
print(json.dumps({"status": "port_occupied", "requested_port": requested_port}, ensure_ascii=False))
return 2
environment = os.environ.copy()
environment["JSRA_FLASK_PORT"] = str(actual_port)
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, env=environment)
time.sleep(.3)
identity = process_identity(process.pid)
dump_json(state_path, {
"kind": kind, "command": command, "pid": process.pid,
"exe": identity.get("exe"), "started": identity.get("started"),
"observed_command": identity.get("command"), "created_at": time.time(),
"requested_port": requested_port, "port": actual_port,
"port_fallback": actual_port is not None and actual_port != requested_port
})
print(json.dumps({"status": "started", "kind": kind, "pid": process.pid, "state": str(state_path), "port": actual_port}, ensure_ascii=False))
return 0
def stop_process(state_path: Path, kill_unknown: bool) -> int:
state = load_json(state_path)
identity = process_identity(int(state["pid"]))
if not identity["alive"]:
state_path.unlink(missing_ok=True)
print("[OK] process already stopped")
return 0
expected = (state.get("exe"), state.get("started"), state.get("observed_command"))
actual = (identity.get("exe"), identity.get("started"), identity.get("command"))
verifiable = any(value is not None for value in expected)
if (not verifiable or expected != actual) and not kill_unknown:
print(f"[ERROR] process identity cannot be verified; expected={expected}, actual={actual}")
return 3
os.kill(int(state["pid"]), signal.SIGTERM)
state_path.unlink(missing_ok=True)
print(f"[OK] stopped pid {state['pid']}")
return 0
def main() -> int:
# Detect the legacy flag interface or the state-file subcommand interface.
if len(sys.argv) > 1 and sys.argv[1] in ("start", "stop", "status"):
# State-file subcommand mode
parser = argparse.ArgumentParser(description="Safe service manager for JSRA.")
sub = parser.add_subparsers(dest="command", required=True)
start = sub.add_parser("start")
start.add_argument("--kind", choices=["jsrpc", "flask"], required=True)
start.add_argument("--state", required=True)
start.add_argument("--jsrpc-binary")
start.add_argument("--flask-script")
stop = sub.add_parser("stop")
stop.add_argument("--state", required=True)
stop.add_argument("--kill-unknown", action="store_true")
status = sub.add_parser("status")
status.add_argument("--state", required=True)
args = parser.parse_args()
state = Path(args.state)
if args.command == "start":
if args.kind == "jsrpc":
binary_path = args.jsrpc_binary
if not binary_path:
binary_path = find_jsrpc_binary()
if not binary_path:
parser.error("--jsrpc-binary is required; auto-discovery failed.")
print(f"[INFO] Auto-discovered JSRPC binary: {binary_path}")
binary = Path(binary_path).expanduser().resolve()
if not binary.is_file() or not os.access(binary, os.X_OK):
parser.error("invalid executable: " + str(binary))
return start_process([str(binary)], state, "jsrpc")
if not args.flask_script:
parser.error("--flask-script is required")
return start_process([sys.executable, str(Path(args.flask_script).resolve())], state, "flask")
if args.command == "stop":
return stop_process(state, args.kill_unknown)
info = load_json(state, {})
print(json.dumps({
"state": info,
"process": process_identity(int(info["pid"])) if info.get("pid") else None
}, ensure_ascii=False, indent=2))
return 0
else:
# Legacy flag-based mode
parser = argparse.ArgumentParser()
parser.add_argument("--service", required=True, choices=["jsrpc", "flask"])
parser.add_argument("--analysis", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--flask-file", default="")
parser.add_argument("--action", choices=["start", "stop", "status"], default="start")
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
result = legacy_mode(args)
dump_json(Path(args.output), result)
print(json.dumps(result, ensure_ascii=False))
return 0 if result.get("status") in ("already_running", "started", "running", "stopped", "not_running") else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Write a fail-closed report when runtime evidence is incomplete.
Usage:
python3 scripts/quarantine.py \
--analysis artifacts/analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--probe artifacts/probe_dump.json \
--module artifacts/module_dump.json \
--graph artifacts/evidence_graph.json \
--differential artifacts/differential_verification_results.json \
--output artifacts/quarantine.json
"""
from __future__ import annotations
import argparse
from pathlib import Path
from common import dump_json, load_json
def main() -> int:
parser = argparse.ArgumentParser(description="Create a JSRA quarantine report.")
parser.add_argument("--analysis", required=True)
parser.add_argument("--candidates", required=False)
parser.add_argument("--probe", required=False)
parser.add_argument("--module", required=False)
parser.add_argument("--graph", required=False)
parser.add_argument("--differential", required=False)
parser.add_argument("--output", required=True)
args = parser.parse_args()
paths = {
"analysis": args.analysis,
"candidates": args.candidates,
"probe_dump": args.probe,
"module_dump": args.module,
"evidence_graph": args.graph,
"differential_verification": args.differential,
}
missing = [name for name, value in paths.items() if not value or not Path(value).exists()]
candidates = load_json(args.candidates, {}) if args.candidates else {}
unresolved = []
for candidate in candidates.get("candidates", []) if isinstance(candidates, dict) else []:
if candidate.get("verified") is not True:
unresolved.append(candidate.get("path") or candidate.get("id"))
elif not candidate.get("verification"):
unresolved.append(candidate.get("path") or candidate.get("id"))
report = {
"status": "quarantined" if missing or unresolved else "ready_for_validation",
"missing_artifacts": missing,
"unverified_candidates": [value for value in unresolved if value],
"reason": "No Burp-ready artifact may be issued until a real page function is invoked and correlated with a target request field.",
"next_actions": [
"Inject runtime_hook_probe.js before navigation and export probe_dump.json.",
"Inject module_probe.js when the entrypoint is bundled and export module_dump.json.",
"Build evidence_graph.json from the captured events.",
"Run differential_verifier emit/apply using captured request-field expectations.",
"Regenerate JSRPC, Flask, and Burp artifacts only after validation passes.",
],
}
dump_json(args.output, report)
print(f"[OK] quarantine report: {args.output}")
return 0 if report["status"] == "ready_for_validation" else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
"use strict";
/**
* Conservative source-level property instrumentation.
*
* This tool intentionally rewrites only explicitly selected member reads such
* as navigator.webdriver, document.cookie,
* document.getElementById("username").value, or
* document["getElementById"]("username")["value"]. It skips strings,
* comments, assignments, and calls so it can be used as a low-risk first tap
* before broader AST instrumentation is introduced.
*
* Usage:
* node scripts/source_instrumentor.js --input bundle.js --output bundle.tap.js \
* --properties webdriver,userAgent,cookie --objects navigator,document
*/
const fs = require("fs");
const path = require("path");
function parseArgs() {
const result = {};
for (let i = 2; i < process.argv.length; i += 2) {
const key = process.argv[i];
if (!key.startsWith("--")) throw new Error(`invalid option: ${key}`);
result[key.slice(2)] = process.argv[i + 1];
}
if (!result.input || !result.output || !result.properties)
throw new Error("Usage: source_instrumentor.js --input FILE --output FILE --properties name[,name]");
return result;
}
function maskStringsAndComments(code) {
const chars = code.split("");
let state = "code";
let quote = "";
for (let i = 0; i < code.length; i += 1) {
const ch = code[i];
const next = code[i + 1];
if (state === "code") {
if (ch === "/" && next === "/") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "line"; continue; }
if (ch === "/" && next === "*") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "block"; continue; }
if (ch === "'" || ch === '"' || ch === "`") { quote = ch; chars[i] = " "; state = "string"; }
continue;
}
if (state === "line") {
if (ch === "\n") state = "code"; else chars[i] = " ";
continue;
}
if (state === "block") {
if (ch === "*" && next === "/") { chars[i] = " "; chars[i + 1] = " "; i += 1; state = "code"; }
else if (ch !== "\n") chars[i] = " ";
continue;
}
if (state === "string") {
if (ch === "\\") { chars[i] = " "; if (i + 1 < code.length) { if (code[i + 1] !== "\n") chars[i + 1] = " "; i += 1; } continue; }
if (ch === quote) { chars[i] = " "; state = "code"; }
else if (ch !== "\n") chars[i] = " ";
}
}
return chars.join("");
}
function lineOf(code, offset) {
return code.slice(0, offset).split("\n").length;
}
function instrument(code, options) {
const objects = new Set((options.objects || "navigator,document,screen,window,location").split(",").map(x => x.trim()).filter(Boolean));
const properties = new Set(options.properties.split(",").map(x => x.trim()).filter(Boolean));
const masked = maskStringsAndComments(code);
// Supports a simple dot/bracket chain with non-nested call arguments in the
// middle. The final member is inspected separately so calls such as
// CryptoJS.AES.encrypt(...) are not wrapped.
const pattern = /\b([A-Za-z_$][\w$]*)\s*((?:(?:\.\s*[A-Za-z_$][\w$]*\s*(?:\([^()\n]*\))?)|(?:\[\s*["'][A-Za-z_$][\w$]*["']\s*\]\s*(?:\([^()\n]*\))?))+)/g;
const edits = [];
let match;
while ((match = pattern.exec(code))) {
// The matching pass runs on source so static bracket names remain visible;
// reject starts that are actually inside a string/comment.
if (masked[match.index] !== code[match.index]) continue;
const objectName = match[1];
const expression = code.slice(match.index, match.index + match[0].length).trim();
const finalMember = /(?:\.\s*([A-Za-z_$][\w$]*)\s*(\([^()\n]*\))?|\[\s*["']([A-Za-z_$][\w$]*)["']\s*\]\s*(\([^()\n]*\))?)\s*$/.exec(expression);
if (!finalMember) continue;
const propertyName = finalMember[1] || finalMember[3];
if (!objects.has(objectName) || !properties.has(propertyName)) continue;
if (finalMember[2] || finalMember[4]) continue;
const end = match.index + match[0].length;
const tail = masked.slice(end).match(/^\s*/)[0].length;
const next = masked[end + tail];
const after = masked[end + tail + 1];
if (next === "(" || (next === "=" && after !== "=" && after !== ">")) continue;
if (expression.includes("__JSRA_TAP_GET__")) continue;
const label = `${objectName}.${propertyName}`;
edits.push({
start: match.index,
end,
replacement: `__JSRA_TAP_GET__(() => (${expression}), ${JSON.stringify(label)}, ${JSON.stringify(options.tag || "source")})`,
line: lineOf(code, match.index),
property: label
});
if (edits.length >= Number(options.maxRewrites || 500)) break;
}
let output = code;
for (const edit of edits.slice().reverse()) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
const bootstrap = `\n;(() => {\n const g = globalThis;\n if (g.__JSRA_TAPS__) g.__JSRA_TAPS__.installed = true;\n else {\n const state = g.__JSRA_TAPS__ = { probe_id: "source-tap", installed: true, events: [], max: 5000 };\n g.__JSRA_TAP_GET__ = function(thunk, path, tag) {\n try { const value = thunk(); state.events.push({ type: "tap_get", path, tag, value_type: typeof value, value_length: value == null ? 0 : String(value).length, timestamp: Date.now() }); if (state.events.length > state.max) state.events.shift(); return value; }\n catch (error) { state.events.push({ type: "tap_get_err", path, tag, error: String(error), timestamp: Date.now() }); throw error; }\n };\n }\n})();\n`;
return { output: bootstrap + output, edits, parser_mode: "conservative-member-read" };
}
function main() {
const args = parseArgs();
const code = fs.readFileSync(args.input, "utf8");
const result = instrument(code, args);
fs.mkdirSync(path.dirname(args.output), { recursive: true });
fs.writeFileSync(args.output, result.output, "utf8");
const reportPath = args.report || `${args.output}.report.json`;
fs.writeFileSync(reportPath, JSON.stringify({
input: path.resolve(args.input),
output: path.resolve(args.output),
parser_mode: result.parser_mode,
rewrite_count: result.edits.length,
edits: result.edits.map(({ replacement, ...edit }) => edit)
}, null, 2) + "\n", "utf8");
console.log(JSON.stringify({ status: "ok", parser_mode: result.parser_mode, rewrite_count: result.edits.length, output: args.output, report: reportPath }));
}
try { main(); }
catch (error) { console.error(String(error.stack || error)); process.exit(2); }

View File

@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Four-layer artifact validator.
Layer 1: Schema validation (analysis_result + candidates against JSON schemas)
Layer 2: Static validation (Python syntax, JS syntax via Node)
Layer 3: Candidate invariant checks (verified=true requires verification records)
Layer 4: Cross-file consistency (actions present in both jsrpc_inject.js and flask_proxy.py)
Usage:
python3 scripts/validate_artifacts.py \
--analysis analysis_result.json \
--candidates artifacts/encryption_candidates.json \
--generated generated/ \
--report artifacts/validation_report.json
"""
from __future__ import annotations
import argparse
import ast
import importlib.util
import json
import py_compile
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from urllib.parse import parse_qsl
from common import dump_json, load_json
def check_schema(name: str, data: dict, schema_path: Path) -> dict:
"""Validate data against a JSON schema."""
try:
from jsonschema import Draft202012Validator
schema = load_json(schema_path)
errors = [e.message for e in Draft202012Validator(schema).iter_errors(data)]
return {"name": name, "ok": not errors, "errors": errors}
except ImportError:
return {"name": name, "ok": False, "errors": ["jsonschema not installed"]}
except FileNotFoundError:
return {"name": name, "ok": True, "skipped": f"schema not found: {schema_path}"}
def check_python(path: Path) -> dict:
"""Check Python syntax."""
try:
py_compile.compile(str(path), doraise=True)
ast.parse(path.read_text(encoding="utf-8"))
return {"name": f"python:{path.name}", "ok": True}
except Exception as error:
return {"name": f"python:{path.name}", "ok": False, "errors": [str(error)]}
def check_javascript(path: Path) -> dict:
"""Check JavaScript syntax via Node.js."""
if not path.exists():
return {"name": f"javascript:{path.name}", "ok": False, "errors": ["missing"]}
node = shutil.which("node")
if not node:
return {"name": f"javascript:{path.name}", "ok": True, "skipped": "node unavailable"}
proc = subprocess.run([node, "--check", str(path)], text=True, capture_output=True)
return {
"name": f"javascript:{path.name}",
"ok": proc.returncode == 0,
"errors": [proc.stderr.strip()] if proc.returncode else []
}
def check_dependency(name: str) -> dict:
"""Require packages used by generated services and schema validation."""
available = importlib.util.find_spec(name) is not None
return {
"name": f"dependency:{name}",
"ok": available,
"errors": [] if available else [f"{name} is not installed"],
}
def check_jsrpc_evidence_gate(path: Path) -> dict:
"""Fail validation when generated JSRPC code is still quarantined."""
if not path.exists():
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": ["missing jsrpc_inject.js"]}
content = path.read_text(encoding="utf-8")
match = re.search(r"const EVIDENCE_ERRORS = (\{.*?\});", content, re.DOTALL)
if not match:
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": ["missing evidence gate"]}
try:
errors = json.loads(match.group(1))
except json.JSONDecodeError as error:
return {"name": "jsrpc_evidence_gate", "ok": False, "errors": [str(error)]}
if errors:
return {
"name": "jsrpc_evidence_gate",
"ok": False,
"errors": [f"quarantined parameters: {', '.join(sorted(errors))}"],
}
return {"name": "jsrpc_evidence_gate", "ok": True, "errors": []}
def check_candidate_invariants(candidates: dict) -> dict:
"""Check candidate verification invariants."""
errors = []
for c in candidates.get("candidates", []):
evidence_types = [e.get("type") for e in c.get("evidence", []) if isinstance(e, dict)]
verification = c.get("verification")
provenance = c.get("provenance")
if c.get("confidence") == "high" and not c.get("verified"):
errors.append(f"{c.get('id')}: high confidence without verification")
if c.get("verified"):
if not isinstance(verification, list) or not verification:
errors.append(f"{c.get('id')}: verified without verification records")
if c.get("source") == "hint" or c.get("type") == "hint":
errors.append(f"{c.get('id')}: manual hint cannot be verified")
matched = any(
isinstance(record, dict) and (
record.get("matched") is True or record.get("verified") is True or
any(isinstance(test, dict) and test.get("matched") is True
for test in record.get("tests", []))
)
for record in (verification or [])
)
if not matched:
errors.append(f"{c.get('id')}: no successful runtime/request match")
return {"name": "candidate_invariants", "ok": not errors, "errors": errors}
def check_cross_file(analysis: dict, generated: Path) -> dict:
"""Check cross-file consistency between JSRPC and Flask."""
errors = []
jsrpc = (generated / "jsrpc_inject.js").read_text(encoding="utf-8") if (generated / "jsrpc_inject.js").exists() else ""
flask = (generated / "flask_proxy.py").read_text(encoding="utf-8") if (generated / "flask_proxy.py").exists() else ""
# Check action names
action_name = analysis.get("jsrpc", {}).get("action_name", "")
if action_name and action_name not in jsrpc:
errors.append(f"action missing from jsrpc_inject.js: {action_name}")
if action_name and action_name not in flask:
errors.append(f"action missing from flask_proxy.py: {action_name}")
# Check entrypoint resolution
if "resolveEntrypoint" not in jsrpc and "resolvePath" not in jsrpc:
errors.append("jsrpc_inject.js does not contain entrypoint resolution")
return {"name": "cross_file_consistency", "ok": not errors, "errors": errors}
def mock_semantics() -> dict:
"""Check HTTP form semantics."""
raw = "a=1&a=2&password=x+y&empty="
pairs = parse_qsl(raw, keep_blank_values=True)
errors = []
if pairs[:2] != [("a", "1"), ("a", "2")]:
errors.append("duplicate form keys not preserved")
if pairs[-1] != ("empty", ""):
errors.append("blank form value not preserved")
return {"name": "mock_http_semantics", "ok": not errors, "errors": errors}
def main() -> int:
parser = argparse.ArgumentParser(description="Four-layer artifact validator.")
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("--generated", required=True, help="Path to generated/ directory.")
parser.add_argument("--report", required=True, help="Path to validation report JSON.")
parser.add_argument("--e2e-result", help="Path to optional E2E result JSON.")
parser.add_argument("--adversarial-trace", help="Optional adversarial runtime trace JSON.")
args = parser.parse_args()
base = Path(__file__).parents[1]
generated = Path(args.generated)
analysis = load_json(args.analysis)
checks = []
# A green report must mean the generated service can start and schema
# checks really ran. Missing required packages are therefore failures,
# not silently skipped checks.
checks.extend(check_dependency(name) for name in ("flask", "requests", "jsonschema"))
# Layer 1: Schema validation
schema_path = base / "schemas" / "analysis_result.schema.json"
if schema_path.exists():
checks.append(check_schema("analysis_schema", analysis, schema_path))
if args.candidates:
candidates = load_json(args.candidates)
candidates_schema = base / "schemas" / "candidates.schema.json"
if candidates_schema.exists():
checks.append(check_schema("candidate_schema", candidates, candidates_schema))
checks.append(check_candidate_invariants(candidates))
# Layer 2: Static validation
for name in ("flask_proxy.py",):
path = generated / name
checks.append(check_python(path) if path.exists() else {"name": f"python:{name}", "ok": False, "errors": ["missing"]})
for name in ("jsrpc_inject.js", "runtime_hook_probe.js", "adversarial_runtime_probe.js", "module_probe.js"):
checks.append(check_javascript(generated / name))
checks.append(check_jsrpc_evidence_gate(generated / "jsrpc_inject.js"))
# Layer 3: Cross-file consistency
checks.append(check_cross_file(analysis, generated))
checks.append(mock_semantics())
# Layer 4: Optional real E2E
if args.e2e_result:
e2e = load_json(args.e2e_result, {})
checks.append({"name": "real_e2e", "ok": bool(e2e.get("passed")), "details": e2e})
if args.adversarial_trace:
trace = load_json(args.adversarial_trace, {})
schema = base / "schemas" / "adversarial_trace.schema.json"
checks.append(check_schema("adversarial_trace_schema", trace, schema))
report = {"passed": all(c.get("ok") for c in checks), "checks": checks}
dump_json(args.report, report)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Validate one live browser/JSRPC action result before it is reported as success.
The validator deliberately checks evidence fields rather than service liveness. It
accepts either a raw /go response or an envelope with ``result`` and optional
bridge/tab/operation metadata.
Usage:
python3 scripts/validate_browser_evidence.py \
--input artifacts/jsrpc_smoke.json \
--output artifacts/browser_evidence.json
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
ERROR_MARKERS = ("JSRA_ERROR", "EvidenceMissing", "unavailable", "not verified")
def non_empty(value: Any) -> bool:
if value is None or value is False:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, (list, dict)):
return bool(value)
return True
def path_value(root: dict[str, Any], *paths: str) -> tuple[Any, str | None]:
for path in paths:
value: Any = root
ok = True
for part in path.split("."):
if not isinstance(value, dict) or part not in value:
ok = False
break
value = value[part]
if ok:
return value, path
return None, None
def marker_in(value: Any) -> str | None:
try:
text = json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
text = str(value)
lowered = text.lower()
for marker in ERROR_MARKERS:
if marker.lower() in lowered:
return marker
return None
def validate(payload: Any, require_session: bool = False) -> dict[str, Any]:
if not isinstance(payload, dict):
return {"passed": False, "reason": "top_level_object_required"}
result = payload.get("result", payload)
# Legacy JSRPC /go responses commonly serialize the action result in the
# outer ``data`` field. Decode that envelope before checking evidence;
# an undecoded transport wrapper is not itself a missing business result.
if isinstance(result, dict) and isinstance(result.get("data"), str):
try:
decoded = json.loads(result["data"])
except (TypeError, ValueError):
decoded = None
if isinstance(decoded, dict):
result = decoded
if isinstance(result, dict) and isinstance(result.get("data"), dict):
data = result["data"]
elif isinstance(result, dict):
data = result
else:
return {"passed": False, "reason": "result_object_required"}
checks: dict[str, dict[str, Any]] = {}
plaintext, plaintext_path = path_value(data, "plaintext", "input", "value")
checks["plaintext"] = {"ok": non_empty(plaintext), "path": plaintext_path}
request_body, request_body_path = path_value(
data, "requestBody", "request.body", "request.bodyText", "ciphertext"
)
checks["request_body"] = {"ok": non_empty(request_body), "path": request_body_path}
status, status_path = path_value(data, "status", "httpStatus", "response.status")
checks["http_status"] = {
"ok": isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599,
"path": status_path,
"value_type": type(status).__name__,
}
route, route_path = path_value(
data, "route", "request.route", "request.url", "url", "request.path"
)
checks["final_route"] = {"ok": non_empty(route), "path": route_path}
response, response_path = path_value(data, "response", "businessResponse", "body")
checks["business_response"] = {"ok": non_empty(response), "path": response_path}
error_marker = marker_in(result)
checks["no_error_marker"] = {"ok": error_marker is None, "marker": error_marker}
session = payload.get("session") or payload.get("browser") or {}
session_checks: dict[str, Any] = {"provided": bool(session)}
if session:
connected = session.get("connected", session.get("bridge", {}).get("connected"))
tab_id = session.get("tabId", session.get("tab", {}).get("tabId"))
document_id = session.get("documentId", session.get("tab", {}).get("documentId"))
session_checks.update({
"connected": connected is True,
"tab_id": non_empty(tab_id),
"document_id": non_empty(document_id),
})
elif require_session:
session_checks["reason"] = "session_metadata_required"
required = [item["ok"] for item in checks.values()]
if require_session:
required.extend(session_checks.get(key, False) for key in ("connected", "tab_id", "document_id"))
return {
"passed": all(required),
"evidence": {
"plaintext_present": checks["plaintext"]["ok"],
"request_body_present": checks["request_body"]["ok"],
"final_route_present": checks["final_route"]["ok"],
"http_status_present": checks["http_status"]["ok"],
"business_response_present": checks["business_response"]["ok"],
},
"checks": checks,
"session": session_checks,
"reportable_success": all(required),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate live browser/JSRPC evidence.")
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--require-session", action="store_true",
help="Require connected bridge, tabId and documentId metadata.")
args = parser.parse_args()
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
report = validate(payload, require_session=args.require_session)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False))
return 0 if report["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())