reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -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;
|
||||
@@ -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())
|
||||
@@ -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); }
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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}¶m=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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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); }
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user