本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/FlintHub_插件在线审计工具_详细规划方案.md。
FlintHub 插件在线审计工具 · 详细规划实施方案
状态:规划阶段,未动代码
日期:2026-10-17
定位:把《插件开发规范》从「给人/AI 读」升级成「可被机器执行」
依据:
docs/插件开发规范.md(1.6)、cli/release_build.php(9 项门禁)、.workbuddy-ai/tools/(6 套现成检查器)、app/Helpers/Updater.php、app/Core/Autoloader.php、app/Core/TemplateCompiler.php
零、一页结论
做,而且比原规划更省——因为仓里已经有一半的轮子,只是散落在 cli/ 和 .workbuddy-ai/tools/ 里,从没被组装成一个产品。
| 维度 | 结论 |
|---|---|
| 技术路线 | 纯 PHP + token_get_all(),不引入 composer 依赖(原规划 2 的 nikic/php-parser 建议否决,理由见 §3.1) |
| 最大复用 | 9 项发布门禁的判定逻辑 + 6 套现成检查器,约 60% 的规则已有可移植实现 |
| 最大风险 | 规则误报。按原规划字面实现「命名空间首段 = 目录名」,会把仓内主流写法 Plugin\PointsMall + points_mall 判成错误(详见 §3.3) |
| 安全红线 | 审计工具绝不能对用户代码做 class_exists() / require / include——那是发布端门禁的做法,搬过来等于执行陌生人的代码(详见 §3.2、§11) |
| MVP 边界 | 1 个上传页 + 12 条确定性规则 + HTML/JSON 报告;不做后台、不做使用数据、不做 HTTP sync |
| 版本策略 | 工具主版本跟随核心版本;另加独立的「规则集版本」(快照 sha256 前 8 位),因为同工具 + 不同快照 = 不同结论 |
一、定位与边界
1.1 一句话
一个独立的 PHP Web 工具,用「确定性的规则检查」代替「AI 的概率性审查」:看代码(用户上传的插件 zip)→ 对照规则(发布源同步的规范 + 核心事实)→ 给建议(哪行有问题、怎么改、依据哪条规范)。
1.2 做
- 读 zip 里的源码,只读不执行
- 用规则对照「规范条款 + 核心事实」
- 输出「文件:行号 + 症状 + 修法 + 规范章节」的结构化报告
- 报告可导出 HTML(人看)与 JSON(CI 用)
1.3 不做
| 不做 | 理由 |
|---|---|
| 不是论坛插件 | 独立部署。装进论坛就要跟着核心升级节奏走,且用户得先有论坛才能审计 |
| 不用大模型 | 确定性优先;AI 只在「语义/架构」类问题上补位,且不在本工具内 |
| 不自动改代码 | 只报不改。改了要担责,且用户代码版本不可控 |
| 不执行用户代码 | 连 php -l 都要 -n 隔离(见 §11) |
| 不做「逻辑正确性」判断 | 那需要理解业务意图,规则做不到 |
1.4 它像什么
「定制版 AI」——但把「推理」换成了「查表」。用户得到的不是「我觉得这里可能有问题」,而是「第 3 行命名空间是 Plugin\AdSlot,目录名是 adslot,camelToSnake('AdSlot')='ad_slot',三条自动加载候选路径全落空,Linux 上会 500,依据 §11.34」。
二、对两份原始规划的裁决
两份规划整体方向都对,架构分层、快照机制、安全边界都成立。以下是逐条裁决——采纳的写清楚怎么落,修正的写清楚为什么。
| # | 原始主张 | 裁决 | 理由(基于代码事实) |
|---|---|---|---|
| 1 | 独立 PHP Web 工具,确定性规则 | ✅ 全采纳 | — |
| 2 | 只读不写、检查完即删临时文件 | ✅ 全采纳 | 再加 try-finally + 递归删(原规划 2 的加固) |
| 3 | 规范 + 核心事实同源 | ✅ 采纳,但实现方式改 | 原规划靠「人写 rules.json/facts.json」→ 必然漂移。改为构建期从核心代码常量生成(见 §6) |
| 4 | 快照另存不覆盖、可复现 | ✅ 全采纳 | 再加 schema_version,防「新事实被老工具静默忽略」 |
| 5 | 三层规则分级(机械/语义/架构) | ✅ 采纳,加第四级 | 原规划只有 A/B/C 三类,缺少「等级」这个维度。改为「域 × 等级」二维(见 §8) |
| 6 | zip 炸弹 / 路径穿越 / 符号链接防护 | ✅ 全采纳 | 补一条:拒绝嵌套压缩包(原规划 2 已提,需落实) |
| 7 | 报告双格式 HTML + JSON | ✅ 全采纳 | JSON 要固定 schema,见 §10 |
| 8 | 后台两页签(快照管理 + 使用数据) | ⚠️ 推迟到二期 | MVP 没有「多份快照」要管,也没有数据量。先做「导入 + 列出」两个动作 |
| 9 | admin/sync.php 发布流水线调用 | ⚠️ MVP 改为离线投放,二期再加签名 HTTP | 一个公开的同步接口是最容易被滥用的攻击面。MVP 用「目录投放 + 启动导入」,零网络零鉴权(见 §6.3) |
| 10 | 工具主版本 = 核心版本 | ✅ 采纳,补规则集版本 | 同一工具 + 不同快照 = 不同结论,报告必须能区分(见 §12) |
| 11 | 引入 nikic/php-parser 做 AST | ❌ 否决 | 核心是零依赖项目(无 composer.json、无 vendor/),生产是宝塔 + 2 核 2G + disable_functions。而仓内已有 3 套 token_get_all() 实现跑过真实事故(见 §3.1) |
| 12 | 规则接口 run(string $filePath, array $ast, array $facts) | ✅ 采纳,补一条约束 | 签名对,但要明确「每文件只解析一次,多规则复用」(见 §9.1) |
| 13 | 「命名空间首段 = 目录名」作为一条规则 | ⚠️ 修正实现 | 字面比较会误报。正确实现是复刻 3 条自动加载候选路径(见 §3.3) |
| 14 | 「零内联(<style> / <script> / on* / style=)」 | ⚠️ 修正实现 | style="--xxx-bg:..." 是规范明文合规做法;<script src> 也合规(见 §3.4) |
| 15 | 「红线静态扫描(unserialize/eval/assert…)」 | ✅ 采纳 | 但要先排除注释与字符串(token_get_all() 天然解决) |
三、从代码里挖出的 7 条「必须先说清楚」的事实
这一节是本方案与两份原始规划的最大差异。每一条都能在代码里指到出处,且每一条都对应一次真实事故或一个必然误报。
3.1 这是零依赖项目 → 不要引入 php-parser
composer.json : 不存在
vendor/ : 不存在而仓内已有 3 套 token_get_all() 实现,都在真实事故里验证过:
| 工具 | 解决的问题 | 为什么 `token_get_all()` 够用 |
|---|---|---|
.workbuddy-ai/tools/php_missing_use_check.php | 漏 use → 运行到那行才 fatal | 注释/字符串一律排除,只认 T_STRING + T_DOUBLE_COLON / T_NEW |
.workbuddy-ai/tools/php_var_cjk_check.php | "$name," 全角逗号被吞进变量名 | 单引号串与注释里的 $x中文 不会被识别为 T_VARIABLE → 零误报 |
.workbuddy-ai/tools/fh_i18n_check.php | 引用键 ↔ 三语字典全量比对 | 字面量引用提取 |
结论:做一个约 300 行的 PhpTokenizer,把 token 流整理成「命名空间 / use 列表 / 类声明 / 函数声明 / 字符串字面量 / 注释区间 / 有效代码行」这几个结构,足够覆盖第一批全部规则。
但保留接口:lib/Parser/ParserInterface.php,二期若真需要完整 AST,换实现即可,规则代码不动。
3.2 ⚠️ 审计工具绝不能执行用户代码(与发布端门禁的关键差异)
发布端门禁 #9(cli/release_build.php:986)做的是独立子进程 class_exists():
$code = "<?php\nrequire_once " . var_export($boot, true) . ";\n"
. 'echo class_exists(' . var_export($cls, true) . ") ? 'FH_OK' : 'FH_NO';\n";这在发布端是安全的——跑的是你自己的代码。但审计工具跑的是陌生人的 zip:一旦 require 了用户的 Plugin.php,顶层任何可执行语句(写库 / exit / 读文件)都会真的执行。
所以审计工具的命名空间规则必须退化为纯静态判定(见 §3.3),并接受「比发布端门禁弱一档」这个事实。这不是妥协,是安全边界的必然结果。
同理,php -l 也要隔离:
php -n -d display_errors=0 -d error_reporting=0 -l -n 是关键——不加载 php.ini,杜绝 auto_prepend_file 注入。
3.3 ⚠️ 「命名空间首段 = 目录名」不是字符串比较
规范 §11.34(docs/插件开发规范.md:1763)的标题写的是「首段必须逐字节等于目录名」,但正文(:1800-1808)明确给了两种合法写法:
| 目录名 | ✅ 可用命名空间 | 命中的候选路径 |
|---|---|---|
adslot | Plugin\adslot | ① ② |
adslot | Plugin\Adslot | ② |
points_mall | Plugin\PointsMall(仓内主流写法) | ② |
user_level | Plugin\user_level(既有先例) | ① |
app/Core/Autoloader.php:36-48 的真实逻辑是三条候选:
$paths = [
$pluginDir . '/' . $rest . '.php', // ① 首段原样
$camelToSnake($pluginDir) . '/' . $rest . '.php', // ② 首段转 snake_case
strtolower($pluginDir) . '/' . strtolower($rest) . '.php', // ③ 全小写(文件名也被小写)
];如果按字面实现「namespace 首段 === 目录名」,Plugin\PointsMall + points_mall 会被判成错误——而这是仓内主流写法,44 个插件里大面积命中。工具第一次跑就废了。
正确实现:复刻这三条候选,用逐字节精确匹配判断「包内是否存在这个文件」(不能用 file_exists(),Windows 会折叠大小写——.workbuddy-ai/tools/plugin_case_lint.py 已经踩过并解决)。
判定逻辑:
对每个 namespace 为 Plugin\<首段>\<其余> 的类:
按三条候选路径在包内逐字节查找 <其余>.php
任一命中 → PASS
全部落空 → CRITICAL并且必须带「探针自检」:plugin_case_lint.py 的注释里记了一次事故——第一版把盘符 D: 当成目录名,导致 45/45 全红。工具在跑规则前,必须先用内置正例/反例验证自己:
正例:dir=adslot, ns=Plugin\adslot → 必须 PASS
正例:dir=points_mall, ns=Plugin\PointsMall → 必须 PASS
反例:dir=adslot, ns=Plugin\AdSlot → 必须 FAIL
反例:dir=adslot, ns=Plugin\Adslot, 但只有 AdminController.php(③ 需要全小写文件名)→ 必须 FAIL自检不过 → 拒绝输出报告,而不是给一堆假错误。
3.4 ⚠️ style= 不都是违规,<script src> 是合规的
规范 §7.3(:569)写得非常细,其中三条最容易做错:
| 写法 | 判定 | 依据 |
|---|---|---|
<script>...</script> 内联块 | ❌ P0 违规 | §7.3-1 |
<script src="...assets/script.js?v=filemtime"> | ✅ 合规 | §7.3-1 明文「视图/钩子只保留 <script src> 引用」 |
onclick= / onchange= / onsubmit= | ❌ P0 违规 | §7.3-2 |
style="margin-top:8px" 静态样式 | ❌ P1 违规 | §7.3-3 |
style="--xxx-bg:<?php echo $s['bg']; ?>;" | ✅ 合规 | §7.3-4 明文「内联 CSS 变量注入属合规做法,审计不视为违规」 |
style="display:none;" | ❌ P1 违规 | §7.3-5,应改 class 或 x-cloak |
结论:style= 规则不能写成 grep style=。必须先判断属性值是否只含 --var: value 形式的 CSS 变量;含任何非变量声明才报违规。
3.5 ⚠️ 「调试残留」必须豁免 assets/lib/
发布端门禁 #4(cli/release_build.php:738)踩过一次:
原判据
strpos($rel, 'assets/lib/') === 0只匹配站点根,插件内的第三方库(plugins/<name>/assets/lib/…)永远豁免不到 → highlight.js / mathjs 这类压缩库被误判「调试残留」而挡住发布。
判据是 VENDOR_LIB_PREFIX = 'assets/lib/'(release_build.php:117),匹配「路径任一位置出现」。审计工具要照抄这个口径,否则每个带第三方库的插件都会报一堆假 console.log。
3.6 ⚠️ icon 在 $map 内 ≠ 字形真的能显示
TemplateCompiler::$map(app/Core/TemplateCompiler.php:623)有 492 个图标名。- 但
HubCatalog::iconFromZip()(plugins/market_hub/HubCatalog.php:550)只校验字符集^[a-z0-9-]{1,40}$,不校验是否在表内——不在表内会渲染成核心兜底字符(问号)。 - 更深一层:图标字体是子集版(FA 448 / Tabler 447 个字形)。名字在
$map里,但如果该字形没被打进子集字体,页面上仍是方块。
三级判定(MVP 只做第一级):
| 级别 | 判据 | MVP |
|---|---|---|
| L1 | icon 是合法字符集 + 在 $map 的 492 个键里 | ✅ 做 |
| L2 | 该图标名对应的码点存在于子集字体的 cmap 里 | ❌ 二期 |
| L3 | 该字形在当前主题下视觉可读 | ❌ 人工 |
L2 需要把子集字体(.woff2)的 cmap 解析出来存进 facts.json,属于「知道怎么做但 MVP 不值得做」。
3.7 发布端 9 项门禁 = 现成的规则说明书
cli/release_build.php:618~1049 的 9 项门禁,本质上就是 9 条已经打磨过的确定性规则。审计工具的第一批规则应当直接对齐它们,保证「工具说能过 = 发布端能过」:
| 门禁 | 检查内容 | 可否移植到审计工具 |
|---|---|---|
| #1 i18n 键集合 | 三语集合零差异 + 键序一致 | ✅ 直接移植 |
| #2 PHP 语法 | 双版本 php -l | ✅ 移植(加 -n 隔离) |
| #3 编码卫生 | BOM / CRLF / 非 UTF-8 | ✅ 直接移植 |
| #4 调试残留 | var_dump / console.log 等 | ✅ 直接移植(保留 assets/lib/ 豁免) |
| #5 卫生 | 无 config.php / .bak / .zip / .sqlite / .log | ✅ 直接移植 |
| #6 包结构复核 | 顶层只有 plugins/、必备 Plugin.php | ✅ 直接移植 |
| #7 路径安全 | 包内容 ⊆ 升级器白名单 + 大小写一致 | ⚠️ 需把白名单搬进 facts.json(工具无站点代码) |
| #8 签名链路 | RSA 私钥↔公钥 | ❌ 不适用(那是发布端的事) |
| #9 命名空间一致性 | 子进程 class_exists | ❌ 不可移植,改为静态三条候选(§3.3) |
四、关键设计决策
D1 零依赖,token_get_all() + 薄封装
见 §3.1。保留 ParserInterface 给二期。
D2 规则与事实分离,且事实由构建期生成
facts.json:核心的客观事实(图标名表、分类枚举、钩子表、路径白名单、排除前缀、CRLF 例外清单)rules.json:规则元数据(check_id、等级、标题、规范章节、修法文案)- 判定逻辑在 PHP 代码里(
lib/Rules/*),不在 JSON 里
理由:把判定逻辑写进 JSON 会变成一门自造的小语言,维护成本高于收益。JSON 只承载「可变的枚举」和「文案」,代码只承载「怎么判」。
D3 快照另存不覆盖,且带 schema_version
audit_facts(core_version PK, schema_version, spec_md, rules_json, facts_json, sha256, fetched_at)schema_version 解决一个原规划没覆盖的坑:工具升级后 facts.json 新增字段,老工具读到会静默忽略,于是「新规则永远不生效」且无任何报错。工具必须显式检查:
快照 schema_version > 工具支持的 max → 拒绝运行,提示「请升级审计工具」
快照 schema_version < 工具支持的 min → 拒绝运行,提示「请重新同步快照」D4 审计核心是纯函数,Web/CLI 只是壳
Auditor::run(array $fileMap, array $facts, array $rules): Report
// $fileMap = ['plugins/x/Plugin.php' => '<源码字符串>', ...]不碰 HTTP、不碰 session、不碰数据库。这样三期接 CI 时不用重写,直接 php cli/audit.php <zip>。
D5 每文件只解析一次,多规则复用
一个插件 30 个文件 × 20 条规则,如果每条规则自己 token_get_all() = 600 次解析。正确循环:
foreach ($fileMap as $rel => $src) {
$tok = PhpTokenizer::parse($rel, $src); // 一次
foreach ($rules as $rule) {
if ($rule->supports($rel)) { // 按扩展名/目录快速跳过
$rule->run($rel, $tok, $facts);
}
}
}D6 等级:CRITICAL / MAJOR / MINOR / INFO
采纳原规划 2 的四级,但收紧 CRITICAL 的定义——只有「核心真的会因此 500 或装不上」才配叫 CRITICAL:
| 等级 | 含义 | 典型 |
|---|---|---|
| CRITICAL | 上线必炸 / 装不上 | 类加载不到、PHP 语法错、包结构非法、路径穿越 |
| MAJOR | 功能或安全实质受损 | i18n 键缺失、内联 <script>、缺 CSRF、漏 use |
| MINOR | 规范一致性 / 卫生债 | BOM/CRLF、调试残留、全角标点、版本号不同步 |
| INFO | 仅提示,需人工判断 | 钩子里用 $this、直接查核心表 |
D7 每条规则必须自带「正例 + 反例」自检
从 §3.3 的事故来的。规则注册时附一段固定夹具:
public function selfTest(): array {
return [
['dir'=>'adslot','ns'=>'Plugin\\adslot','files'=>['AdminController.php'],'expect'=>'PASS'],
['dir'=>'points_mall','ns'=>'Plugin\\PointsMall','files'=>['Mall.php'],'expect'=>'PASS'],
['dir'=>'adslot','ns'=>'Plugin\\AdSlot','files'=>['AdminController.php'],'expect'=>'FAIL'],
];
}任何一条自检不过 → 该规则整体停用并标 DISABLED,而不是输出可能错误的结论。 这条比多写 5 条规则重要。
D8 MVP 不做 HTTP sync
见 §6.3。用「目录投放 + 启动导入」,把鉴权/签名/防重放全部推到二期。
D9 报告必须能自证「按什么版本、什么规则集扫的」
报告头固定四行:
工具版本 : 1.2.0
扫描基准核心 : FlintHub 1.2.0
规则集版本 : 3f9a1c2d(快照 sha256 前 8 位)
扫描时间 : 2026-10-17 18:30:12 +08:00没有这四行,报告三个月后无法复现,等于没有审计价值。
D10 临时目录用完即删,且不依赖 GC
$tmp = null;
try {
$tmp = self::makeTempDir();
...
} finally {
if ($tmp !== null) { self::removeTree($tmp); } // 递归删,不用通配
}removeTree() 必须自己实现(RecursiveDirectoryIterator + unlink),不调用 exec('rm -rf')——生产有 disable_functions,且路径拼接出错就是灾难。
五、架构与目录
plugin-audit/ # 独立部署,与论坛无任何代码依赖
├── index.php # 入口(上传页)
├── cli/
│ └── audit.php # CLI 入口(二期,接 CI)
├── admin/
│ ├── snapshots.php # 快照列表 + 导入(MVP)
│ ├── sync.php # 发布流水线同步接口(二期,带签名)
│ └── dashboard.php # 使用数据看板(三期)
├── lib/
│ ├── Parser/
│ │ ├── ParserInterface.php # 二期换 AST 实现的接缝
│ │ └── PhpTokenizer.php # token_get_all() 薄封装(~300 行)
│ ├── SiteReader.php # 读发布源(二期)
│ ├── SnapshotStore.php # 快照读写(SQLite)
│ ├── ZipHandler.php # 安全解压
│ ├── FactsResolver.php # facts/rules 装载 + schema 校验
│ ├── Auditor.php # 调度器(纯函数)
│ ├── Rules/
│ │ ├── RuleInterface.php
│ │ ├── RuleResult.php # 单条命中结果值对象
│ │ ├── Critical/ # 4 条
│ │ ├── Major/ # 5 条
│ │ └── Minor/ # 4 条
│ └── Report.php # 报告生成(HTML + JSON)
├── views/
│ ├── upload.php
│ └── report.php
├── data/
│ ├── audit.sqlite # 快照 + 使用数据
│ ├── snapshots/ # 离线投放的原始 json(导入后保留,便于重放)
│ └── tmp/ # 解压临时目录(用完即删)
├── config.php # 发布源地址、上限、缓存策略
└── version.php # 工具版本(跟随核心主版本)与原始规划的差异:
| 差异 | 原因 |
|---|---|
新增 Parser/PhpTokenizer.php | 原规划没有「怎么解析 PHP」这一层,直接假设有 AST |
新增 Rules/RuleResult.php | 原规划的接口返回 array,字段全靠约定;用值对象可避免「漏字段」 |
admin/ 从 MVP 降级 | 见 D8 |
新增 data/snapshots/ | 离线投放的原始文件要留着,便于「同一份快照重新导入」 |
删掉 lib/FactsResolver.php 的「网络拉取」职责 | MVP 只从本地目录导入 |
六、发布源 audit/ 结构与生成方式
6.1 目录
release/
├── core/
├── plugins/
└── audit/
├── 1.2.0/
│ ├── manifest.json # 版本 + 三文件 sha256 + 签名(二期)
│ ├── spec.md # 规范原文(带 锚点)
│ ├── rules.json # 规则元数据
│ └── facts.json # 核心事实
└── latest.json # 指向当前版本6.2 ⚠️ 三个文件必须由构建脚本生成,不能手写
原规划让「发布流程加一步:构建 audit/1.2.0/ 三个文件」——方向对,但没说怎么构建。手写必然漂移,而这正是「同源」原则要解决的问题。
新增 cli/build_audit_snapshot.php,从核心代码常量直接导出:
| facts.json 字段 | 来源 | 出处 |
|---|---|---|
whitelist_dirs | Updater::WHITELIST_DIRS | app/Helpers/Updater.php:64 |
whitelist_files | Updater::WHITELIST_FILES | :72 |
blacklist | Updater::BLACKLIST | :36 |
blacklist_ext | Updater::BLACKLIST_EXT | :55 |
blacklist_suffix | Updater::BLACKLIST_SUFFIX | :56 |
plugin_excludes | Updater::PLUGIN_EXCLUDES | :115 |
assets_excludes | Updater::ASSETS_EXCLUDES | :118 |
plugin_root_prefix | Updater::PLUGIN_ROOT_PREFIX | :112 |
icons | TemplateCompiler::$map 的键 | app/Core/TemplateCompiler.php:623(492 个) |
categories | PluginController 的 10 类 | app/Controllers/Admin/PluginController.php:23 |
hooks | 扫 Plugin::hook('...') 调用点 | 复用 plugin_hook_audit.php 的逻辑 |
vendor_lib_prefix | VENDOR_LIB_PREFIX | cli/release_build.php:117 |
crlf_exceptions | CRLF_EXCEPTIONS | cli/release_build.php:76 |
shell_files | SHELL_FILES | cli/release_build.php:106 |
脚本必须自校验:导出后重新读回来,断言「图标名数 == 492」「分类数 == 10」「钩子数 == 规范 §9.1 声明的数字」,不符就中止并报错。
6.3 MVP 的同步方式:目录投放,不做 HTTP
1. 发布流程跑 cli/build_audit_snapshot.php --version=1.2.0
2. 产出 release/audit/1.2.0/{manifest,spec,rules,facts}.json
3. 把整个 1.2.0/ 目录丢进工具的 data/snapshots/
4. 工具下次启动时自动扫描 data/snapshots/,校验 sha256 后导入 DB(已存在的跳过)好处:
- 零网络请求 → 零攻击面(没有「谁能调 sync 接口」这个问题)
- 零鉴权 → 不用在 MVP 阶段设计签名/防重放
- 仍然满足「另存不覆盖 + 可复现」
二期再加 admin/sync.php,鉴权复用发布源的 RSA-2048 公钥体系(Updater::releaseKeys() / app/Helpers/release_keys.php),并对请求体做签名校验。
6.4 spec.md 的锚点
规范原文太长(153 KB),报告里不能整篇塞。做法:
- 在
spec.md里,每个被规则引用的章节标题后插入 HTML 注释:<!-- check_id: FH-NS-001 --> - 报告里的「查看原文」链接指向
spec.md#FH-NS-001 - 构建脚本负责校验「每条规则声明的 check_id 在 spec.md 里恰好出现一次」,漏了就中止
七、数据模型
PRAGMA journal_mode = WAL; -- 并发读 + 单写,避免后台导入时前台阻塞
-- 快照:只追加不覆盖
CREATE TABLE audit_facts (
core_version TEXT PRIMARY KEY, -- '1.2.0'
schema_version INTEGER NOT NULL, -- facts.json 结构版本
spec_md TEXT NOT NULL,
rules_json TEXT NOT NULL,
facts_json TEXT NOT NULL,
sha256 TEXT NOT NULL, -- 三文件规范化拼接后的哈希
imported_at TEXT NOT NULL
);
-- 使用记录(三期才写入;MVP 建表但不写)
CREATE TABLE audit_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL,
core_version TEXT NOT NULL,
rule_set TEXT NOT NULL, -- 快照 sha256 前 8 位
hit_rules TEXT NOT NULL, -- JSON 数组,如 ["FH-NS-001","FH-ENC-001"]
level_counts TEXT NOT NULL, -- JSON,如 {"CRITICAL":0,"MAJOR":2}
checked_at TEXT NOT NULL
);
CREATE INDEX idx_records_time ON audit_records(checked_at);明确不存:用户 IP、User-Agent、源码内容、源码片段、zip 文件名。
原规划已提「不存 IP、不存源码」,这里再补一条:也不存原始文件名——用户上传的 zip 名可能带公司名/项目名,属于可识别信息。报告里只显示「插件名(从 plugin.json 读)」。
八、规则清单(第一批)
check_id 命名:FH-<域>-<序号>。域:NS 命名空间 / PKG 包结构 / SYN 语法 / ENC 编码 / I18N / INL 内联 / SEC 安全 / META 元数据 / HOOK 钩子 / DB 数据库。
8.1 CRITICAL(4 条,上线必炸)
| check_id | 规则 | 规范出处 | 实现方式 | 误报风险 |
|---|---|---|---|---|
FH-PKG-001 | 包结构:所有路径必须在 plugins/<name>/ 下,且含 Plugin.php + plugin.json | §二 / §11.35 | 逐条路径前缀校验 | 低 |
FH-PKG-002 | 目录名合法:^[a-z0-9_]{2,40}$ | §11.35 | 正则 | 低 |
FH-PKG-003 | 路径安全:不含 ..、\0、:、前导 /、全角斜杠、bidi 控制符 | Updater::normalizePath | 移植 normalizePath 的 2/3/3b 步 | 低 |
FH-NS-001 | 命名空间类能被自动加载器找到(复刻 3 条候选路径,逐字节匹配) | §11.34 | PhpTokenizer 取 namespace + 文件清单,逐字节比对 | 高 → 必须带自检(§3.3) |
FH-SYN-001 | PHP 语法(php -n -l) | §11.19 | 子进程 | 低(但要 -n) |
注:
FH-SYN-001依赖运行环境有 PHP 二进制。若工具部署环境不允许exec,降级为 SKIP 并在报告里明说「语法未检查」——不能静默当作 PASS。
8.2 MAJOR(5 条)
| check_id | 规则 | 规范出处 | 实现方式 | 误报风险 |
|---|---|---|---|---|
FH-I18N-001 | 插件三语字典键集合零差异 + 键序一致 | §八 | include 后 array_keys 比对(只 include 语言包,那是纯数组返回,不执行逻辑) | 低 |
FH-I18N-002 | 代码里字面量引用的键在字典里存在 | §八 | 正则 + 字典比对;动态拼接会误报 → 只报「字面量」 | 中(动态键) |
FH-INL-001 | 无内联 <script>...</script> 块(<script src> 豁免) | §7.3-1 | 正则区分有无 src | 中(需正确排除 <script src>) |
FH-INL-002 | 无 on* 事件属性 | §7.3-2 | 正则 | 低 |
FH-SEC-001 | 红线函数:unserialize / eval / assert / extract / create_function / preg_replace + /e | §一五 | token_get_all() 认 T_STRING 函数调用(排除注释与字符串) | 低 |
FH-SEC-002 | 写操作必须「先登录后 CSRF」 | §11.28 | PhpTokenizer 找 requireLogin / Csrf::verify 的先后顺序 | 中(调用形式多) |
FH-USE-001 | 同命名空间下调用未 import 的 app 类 | §12.1-6 | 直接移植 php_missing_use_check.php | 低(已验证) |
8.3 MINOR(4 条)
| check_id | 规则 | 规范出处 | 实现方式 | 误报风险 |
|---|---|---|---|---|
FH-ENC-001 | 无 BOM / 无 CRLF / 全 UTF-8 | §11.19 | 移植门禁 #3 | 低 |
FH-ENC-002 | 变量名不含非 ASCII 字节(全角标点吞变量名) | §12.1-4 | 直接移植 php_var_cjk_check.php | 零(token 级) |
FH-META-001 | plugin.json 字段完整:name/version/min_core/category/icon/author/description | §三 | JSON 解析 + 必填校验 | 低 |
FH-META-002 | category 在 10 类内 | §三 | 对照 facts.categories | 低 |
FH-META-003 | icon 在 492 个图标名内 | §6.4-4 | 对照 facts.icons | 低(但见 §3.6 的 L2/L3 限制) |
FH-META-004 | hooks 声明的钩子名在核心钩子表内 | §九.1 | 对照 facts.hooks | 低 |
FH-META-005 | 版本号多处一致(plugin.json + 说明.md + PHP 文件头 @version + assets) | §12.2-7 | 正则提取后比对 | 中(历史版本号会被误抓,需限定「当前版本」语义) |
FH-INL-003 | 无静态 style=""(CSS 变量注入豁免) | §7.3-3/4 | 解析属性值,判是否只含 --var: | 高 → 需自检(§3.4) |
FH-ENC-003 | 无调试残留(var_dump/print_r/console.log/debugger) | §11.5 | 移植门禁 #4,保留 assets/lib/ 豁免 | 中(第三方库) |
8.4 INFO(3 条,只提示)
| check_id | 规则 | 规范出处 |
|---|---|---|
FH-HOOK-001 | 钩子文件里出现 $this->(闭包作用域不可用) | §11.24 |
FH-HOOK-002 | 钩子文件里声明顶层函数/类(双 include 陷阱) | §9.5 |
FH-DB-001 | 直接查询核心表(threads / users / topic_index) | §11.8 |
8.5 明确「不做」的规则
| 不做 | 原因 |
|---|---|
| N+1 查询判断 | 需要理解数据流,静态做不到 |
| 事务原子性 | 需要理解业务语义 |
性能预算(Plugin::budget) | 运行期才成立 |
| SQL 拼接检测 | 只能做到「疑似」(找 query("... $var ...")),误报率过高,降级为 INFO 且默认关闭 |
| 图标字形是否在子集字体里 | 见 §3.6,二期 |
九、检查引擎
9.1 规则接口
interface RuleInterface
{
public function getCheckId(): string; // 'FH-NS-001'
public function getLevel(): string; // CRITICAL|MAJOR|MINOR|INFO
public function supports(string $rel): bool; // 按扩展名/目录快速跳过
public function run(string $rel, TokenStream $tok, array $facts): array; // RuleResult[]
public function selfTest(): array; // 正例/反例夹具,见 D7
}TokenStream 是 PhpTokenizer::parse() 的产物,同一文件只构造一次:
final class TokenStream
{
public array $namespaces; // [['name'=>'Plugin\\Foo','line'=>3]]
public array $uses; // [['fqcn'=>'app\\Helpers\\Csrf','alias'=>'Csrf','line'=>9]]
public array $classes; // [['name'=>'Plugin','line'=>20,'kind'=>'class']]
public array $functions; // [['name'=>'foo','line'=>40]]
public array $calls; // [['name'=>'unserialize','line'=>55,'args'=>...]]
public array $strings; // [['value'=>'...','line'=>12]] ← 字符串字面量
public array $comments; // [[start,end]] ← 供规则排除注释区间
public array $codeLines; // 有效代码行号集合
}关键:strings 与 comments 分开暴露,规则才能做到「只认代码、不认注释和字符串」——这是 php_missing_use_check.php 已经验证过的做法。
9.2 调度
final class Auditor
{
/** @return Report 纯函数:不碰 HTTP / session / DB */
public static function run(array $fileMap, array $facts, array $rules, array $opts = []): Report
{
// 0) 规则自检(D7)—— 不过就整条停用
$rules = array_filter($rules, fn($r) => selfTestPassed($r));
$results = [];
foreach ($fileMap as $rel => $src) {
$tok = isPhp($rel) ? PhpTokenizer::parse($rel, $src) : null;
foreach ($rules as $rule) {
if (!$rule->supports($rel)) continue;
foreach ($rule->run($rel, $tok, $facts) as $res) {
$results[] = $res;
}
}
}
return new Report($results, $facts['core_version'], $facts['rule_set'], $opts);
}
}9.3 退出码(给 CI 用)
| 码 | 含义 |
|---|---|
| 0 | 无 CRITICAL、无 MAJOR |
| 1 | 有 CRITICAL |
| 2 | 无 CRITICAL 但有 MAJOR |
| 3 | 工具自身错误(zip 非法、快照缺失、规则自检失败) |
十、报告规范
10.1 单条命中的字段
{
"check_id": "FH-NS-001",
"level": "CRITICAL",
"file": "plugins/adslot/AdminController.php",
"line": 3,
"title": "命名空间类无法被自动加载器找到",
"symptom": "命名空间是 Plugin\\AdSlot,目录名是 adslot;camelToSnake('AdSlot')='ad_slot',三条候选路径全部落空",
"fix": "改成 Plugin\\adslot(推荐),或在 Plugin.php 末尾补 class_alias 桥接",
"spec_anchor": "FH-NS-001",
"spec_section": "§11.34",
"evidence": "namespace Plugin\\AdSlot;",
"confidence": "high"
}字段说明:
line可能为null(包级问题,如结构错误)evidence必须截断到 200 字符并脱敏(去掉可能的密钥/邮箱)confidence=high/medium,对应「确定性判定」与「疑似」fix对机械规则必须给可直接复制的代码;对语义规则只给方向 + 章节
10.2 报告头
工具版本 : 1.2.0
扫描基准核心 : FlintHub 1.2.0
规则集版本 : 3f9a1c2d
扫描时间 : 2026-10-17 18:30:12 +08:00
扫描文件数 : 29(PHP 24 / JS 2 / CSS 1 / JSON 2)
规则执行情况 : 12 条执行 / 1 条自检失败已停用 / 1 条环境不支持已跳过「规则执行情况」这行必须有——否则用户不知道「没报错」是因为代码干净,还是因为规则根本没跑。
10.3 结论行
结论:❌ 未通过(1 个 CRITICAL,2 个 MAJOR)
CRITICAL 必须修复后才能发布;MAJOR 建议修复。十一、安全边界
11.1 核心事实(可信)
- 来自发布源,构建期由
cli/build_audit_snapshot.php从核心代码常量导出 - 导入时校验
sha256(manifest.json声明 vs 实际计算) - 二期加 RSA 签名(复用
app/Helpers/release_keys.php的公钥表)
11.2 插件包(不可信)
| 防护 | 做法 |
|---|---|
| zip 炸弹 | 解压前读 statIndex() 累加 size,超过 config.max_uncompressed(建议 50 MB)直接拒绝;再限制条目数(建议 2000) |
| 路径穿越 | 每个条目的 getName() 必须匹配 ^plugins/[a-z0-9_]{2,40}/,且不含 .. / \0 / 前导 / |
| 符号链接 | ZipArchive::getExternalAttributesIndex() 判 Unix 模式位 S_IFLNK → 拒绝 |
| 嵌套压缩包 | 条目扩展名为 zip/tar/gz/7z/rar → 拒绝(不是「解压它」,是「拒绝这个包」) |
| 目录隔离 | 临时目录设在 data/tmp/<random>/,解压后不设 open_basedir(CLI 下改不动),改为「所有文件访问都用解压时记录的白名单路径」,不做目录遍历 |
| 不执行 | 绝不 require / include / class_exists 用户代码;php -l 用 -n 隔离;绝不用 eval 读配置 |
| 即删 | try/finally + 自实现 removeTree(),异常路径也删 |
11.3 ⚠️ 一条容易被忽略的:include 语言包
FH-I18N-001 需要读语言包的键集合。include 用户的 lang/zh.php 就是在执行用户代码——虽然它通常只 return [...],但完全可以写成:
<?php unlink('/var/www/html/config.php'); return [];替代方案:用 PhpTokenizer 解析 return [ 'k' => 'v', ... ]; 的字面量结构,不 include。
代价:语言包里如果有 I18n::get(...) 或变量拼接,解析不出来 → 该文件跳过并标 INFO「无法静态解析,跳过」。这是正确的取舍:宁可漏检,不可执行。
十二、版本与快照策略
| 概念 | 取值 | 说明 |
|---|---|---|
| 工具版本 | 1.2.0 | 跟随核心主版本 |
| 工具补丁 | 1.2.0.1 | 工具自身小修,不等核心发版 |
| 快照版本 | 1.2.0 | 一份核心版本一份快照 |
| 规则集版本 | 3f9a1c2d | 快照 sha256 前 8 位。报告必须写 |
为什么需要「规则集版本」:同一工具版本 + 不同快照(比如核心 1.2.0 的规范改了一条)会给出不同结论。只有工具版本无法区分「结论变了是因为代码变了还是规则变了」。
快照保留策略:永久保留,不清理。每份约 200 KB(spec.md 是大头),一年 50 个版本也就 10 MB。
十三、分期路线
一期(MVP)—— 目标:跑通「输入 → 检查 → 输出」一条线
范围:
- 上传 zip → 安全解压(§11.2 全部防护)
- 从
data/snapshots/导入快照(D8,无 HTTP) PhpTokenizer+RuleInterface+ 调度器- 12 条规则:
FH-PKG-001/002/003、FH-NS-001、FH-SYN-001、FH-I18N-001、FH-INL-001/002/003、FH-ENC-001/002、FH-META-001 - HTML 报告(含规范锚点跳转)+ JSON 导出
- 不做:后台、使用数据、HTTP sync、AST、CLI
验收标准(每条都要能演示):
- 拿
plugins/mcp_server打成 zip 上传 → 0 个 CRITICAL - 手工把
Plugin.php的 namespace 改成Plugin\McpServer并把class_alias删掉 → 报FH-NS-001CRITICAL,line指向 namespace 那行 - 手工加一个
style="margin-top:8px"→ 报FH-INL-003 - 手工加一个
style="--x-bg:#fff"→ 不报(§3.4 的反例) - 手工加一个
<script src="...">→ 不报;改成<script>alert(1)</script>→ 报FH-INL-001 - 上传一个含
../路径的恶意 zip → 拒绝并说明原因,data/tmp/无残留 - 上传一个 60 MB 解压后体积的 zip → 拒绝(炸弹防护)
- 把某条规则的自检夹具改坏 → 该规则标
DISABLED,报告里明示 - 报告头四行(工具版本/基准核心/规则集版本/扫描时间)齐全
规模估计:约 15 个 PHP 文件、3000~3500 行(其中 PhpTokenizer 约 300 行、12 条规则各 60~150 行)。
二期 —— 结构化 + 生态
- 完整
PhpTokenizer能力(类型声明、属性、trait) - 新增规则:
FH-USE-001、FH-SEC-001/002、FH-META-002/003/004/005、FH-ENC-003、FH-HOOK-001/002 admin/sync.php(RSA 签名鉴权)+ 快照管理页- 批量上传(多插件 zip)
- 图标字形 L2 校验(子集字体 cmap)
三期 —— 闭环
- CLI 入口接发布流水线:打包前自动审计,有 CRITICAL 直接终止打包
- 使用数据看板(命中规则排行 → 反向优化规范)
- 白名单机制(某插件可跳过某条规则,白名单记录入库可追溯)
- 「规范驱动生成」:规则反过来做脚手架(原规划十三的远期展望)
十四、风险登记
| # | 风险 | 影响 | 应对 |
|---|---|---|---|
| R1 | 规则误报 → 用户不信任工具 | 致命(工具白做) | 每条规则带自检(D7);confidence 字段区分确定/疑似;style= 与命名空间两条高风险规则必须先跑通验收用例 3/4/2 |
| R2 | facts.json 与核心代码漂移 | 高(判据过期) | 构建期从常量导出(§6.2)+ 导出自校验(图标 492 / 分类 10 / 钩子数与规范一致) |
| R3 | 工具与核心版本错位 | 中 | 报告头强制标注基准核心版本;min_core 不匹配时提示「本次按 X 版本规范检查,你的插件声明 min_core 是 Y」 |
| R4 | 环境无 exec(disable_functions) | 中 | FH-SYN-001 降级为 SKIP 并在报告明示,不静默 PASS |
| R5 | 大包超时(2 核 2G) | 中 | 限制解压体积/条目数;单文件 > 1 MB 跳过并标 INFO;整体超时 30s 中止并给部分报告 |
| R6 | include 语言包导致代码执行 | 安全 | 改用 token 解析,不 include(§11.3) |
| R7 | 用户拿报告当「安全认证」 | 中 | 报告页固定免责声明:「本报告仅覆盖确定性规则,不代表代码安全」 |
十五、未决问题(需要你拍板)
| # | 问题 | 选项 |
|---|---|---|
| Q1 | 工具是否公开给外部开发者用? | A. 只自己用(可放宽安全投入)/B. 公开(需加限流、免责、隐私政策) |
| Q2 | 上传上限定多少? | 建议:zip ≤ 5 MB(对齐投稿上限)、解压后 ≤ 50 MB、条目 ≤ 2000 |
| Q3 | 报告是否保留历史? | A. 不保留(每次即扫即弃)/B. 保留最近 N 次(需处理隐私) |
| Q4 | 是否接受「工具比发布端门禁弱一档」? | 命名空间检查无法做 class_exists 实测(§3.2),静态判定可能漏掉「namespace 对但文件缺失」的边角情况。建议接受,并在报告里注明「发布端还会做一次更强校验」 |
| Q5 | spec.md 全文进快照(153 KB × N 版本)还是只存被引用片段? | 建议全文——体积可控(10 MB/年),且能保证锚点稳定 |
附录 A:facts.json 字段草案
{
"schema_version": 1,
"core_version": "1.2.0",
"generated_at": "2026-10-17T18:00:00+08:00",
"plugin_root_prefix": "plugins/",
"whitelist_dirs": ["app/", "assets/", "lang/", "cli/", "docs/"],
"whitelist_files": ["index.php", "install.php", "..."],
"blacklist": ["..."],
"blacklist_ext": ["zip", "bak", "log", "sqlite"],
"blacklist_suffix": ["-wal", "-shm"],
"plugin_excludes": ["data/"],
"assets_excludes": ["assets/uploads/", "assets/themes/"],
"vendor_lib_prefix": "assets/lib/",
"crlf_exceptions": ["..."],
"shell_files": ["..."],
"categories": ["content", "community", "identity", "economy", "operation",
"governance", "tools", "integration", "entertainment", "other"],
"icons": ["home", "forum", "blog", "..."],
"hooks": [
{"name": "init_after", "file": "index.php", "line": 120, "params": []},
{"name": "route_register", "file": "index.php", "line": 415, "params": ["router"]}
],
"icon_glyphs": null
}
icon_glyphs二期填(图标名 → 码点 → 是否在子集字体内)。
附录 B:rules.json 字段草案
{
"schema_version": 1,
"rule_set": "3f9a1c2d",
"core_version": "1.2.0",
"rules": [
{
"check_id": "FH-NS-001",
"level": "CRITICAL",
"title": "命名空间类无法被自动加载器找到",
"spec_section": "§11.34",
"spec_anchor": "FH-NS-001",
"fix_hint": "改成 Plugin\\<目录名>,或在 Plugin.php 末尾补 class_alias 桥接",
"enabled": true
}
]
}附录 C:与两份原始规划的对照总表
| 原始规划要素 | 本方案 | 说明 |
|---|---|---|
| 独立 PHP Web 工具 | 保留 | — |
| 只读不写 / 即删 | 保留 + 加固 | try-finally + 自实现 removeTree |
| 规范即规格 | 保留 + 改实现 | 构建期生成,不手写 |
| 快照同源 | 保留 + 加 schema_version | — |
| 两层输入分离 | 保留 | — |
| 确定性优先 | 保留 + 加 confidence 字段 | — |
| 版本对齐 | 保留 + 加规则集版本 | — |
nikic/php-parser | 否决 | 零依赖项目,token_get_all() 已够用且有 3 套现成实现 |
RuleInterface | 保留 + 补 selfTest() | 自检是防误报的关键 |
| 四级等级 | 保留 + 收紧 CRITICAL | 只有「真会 500 / 装不上」才算 |
| zip 安全加固 | 保留 + 补拒绝嵌套压缩包 | — |
| 快照防篡改(RSA) | 二期 | MVP 用离线投放,零攻击面 |
| 报告锚点 + 双格式 | 保留 | JSON 需固定 schema |
| SQLite WAL + 唯一主键 | 保留 | — |
| 后台两页签 | 二期/三期 | MVP 只做导入 + 列表 |
| 三期路线 | 保留 | 微调:一期加「规则自检」为硬门槛 |
方案完。