01 · 整体架构#
本章回答两个问题:项目是怎么分层的、一次扫描从点击到出报告发生了什么。
一、分层结构#
项目采用自上而下的单向分层,越靠下的层被越多上层依赖:
┌──────────────────────────────────────────────────────────────────────┐
│ 接入层 main.py(参数解析)· web/index.html · desktop/(Tauri+React)│
└───────────────────────────┬──────────────────────────────────────────┘
│ Namespace / HTTP
┌───────────────────────────▼──────────────────────────────────────────┐
│ 控制层 cli/(dispatcher · runner · preflight · 各模式执行器) │
│ api/(FastAPI 应用工厂 · 路由 · WebSocket · 鉴权) │
└───────────────────────────┬──────────────────────────────────────────┘
│ ScanRequest
┌───────────────────────────▼──────────────────────────────────────────┐
│ 编排层 core/orchestrator.py —— ScanOrchestrator(CLI/API 共用) │
└───────────────────────────┬──────────────────────────────────────────┘
│ 插件类列表 + SessionManager
┌───────────────────────────▼──────────────────────────────────────────┐
│ 引擎层 core/engine.py(并发 + 令牌桶) │
│ core/chain.py(利用链 DAG 引擎) │
└───────────────────────────┬──────────────────────────────────────────┘
│ verify(target, session)
┌───────────────────────────▼──────────────────────────────────────────┐
│ 插件层 plugins/base.py(PluginBase 契约) │
│ ruoyi/ · spring/ · common/ · jeecgboot/ · chain/ │
└───────────────────────────┬──────────────────────────────────────────┘
│ 基础能力调用
┌───────────────────────────▼──────────────────────────────────────────┐
│ 能力层 core/session.py(网络)· core/fingerprint.py(识别) │
│ lib/*(41 个独立工具模块) │
└───────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 common/models.py(ScanResult 三态)· common/logger.py │
│ config/settings.py · data/(字典与 CVE 映射) │
└──────────────────────────────────────────────────────────────────────┘
各层职责与约束#
| 层 | 目录 | 职责 | 关键约束 |
|---|---|---|---|
| 接入层 | main.py、web/、desktop/ | 参数解析、UI 呈现 | main.py 只做参数解析 + 分发,业务编排全部下沉(P0 重构) |
| 控制层 | cli/、api/ | 把用户输入翻译成 ScanRequest,消费事件与结果 | 不直接调用插件;事件输出与业务编排解耦 |
| 编排层 | core/orchestrator.py | 串起「预检 → 识别 → 加载 → 执行 → 报告」 | CLI 与 API 共用同一实现,避免双份逻辑漂移 |
| 引擎层 | core/engine.py、core/chain.py | 并发调度、限速、结果收集、链式拓扑执行 | 引擎不关心插件语义,只负责「跑起来 + 兜异常」 |
| 插件层 | plugins/ | 单漏洞判定逻辑 | 每个插件必须返回三态;异常由引擎兜为 UNKNOWN |
| 能力层 | core/session.py、core/fingerprint.py、lib/* | 可复用的横切能力 | lib/ 模块之间尽量互不依赖,通过编排层组合 |
| 基础层 | common/、config/、data/ | 数据模型、日志、配置、字典 | common/ + core/ 启用 mypy --strict(见 08) |
二、端到端数据流#
2.1 核心数据对象#
| 对象 | 定义位置 | 作用 |
|---|---|---|
ScanResult | common/models.py | 全局结果契约。含 kind/name/severity/status/url/evidence/extra/fix/fix_detail/reproduce/cve/cvss_score/cvss_vector/compliance |
FingerprintResult | 同上 | 指纹识别结果:cms/version/confidence/matched/variant |
ComponentVersionResult | 同上 | 组件版本检测结果(lib/component_detect.py 产出) |
ScanRequest | core/orchestrator.py | 跨 CLI/API 的输入契约,封装一次扫描的全部参数 |
ScanTask | 同上 | 任务句柄:状态、结果、计时、报告路径,供 TaskRegistry 管理 |
设计要点:
ScanRequest与ScanResult分别是系统的输入契约与输出契约。CLI 与 API 都只构造ScanRequest,这让「HTTP 提交的扫描」和「命令行扫描」走完全相同的代码路径。
2.2 结果对象的三态语义#
STATUS_CONFIRMED = "CONFIRMED" # 确认存在
STATUS_SAFE = "SAFE" # 确认不存在
STATUS_UNKNOWN = "UNKNOWN" # 无法判定(网络异常等)
ScanResult.kind 与 status 是联动的:只有 CONFIRMED 才能是 kind="vuln",其余一律 kind="info"。这个归一化在 plugins/base.py 的 PluginBase.enrich() 中强制执行,防止 SAFE 结果被下游报告当成漏洞渲染。
三、一次扫描的完整流程#
ScanOrchestrator._run()(core/orchestrator.py)是全局主流程,共 9 步:
run_sync(req, on_event) ← CLI 入口
└── 构造 ScanTask(uuid12) → _run(task, on_event)
submit(req) → TaskRegistry.register() ← API 入口
└── _DaemonThreadPoolExecutor.submit(_run) → 返回 task_id
_run() 内部步骤(代码中带编号注释,可直接对照):
| 步骤 | 动作 | 关键实现 |
|---|---|---|
| 1 | 端口扫描(可选 --portscan) | core.portscan.PortScanner,纯 socket,不依赖 nmap |
| 1′ | 主动信息收集(可选 --crawl/--subdomain/--js-extract) | _run_recon():爬虫 + 子域名 + JS 端点提取,使用独立临时 session 避免污染主 session |
| 2 | 创建会话与引擎 | SessionManager(proxy, debug, timeout) + ScanEngine(threads, rate);注入 req.auth 认证信息 |
| 3 | 口令字典分级 | 按 pass_level 解析 top100 / top1000 / full 字典路径,注入 session.password_dict(扫描级隔离,不改写 settings.PASSWORD_DICT) |
| 4 | 指纹识别 | 人工 --cms 走 Router.resolve_by_name();否则 detect_cms() 自动识别(含若依变体与版本) |
| 5 | WAF 探测 + 组件检测(可选) | detect_waf() → _build_waf_bypass() 构造绕过协调器;--components 走 ComponentDetector |
| 6 | 插件加载与路由 | 路由结果 + plugins.common(始终参与)+ 外部路径 + 用户安装目录 + entry_points + nuclei 模板;再按 --template 过滤 |
| 6′ | 按 category 分组 | u=[recon,vuln,brute]、m=[recon]、p=[vuln]、l=[brute] |
| 7 | 引擎执行 | 逐 category 调 engine.run(classes, target, session, on_result, waf_bypass_coordinator) |
| 8 | 报告生成(可选 report_dir) | ReportBuilder.render_all() + 版本对照矩阵 build_version_matrix() |
| 9 | 完成 / 异常 | 成功发 complete 事件;异常发 error + status=failed,已收集的结果仍返回 |
3.1 引擎内部单插件执行序列#
ScanEngine._exec()(core/engine.py)对每个插件类做 5 件事:
1. _rate_limit() ← 令牌桶限速(sleep 在锁外,避免并发退化)
2. inst = cls()
3. result = inst.verify(target, session)
4. WAF 绕过 ← 仅当:协调器存在 + 插件 supports_waf_bypass + 原状态非 CONFIRMED
5. inst.enrich(result) ← 元信息回填(cve/cvss/compliance/fix_detail/reproduce)
except Exception → ScanResult(kind="error", status=UNKNOWN) ← 绝不判 SAFE
共享会话的认证状态泄漏:引擎所有插件复用同一个
SessionManager,而某些插件(如job_invoke_target)会在其上登录。代码中明确注释:不能用 cookie 快照/还原做隔离(Shiro 认证状态存在服务端 session,按JSESSIONID索引,还原 cookie 无效)——需鉴权的插件必须自建独立会话。
3.2 事件机制(on_event)#
编排层的 _emit(event_type, payload) 有两个消费方:
| 消费方 | 通道 | 用途 |
|---|---|---|
| CLI | on_event 回调 | cli/runner.py:_cli_event_handler() 把事件渲染成彩色终端输出 |
| API | registry.notify() | 推送到 WebSocket 订阅队列,并落 SQLite 历史 |
事件类型(api/ws/events.py):status、complete、fingerprint、waf、portscan、category_start、result、progress、report、error。 编排层额外发出:recon、recon_start、recon_error、auth、template、component、waf_bypass、plugin_fallback、plugins_loaded、nuclei_error。
注意:
submit()调用_run(task, None)时on_event传None。原因是事件推送已统一走_emit内的registry.notify,若再传回调会导致每个事件被推送两次(代码中有明确注释)。
四、核心设计纪律#
这些约束贯穿全代码库,是本项目与通用扫描器的主要区别,改动代码时不应破坏。
4.1 三态判定纪律#
- 网络异常、执行异常、无法取证 → 一律
UNKNOWN; - 绝不把无法判定降级为 SAFE(
ScanEngine._exec的except分支、SessionManager的熔断异常都遵守); - 报告与 UI 中 SAFE / UNKNOWN 与 CONFIRMED 分开展示,不做「结果全红」;
- WAF 绕过异常也不降级为 SAFE,只把错误写进
result.extra["waf_bypass_error"]。
4.2 保守过滤:识别不出就不筛#
Router.resolve() 的策略是信息不足时跑全部 POC:
理由:漏报的代价高于多跑几个 POC 的代价。
4.3 目标无响应熔断#
SessionManager 维护按 host 分键的连续超时熔断(_HostBreaker,模块级共享):
| 机制 | 说明 |
|---|---|
| 阈值 | settings.TIMEOUT_BREAKER_THRESHOLD = 5 |
| 触发后 | 抛出 TargetUnresponsiveError(继承 ConnectionError),后续请求立即失败 |
为何继承 ConnectionError | 插件既有的 except Exception 语义不变,结果仍按三态降级为 UNKNOWN |
| 为何按 host 分键 | 批量扫描时某个死目标不应短路其余目标 |
| 为何模块级而非实例级 | 一次扫描会创建多个 SessionManager(侦察/漏洞/爆破/认证链),实例级计数会让每个新实例重新积累阈值 |
| 超时识别 | is_timeout_error() 沿 reason/__cause__/__context__ 链判定——启用 Retry 后超时会被包装成 MaxRetryError → ConnectionError,只看最外层类型熔断永不触发 |
| 计数复位规则 | 仅在确实收到响应时复位;超时与其他错误交替的目标不会让计数反复归零 |
熔断异常消息刻意不含请求 URL(否则每条路径异常文本不同,下游按原因去重失效、报告 evidence 被撑爆)。
4.4 TLS 校验默认关闭#
settings.VERIFY_TLS = False(可用 --verify-tls 打开)。理由:内网若依部署普遍用自签名证书,开启校验会让每个请求抛 SSLError,插件按三态纪律全部降级为 UNKNOWN——工具对这类目标完全失效且没有提示。关闭时同步 urllib3.disable_warnings(InsecureRequestWarning) 避免刷屏。
4.5 结果去重聚合#
core/dedup.py 用 sha1(endpoint | vuln_type | payload_class) 构造指纹,把「同一漏洞被多个插件/多条 payload 命中」合并为一条:
- endpoint 归一化:去参数、去尾部括号;
- 合并策略:证据拼接、
extra合并、URL 主记录取代表; - 输出
DedupReport(original_count/aggregated_count/merged_groups),CLI 会打印去重统计; --no-dedup可关闭。
4.6 版本感知与变体感知过滤#
三层收窄,逐层兜底:
Router.candidates(fp) → 按 cms + variant 收窄(插件 variant='' 表示通用)
Router.resolve(fp) → 再按 affected_versions 版本范围收窄
build_version_matrix() → 用「未过滤候选集」生成对照表,展示哪些插件因版本被跳过
若依变体在 core/fingerprint_features.py 中以数据驱动方式定义:ruoyi-vue3、ruoyi-app、ruoyi-plus、ruoyi-cloud-plus、ruoyi-magic,全部共享 plugins.ruoyi 插件包(Router.mapping)。
4.7 CLI / API 共用编排#
ScanOrchestrator 的架构红线(源码注释原文):
core/engine.py零修改(通过on_result回调)、core/router.py零修改、core/models.py零修改
即:新增能力应通过回调与组合接入,而不是修改引擎核心。这是 CLI 与 API 行为一致、且新增 D 系列特性不互相打架的原因。
4.8 daemon 线程池#
_DaemonThreadPoolExecutor 重写 _adjust_thread_count(),不把线程注册到 concurrent.futures.thread._threads_queues。原因:标准 ThreadPoolExecutor 的 _python_exit atexit 处理器会 join 所有注册线程(即使 daemon),导致 API 测试中后台扫描线程阻止 pytest 退出(CI 挂起根因)。
五、命令与代码路径对照#
| 用户命令 | 代码路径 |
|---|---|
-p <url> | main.main → cli.dispatcher.dispatch → preflight_target → cli.runner.run_mode('p') → ScanOrchestrator.run_sync |
-u <url> | 同上,mode='u',category 顺序 recon → vuln → brute |
-f targets.txt -p | cli.runner.run_mode_batch → 逐目标 preflight + run_mode → BatchReport |
-f targets.txt -p --async | cli.runner._run_batch_async → lib.async_engine.scan_batch_targets |
--serve | cli.serve_runner.run_serve_mode → api.app.create_app → uvicorn.run |
--chain <name> | cli.chain_runner.run_chain_mode → core.chain.ChainEngine.run |
--passive | cli.passive_runner.run_passive_mode → core.proxy_server |
--plugin-* / --wiki / --ci-init / --diff-only / --template-list | cli.plugin_runner / cli.tool_runner(纯工具模式,不扫描) |
--ai / --ai-validate / --cve-sync / --oast-server / --web-ui / --cache-* | lib/* 对应的 run_*_mode() |
六、架构上的取舍记录#
| 取舍 | 选择 | 代价/理由 |
|---|---|---|
| 插件并发 vs 结果顺序 | threads<=1 时串行保证顺序稳定;>1 用 as_completed(无序) | 顺序在报告可读性上有价值,默认 THREADS=1 |
| 限速实现 | 时间戳滑动窗口令牌桶 | 简单、无外部依赖;sleep 必须在锁外 |
| 配置求值时机 | settings 模块导入时求值 | 简单直接;运行时的扫描级差异(如 pass_level 字典)改为注入 session,不再改写全局 |
| 循环依赖规避 | 函数内延迟导入(如 cli/runner.py 底部导入子模块) | 保持模块顶层依赖图清晰,代价是可读性略降 |
| 报告无第三方依赖 | HTML/JSON/CSV 用标准库;PDF/DOCX/XLSX 走可选依赖组 | pip install ruoyi-scan 保持极轻(仅 requests) |
| 新增框架不改代码 | 指纹特征库 + Router 动态 plugins.<cms> 推导 | JeecgBoot 是第一个实证(plugins/jeecgboot/,指纹/路由/三态/报告零改动接入) |