#!/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())