[审计]FlintHub 插件在线审计工具 · 详细规划实施方案

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:46:59

本文由 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)
6zip 炸弹 / 路径穿越 / 符号链接防护✅ 全采纳补一条:拒绝嵌套压缩包(原规划 2 已提,需落实)
7报告双格式 HTML + JSON✅ 全采纳JSON 要固定 schema,见 §10
8后台两页签(快照管理 + 使用数据)⚠️ 推迟到二期MVP 没有「多份快照」要管,也没有数据量。先做「导入 + 列出」两个动作
9admin/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)明确给了两种合法写法:

目录名✅ 可用命名空间命中的候选路径
adslotPlugin\adslot① ②
adslotPlugin\Adslot②
points_mallPlugin\PointsMall(仓内主流写法)②
user_levelPlugin\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}$,不校验是否在表内——不在表内会渲染成核心兜底字符 &#xf128;(问号)。
  • 更深一层:图标字体是子集版(FA 448 / Tabler 447 个字形)。名字在 $map 里,但如果该字形没被打进子集字体,页面上仍是方块。

三级判定(MVP 只做第一级):

级别判据MVP
L1icon 是合法字符集 + 在 $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_dirsUpdater::WHITELIST_DIRSapp/Helpers/Updater.php:64
whitelist_filesUpdater::WHITELIST_FILES:72
blacklistUpdater::BLACKLIST:36
blacklist_extUpdater::BLACKLIST_EXT:55
blacklist_suffixUpdater::BLACKLIST_SUFFIX:56
plugin_excludesUpdater::PLUGIN_EXCLUDES:115
assets_excludesUpdater::ASSETS_EXCLUDES:118
plugin_root_prefixUpdater::PLUGIN_ROOT_PREFIX:112
iconsTemplateCompiler::$map 的键app/Core/TemplateCompiler.php:623(492 个)
categoriesPluginController 的 10 类app/Controllers/Admin/PluginController.php:23
hooks扫 Plugin::hook('...') 调用点复用 plugin_hook_audit.php 的逻辑
vendor_lib_prefixVENDOR_LIB_PREFIXcli/release_build.php:117
crlf_exceptionsCRLF_EXCEPTIONScli/release_build.php:76
shell_filesSHELL_FILEScli/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.34PhpTokenizer 取 namespace + 文件清单,逐字节比对高 → 必须带自检(§3.3)
FH-SYN-001PHP 语法(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.28PhpTokenizer 找 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-001plugin.json 字段完整:name/version/min_core/category/icon/author/description§三JSON 解析 + 必填校验低
FH-META-002category 在 10 类内§三对照 facts.categories低
FH-META-003icon 在 492 个图标名内§6.4-4对照 facts.icons低(但见 §3.6 的 L2/L3 限制)
FH-META-004hooks 声明的钩子名在核心钩子表内§九.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

验收标准(每条都要能演示):

  1. 拿 plugins/mcp_server 打成 zip 上传 → 0 个 CRITICAL
  2. 手工把 Plugin.php 的 namespace 改成 Plugin\McpServer 并把 class_alias 删掉 → 报 FH-NS-001 CRITICAL,line 指向 namespace 那行
  3. 手工加一个 style="margin-top:8px" → 报 FH-INL-003
  4. 手工加一个 style="--x-bg:#fff" → 不报(§3.4 的反例)
  5. 手工加一个 <script src="..."> → 不报;改成 <script>alert(1)</script> → 报 FH-INL-001
  6. 上传一个含 ../ 路径的恶意 zip → 拒绝并说明原因,data/tmp/ 无残留
  7. 上传一个 60 MB 解压后体积的 zip → 拒绝(炸弹防护)
  8. 把某条规则的自检夹具改坏 → 该规则标 DISABLED,报告里明示
  9. 报告头四行(工具版本/基准核心/规则集版本/扫描时间)齐全

规模估计:约 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
R2facts.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 中止并给部分报告
R6include 语言包导致代码执行安全改用 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 对但文件缺失」的边角情况。建议接受,并在报告里注明「发布端还会做一次更强校验」
Q5spec.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 只做导入 + 列表
三期路线保留微调:一期加「规则自检」为硬门槛


方案完。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 2 | Replies 0

All Replies (0)

No replies yet

Please Log in