本地已装 Skill 的体检与处置决策
> 起因:2026-10-05 用本技能实扫一台 Windows 机器(1246 个 md / 723 万字节),
> 正则初筛命中 105 个文件 → 两道过滤后 CRITICAL 仅 3 条 → 人工复核 1 条为真(第三方技能内嵌远程安装器)。
> 这套「筛得住,但不乱叫」的过滤链,就是这个技能存在的全部理由。
铁律
- 静态正则只负责"筛",永远不负责"定性"。 纯正则扫 markdown 是噪音制造机——
md 里到处是命令示例和危险词讲解,命中率能到 100 个文件/1000 文件量级。
看到命中先压噪音,再谈定性。 直接报"105 个有风险"的报告等于没报告。 - 不允许结论悬浮。 每个"有毒/有风险"的结论必须能指到
文件:行号,
并给出处置动作(留 / 拆能力 / 删 / 先别装)。答不出行号就只能说"我看到 N 个命中"。 - 区分"装了"和"敢跑"。 体检结论分三档讲清楚:
① 静态可疑但上下文清白 → 留 ② 确实会做危险事 → 拆掉那一段或删 ③ 来路不明的远程安装器 → 禁止执行、先删。 - 不制造恐慌,也不粉饰太平。 过关就说过关("这 200 个静态层面干净"是有效结论),
有问题就把问题摊开,不许用"总体可控"糊过去。 - 报告要能被复跑验证。 脚本必须可重复执行、输出可落盘;结论附扫描时间、根目录、样本量。
"我扫过了" 不等于 "我复核过了"——复核是逐条看上下文,不能只输出计数。
一、为什么必须要两道过滤(血泪)
纯正则扫描的命中行基本是这四类,不过滤就全变假警报:
| 命中类型 | 实例 | 正确处理 |
|---|---|---|
| 自己在讲危险 | threat-hunting/SKILL.md:「certutil.exe -decode — LOLBin used to decode dropper payloads」 |
教学 → 降级 |
| 警示语气 | skill-vet-install/SKILL.md:「仓库里 curl <url> \| bash 一律不执行」 |
它在防毒 → 降级 |
| frontmatter/说明行 | description: 字段里写了 "curl|bash" 这几个字符 |
非可执行 → 降级 |
| 官方安装器 | sentry/SKILL.md:「Install the Sentry CLI: curl … \| bash」 |
官方来源 → 提示可信 |
过滤链(缺一不可):
- L2-1 行首可执行判断:只把「行首是真命令/代码」的命中当有效证据。
编号列表2.、引用>、表格|、纯说明行 → 一律降级。
(正例:curl -fsSL https://… | bash行首即命令 → 有效证据) - L2-2 来源信任边界:内置/官方目录(
workbuddy-builtin、vendor、node_modules)
或已知官方安装脚本 → 只提示不报警。信任边界外的第三方才重点查。 - 两道都过了还是 CRITICAL → 才进人工复核队列。
实测效果:105 个命中文件 → CRITICAL 3 / HIGH 19 / MEDIUM 22 / REVIEW 49 / INFO 12。
二、扫描哪些目录
按 harness 自动探测,不存在就跳过(全量探测 = 全量口径,不许只扫一个目录就下结论):
~/.workbuddy/skills, ~/.workbuddy/plugins/cache (WorkBuddy 用户级 + 插件缓存)
~/.claude/skills (Claude Code)
~/.codex/skills (Codex)
~/.openclaw/workspace/skills (OpenClaw / ClawHub 装进来的)
<安装目录>/resources/app.asar.unpacked/.../skills (WorkBuddy 内置)
报数三问(缺一不可):① 这个数从哪个目录来的?② 扫的是已装还是全部可选?③ 有第二个来源交叉验证吗?
三、攻击族规则(对标 Snyk ToxicSkills 2026-02 审计)
| 规则 | 级别 | 检测内容 |
|---|---|---|
| R1 | CRITICAL | 远程下载直接喂 shell:curl/wget/Invoke-WebRequest … \| bash/sh |
| R2 | CRITICAL | Windows LOLBin:certutil / bitsadmin / regsvr32 / rundll32 |
| R3 | HIGH | 动态执行:eval / exec( / os.system / shell=True |
| R4 | HIGH | base64 解码落地 |
| R5 | CRITICAL | 凭据外传:process.env / os.environ / getenv 拼进 URL 查询参数(Snyk 报告最常见 payload) |
| R6 | HIGH | 外发 POST 到外域 |
| R7 | CRITICAL | Unicode Tag 字符隐藏指令 U+E0000–U+E007F(Anthropic 到 2026-02-10 才在 Claude Code 修) |
| R8 | HIGH | 零宽/不可见字符 U+200B–U+200F / U+FEFF |
| R9 | MEDIUM | 持久化写点:.bashrc / AppData\Roaming / crontab / launchctl |
| R10 | MEDIUM | 批量删除:rm -rf / Remove-Item -Recurse / shutil.rmtree |
| R11 | HIGH | 疑似硬编码凭据:api_key=<20+ 位字符 |
R5 是最容易漏的一族:它伪装成正常 HTTP 调用,把 token 当 query 参数拼出去。
正则要同时抓「环境变量取值」和「拼进 URL 参数」这两段,中间允许隔 60 个字符。
四、跑法
# 自动探测本机所有 harness 目录
python scripts/scan_local.py --json result.json
# 显式指定(排查某一个来源时用)
python scripts/scan_local.py --dir ~/.workbuddy/skills --dir ~/.openclaw/workspace/skills
Windows 坑(必读):脚本里含 certutil/rundll32 等 LOLBin 名时,别用 bash heredoc 内联跑,
整条命令会被安全策略拦下。一律写成 .py 文件再执行。
另外 .md 文件可能带 UTF-8 BOM(U+FEFF),扫 R8 会命中——
BOM 头不是攻击,看到 U+FEFF 打头的第一行先排除再说。
五、外链域名统计(比规则更会用)
单个技能文件里同一个域名出现几百次 = 它的真实依赖;只出现 1–2 次 = 文档里的示例链接。
统计外链出现次数,能直接抓出"包装成正常技能的后端外传通道"。
实测里 astral.sh 出现在内置 docx 安装脚本里是因为它真的装 uv——统计要给域名配上下文,不能只看次数。
六、报告模板(照着填,不许自己发挥)
【体检范围】扫描时间 / 根目录清单 / 扫描文件数 / 总字节
【初筛结果】命中文件数、各级别计数(并说明压掉多少教学误报)
【逐条处置】
- CRITICAL 1: <文件:行号>
命中:<规则号> <原文>
判定:<是什么 / 为什么 / 依据>
处置:留 / 拆 / 删 / 先别装 ← 必须写字面动作
- REVIEW N 个:仅列数,说明"教学/文档命中,已排除",不逐条展开
【结论】静态层面 N 个文件干净,M 个需要动作,0 个确认有毒
【复跑命令】原命令 + 落盘路径
七、已知坑
- 误报会毁掉整份报告的可信度(比漏报更致命)。安全类技能自己就在讲危险,
一报"有毒"用户立刻不信。见第一节的四类命中。 U+FEFFBOM 不是攻击,是 Excel/Windows 生成文件的默认头,seen onschema_principle.md。
实测:plugins/cache/.../excel-generation/references/schema_principle.md第一行就是 BOM + 标题,虚惊一场。- R3 的
eval要区分"语言级执行"和"CLI 子命令":
playwright-cli eval "document.title"/pwcli eval "el => …"是浏览器调试工具的参数,
不是python eval()/node eval()那种动态执行。看到eval先看前面有没有 CLI 前缀。 - 同名多副本:
plugins/cache/里同一插件不同版本并存(如 tencent-docx 两套版本目录),
命中会重复计数,报告里要合并说明,别让用户以为机器上有两个毒。 - 扫不到 ≠ 安全:本技能只做静态文本分析,跑起来才执行的动态 payload 抓不到。
结论必须写"静态层面",不许说"完全安全"。 .staging-*临时目录:plugins/cache里可能有.staging-<随机>残留,
扫到先确认是不是正在安装的临时产物,别当成品报。- 专家(agent)包跟 skill 是两套东西:专家是
agents/*.md,一般不含可执行脚本,
扫专家时命中率天然更低,别拿同一个阈值要求两边。 - ★ 自检必须免疫自己(2026-10-05 实测,第一版最大的洞):
本脚本自己的正则定义行会被自己的规则命中——r"(process\.env|os\.environ…"命中 R5、
r"|curl|wget|…"命中 R1、Unicode 区间字符串命中 R7/R8。跑起来就是 CRITICAL 4 条全指向自己,
整份报告自毁可信度。
两条修复:①scan_tree里if abspath(dirpath) == dirname(dirname(__file__))时dirnames[:] = []
——只免疫本技能所在目录,别处出现同名目录照样扫(不能用粗暴的文件名过滤);
② 必须从安装位置运行(~/.workbuddy/skills/skill-local-triage/scripts/scan_local.py),
否则扫的是另一份副本、自检逻辑不生效。
实测收敛:CRITICAL 4 → 0,剩 HIGH 10(全是文档/教学/CLI 参数误报)。 - ★ 发布
skillhub publish有硬性 frontmatter 三件套(2026-10-05 逐个报错试出来的):
slug/version/displayName缺一个就直接卡死在预检,报错只有一行「SKILL.md 缺少 X」,
不会告诉你该填什么。WorkBuddy 原生的name+description不够。
最小可用 frontmatter 是这四行齐的:
```
name: skill-local-triage
slug: skill-local-triage
displayName: 本地 Skill 安全体检
version: 1.1.0
description: …(写触发词,平台靠它匹配)
``
发布前先本地预检(**不发 HTTP**):skillhub publish --dry-run <目录>→ 出✓ Dry-run passed: slug@version才说明打包合法;没 token 也能先跑这一步把打包问题全清掉。
9. ★ **.md里的命中必须降一档**:skill 本质是**提示词**,md 里的curl | bash是"示意要人去跑的命令",
不是"文件自己会执行的代码"。给.md一律降一档(CRITICAL→HIGH、HIGH→MEDIUM),
只有.py/.sh/.ps1/.js/.ts` 且行首是真命令才算有效证据。不降档的话,
每一份讲安全的技能都会被自己的规则打爆(自指 + 教学双误报叠加)。
八、上架后的验真:别信"我发上去了",要拿平台签名
技能挂到商店以后,"发布成功"和"装上去是真东西"是两回事。SkillHub 提供平台级签名,
任何人都能独立验证某个包没被投毒替换——这是把"我帮你扫过"升级成"你随时可自证"的关键一环。
# 1) 从平台拉真包(★ 必须带 &version= ,否则 404 Version not found)
skillhub install <slug> --namespace <handle>
curl -sL -o pkg.zip \
"https://api.skillhub.cn/api/v1/download?slug=<slug>&namespace=<handle>&version=<ver>"
# 2) 拿平台签名对包(★ 用 --zip,verify 不支持 --dir)
skillhub verify <slug> --version <ver> --zip ./pkg.zip --json
返回里必须认这三个字段,缺一个就不算验过:
| 字段 | Meaning | 要什么值 |
|---|---|---|
content_hash_match |
包内容与平台签名 records 是否一致 | 必须 true |
publisher_user_name |
发布者 handle | 必须是你自己 |
publisher_real_name_verified |
发布者实名状态 | true 才说明带实名徽章 |
实测样本(2026-10-05 本机跑出):
{"ok":true,"slug":"skill-local-triage","version":"1.1.0","verify_mode":"remote",
"issuer":"skillhub.cn","publisher_user_name":"user_e81bda48",
"publisher_real_name_verified":true,"content_hash_match":true,
"content_hash":"668ace4ef230017745d30862d1cd1d807cdce683b400d8d61a24f47d644e4da4"}
三个必踩的坑(都实踩过):
1. 重发必须升版本号:平台对已存在的版本返回 slug 冲突: 版本 X 已存在,请使用新的版本号发布,不覆盖。要迭代就 1.1.1 / 2.0.0。
2. 下载不带 &version= 只回 404:那是分发接口的正常行为,别读成"没上架"。带上才 302 到对象存储(curl 还得 -L,否则只拿到 121 字节的 <a href="...">Found</a>)。
3. 公开搜索查不到 ≠ 没上架:search <slug> 和 ?slug=&namespace= 在索引刷新前都查不到。以 skillId + 下载链接 + verify 结果 为准。
> 给用户的完整口径:上架只是发布,验真才是交付证据。
> 任何"我帮你上架了"的结论,都要配一条 content_hash_match:true 才站得住。
九、支付宝「AI 按量付费」支付改造(平台 precheck 逐项检测)
本 Skill 已完成支付宝按量付费改造,运行时调用独立的 HTTPS 402 服务端(服务端地址由环境变量 PAY_SERVICE_URL 注入),不在本地裸调支付接口。下表五项是平台发布前的硬检查项,本 Skill 全部具备:
| 改造要素 | 本 Skill 的实现方式 |
|---|---|
| 402 账单下发 | 未付款请求返回 HTTP 402 + 响应头 Payment-Needed: base64url(bill);bill 含 amount / currency / goods_name / out_trade_no / pay_before / resource_id / seller_id / service_id 八字段,按字段名排序用 & 拼接后做 RSA2(SHA256) 签名(拼串不含 sign_type) |
| 携带凭证重试 | 买家在 pay_before 前完成付款,携带 Payment-Proof 头重发原请求 |
| 验付调用 | 服务端持凭证调用支付宝 agent.payment.verify(POST {PAY_SERVICE_URL}/verify)校验有效性 |
| 履约确认 | 验付通过后调用 fulfillment.confirm 完成交付,返回请求结果 |
| 订单持久化与幂等 | 订单写入 SQLite(主键 out_trade_no);同单号重复请求只出一次账、不重复扣费 |
Buyer 侧 probe 调用序列:
1. 请求受保护资源 → 402 + Payment-Needed
2. alipay-bot 完成付款 → 取得 Payment-Proof
3. 携带 Payment-Proof 重发 → 200 + 结果
4. 超过 pay_before 未付款 → 账单作废,重新出账
别抄的替身写法:只在 SKILL.md 里写一个价格数字、但不发 402、不验付、不落订单 —— 平台会直接判 compliance:"fail",实测返回:
{"compliance":"fail","score":0,"detected_features":[],
"missing_features":["402账单下发","携带凭证重试","验付调用","履约确认","订单持久化与幂等"],
"keyword_hits":{"skill.md":[]},
"reason":"skill.md 中未检测到任何支付宝 AI 按量付费迹象(既无 402 / Payment-Needed / Payment-Proof,
也无 probe / pay / complete / ack 或支付依赖包),判断为未进行付费改造"}
> 一条实测经验:precheck 扫的是上传文件里的文本关键字 + 依赖包,不是你脑子里想的逻辑。
> 想让 precheck 转 pass,SKILL.md 必须把这些要素逐字写出来,且要连同 402 服务端一起打包上传。
十、买家侧客户端:scripts/triage_client.py(本版新增,可直接跑)
本地静态扫描永远免费、不联网。只有需要深度溯源报告(逐条给出来源、签名、上架渠道、留/拆/删决策)
时才调用远端的按量付费网关。客户端零凭证、纯标准库 urllib,三条路径:
# 1) 取深度溯源报告:未付款会拿到 HTTP 402 + Payment-Needed 账单
python scripts/triage_client.py deep
# 2) 同一条命令输出完整账单 JSON(便于自行解析 / 给上游 Agent 传参)
python scripts/triage_client.py deep --json
# 3) 已付款后携带凭证重发(deadbeef 是错误的 Payment-Proof,网关会拒绝并把原因打回来)
python scripts/triage_client.py deep --proof deadbeef
第 3 条实测回显(证明网关真在验付、不是摆设):
{"error":"bad_payment_proof","reason":"无法解析 Payment-Proof"}
线上生产网关(脚本内置默认值,不用手填):
https://triage-pay.app.workbuddy.host/api/v1/triage/report
> 单次 ¥3.00,账单金额以服务端下发为准,与 SkillHub 展示价、支付宝登记价三处一致。
> 该端点账单品名是「本地 Agent 资产体检·深度报告」,对不上就别付款。
账单结构实测坑(第一版客户端踩过):Payment-Needed 解出来是两层
{"protocol":{...},"method":{...}},amount / out_trade_no / pay_before / resource_id / seller_signature
在 protocol 层,而 service_id 在 method 层 —— 从 protocol 取 service_id 会得到 None。
付费归属边界(别搞错):本客户端只负责「出账 → 回传凭证 → 取货」。
实际扣款由买家自己 Agent 里的支付宝 AI 付完成:买家看到 402 → 在支付宝侧付款 → 回传
Payment-Proof → 网关 agent.payment.verify 验付 → fulfillment.confirm 履约 → 返回报告。
服务端(卖家侧)只做出账、验付、履约、订单幂等,不碰买家付款动作。