""" First GUI — PySide6 版 Author: Spade-sec | https://github.com/Spade-sec/First """ import asyncio import json import multiprocessing import os import queue import sys import threading from datetime import datetime from PySide6.QtCore import ( Qt, QTimer, QPropertyAnimation, QEasingCurve, Property, QRect, Signal, QPoint, QUrl, ) from PySide6.QtGui import QPainter, QColor, QFont, QIcon, QPixmap, QDesktopServices from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QFrame, QPushButton, QScrollArea, QTextEdit, QTreeWidget, QTreeWidgetItem, QProgressBar, QStackedWidget, QMenu, QHeaderView, QAbstractItemView, ) from src.cli import CliOptions, DEBUG_PORT, CDP_PORT from src.logger import Logger from src.engine import DebugEngine from src.navigator import MiniProgramNavigator from src.cloud_audit import CloudAuditor from src.userscript import load_userscripts_by_files from src.js_analyzer import analyze_js, merge_results, CATEGORY_INFO, save_report, load_reports, delete_report # ══════════════════════════════════════════ # 配色 # ══════════════════════════════════════════ _D = dict( bg="#1c1c24", card="#262632", input="#181820", sidebar="#111118", sb_hover="#1c1c28", sb_active="#222232", border="#303040", border2="#3a3a4c", text1="#e8e8f0", text2="#8888a0", text3="#5c5c6c", text4="#3c3c4c", accent="#4ade80", accent2="#22c55e", success="#4ade80", error="#f87171", warning="#fbbf24", ) _L = dict( bg="#f2f2f6", card="#ffffff", input="#eeeef2", sidebar="#ffffff", sb_hover="#f2f2f6", sb_active="#e6e6ea", border="#d8d8dc", border2="#c8c8cc", text1="#1a1a22", text2="#6e6e78", text3="#9e9ea8", text4="#c0c0c8", accent="#16a34a", accent2="#15803d", success="#16a34a", error="#dc2626", warning="#ca8a04", ) _TH = {"dark": _D, "light": _L} _FN = "Microsoft YaHei UI" _FM = "Consolas" _MENU = [ ("control", "\u25c9", "控制台"), ("navigator", "\u2b21", "路由导航"), ("hook", "\u25c8", "Hook"), ("cloud", "\u2601", "云扫描"), ("security", "\u2623", "敏感提取"), ("logs", "\u2261", "运行日志"), ] # ══════════════════════════════════════════ # 配置持久化 # ══════════════════════════════════════════ _BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if getattr(sys, 'frozen', False): _BASE_DIR = os.path.dirname(sys.executable) _CFG_FILE = os.path.join(_BASE_DIR, "gui_config.json") os.makedirs(os.path.join(_BASE_DIR, "userscripts"), exist_ok=True) os.makedirs(os.path.join(_BASE_DIR, "hook_scripts"), exist_ok=True) os.makedirs(os.path.join(_BASE_DIR, "scan_reports"), exist_ok=True) def _load_cfg(): try: with open(_CFG_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_cfg(data): try: with open(_CFG_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception: pass # ══════════════════════════════════════════ # QSS 主题 # ══════════════════════════════════════════ def build_qss(tn): c = _TH[tn] sel_bg = "#1e3a2a" if tn == "dark" else "#d4edda" sel_fg = "#a0f0c0" if tn == "dark" else "#155724" hdr_bg = "#222230" if tn == "dark" else "#e8e8ec" row_bg = c["input"] return f""" /* ── 全局 ── */ QMainWindow, QWidget#central {{ background: {c['bg']}; }} /* ── 侧栏 ── */ QFrame#sidebar {{ background: {c['sidebar']}; }} QFrame#sidebar QLabel {{ background: transparent; }} QFrame#sb_head {{ background: {c['sidebar']}; }} QLabel#sb_logo {{ color: {c['text1']}; font-size: 13px; font-weight: bold; background: transparent; }} QFrame#sb_hline {{ background: {c['border']}; max-height: 1px; min-height: 1px; }} QLabel#sb_theme {{ color: {c['text3']}; background: transparent; padding: 4px 12px; }} QLabel#sb_theme:hover {{ color: {c['text1']}; }} /* ── 菜单项 ── */ QFrame.sb_item {{ background: {c['sidebar']}; border-radius: 8px; padding: 8px 10px; }} QFrame.sb_item:hover {{ background: {c['sb_hover']}; }} QFrame.sb_item_active {{ background: {c['sb_active']}; border-radius: 8px; padding: 8px 10px; }} QFrame.sb_item QLabel.sb_icon {{ color: {c['text3']}; background: transparent; }} QFrame.sb_item QLabel.sb_name {{ color: {c['text2']}; background: transparent; }} QFrame.sb_item_active QLabel.sb_icon {{ color: {c['accent']}; background: transparent; }} QFrame.sb_item_active QLabel.sb_name {{ color: {c['text1']}; background: transparent; }} /* ── 分割线 ── */ QFrame#vline {{ background: {c['border']}; max-width: 1px; min-width: 1px; }} QFrame#hdr_line {{ background: {c['border']}; max-height: 1px; min-height: 1px; }} /* ── 标题 ── */ QLabel#page_title {{ color: {c['text1']}; font-size: 17px; font-weight: bold; padding-left: 24px; background: transparent; }} /* ── 圆角卡片 ── */ QFrame.card {{ background: {c['card']}; border-radius: 12px; border: none; }} QFrame.card QLabel {{ background: transparent; }} QFrame.card QLabel.title {{ color: {c['text1']}; font-weight: bold; font-size: 11px; }} QFrame.card QLabel.subtitle {{ color: {c['text2']}; font-size: 9px; }} /* ── 通用 Label ── */ QLabel {{ color: {c['text2']}; background: transparent; }} QLabel.bold {{ color: {c['text1']}; font-weight: bold; }} QLabel.muted {{ color: {c['text3']}; }} QLabel.accent {{ color: {c['accent']}; }} /* ── 按钮 ── */ QPushButton {{ background: {c['accent']}; color: #111118; border: none; border-radius: 8px; padding: 5px 16px; font-size: 10px; }} QPushButton:hover {{ background: {c['accent2']}; }} QPushButton:disabled {{ background: {"#1a3a2a" if tn == "dark" else "#b0dfc0"}; color: {"#3a6a4a" if tn == "dark" else "#5a8a6a"}; }} /* ── 输入框 ── */ QLineEdit {{ background: {c['input']}; color: {c['text1']}; border: none; border-radius: 10px; padding: 6px 12px; font-size: 10px; selection-background-color: {c['accent']}; selection-color: #111118; }} QLineEdit:focus {{ border: 1px solid {c['accent']}; }} /* ── 文本框 ── */ QTextEdit {{ background: {c['input']}; color: {c['accent']}; border: none; border-radius: 8px; padding: 10px 14px; font-family: {_FM}; font-size: 10px; selection-background-color: {c['accent']}; selection-color: #111118; }} /* ── 树形控件 ── */ QTreeWidget {{ background: {c['card']}; color: {c['text2']}; border: none; font-size: 10px; outline: 0; }} QTreeWidget::item {{ padding: 4px 8px; border: none; text-align: left; }} QTreeWidget::item:selected {{ background: {sel_bg}; color: {sel_fg}; }} QTreeWidget::item:hover {{ background: {c['sb_hover']}; }} QHeaderView::section {{ background: {hdr_bg}; color: {c['text1']}; border: none; padding: 4px 8px; font-weight: bold; font-size: 10px; text-align: left; }} /* ── 进度条 ── */ QProgressBar {{ background: {c['border']}; border: none; border-radius: 4px; height: 6px; text-align: center; }} QProgressBar::chunk {{ background: {c['accent']}; border-radius: 4px; }} /* ── 滚动条 ── */ QScrollBar:vertical {{ background: transparent; width: 6px; margin: 0; }} QScrollBar::handle:vertical {{ background: {"#3a6a4a" if tn == "dark" else "#8fc4a0"}; border-radius: 3px; min-height: 20px; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} QScrollBar:horizontal {{ background: transparent; height: 6px; }} QScrollBar::handle:horizontal {{ background: {"#3a6a4a" if tn == "dark" else "#8fc4a0"}; border-radius: 3px; min-width: 20px; }} QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{ background: transparent; }} /* ── 滚动区域 ── */ QScrollArea {{ background: transparent; border: none; }} QScrollArea > QWidget > QWidget {{ background: transparent; }} /* ── 右键菜单 ── */ QMenu {{ background: {c['card']}; color: {c['text1']}; border: 1px solid {c['border']}; border-radius: 6px; padding: 4px; }} QMenu::item {{ padding: 6px 20px; border-radius: 4px; }} QMenu::item:selected {{ background: {c['accent']}; color: #ffffff; }} QMenu::separator {{ height: 1px; background: {c['border']}; margin: 4px 8px; }} /* ── Hook 行 ── */ QFrame.hook_row {{ background: {row_bg}; border-radius: 8px; }} QFrame.hook_row QLabel {{ background: transparent; }} QLabel.js_badge {{ background: {c['accent']}; color: {"#ffffff" if tn == "dark" else "#111118"}; font-weight: bold; font-size: 9px; padding: 2px 6px; border-radius: 4px; }} /* ── Completer popup ── */ QListView {{ background: {c['input']}; color: {c['text1']}; border: 1px solid {c['border']}; border-radius: 6px; outline: 0; }} QListView::item:selected {{ background: {c['accent']}; color: #111118; }} """ # ══════════════════════════════════════════ # 自定义控件 # ══════════════════════════════════════════ class ToggleSwitch(QWidget): toggled = Signal(bool) def __init__(self, checked=False, parent=None): super().__init__(parent) self._checked = checked self._thumb_pos = 1.0 if checked else 0.0 self._on_color = QColor("#4ade80") self._off_color = QColor("#3c3c4c") self._thumb_color = QColor("#ffffff") self.setFixedSize(44, 24) self.setCursor(Qt.PointingHandCursor) self._anim = QPropertyAnimation(self, b"thumbPos") self._anim.setDuration(150) self._anim.setEasingCurve(QEasingCurve.OutCubic) def isChecked(self): return self._checked def setChecked(self, v): if self._checked == v: return self._checked = v self._anim.stop() self._anim.setStartValue(self._thumb_pos) self._anim.setEndValue(1.0 if v else 0.0) self._anim.start() self.toggled.emit(v) def _get_thumb_pos(self): return self._thumb_pos def _set_thumb_pos(self, v): self._thumb_pos = v self.update() thumbPos = Property(float, _get_thumb_pos, _set_thumb_pos) def mousePressEvent(self, e): self.setChecked(not self._checked) def paintEvent(self, e): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w, h = self.width(), self.height() r = h / 2 # track track_color = QColor(self._on_color) if self._checked else QColor(self._off_color) p.setPen(Qt.NoPen) p.setBrush(track_color) p.drawRoundedRect(0, 0, w, h, r, r) # thumb tr = r - 3 cx = r + self._thumb_pos * (w - 2 * r) p.setBrush(self._thumb_color) p.drawEllipse(QPoint(int(cx), int(r)), int(tr), int(tr)) def set_colors(self, on, off): self._on_color = QColor(on) self._off_color = QColor(off) self.update() class AnimatedStackedWidget(QStackedWidget): """Page switch with a lightweight vertical slide animation. Uses QPropertyAnimation on widget geometry instead of QGraphicsOpacityEffect, which forces expensive off-screen compositing of the entire subtree (causing visible lag on heavy pages like the cloud-scan QTreeWidget). """ def __init__(self, parent=None): super().__init__(parent) self._anim = None def setCurrentIndexAnimated(self, idx): if idx == self.currentIndex(): return old_idx = self.currentIndex() old_widget = self.currentWidget() new_widget = self.widget(idx) if new_widget is None: self.setCurrentIndex(idx) return # Determine slide direction: down when going forward, up when back h = self.height() offset = h // 4 # slide only a quarter of the height for subtlety start_y = offset if idx > old_idx else -offset # Immediately switch the page (no off-screen compositing) self.setCurrentIndex(idx) # Animate just the position of the new page final_rect = new_widget.geometry() start_rect = QRect(final_rect) start_rect.moveTop(final_rect.top() + start_y) anim = QPropertyAnimation(new_widget, b"geometry") anim.setDuration(150) anim.setStartValue(start_rect) anim.setEndValue(final_rect) anim.setEasingCurve(QEasingCurve.OutCubic) self._anim = anim # prevent GC anim.start() class StatusDot(QWidget): def __init__(self, parent=None): super().__init__(parent) self.setFixedSize(10, 10) self._color = QColor("#3c3c4c") def set_color(self, color): self._color = QColor(color) self.update() def paintEvent(self, e): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(self._color) p.drawEllipse(1, 1, 8, 8) # ══════════════════════════════════════════ # 辅助函数 # ══════════════════════════════════════════ def _make_card(): f = QFrame() f.setProperty("class", "card") return f def _make_label(text, bold=False, muted=False, mono=False): l = QLabel(text) if bold: l.setProperty("class", "bold") elif muted: l.setProperty("class", "muted") if mono: l.setFont(QFont(_FM, 10)) return l def _make_btn(text, callback=None): b = QPushButton(text) if callback: b.clicked.connect(callback) return b def _make_entry(placeholder="", width=None): e = QLineEdit() e.setPlaceholderText(placeholder) if width: e.setFixedWidth(width) return e # ══════════════════════════════════════════ # 敏感提取 — 独立进程 worker # ══════════════════════════════════════════ def _sec_analyze_one(src): """独立进程 Pool 的 worker 函数:分析单个 JS 源码。""" try: return analyze_js(src) except Exception: return None def _sec_worker_proc(js_sources, appid, base_dir, result_q, name=""): """在独立进程中并行分析 JS,通过 multiprocessing.Queue 回传进度和结果。""" try: total = len(js_sources) total_size = sum(len(s) for s in js_sources) if total == 0: result_q.put(("done", {}, 0, 0)) return import os workers = min(total, max(4, os.cpu_count() or 4)) result_q.put(("progress", 35, f"并行分析 ({workers} 线程) ...")) from concurrent.futures import ThreadPoolExecutor, as_completed results = [] done_count = 0 with ThreadPoolExecutor(max_workers=workers) as pool: futs = {pool.submit(_sec_analyze_one, src): i for i, src in enumerate(js_sources)} for fut in as_completed(futs): r = fut.result() if r: results.append(r) done_count += 1 if done_count % 5 == 0 or done_count == total: pct = 30 + int(done_count / total * 60) result_q.put(("progress", pct, f"分析中 {done_count}/{total} ...")) merged = merge_results(results) if results else {} result_q.put(("progress", 95, "生成报告...")) try: save_report(base_dir, appid, merged, total, total_size, name=name) except Exception: pass result_q.put(("done", merged, total, total_size)) except Exception as e: result_q.put(("error", f"分析进程异常: {e}")) result_q.put(("done", None, 0, 0)) def _sec_run_worker(js_sources, appid, base_dir, sec_q, name=""): """启动独立分析进程,用后台线程转发 multiprocessing.Queue 到 UI 的 queue.Queue。""" mp_q = multiprocessing.Queue() proc = multiprocessing.Process( target=_sec_worker_proc, args=(js_sources, appid, base_dir, mp_q, name), daemon=True, ) proc.start() # 后台线程:等待进程完成,转发消息到 UI 队列 def _relay(): while proc.is_alive() or not mp_q.empty(): try: item = mp_q.get(timeout=0.2) sec_q.put(item) except Exception: pass # 确保队列清空 while not mp_q.empty(): try: sec_q.put(mp_q.get_nowait()) except Exception: break t = threading.Thread(target=_relay, daemon=True) t.start() # ══════════════════════════════════════════ # 主窗口 # ══════════════════════════════════════════ class App(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("First") _ico = os.path.join(_BASE_DIR, "icon.ico") if os.path.exists(_ico): self.setWindowIcon(QIcon(_ico)) self.resize(960, 620) self.setMinimumSize(780, 500) self._cfg = _load_cfg() self._tn = self._cfg.get("theme", "dark") self._pg = "control" self._running = False self._loop = self._loop_th = self._engine = self._navigator = self._auditor = None self._cloud_call_history = {} self._cloud_all_items = [] self._cloud_row_results = {} self._cancel_ev = None self._route_poll_id = None self._all_routes = [] self._cloud_scan_active = False self._cloud_scan_poll_timer = None self._redirect_guard_on = False self._hook_injected = set() self._blocked_seen = 0 self._sec_scanning = False self._sec_result = None self._sec_view = "scan" # "scan" | "report" | "history" self._log_q = queue.Queue() self._sts_q = queue.Queue() self._rte_q = queue.Queue() self._cld_q = queue.Queue() self._sec_q = queue.Queue() self._selected_preload = list(self._cfg.get("selected_preload_scripts", [])) self._nav_route_idx = -1 self._sb_items = {} self._page_map = {} self._build() self.setStyleSheet(build_qss(self._tn)) self._show("control") self._tick_timer = QTimer() self._tick_timer.timeout.connect(self._tick) self._tick_timer.start(80) # ────────────────────────────────── # 布局 # ────────────────────────────────── def _build(self): central = QWidget() central.setObjectName("central") self.setCentralWidget(central) root_h = QHBoxLayout(central) root_h.setContentsMargins(0, 0, 0, 0) root_h.setSpacing(0) # ── 侧栏 ── self._sb = QFrame() self._sb.setObjectName("sidebar") self._sb.setFixedWidth(180) sb_lay = QVBoxLayout(self._sb) sb_lay.setContentsMargins(0, 0, 0, 0) sb_lay.setSpacing(0) sb_head = QFrame() sb_head.setObjectName("sb_head") sb_head.setFixedHeight(76) sb_head_lay = QVBoxLayout(sb_head) sb_head_lay.addStretch() logo_row = QHBoxLayout() logo_row.setContentsMargins(0, 0, 0, 0) logo_row.setSpacing(6) logo_row.addStretch() _ico_path = os.path.join(_BASE_DIR, "icon.ico") if os.path.exists(_ico_path): _ico_lbl = QLabel() _pix = QPixmap(_ico_path).scaled(20, 20, Qt.KeepAspectRatio, Qt.SmoothTransformation) _ico_lbl.setPixmap(_pix) _ico_lbl.setFixedSize(20, 20) logo_row.addWidget(_ico_lbl) self._sb_logo = QLabel("First") self._sb_logo.setObjectName("sb_logo") logo_row.addWidget(self._sb_logo) logo_row.addStretch() sb_head_lay.addLayout(logo_row) sb_head_lay.addStretch() sb_lay.addWidget(sb_head) hline = QFrame() hline.setObjectName("sb_hline") hline.setFixedHeight(1) sb_lay.addWidget(hline, 0, Qt.AlignTop) sb_nav = QWidget() sb_nav_lay = QVBoxLayout(sb_nav) sb_nav_lay.setContentsMargins(8, 10, 8, 10) sb_nav_lay.setSpacing(2) for pid, icon, name in _MENU: row = QFrame() row.setCursor(Qt.PointingHandCursor) row.setProperty("class", "sb_item") row_lay = QHBoxLayout(row) row_lay.setContentsMargins(10, 0, 8, 0) row_lay.setSpacing(6) ic = QLabel(icon) ic.setProperty("class", "sb_icon") ic.setFont(QFont(_FN, 13)) nm = QLabel(name) nm.setProperty("class", "sb_name") nm.setFont(QFont(_FN, 10)) row_lay.addWidget(ic) row_lay.addWidget(nm, 1) sb_nav_lay.addWidget(row) row.mousePressEvent = lambda e, p=pid: self._show(p) self._sb_items[pid] = (row, ic, nm) sb_nav_lay.addStretch() sb_lay.addWidget(sb_nav, 1) self._sb_theme = QLabel() self._sb_theme.setObjectName("sb_theme") self._sb_theme.setAlignment(Qt.AlignCenter) self._sb_theme.setCursor(Qt.PointingHandCursor) self._sb_theme.setFont(QFont(_FN, 9)) self._sb_theme.mousePressEvent = lambda e: self._toggle_theme() sb_lay.addWidget(self._sb_theme) sb_author = QLabel("by vs-olitus") sb_author.setObjectName("sb_theme") sb_author.setAlignment(Qt.AlignCenter) sb_author.setFont(QFont(_FN, 8)) sb_lay.addWidget(sb_author) sb_gh = QLabel("github.com/Spade-sec/First") sb_gh.setObjectName("sb_theme") sb_gh.setAlignment(Qt.AlignCenter) sb_gh.setFont(QFont(_FN, 7)) sb_gh.setCursor(Qt.PointingHandCursor) sb_gh.mousePressEvent = lambda e: ( QDesktopServices.openUrl(QUrl("https://github.com/Spade-sec/First")), self._log_add("info", "[gui] 已打开 GitHub 页面")) sb_lay.addWidget(sb_gh) sb_lay.addSpacing(12) self._update_theme_label() root_h.addWidget(self._sb) vline = QFrame() vline.setObjectName("vline") vline.setFixedWidth(1) root_h.addWidget(vline) # ── 右侧 ── right = QWidget() right_lay = QVBoxLayout(right) right_lay.setContentsMargins(0, 0, 0, 0) right_lay.setSpacing(0) hdr_frame = QWidget() hdr_frame.setFixedHeight(60) hdr_lay = QHBoxLayout(hdr_frame) hdr_lay.setContentsMargins(0, 0, 0, 0) self._hdr_title = QLabel("") self._hdr_title.setObjectName("page_title") hdr_lay.addWidget(self._hdr_title) hdr_lay.addStretch() right_lay.addWidget(hdr_frame) hdr_line = QFrame() hdr_line.setObjectName("hdr_line") hdr_line.setFixedHeight(1) right_lay.addWidget(hdr_line) self._stack = AnimatedStackedWidget() right_lay.addWidget(self._stack, 1) root_h.addWidget(right, 1) self._build_control() self._build_navigator() self._build_hook() self._build_cloud() self._build_security() self._build_logs() # ── 控制台 ── def _build_control(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 8, 24, 8) lay.setSpacing(6) lay.setAlignment(Qt.AlignTop) # Card 1: 连接设置 c1 = _make_card() c1_lay = QVBoxLayout(c1) c1_lay.setContentsMargins(16, 10, 16, 10) c1_lay.setSpacing(6) c1_lay.addWidget(_make_label("连接设置", bold=True)) row1 = QHBoxLayout() row1.addWidget(QLabel("调试端口")) self._dp_ent = _make_entry(width=100) self._dp_ent.setText(str(self._cfg.get("debug_port", DEBUG_PORT))) self._dp_ent.textChanged.connect(lambda: self._auto_save()) row1.addWidget(self._dp_ent) row1.addSpacing(20) row1.addWidget(QLabel("CDP 端口")) self._cp_ent = _make_entry(width=100) self._cp_ent.setText(str(self._cfg.get("cdp_port", CDP_PORT))) self._cp_ent.textChanged.connect(lambda: self._auto_save()) row1.addWidget(self._cp_ent) row1.addStretch() c1_lay.addLayout(row1) c1_lay.addWidget(_make_label("调试选项", bold=True)) chkr = QHBoxLayout() self._tog_dm = ToggleSwitch(self._cfg.get("debug_main", False)) self._tog_dm.toggled.connect(lambda v: self._auto_save()) chkr.addWidget(self._tog_dm) chkr.addWidget(QLabel("调试主包")) chkr.addSpacing(24) self._tog_df = ToggleSwitch(self._cfg.get("debug_frida", False)) self._tog_df.toggled.connect(lambda v: self._auto_save()) chkr.addWidget(self._tog_df) chkr.addWidget(QLabel("调试 Frida")) chkr.addStretch() c1_lay.addLayout(chkr) lay.addWidget(c1) # Card 2: 前加载脚本 c2 = _make_card() c2_lay = QVBoxLayout(c2) c2_lay.setContentsMargins(16, 10, 16, 10) c2_lay.setSpacing(4) hdr_row = QHBoxLayout() hdr_row.addWidget(_make_label("前加载脚本", bold=True)) hdr_row.addWidget(_make_label("(启动调试前可用)", muted=True)) hdr_row.addStretch() self._btn_preload_refresh = _make_btn("刷新", self._preload_refresh) hdr_row.addWidget(self._btn_preload_refresh) c2_lay.addLayout(hdr_row) self._preload_container = QVBoxLayout() self._preload_container.setSpacing(2) c2_lay.addLayout(self._preload_container) lay.addWidget(c2) self._preload_refresh() # Action row ar = QHBoxLayout() self._btn_start = _make_btn("▶ 启动调试", self._do_start) self._btn_start.setFont(QFont(_FN, 10, QFont.Bold)) ar.addWidget(self._btn_start) self._btn_stop = _make_btn("■ 停止", self._do_stop) self._btn_stop.setFont(QFont(_FN, 10, QFont.Bold)) self._btn_stop.setEnabled(False) ar.addWidget(self._btn_stop) ar.addStretch() lay.addLayout(ar) # DevTools URL dt_row = QHBoxLayout() self._devtools_lbl = QLabel("") self._devtools_lbl.setProperty("class", "accent") self._devtools_lbl.setFont(QFont(_FM, 8)) self._devtools_lbl.setCursor(Qt.PointingHandCursor) self._devtools_lbl.mousePressEvent = lambda e: self._copy_devtools_url() dt_row.addWidget(self._devtools_lbl) self._devtools_copy_hint = QLabel("") self._devtools_copy_hint.setProperty("class", "muted") self._devtools_copy_hint.setFont(QFont(_FN, 8)) dt_row.addWidget(self._devtools_copy_hint) dt_row.addStretch() lay.addLayout(dt_row) # Card 3: 运行状态 c3 = _make_card() c3_lay = QVBoxLayout(c3) c3_lay.setContentsMargins(16, 10, 16, 10) c3_lay.setSpacing(2) c3_lay.addWidget(_make_label("运行状态", bold=True)) self._dots = {} for key, name in [("frida", "Frida"), ("miniapp", "小程序"), ("devtools", "DevTools")]: dr = QHBoxLayout() dot = StatusDot() dr.addWidget(dot) lb = QLabel(f"{name}: 未连接") dr.addWidget(lb) dr.addStretch() c3_lay.addLayout(dr) self._dots[key] = (dot, lb, name) self._app_lbl = QLabel("应用: --") self._app_lbl.setProperty("class", "muted") c3_lay.addWidget(self._app_lbl) lay.addWidget(c3) self._stack.addWidget(page) self._page_map["control"] = self._stack.count() - 1 # ── 路由导航 ── def _build_navigator(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) # 搜索栏 sf = QHBoxLayout() sf.addWidget(QLabel("搜索")) self._srch_ent = _make_entry("输入路由关键字搜索...") self._srch_ent.textChanged.connect(self._do_filter) sf.addWidget(self._srch_ent, 1) lay.addLayout(sf) # 路由树 tc = _make_card() tc_lay = QVBoxLayout(tc) tc_lay.setContentsMargins(0, 0, 0, 0) self._tree = QTreeWidget() self._tree.setHeaderHidden(True) self._tree.setSelectionMode(QAbstractItemView.SingleSelection) self._tree.setContextMenuPolicy(Qt.CustomContextMenu) self._tree.customContextMenuRequested.connect(self._nav_context_menu) tc_lay.addWidget(self._tree) lay.addWidget(tc, 1) # 手动输入跳转 mi = QHBoxLayout() mi.addWidget(QLabel("手动跳转")) self._nav_input = _make_entry("输入路由路径,回车跳转...") self._nav_input.returnPressed.connect(self._do_manual_go) mi.addWidget(self._nav_input, 1) self._btn_manual_go = _make_btn("跳转", self._do_manual_go) mi.addWidget(self._btn_manual_go) self._btn_copy_route = _make_btn("复制路由", self._do_copy_route) self._btn_copy_route.setEnabled(False) mi.addWidget(self._btn_copy_route) lay.addLayout(mi) # 导航按钮行 1 b1 = QHBoxLayout() self._btn_go = _make_btn("跳转", self._do_go) self._btn_go.setEnabled(False) b1.addWidget(self._btn_go) self._btn_redir = _make_btn("重定向", self._do_redir) self._btn_redir.setEnabled(False) b1.addWidget(self._btn_redir) self._btn_relaunch = _make_btn("重启到页面", self._do_relaunch) self._btn_relaunch.setEnabled(False) b1.addWidget(self._btn_relaunch) self._btn_back = _make_btn("返回上页", self._do_back) self._btn_back.setEnabled(False) b1.addWidget(self._btn_back) b1.addStretch() self._btn_fetch = _make_btn("获取路由", self._do_fetch) self._btn_fetch.setEnabled(False) b1.addWidget(self._btn_fetch) lay.addLayout(b1) # 导航按钮行 2: 上一个/下一个 + 遍历 + 防跳转 b2 = QHBoxLayout() self._btn_prev = _make_btn("◀ 上一个", self._do_prev) self._btn_prev.setEnabled(False) b2.addWidget(self._btn_prev) self._btn_next = _make_btn("下一个 ▶", self._do_next) self._btn_next.setEnabled(False) b2.addWidget(self._btn_next) b2.addSpacing(12) self._btn_auto = _make_btn("自动遍历", self._do_autovis) self._btn_auto.setEnabled(False) b2.addWidget(self._btn_auto) self._btn_autostop = _make_btn("停止遍历", self._do_autostop) self._btn_autostop.setEnabled(False) b2.addWidget(self._btn_autostop) b2.addSpacing(12) self._guard_switch = ToggleSwitch(False) self._guard_switch.setFixedSize(36, 18) self._guard_switch.setEnabled(False) self._guard_switch.toggled.connect(self._do_toggle_guard_switch) b2.addWidget(self._guard_switch) self._guard_label = QLabel("防跳转: 关闭") b2.addWidget(self._guard_label) b2.addStretch() lay.addLayout(b2) self._prog = QProgressBar() self._prog.setMaximum(100) self._prog.setValue(0) self._prog.setTextVisible(False) self._prog.setFixedHeight(6) lay.addWidget(self._prog) self._route_lbl = QLabel("当前路由: --") lay.addWidget(self._route_lbl) self._stack.addWidget(page) self._page_map["navigator"] = self._stack.count() - 1 # ── Hook 页面 ── def _build_hook(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) tip_row = QHBoxLayout() self._hook_tip = QLabel("将 .js 文件放入 hook_scripts/ 目录,点击「注入」即时执行") self._hook_tip.setProperty("class", "muted") tip_row.addWidget(self._hook_tip) tip_row.addStretch() self._btn_hook_refresh = _make_btn("刷新列表", self._hook_refresh) tip_row.addWidget(self._btn_hook_refresh) lay.addLayout(tip_row) c1 = _make_card() c1_lay = QVBoxLayout(c1) c1_lay.setContentsMargins(12, 12, 12, 12) c1_lay.setSpacing(6) scroll = QScrollArea() scroll.setWidgetResizable(True) self._hook_inner = QWidget() self._hook_inner_lay = QVBoxLayout(self._hook_inner) self._hook_inner_lay.setContentsMargins(0, 0, 0, 0) self._hook_inner_lay.setSpacing(6) self._hook_inner_lay.addStretch() scroll.setWidget(self._hook_inner) c1_lay.addWidget(scroll) lay.addWidget(c1, 1) self._hook_status_lbls = {} self._hook_refresh() self._stack.addWidget(page) self._page_map["hook"] = self._stack.count() - 1 def _hook_refresh(self): while self._hook_inner_lay.count() > 1: item = self._hook_inner_lay.takeAt(0) w = item.widget() if w: w.deleteLater() self._hook_status_lbls = {} hook_dir = os.path.join(_BASE_DIR, "hook_scripts") js_files = sorted(f for f in os.listdir(hook_dir) if f.endswith(".js")) if os.path.isdir(hook_dir) else [] if not js_files: lbl = QLabel("hook_scripts/ 目录下无 .js 文件") lbl.setAlignment(Qt.AlignCenter) self._hook_inner_lay.insertWidget(0, lbl) return for fn in js_files: row = QFrame() row.setProperty("class", "hook_row") row.setFixedHeight(52) row_lay = QHBoxLayout(row) row_lay.setContentsMargins(12, 0, 12, 0) row_lay.setSpacing(8) icon_lbl = QLabel("JS") icon_lbl.setProperty("class", "js_badge") icon_lbl.setFont(QFont(_FM, 8, QFont.Bold)) icon_lbl.setFixedWidth(30) icon_lbl.setAlignment(Qt.AlignCenter) row_lay.addWidget(icon_lbl) name_lbl = QLabel(fn) name_lbl.setFont(QFont(_FN, 10)) row_lay.addWidget(name_lbl, 1) injected = fn in self._hook_injected status_lbl = QLabel("● 已注入" if injected else "○ 未注入") c = _TH[self._tn] status_lbl.setStyleSheet(f"color: {c['success'] if injected else c['text3']};") row_lay.addWidget(status_lbl) self._hook_status_lbls[fn] = status_lbl inject_btn = _make_btn("注入", lambda checked=False, f=fn: self._hook_inject(f)) row_lay.addWidget(inject_btn) clear_btn = _make_btn("清除", lambda checked=False, f=fn: self._hook_clear(f)) row_lay.addWidget(clear_btn) self._hook_inner_lay.insertWidget(self._hook_inner_lay.count() - 1, row) def _hook_inject(self, filename): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[Hook] 请先启动调试") return hook_dir = os.path.join(_BASE_DIR, "hook_scripts") filepath = os.path.join(hook_dir, filename) try: with open(filepath, "r", encoding="utf-8") as f: source = f.read() except Exception as e: self._log_add("error", f"[Hook] 读取文件失败: {e}") return asyncio.run_coroutine_threadsafe( self._ahook_inject(filename, source), self._loop) async def _ahook_inject(self, filename, source): try: await self._engine.evaluate_js(source, timeout=5.0) self._hook_injected.add(filename) self._log_q.put(("info", f"[Hook] 已注入: {filename}")) self._log_q.put(("__hook_status__", filename, True)) except Exception as e: self._log_q.put(("error", f"[Hook] 注入失败 {filename}: {e}")) def _hook_clear(self, filename): self._hook_injected.discard(filename) self._hook_update_status(filename, False) self._log_add("info", f"[Hook] 已清除标记: {filename}(注意: JS 注入后无法真正撤销,需刷新页面)") def _hook_update_status(self, filename, injected): c = _TH[self._tn] lbl = self._hook_status_lbls.get(filename) if lbl: lbl.setText("● 已注入" if injected else "○ 未注入") lbl.setStyleSheet(f"color: {c['success'] if injected else c['text3']};") # ── 云扫描 ── def _build_cloud(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) ctrl = QHBoxLayout() self._btn_cloud_toggle = _make_btn("停止捕获", self._cloud_do_toggle) ctrl.addWidget(self._btn_cloud_toggle) self._btn_cloud_static = _make_btn("静态扫描", self._cloud_do_static_scan) ctrl.addWidget(self._btn_cloud_static) self._btn_cloud_clear = _make_btn("清空记录", self._cloud_do_clear) ctrl.addWidget(self._btn_cloud_clear) self._cloud_scan_lbl = QLabel("") ctrl.addWidget(self._cloud_scan_lbl) ctrl.addStretch() self._btn_cloud_export = _make_btn("导出报告", self._cloud_do_export) ctrl.addWidget(self._btn_cloud_export) lay.addLayout(ctrl) tc = _make_card() tc_lay = QVBoxLayout(tc) tc_lay.setContentsMargins(12, 8, 12, 8) tc_lay.setSpacing(4) title_row = QHBoxLayout() title_row.addWidget(_make_label("云函数捕获记录", bold=True)) self._cloud_env_lbl = QLabel("全局捕获(默认开启)") title_row.addWidget(self._cloud_env_lbl) title_row.addStretch() title_row.addWidget(QLabel("搜索")) self._cloud_search_ent = _make_entry(width=180) self._cloud_search_ent.textChanged.connect(self._cloud_filter) title_row.addWidget(self._cloud_search_ent) tc_lay.addLayout(title_row) self._cloud_tree = QTreeWidget() self._cloud_tree.setRootIsDecorated(False) self._cloud_tree.setIndentation(0) self._cloud_tree.setHeaderLabels(["AppID", "类型", "名称", "参数", "状态", "时间"]) header = self._cloud_tree.header() header.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) header.setStretchLastSection(False) header.setSectionResizeMode(0, QHeaderView.Interactive) header.setSectionResizeMode(1, QHeaderView.Interactive) header.setSectionResizeMode(2, QHeaderView.Interactive) header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionResizeMode(4, QHeaderView.Interactive) header.setSectionResizeMode(5, QHeaderView.Interactive) self._cloud_tree.setColumnWidth(0, 100) self._cloud_tree.setColumnWidth(1, 70) self._cloud_tree.setColumnWidth(2, 140) self._cloud_tree.setColumnWidth(4, 50) self._cloud_tree.setColumnWidth(5, 70) self._cloud_tree.setSelectionMode(QAbstractItemView.SingleSelection) self._cloud_tree.itemClicked.connect(self._cloud_on_select) self._cloud_tree.setContextMenuPolicy(Qt.CustomContextMenu) self._cloud_tree.customContextMenuRequested.connect(self._cloud_tree_context_menu) tc_lay.addWidget(self._cloud_tree) lay.addWidget(tc, 1) call_row = QHBoxLayout() call_row.addWidget(QLabel("手动调用")) self._cloud_name_ent = _make_entry(width=140) call_row.addWidget(self._cloud_name_ent) call_row.addWidget(QLabel("参数")) self._cloud_data_ent = _make_entry() self._cloud_data_ent.setText("{}") call_row.addWidget(self._cloud_data_ent, 1) self._btn_cloud_call = _make_btn("调用", self._cloud_do_call) call_row.addWidget(self._btn_cloud_call) lay.addLayout(call_row) self._cloud_result = QTextEdit() self._cloud_result.setReadOnly(True) self._cloud_result.setFixedHeight(120) self._cloud_result.setFont(QFont(_FM, 9)) lay.addWidget(self._cloud_result) bot = QHBoxLayout() self._cloud_status_lbl = QLabel("捕获: 0 条") bot.addWidget(self._cloud_status_lbl) bot.addStretch() lay.addLayout(bot) self._stack.addWidget(page) self._page_map["cloud"] = self._stack.count() - 1 # ── 敏感提取 ── def _build_security(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 8, 24, 8) lay.setSpacing(6) # 顶部操作栏 bar = QHBoxLayout() self._btn_sec_scan = _make_btn("扫描当前小程序", self._sec_do_scan) bar.addWidget(self._btn_sec_scan) self._btn_sec_history = _make_btn("历史记录", self._sec_show_history) bar.addWidget(self._btn_sec_history) self._btn_sec_back = _make_btn("返回", self._sec_back_to_scan) self._btn_sec_back.setVisible(False) bar.addWidget(self._btn_sec_back) bar.addStretch() self._sec_status_lbl = QLabel("") self._sec_status_lbl.setProperty("class", "muted") bar.addWidget(self._sec_status_lbl) lay.addLayout(bar) # 进度条 self._sec_prog = QProgressBar() self._sec_prog.setFixedHeight(6) self._sec_prog.setRange(0, 100) self._sec_prog.setValue(0) self._sec_prog.setTextVisible(False) self._sec_prog.setVisible(False) lay.addWidget(self._sec_prog) # 主内容区 — 内含 QStackedWidget 切换 scan/report/history self._sec_stack = QStackedWidget() # --- 扫描提示页 (index 0) --- scan_hint = QWidget() sh_lay = QVBoxLayout(scan_hint) sh_lay.setAlignment(Qt.AlignCenter) hint_lbl = QLabel("连接小程序后,点击「扫描当前小程序」提取 JS 中的敏感信息") hint_lbl.setProperty("class", "muted") hint_lbl.setAlignment(Qt.AlignCenter) hint_lbl.setWordWrap(True) sh_lay.addWidget(hint_lbl) self._sec_stack.addWidget(scan_hint) # --- 报告页 (index 1) --- report_page = QWidget() rp_lay = QVBoxLayout(report_page) rp_lay.setContentsMargins(0, 0, 0, 0) rp_lay.setSpacing(6) # 报告头部: appid + 概要 rp_hdr = QHBoxLayout() self._sec_rpt_header = QLabel("") self._sec_rpt_header.setProperty("class", "bold") rp_hdr.addWidget(self._sec_rpt_header) rp_hdr.addStretch() self._sec_rpt_summary = QLabel("") self._sec_rpt_summary.setProperty("class", "muted") rp_hdr.addWidget(self._sec_rpt_summary) rp_lay.addLayout(rp_hdr) # 左右分栏 rp_split = QHBoxLayout() rp_split.setSpacing(8) # 左栏: 类别列表 (滚动) left_scroll = QScrollArea() left_scroll.setWidgetResizable(True) left_scroll.setFrameShape(QFrame.NoFrame) left_scroll.setFixedWidth(220) left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) left_inner = QWidget() self._sec_cat_lay = QVBoxLayout(left_inner) self._sec_cat_lay.setContentsMargins(0, 0, 0, 0) self._sec_cat_lay.setSpacing(2) self._sec_cat_lay.addStretch() left_scroll.setWidget(left_inner) rp_split.addWidget(left_scroll) # 右栏: 详情列表 (滚动) right_card = _make_card() right_card_lay = QVBoxLayout(right_card) right_card_lay.setContentsMargins(0, 0, 0, 0) right_card_lay.setSpacing(0) # 右栏顶部: 类别名 + 复制全部 self._sec_detail_bar = QHBoxLayout() self._sec_detail_bar.setContentsMargins(14, 8, 14, 4) self._sec_detail_title = QLabel("选择左侧类别查看详情") self._sec_detail_title.setProperty("class", "bold") self._sec_detail_bar.addWidget(self._sec_detail_title) self._sec_detail_bar.addStretch() self._btn_sec_copy_all = _make_btn("复制全部", self._sec_copy_all) self._btn_sec_copy_all.setVisible(False) self._sec_detail_bar.addWidget(self._btn_sec_copy_all) right_card_lay.addLayout(self._sec_detail_bar) # 右栏内容: QTextEdit (只读,自带滚动条,填满剩余空间) self._sec_detail_text = QTextEdit() self._sec_detail_text.setReadOnly(True) self._sec_detail_text.setFont(QFont(_FM, 9)) self._sec_detail_text.setTextInteractionFlags( Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard) self._sec_detail_text.setFrameShape(QFrame.NoFrame) right_card_lay.addWidget(self._sec_detail_text, 1) rp_split.addWidget(right_card, 1) rp_lay.addLayout(rp_split, 1) self._sec_cur_cat = None # 当前选中类别 key self._sec_cat_widgets = {} # key -> row widget self._sec_stack.addWidget(report_page) # --- 历史页 (index 2) --- history_page = QWidget() hp_lay = QVBoxLayout(history_page) hp_lay.setContentsMargins(0, 0, 0, 0) hp_lay.setSpacing(6) self._sec_hist_tree = QTreeWidget() self._sec_hist_tree.setHeaderLabels(["时间", "AppID", "名称", "JS数量", "发现项"]) self._sec_hist_tree.setColumnCount(5) self._sec_hist_tree.header().setStretchLastSection(True) self._sec_hist_tree.setRootIsDecorated(False) self._sec_hist_tree.setAlternatingRowColors(False) self._sec_hist_tree.header().setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) self._sec_hist_tree.itemDoubleClicked.connect(self._sec_hist_open) self._sec_hist_tree.setContextMenuPolicy(Qt.CustomContextMenu) self._sec_hist_tree.customContextMenuRequested.connect(self._sec_hist_menu) hp_lay.addWidget(self._sec_hist_tree, 1) self._sec_stack.addWidget(history_page) lay.addWidget(self._sec_stack, 1) self._stack.addWidget(page) self._page_map["security"] = self._stack.count() - 1 # ── 日志 ── def _build_logs(self): page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(24, 12, 24, 16) lay.setSpacing(10) hdr = QHBoxLayout() hdr.addWidget(_make_label("日志输出", bold=True)) hdr.addStretch() self._btn_clear = _make_btn("清空", self._do_clear) hdr.addWidget(self._btn_clear) lay.addLayout(hdr) lc = _make_card() lc_lay = QVBoxLayout(lc) lc_lay.setContentsMargins(0, 0, 0, 0) self._logbox = QTextEdit() self._logbox.setReadOnly(True) self._logbox.setFont(QFont(_FM, 9)) lc_lay.addWidget(self._logbox) lay.addWidget(lc, 1) self._stack.addWidget(page) self._page_map["logs"] = self._stack.count() - 1 # ────────────────────────────────── # 前加载脚本 # ────────────────────────────────── def _preload_refresh(self): while self._preload_container.count(): item = self._preload_container.takeAt(0) w = item.widget() if w: w.deleteLater() scripts_dir = os.path.join(_BASE_DIR, "userscripts") js_files = sorted(f for f in os.listdir(scripts_dir) if f.endswith(".js")) if os.path.isdir(scripts_dir) else [] if not js_files: lbl = QLabel("userscripts/ 目录下无 .js 文件") lbl.setProperty("class", "muted") self._preload_container.addWidget(lbl) return for fn in js_files: row = QHBoxLayout() tog = ToggleSwitch(fn in self._selected_preload) tog.setFixedSize(36, 18) tog.toggled.connect(lambda v, f=fn: self._preload_toggle(f, v)) row.addWidget(tog) row.addWidget(QLabel(fn)) row.addStretch() w = QWidget() w.setLayout(row) self._preload_container.addWidget(w) def _preload_toggle(self, filename, val): if val: if filename not in self._selected_preload: self._selected_preload.append(filename) else: if filename in self._selected_preload: self._selected_preload.remove(filename) self._auto_save() # ────────────────────────────────── # 页面切换 # ────────────────────────────────── def _show(self, pid): self._pg = pid idx = self._page_map.get(pid, 0) self._stack.setCurrentIndexAnimated(idx) titles = {k: n for k, _, n in _MENU} self._hdr_title.setText(titles.get(pid, "")) self._hl_sb() def _hl_sb(self): for pid, (fr, ic, nm) in self._sb_items.items(): if pid == self._pg: fr.setProperty("class", "sb_item_active") else: fr.setProperty("class", "sb_item") fr.style().unpolish(fr) fr.style().polish(fr) ic.style().unpolish(ic) ic.style().polish(ic) nm.style().unpolish(nm) nm.style().polish(nm) # ────────────────────────────────── # 主题 # ────────────────────────────────── def _toggle_theme(self): self._tn = "light" if self._tn == "dark" else "dark" self.setStyleSheet(build_qss(self._tn)) self._update_theme_label() self._update_toggle_colors() self._hl_sb() self._auto_save() def _update_theme_label(self): txt = "\u2600 浅色模式" if self._tn == "dark" else "\u263d 深色模式" self._sb_theme.setText(txt) def _update_toggle_colors(self): c = _TH[self._tn] for tog in (self._tog_dm, self._tog_df): tog.set_colors(c["accent"], c["text4"]) def _auto_save(self): data = { "theme": self._tn, "debug_port": self._dp_ent.text(), "cdp_port": self._cp_ent.text(), "debug_main": self._tog_dm.isChecked(), "debug_frida": self._tog_df.isChecked(), "selected_preload_scripts": list(self._selected_preload), } _save_cfg(data) # ────────────────────────────────── # 业务 # ────────────────────────────────── def _copy_devtools_url(self): url = self._devtools_lbl.text() if url: QApplication.clipboard().setText(url) c = _TH[self._tn] self._devtools_copy_hint.setText("已复制!") self._devtools_copy_hint.setStyleSheet(f"color: {c['success']};") QTimer.singleShot(1500, lambda: ( self._devtools_copy_hint.setText("点击复制"), self._devtools_copy_hint.setStyleSheet(f"color: {c['text3']};") )) self._log_add("info", "[gui] DevTools 链接已复制到剪贴板") def _do_clear(self): self._logbox.clear() _LOG_MAX_BLOCKS = 500 # 最多保留的日志行数 def _log_add(self, lv, txt): c = _TH[self._tn] color_map = { "info": c["text2"], "error": c["error"], "debug": c["text3"], "frida": c["accent"], "warn": c["warning"], } color = color_map.get(lv, c["text2"]) self._logbox.append(f'{txt}') # 限制日志行数,防止 QTextEdit 内容过多导致 UI 卡顿 doc = self._logbox.document() overflow = doc.blockCount() - self._LOG_MAX_BLOCKS if overflow > 50: # 攒够 50 行再批量删,减少操作频率 cursor = self._logbox.textCursor() cursor.movePosition(cursor.MoveOperation.Start) for _ in range(overflow): cursor.movePosition(cursor.MoveOperation.Down, cursor.MoveMode.KeepAnchor) cursor.removeSelectedText() cursor.deleteChar() # 删掉残留空行 sb = self._logbox.verticalScrollBar() sb.setValue(sb.maximum()) def _do_start(self): if self._running: return try: dp = int(self._dp_ent.text()) cp = int(self._cp_ent.text()) except ValueError: self._log_add("error", "[gui] 端口号无效") return scripts_dir = os.path.join(_BASE_DIR, "userscripts") selected_files = [os.path.join(scripts_dir, fn) for fn in self._selected_preload] opts = CliOptions( debug_port=dp, cdp_port=cp, debug_main=self._tog_dm.isChecked(), debug_frida=self._tog_df.isChecked(), scripts_dir=scripts_dir, script_files=selected_files) logger = Logger(opts) logger.set_output_callback(lambda lv, tx: self._log_q.put((lv, tx))) us = load_userscripts_by_files(selected_files) if selected_files else [] if us: logger.info(f"[脚本] 已加载 {len(us)} 个") else: logger.info("[脚本] 无脚本") self._engine = DebugEngine(opts, logger, us) self._navigator = MiniProgramNavigator(self._engine) self._auditor = CloudAuditor(self._engine) self._engine.on_status_change(lambda s: self._sts_q.put(s)) self._loop = asyncio.new_event_loop() self._loop_th = threading.Thread( target=lambda: (asyncio.set_event_loop(self._loop), self._loop.run_forever()), daemon=True) self._loop_th.start() asyncio.run_coroutine_threadsafe(self._astart(), self._loop) self._running = True self._btn_start.setEnabled(False) self._btn_stop.setEnabled(True) self._btn_fetch.setEnabled(True) url = f"devtools://devtools/bundled/inspector.html?ws=127.0.0.1:{cp}" self._devtools_lbl.setText(url) c = _TH[self._tn] self._devtools_copy_hint.setText("点击复制") self._devtools_copy_hint.setStyleSheet(f"color: {c['text3']};") self._log_add("info", f"[gui] 浏览器访问: {url}") async def _astart(self): try: await self._engine.start() except Exception as e: self._log_q.put(("error", f"[gui] 启动失败: {e}")) QTimer.singleShot(0, self._on_fail) def _on_fail(self): self._running = False self._btn_start.setEnabled(True) self._btn_stop.setEnabled(False) self._btn_fetch.setEnabled(False) self._nav_btns(False) if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop) def _do_stop(self): if not self._running: return self._running = False self._poll_route_stop() if self._cloud_scan_active: self._cloud_scan_active = False if self._cloud_scan_poll_timer: self._cloud_scan_poll_timer.stop() self._cloud_scan_poll_timer = None if self._cancel_ev: self._cancel_ev.set() if self._engine and self._loop and self._loop.is_running(): fut = asyncio.run_coroutine_threadsafe(self._engine.stop(), self._loop) fut.add_done_callback(lambda _: self._loop.call_soon_threadsafe(self._loop.stop)) self._btn_start.setEnabled(True) self._btn_stop.setEnabled(False) self._btn_fetch.setEnabled(False) self._nav_btns(False) self._btn_autostop.setEnabled(False) self._redirect_guard_on = False self._guard_switch.setChecked(False) self._guard_label.setText("防跳转: 关闭") self._devtools_lbl.setText("") self._devtools_copy_hint.setText("") if self._sec_scanning: self._sec_scanning = False self._btn_sec_scan.setEnabled(True) self._sec_prog.setVisible(False) def _nav_btns(self, on): for b in (self._btn_go, self._btn_redir, self._btn_relaunch, self._btn_back, self._btn_auto, self._btn_prev, self._btn_next, self._btn_copy_route): b.setEnabled(on) self._guard_switch.setEnabled(on) def _do_fetch(self): if self._engine and self._loop: asyncio.run_coroutine_threadsafe(self._afetch(), self._loop) async def _afetch(self): try: await self._navigator.fetch_config() self._rte_q.put(("routes", self._navigator.pages, self._navigator.tab_bar_pages)) self._rte_q.put(("app_info", self._navigator.app_info)) QTimer.singleShot(0, self._poll_route_start) except Exception as e: self._log_q.put(("error", f"[导航] 获取失败: {e}")) def _poll_route_start(self): if not self._running: return if self._engine and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._apoll_route(), self._loop) self._route_poll_id = QTimer.singleShot(2000, self._poll_route_start) def _poll_route_stop(self): self._route_poll_id = None async def _apoll_route(self): try: r = await self._navigator.get_current_route() self._rte_q.put(("current", r)) if self._redirect_guard_on: blocked = await self._navigator.get_blocked_redirects() if blocked: self._rte_q.put(("blocked", blocked)) except Exception: pass def _sel_route(self): items = self._tree.selectedItems() if not items: self._log_add("error", "[导航] 请先选择路由") return None item = items[0] return item.data(0, Qt.UserRole) def _do_go(self): r = self._sel_route() if r and self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", r, "跳转"), self._loop) def _do_redir(self): r = self._sel_route() if r and self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("redirect_to", r, "重定向"), self._loop) def _do_relaunch(self): r = self._sel_route() if r and self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("relaunch_to", r, "重启"), self._loop) def _do_back(self): if self._engine and self._loop: asyncio.run_coroutine_threadsafe(self._aback(), self._loop) async def _anav(self, method, route, desc): try: await getattr(self._navigator, method)(route) self._log_q.put(("info", f"[导航] 已{desc}到: {route}")) except Exception as e: self._log_q.put(("error", f"[导航] {desc}失败: {e}")) async def _aback(self): try: await self._navigator.navigate_back() self._log_q.put(("info", "[导航] 已返回")) except Exception as e: self._log_q.put(("error", f"[导航] 返回失败: {e}")) def _do_autovis(self): if not self._navigator or not self._navigator.pages: self._log_add("error", "[导航] 请先获取路由") return self._cancel_ev = asyncio.Event() self._btn_auto.setEnabled(False) self._btn_autostop.setEnabled(True) asyncio.run_coroutine_threadsafe( self._aauto(list(self._navigator.pages)), self._loop) async def _aauto(self, pages): def prog(i, total, route): self._rte_q.put(("progress", i, total, route)) try: await self._navigator.auto_visit( pages, delay=2.0, on_progress=prog, cancel_event=self._cancel_ev) except Exception as e: self._log_q.put(("error", f"[导航] 遍历出错: {e}")) finally: self._rte_q.put(("auto_done",)) def _do_autostop(self): if self._cancel_ev: self._cancel_ev.set() self._btn_autostop.setEnabled(False) self._btn_auto.setEnabled(True) def _do_prev(self): if not self._all_routes: self._log_add("error", "[导航] 请先获取路由") return if self._nav_route_idx <= 0: self._nav_route_idx = len(self._all_routes) - 1 else: self._nav_route_idx -= 1 route = self._all_routes[self._nav_route_idx] self._select_tree_route(route) self._log_add("info", f"[导航] 上一个: {route} ({self._nav_route_idx + 1}/{len(self._all_routes)})") if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_next(self): if not self._all_routes: self._log_add("error", "[导航] 请先获取路由") return if self._nav_route_idx >= len(self._all_routes) - 1: self._nav_route_idx = 0 else: self._nav_route_idx += 1 route = self._all_routes[self._nav_route_idx] self._select_tree_route(route) self._log_add("info", f"[导航] 下一个: {route} ({self._nav_route_idx + 1}/{len(self._all_routes)})") if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_manual_go(self): route = self._nav_input.text().strip().lstrip("/") if not route: self._log_add("error", "[导航] 请输入路由路径") return if self._engine and self._loop: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) def _do_copy_route(self): items = self._tree.selectedItems() if not items: self._log_add("error", "[导航] 请先选择路由") return route = items[0].data(0, Qt.UserRole) if route: QApplication.clipboard().setText(route) self._log_add("info", f"[导航] 已复制路由: {route}") def _nav_context_menu(self, pos): item = self._tree.itemAt(pos) if not item: return route = item.data(0, Qt.UserRole) if not route: return self._tree.setCurrentItem(item) menu = QMenu(self) menu.addAction("复制路由", lambda: ( QApplication.clipboard().setText(route), self._log_add("info", f"[导航] 已复制: {route}"))) menu.addSeparator() menu.addAction("跳转", lambda: asyncio.run_coroutine_threadsafe( self._anav("navigate_to", route, "跳转"), self._loop) if self._engine and self._loop else None) menu.addAction("重定向", lambda: asyncio.run_coroutine_threadsafe( self._anav("redirect_to", route, "重定向"), self._loop) if self._engine and self._loop else None) menu.addAction("重启到页面", lambda: asyncio.run_coroutine_threadsafe( self._anav("relaunch_to", route, "重启"), self._loop) if self._engine and self._loop else None) menu.exec(self._tree.viewport().mapToGlobal(pos)) def _do_toggle_guard_switch(self, checked): if not self._engine or not self._loop: self._guard_switch.blockSignals(True) self._guard_switch.setChecked(not checked) self._guard_switch.blockSignals(False) return asyncio.run_coroutine_threadsafe(self._atoggle_guard(checked), self._loop) async def _atoggle_guard(self, enable): try: if enable: r = await self._navigator.enable_redirect_guard() if r.get("ok"): self._redirect_guard_on = True self._blocked_seen = 0 self._log_q.put(("info", "[导航] 防跳转已开启,将拦截 redirectTo/reLaunch")) QTimer.singleShot(0, lambda: self._guard_label.setText("防跳转: 开启")) else: self._redirect_guard_on = False self._log_q.put(("error", "[导航] 开启防跳转失败")) QTimer.singleShot(0, self._guard_reset_switch) else: await self._navigator.disable_redirect_guard() self._redirect_guard_on = False self._log_q.put(("info", "[导航] 防跳转已关闭")) QTimer.singleShot(0, lambda: self._guard_label.setText("防跳转: 关闭")) except Exception as e: self._log_q.put(("error", f"[导航] 防跳转切换失败: {e}")) QTimer.singleShot(0, self._guard_reset_switch) def _guard_reset_switch(self): self._guard_switch.blockSignals(True) self._guard_switch.setChecked(self._redirect_guard_on) self._guard_switch.blockSignals(False) self._guard_label.setText("防跳转: 开启" if self._redirect_guard_on else "防跳转: 关闭") def _do_filter(self): q = self._srch_ent.text().strip().lower() if not q: if self._navigator: self._fill_tree(self._all_routes, self._navigator.tab_bar_pages) return flt = [p for p in self._all_routes if q in p.lower()] self._tree.clear() for p in flt: item = QTreeWidgetItem([p]) item.setData(0, Qt.UserRole, p) self._tree.addTopLevelItem(item) def _fill_tree(self, pages, tab_bar): self._tree.clear() tabs = set(tab_bar) groups = {} for p in pages: parts = p.split("/") g = parts[0] if len(parts) > 1 else "(root)" groups.setdefault(g, []).append(p) tl = [p for p in pages if p in tabs] if tl: nd = QTreeWidgetItem(["TabBar"]) nd.setExpanded(True) self._tree.addTopLevelItem(nd) for p in tl: d = p.split("/")[-1] if "/" in p else p child = QTreeWidgetItem([d]) child.setData(0, Qt.UserRole, p) nd.addChild(child) for g in sorted(groups): nd = QTreeWidgetItem([g]) self._tree.addTopLevelItem(nd) for p in groups[g]: if p in tabs: continue d = p[len(g) + 1:] if p.startswith(g + "/") else p child = QTreeWidgetItem([d]) child.setData(0, Qt.UserRole, p) nd.addChild(child) def _select_tree_route(self, route): """Select the tree item matching the given route path.""" for i in range(self._tree.topLevelItemCount()): top = self._tree.topLevelItem(i) if top.data(0, Qt.UserRole) == route: self._tree.setCurrentItem(top) self._tree.scrollToItem(top) return for j in range(top.childCount()): child = top.child(j) if child.data(0, Qt.UserRole) == route: self._tree.setCurrentItem(child) self._tree.scrollToItem(child) return # ────────────────────────────────── # 云扫描业务 # ────────────────────────────────── def _cloud_tree_context_menu(self, pos): item = self._cloud_tree.itemAt(pos) if not item: return self._cloud_tree.setCurrentItem(item) vals = [item.text(i) for i in range(6)] menu = QMenu(self) full_text = " | ".join(vals) menu.addAction("复制整行", lambda: QApplication.clipboard().setText(full_text)) name_str = vals[2] if len(vals) > 2 else "" if name_str: menu.addAction(f"复制名称: {name_str[:30]}", lambda: QApplication.clipboard().setText(name_str)) menu.addSeparator() row_id = id(item) if row_id in self._cloud_row_results: res = self._cloud_row_results[row_id] menu.addAction("查看返回结果", lambda: self._cloud_show_result(name_str, res)) menu.addSeparator() menu.addAction("删除此项", lambda: self._cloud_delete_item(item)) menu.exec(self._cloud_tree.viewport().mapToGlobal(pos)) def _cloud_delete_item(self, item): vals = tuple(item.text(i) for i in range(6)) idx = self._cloud_tree.indexOfTopLevelItem(item) if idx >= 0: self._cloud_tree.takeTopLevelItem(idx) self._cloud_all_items = [v for v in self._cloud_all_items if tuple(str(x) for x in v) != vals] self._cloud_row_results.pop(id(item), None) self._cloud_update_status() def _cloud_show_result(self, name, result): detail = json.dumps(result, ensure_ascii=False, indent=2, default=str) c = _TH[self._tn] self._cloud_result.setHtml(f'「{name}」返回结果:\n{detail}') def _cloud_update_status(self): count = self._cloud_tree.topLevelItemCount() total = len(self._cloud_all_items) if count < total: self._cloud_status_lbl.setText(f"显示: {count} / {total} 条") else: self._cloud_status_lbl.setText(f"捕获: {count} 条") def _cloud_filter(self): kw = self._cloud_search_ent.text().strip().lower() self._cloud_tree.clear() for vals in self._cloud_all_items: if kw and not any(kw in str(v).lower() for v in vals): continue item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(item) self._cloud_update_status() def _cloud_on_select(self, item): if item and item.columnCount() >= 4: self._cloud_name_ent.setText(item.text(2)) data_str = item.text(3).strip() try: json.loads(data_str) self._cloud_data_ent.setText(data_str) except Exception: self._cloud_data_ent.setText("{}") def _cloud_ensure_auditor(self): if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[云扫描] 请先启动调试") return False if not self._auditor: self._auditor = CloudAuditor(self._engine) return True def _cloud_do_toggle(self): if not self._cloud_ensure_auditor(): return if self._cloud_scan_active: self._cloud_stop_scan() else: self._cloud_start_scan() def _cloud_start_scan(self): if not self._cloud_ensure_auditor(): return self._cloud_scan_active = True c = _TH[self._tn] self._btn_cloud_toggle.setText("停止捕获") self._cloud_scan_lbl.setText("捕获中...") self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};") self._log_add("info", "[云扫描] 全局捕获已启动") asyncio.run_coroutine_threadsafe(self._acloud_start(), self._loop) self._cloud_scan_poll() async def _acloud_start(self): try: await self._auditor.start() except Exception as e: self._log_q.put(("error", f"[云扫描] Hook 启动异常: {e}")) def _cloud_stop_scan(self): self._cloud_scan_active = False c = _TH[self._tn] self._btn_cloud_toggle.setText("开启捕获") self._cloud_scan_lbl.setText("已停止") self._cloud_scan_lbl.setStyleSheet(f"color: {c['text3']};") if self._cloud_scan_poll_timer: self._cloud_scan_poll_timer.stop() self._cloud_scan_poll_timer = None if self._auditor and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._auditor.stop(), self._loop) self._log_add("info", "[云扫描] 全局捕获已停止") def _cloud_scan_poll(self): if not self._cloud_scan_active or not self._auditor: return if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._acloud_poll(), self._loop) self._cloud_scan_poll_timer = QTimer() self._cloud_scan_poll_timer.setSingleShot(True) self._cloud_scan_poll_timer.timeout.connect(self._cloud_scan_poll) self._cloud_scan_poll_timer.start(2000) async def _acloud_poll(self): try: new_calls = await self._auditor.poll() if new_calls: self._cld_q.put(("new_calls", new_calls)) except Exception: pass def _cloud_do_static_scan(self): if not self._cloud_ensure_auditor(): return self._btn_cloud_static.setEnabled(False) self._log_add("info", "[云扫描] 开始静态扫描 JS 源码...") asyncio.run_coroutine_threadsafe(self._acloud_static_scan(), self._loop) async def _acloud_static_scan(self): try: def progress(msg): self._log_q.put(("info", f"[云扫描] {msg}")) results = await self._auditor.static_scan(on_progress=progress) self._cld_q.put(("static_results", results)) except Exception as e: self._log_q.put(("error", f"[云扫描] 静态扫描异常: {e}")) finally: self._cld_q.put(("static_done",)) def _cloud_do_clear(self): self._cloud_tree.clear() self._cloud_all_items.clear() self._cloud_row_results.clear() if self._auditor and self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe(self._auditor.clear(), self._loop) self._cloud_status_lbl.setText("捕获: 0 条") def _cloud_do_call(self): if not self._cloud_ensure_auditor(): return name = self._cloud_name_ent.text().strip() if not name: self._cloud_result.setPlainText("请输入函数名") return try: data = json.loads(self._cloud_data_ent.text()) except (json.JSONDecodeError, TypeError): self._cloud_result.setPlainText("参数 JSON 格式错误") return self._btn_cloud_call.setEnabled(False) self._cloud_result.setPlainText(f"正在调用 {name} ...") asyncio.run_coroutine_threadsafe(self._acloud_call(name, data), self._loop) async def _acloud_call(self, name, data): try: res = await self._auditor.call_function(name, data) self._cld_q.put(("call_result", name, res)) except Exception as e: self._cld_q.put(("call_result", name, {"ok": False, "status": "fail", "error": str(e)})) def _cloud_do_export(self): if not self._auditor: self._log_add("error", "[云扫描] 无数据") return report = self._auditor.export_report(self._cloud_all_items, self._cloud_call_history) path = os.path.join(_BASE_DIR, "cloud_audit_report.json") try: with open(path, "w", encoding="utf-8") as f: json.dump(report, f, indent=2, ensure_ascii=False, default=str) self._log_add("info", f"[云扫描] 报告已导出: {path}") except Exception as e: self._log_add("error", f"[云扫描] 导出失败: {e}") # ────────────────────────────────── # 敏感提取业务 # ────────────────────────────────── def _sec_do_scan(self): if self._sec_scanning: self._log_add("info", "[敏感提取] 扫描正在进行中,请稍候...") return if not self._engine or not self._loop or not self._loop.is_running(): self._log_add("error", "[敏感提取] 请先启动调试并连接小程序") return if not self._engine.miniapp_clients: self._log_add("error", "[敏感提取] 小程序未连接,请先打开小程序") return self._sec_scanning = True self._btn_sec_scan.setEnabled(False) self._sec_prog.setVisible(True) self._sec_prog.setValue(0) self._sec_status_lbl.setText("正在提取 JS 源码...") self._sec_stack.setCurrentIndex(0) self._log_add("info", "[敏感提取] 开始扫描...") fut = asyncio.run_coroutine_threadsafe(self._asec_scan(), self._loop) # 捕获 future 异常,防止静默失败 def _on_scan_done(f): try: f.result() except Exception as e: self._sec_q.put(("error", f"扫描协程异常: {e}")) self._sec_q.put(("done", None, 0, 0)) fut.add_done_callback(_on_scan_done) async def _asec_scan(self): try: # Step 1: 通过 CDP Debugger 获取所有 JS 脚本源码 self._sec_q.put(("progress", 5, "正在收集脚本列表...")) script_ids = [] def _on_parsed(data): p = data.get("params", {}) sid = p.get("scriptId") if sid: script_ids.append((sid, p.get("url", ""))) self._engine.on_cdp_event("Debugger.scriptParsed", _on_parsed) try: try: await self._engine.send_cdp_command("Debugger.disable", timeout=3.0) except Exception: pass await asyncio.sleep(0.3) try: await self._engine.send_cdp_command("Debugger.enable", timeout=5.0) except asyncio.TimeoutError: self._sec_q.put(("log", "Debugger.enable 超时,尝试重新启用...")) await asyncio.sleep(0.5) try: await self._engine.send_cdp_command("Debugger.enable", timeout=5.0) except Exception as e2: self._sec_q.put(("log", f"Debugger.enable 重试失败: {e2}")) try: await self._engine.send_cdp_command( "Debugger.setSkipAllPauses", {"skip": True}, timeout=3.0) except Exception: pass # 等待 scriptParsed 事件全部到达: # 先等 2 秒让大部分事件到达,然后连续 3 次(每次 0.4s)数量不变才认为稳定 await asyncio.sleep(2.0) stable_count = 0 prev = 0 for _ in range(15): cur = len(script_ids) if cur == prev and cur > 0: stable_count += 1 if stable_count >= 3: break else: stable_count = 0 prev = cur await asyncio.sleep(0.4) self._sec_q.put(("log", f"脚本收集完成: {len(script_ids)} 个")) except Exception as e: self._sec_q.put(("log", f"Debugger 启动异常: {e}")) finally: self._engine.off_cdp_event("Debugger.scriptParsed", _on_parsed) if not script_ids: self._sec_q.put(("error", "未能获取到脚本列表,请确保小程序已打开")) self._sec_q.put(("done", None, 0, 0)) return self._sec_q.put(("progress", 15, f"发现 {len(script_ids)} 个脚本,正在提取源码...")) # Step 2: 逐个获取脚本源码 js_sources = [] for i, (sid, url) in enumerate(script_ids): try: resp = await self._engine.send_cdp_command( "Debugger.getScriptSource", {"scriptId": sid}, timeout=8.0) source = resp.get("result", {}).get("scriptSource", "") if source and len(source) > 20: js_sources.append(source) except Exception: pass if i % 10 == 0: pct = 15 + int((i + 1) / len(script_ids) * 15) self._sec_q.put(("progress", pct, f"提取源码 {i+1}/{len(script_ids)} ...")) try: await self._engine.send_cdp_command("Debugger.disable", timeout=3.0) except Exception: pass if not js_sources: self._sec_q.put(("error", "脚本源码提取为空")) self._sec_q.put(("done", None, 0, 0)) return total_size = sum(len(s) for s in js_sources) self._sec_q.put(("progress", 30, f"已提取 {len(js_sources)} 个 JS 源码,启动独立分析进程...")) # Step 3: 获取小程序信息 (若还没获取过,自动 fetch_config) appid = "" app_name = "" if self._navigator: if not self._navigator.app_info or not self._navigator.app_info.get("appid"): try: await self._navigator.fetch_config() except Exception: pass appid = self._navigator.app_info.get("appid", "") app_name = self._navigator.app_info.get("name", "") if not appid: appid = "unknown" # Step 4: 在独立进程中分析 (不阻塞主进程/UI) _sec_run_worker(js_sources, appid, _BASE_DIR, self._sec_q, name=app_name) except Exception as e: self._sec_q.put(("error", f"分析失败: {e}")) self._sec_q.put(("done", None, 0, 0)) def _sec_extract_val(self, result): if not result: return None r = result.get("result", {}) inner = r.get("result", {}) return inner.get("value") def _sec_show_report(self, result, js_count=0, total_size=0, appid="", scan_time="", name=""): """在报告页渲染分析结果 (左类别列表 + 右详情)""" self._sec_result = result c = _TH[self._tn] if not scan_time: scan_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") header_parts = [] if name: header_parts.append(name) if appid: header_parts.append(f"AppID: {appid}") self._sec_rpt_header.setText(" | ".join(header_parts) if header_parts else "分析报告") total_findings = sum(len(v) for v in result.values() if isinstance(v, list)) summary = f"{js_count} 个 JS ({self._fmt_size(total_size)}) | " \ f"{total_findings} 项 | {scan_time}" if js_count else \ f"{total_findings} 项 | {scan_time}" self._sec_rpt_summary.setText(summary) # 清除旧的左栏类别行 while self._sec_cat_lay.count() > 1: item = self._sec_cat_lay.takeAt(0) w = item.widget() if w: w.deleteLater() self._sec_cat_widgets.clear() self._sec_cur_cat = None # 生成左栏类别行 for key in CATEGORY_INFO: items = result.get(key, []) cn, _ = CATEGORY_INFO[key] count = len(items) row = QFrame() row.setFixedHeight(38) row.setCursor(Qt.PointingHandCursor) row_lay = QHBoxLayout(row) row_lay.setContentsMargins(0, 0, 6, 0) row_lay.setSpacing(6) # 绿色竖条 bar = QFrame() bar.setFixedSize(4, 24) bar.setStyleSheet(f"background: {c['accent']}; border-radius: 2px;") row_lay.addWidget(bar) name_lbl = QLabel(cn) name_lbl.setProperty("class", "bold") name_lbl.setFont(QFont(_FN, 10)) row_lay.addWidget(name_lbl, 1) count_lbl = QLabel(str(count)) count_lbl.setFont(QFont(_FM, 9)) if count > 0: count_lbl.setStyleSheet(f"color: {c['accent']};") else: count_lbl.setStyleSheet(f"color: {c['text3']};") row_lay.addWidget(count_lbl) copy_btn = QPushButton("复制") copy_btn.setFixedSize(52, 26) copy_btn.setFont(QFont(_FN, 9)) copy_btn.clicked.connect(lambda _, k=key, b=copy_btn: self._sec_copy_cat(k, b)) row_lay.addWidget(copy_btn) row.mousePressEvent = lambda e, k=key: self._sec_select_cat(k) self._sec_cat_widgets[key] = row self._sec_cat_lay.insertWidget(self._sec_cat_lay.count() - 1, row) # 清空右侧 self._sec_clear_detail() self._sec_detail_title.setText("选择左侧类别查看详情") self._btn_sec_copy_all.setVisible(False) # 自动选中第一个有内容的类别 for key in CATEGORY_INFO: if result.get(key): self._sec_select_cat(key) break self._sec_stack.setCurrentIndex(1) self._btn_sec_back.setVisible(True) def _sec_select_cat(self, key): """选中左栏类别,在右栏显示详情列表""" c = _TH[self._tn] self._sec_cur_cat = key # 高亮左栏选中项 for k, row in self._sec_cat_widgets.items(): if k == key: row.setStyleSheet( f"QFrame {{ background: {c['sb_active']}; border-radius: 6px; }}" f" QLabel {{ color: {c['text1']}; }}" f" QPushButton {{ color: {c['text1']}; }}" ) else: row.setStyleSheet("") items = self._sec_result.get(key, []) if self._sec_result else [] cn, _ = CATEGORY_INFO.get(key, (key, "")) self._sec_detail_title.setText(f"{cn} ({len(items)})") self._btn_sec_copy_all.setVisible(len(items) > 0) # 直接填充 QTextEdit,每行一条 if items: self._sec_detail_text.setStyleSheet( f"QTextEdit {{ color: {c['text1']}; background: transparent;" f" selection-background-color: {c['accent']}; }}" ) self._sec_detail_text.setPlainText("\n".join(str(v) for v in items)) else: self._sec_detail_text.clear() def _sec_clear_detail(self): """清空右栏详情""" self._sec_detail_text.clear() def _sec_copy_cat(self, key, btn=None): """复制某个类别的全部内容""" items = self._sec_result.get(key, []) if self._sec_result else [] if items: QApplication.clipboard().setText("\n".join(str(v) for v in items)) cn, _ = CATEGORY_INFO.get(key, (key, "")) self._sec_status_lbl.setText(f"已复制 {cn} ({len(items)} 项)") if btn and isinstance(btn, QPushButton): old_text = btn.text() btn.setText("已复制") QTimer.singleShot(1200, lambda: btn.setText(old_text) if btn else None) def _sec_copy_all(self): """复制当前选中类别的全部内容""" if self._sec_cur_cat: self._sec_copy_cat(self._sec_cur_cat, self._btn_sec_copy_all) def _sec_show_history(self): self._sec_hist_tree.clear() reports = load_reports(_BASE_DIR) for r in reports: summary = r.get("summary", {}) total = sum(summary.values()) item = QTreeWidgetItem([ r.get("time", ""), r.get("appid", ""), r.get("name", ""), str(r.get("js_count", 0)), str(total), ]) item.setData(0, Qt.UserRole, r) self._sec_hist_tree.addTopLevelItem(item) self._sec_stack.setCurrentIndex(2) self._btn_sec_back.setVisible(True) self._sec_status_lbl.setText(f"共 {len(reports)} 条记录") def _sec_hist_open(self, item): r = item.data(0, Qt.UserRole) if not r: return result = r.get("result", {}) self._sec_show_report( result, js_count=r.get("js_count", 0), total_size=r.get("total_size", 0), appid=r.get("appid", ""), scan_time=r.get("time", ""), name=r.get("name", ""), ) def _sec_hist_menu(self, pos): item = self._sec_hist_tree.itemAt(pos) if not item: return r = item.data(0, Qt.UserRole) menu = QMenu(self) menu.addAction("查看报告", lambda: self._sec_hist_open(item)) if r: menu.addAction("删除", lambda: self._sec_hist_delete(item, r)) menu.exec(self._sec_hist_tree.viewport().mapToGlobal(pos)) def _sec_hist_delete(self, item, r): fn = r.get("_filename", "") if fn: delete_report(_BASE_DIR, fn) idx = self._sec_hist_tree.indexOfTopLevelItem(item) if idx >= 0: self._sec_hist_tree.takeTopLevelItem(idx) def _sec_back_to_scan(self): if self._sec_result: self._sec_stack.setCurrentIndex(1) else: self._sec_stack.setCurrentIndex(0) self._btn_sec_back.setVisible(False) self._sec_status_lbl.setText("") def _handle_sec(self, item): kind = item[0] if kind == "progress": _, pct, msg = item self._sec_prog.setValue(pct) self._sec_status_lbl.setText(msg) elif kind == "log": self._log_add("info", f"[敏感提取] {item[1]}") elif kind == "error": self._log_add("error", f"[敏感提取] {item[1]}") self._sec_status_lbl.setText(item[1]) elif kind == "done": _, result, js_count, total_size = item self._sec_scanning = False self._btn_sec_scan.setEnabled(True) self._sec_prog.setValue(100) QTimer.singleShot(500, lambda: self._sec_prog.setVisible(False)) if result: total_findings = sum(len(v) for v in result.values() if isinstance(v, list)) self._sec_status_lbl.setText(f"分析完成,发现 {total_findings} 项") self._log_add("info", f"[敏感提取] 完成: {js_count} 个 JS, 发现 {total_findings} 项") appid = "" app_name = "" if self._navigator and self._navigator.app_info: appid = self._navigator.app_info.get("appid", "") app_name = self._navigator.app_info.get("name", "") self._sec_show_report(result, js_count, total_size, appid, name=app_name) else: self._sec_status_lbl.setText("分析完成,无结果") @staticmethod def _fmt_size(n): if n < 1024: return f"{n} B" elif n < 1024 * 1024: return f"{n/1024:.1f} KB" else: return f"{n/1024/1024:.1f} MB" @staticmethod def _html_esc(s): return s.replace("&", "&").replace("<", "<").replace(">", ">") # ────────────────────────────────── # 轮询 # ────────────────────────────────── def _tick(self): for _ in range(60): # 每轮最多处理60条日志,防止阻塞UI try: msg = self._log_q.get_nowait() except queue.Empty: break if isinstance(msg, tuple) and len(msg) == 3 and msg[0] == "__hook_status__": _, fn, ok = msg self._hook_update_status(fn, ok) else: lv, tx = msg self._log_add(lv, tx) for _ in range(50): try: s = self._sts_q.get_nowait() except queue.Empty: break self._apply_sts(s) for _ in range(50): try: item = self._rte_q.get_nowait() except queue.Empty: break self._handle_rte(item) for _ in range(50): try: item = self._cld_q.get_nowait() except queue.Empty: break self._handle_cld(item) for _ in range(50): try: item = self._sec_q.get_nowait() except queue.Empty: break self._handle_sec(item) def _apply_sts(self, sts): c = _TH[self._tn] for key, (dot, lb, name) in self._dots.items(): on = sts.get(key, False) dot.set_color(c["success"] if on else c["text4"]) lb.setText(f"{name}: {'已连接' if on else '未连接'}") lb.setStyleSheet(f"color: {c['success'] if on else c['text2']};") self._nav_btns(sts.get("miniapp", False)) if sts.get("miniapp") and not self._cloud_scan_active and self._auditor: self._cloud_start_scan() def _handle_rte(self, item): kind = item[0] if kind == "routes": _, pages, tab = item self._all_routes = list(pages) self._fill_tree(pages, tab) elif kind == "app_info": info = item[1] aid = info.get("appid", "") ent = info.get("entry", "") txt = f"应用: {aid}" if aid else "应用: --" if ent: txt += f" | 入口: {ent}" self._app_lbl.setText(txt) elif kind == "current": r = item[1] self._route_lbl.setText(f"当前路由: /{r}" if r else "当前路由: --") elif kind == "progress": _, i, total, route = item if total > 0: self._prog.setValue(int((i / total) * 100)) if route != "done": self._select_tree_route(route) self._route_lbl.setText( f"正在访问: /{route}" if route != "done" else "遍历完成") elif kind == "blocked": blocked = item[1] for b in blocked[self._blocked_seen:]: self._log_add("warn", f"[防跳转] 拦截 {b.get('type','')} → {b.get('url','')} ({b.get('time','')})") self._blocked_seen = len(blocked) elif kind == "auto_done": self._prog.setValue(100) self._btn_auto.setEnabled(True) self._btn_autostop.setEnabled(False) self._log_add("info", "[导航] 遍历完成") def _handle_cld(self, item): kind = item[0] c = _TH[self._tn] _type_cn = {"function": "云函数", "storage": "存储", "container": "容器"} if kind == "new_calls": calls = item[1] if calls: kw = self._cloud_search_ent.text().strip().lower() for call in calls: data_str = json.dumps(call.get("data", {}), ensure_ascii=False) if len(data_str) > 80: data_str = data_str[:77] + "..." ctype = call.get("type", "function") type_label = _type_cn.get(ctype, ctype) if ctype.startswith("db"): type_label = "数据库" status = call.get("status", "") vals = (call.get("appId", ""), type_label, call.get("name", ""), data_str, status, call.get("timestamp", "")) self._cloud_all_items.append(vals) if kw and not any(kw in str(v).lower() for v in vals): continue tree_item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(tree_item) result_data = call.get("result") or call.get("error") if result_data is not None: self._cloud_row_results[id(tree_item)] = { "status": status, "result": call.get("result"), "error": call.get("error"), "data": call.get("data"), } self._cloud_tree.scrollToBottom() self._cloud_update_status() self._cloud_scan_lbl.setText(f"捕获中... {len(self._cloud_all_items)} 条") self._cloud_scan_lbl.setStyleSheet(f"color: {c['success']};") elif kind == "static_results": funcs = item[1] if funcs: kw = self._cloud_search_ent.text().strip().lower() for f in funcs: params = ", ".join(f.get("params", [])) or "--" if len(params) > 80: params = params[:77] + "..." ftype = f.get("type", "function") type_label = {"function": "云函数", "storage": "存储", "database": "数据库"}.get(ftype, ftype) vals = (f.get("appId", ""), f"[静态]{type_label}", f["name"], params, f"x{f.get('count',1)}", "") self._cloud_all_items.append(vals) if kw and not any(kw in str(v).lower() for v in vals): continue tree_item = QTreeWidgetItem([str(v) for v in vals]) self._cloud_tree.addTopLevelItem(tree_item) self._cloud_tree.scrollToBottom() self._cloud_update_status() self._log_add("info", f"[云扫描] 静态扫描发现 {len(funcs)} 个云函数引用") elif kind == "static_done": self._btn_cloud_static.setEnabled(True) elif kind == "call_result": _, name, res = item self._btn_cloud_call.setEnabled(True) status = res.get("status", "unknown") if status == "success": detail = json.dumps(res.get("result", {}), ensure_ascii=False, default=str) self._cloud_result.setHtml( f'{name} -> 成功:\n{detail}') elif status == "fail": err = res.get("error", "") or res.get("reason", "未知错误") self._cloud_result.setHtml( f'{name} -> 失败: {err}') else: detail = json.dumps(res, ensure_ascii=False, default=str) self._cloud_result.setHtml( f'{name} -> {detail}') # ────────────────────────────────── # 退出 # ────────────────────────────────── def closeEvent(self, event): if self._running: self._do_stop() QTimer.singleShot(400, lambda: QApplication.quit()) event.ignore() else: event.accept() if __name__ == "__main__": multiprocessing.freeze_support() # PyInstaller 打包需要 import signal signal.signal(signal.SIGINT, signal.SIG_DFL) # Ctrl+C 直接退出 app = QApplication(sys.argv) app.setFont(QFont(_FN, 9)) window = App() window.show() sys.exit(app.exec())