SkillSpector Batch Scan 架构流程详解:从 CLI 入口到多线程 LangGraph 扫描流水线
【免费下载链接】SkillSpectorSecurity scanner for AI agent skills. Detect vulnerabilities, malicious patterns, security risks, prompt injection, data exfiltration, and supply-chain risks in Claude Code, Codex, and MCP skills before you install them.项目地址: https://gitcode.com/GitHub_Trending/sk/SkillSpector
本文围绕 contrib/batch_scan/docs/archive/FLOW_DIAGRAM.md 中记录的批处理扫描架构流程展开,逐段拆解 SkillSpector 多语言批处理扫描器(
contrib.batch_scan)的完整数据流:从 CLI 参数解析、SKILL.md 发现、语言检测、API Key 池构建、ThreadPoolExecutor多线程并发,到每个技能内部的 LangGraph 流水线与 7 个 DeepSeek 兼容性安全补丁。读完本文,你将掌握该模块的调用链全貌、单技能扫描的 fan-out/fan-in 过程,以及如何排查并发扫描下的挂起、限流与 JSON 解析问题。
一、模块定位:零侵入地复用 SkillSpector 核心图
contrib/batch_scan是一个建立在 SkillSpector 核心之上的贡献模块,其设计约束是零修改src/skillspector/。它把原本单技能扫描的skillspector scan <skill>扩展为按目录批量并行扫描,并额外提供了三个能力(见 contrib/batch_scan/batch_scan.py 模块 docstring):
- 并发扫描目录下所有技能(
ThreadPoolExecutor+--workers控制); - 基于 Unicode 字符比率的语言检测(en / zh / ja / ko);
- 对非英语技能执行定向 LLM gap-fill,弥补 8 条英文关键词静态规则在非英语文本上的召回损失。
整体架构可以用下图概括(与原文档"Batch Entry Point"流程一一对应):
CLI │ python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 4 [--no-llm] ▼ batch_scan.py :: main() ① discovery.discover_skills(root) └─ rglob("SKILL.md") → [Path, ...] sorted ② detection.detect_skill_language(file_cache) per skill └─ main thread pre-reads → Unicode script ratio → zh/ja/ko/en ③ api_pool.create_api_key_pool_from_env() optional └─ SKILLSPECTOR_API_KEYS → ApiKeyPool(N keys) ④ ThreadPoolExecutor(max_workers=N) ├─ Thread A: skill_1 → _scan_skill() ├─ Thread B: skill_2 → _scan_skill() └─ ... 每技能 90s 超时 ⑤ Collect results, sort by risk_score descending ⑥ reports._format_terminal / _format_json / _format_markdown二、批处理入口:六个阶段的流水线
① 技能发现(discovery)
main()首先调用discover_skills(root),其实现位于 contrib/batch_scan/discovery.py:
- 用
root.rglob("SKILL.md")递归查找所有SKILL.md; - 每个
SKILL.md的父目录即一个技能目录; - 根目录本身即使包含
SKILL.md也不会被当作技能; - 返回结果按路径字典序排序,保证批处理输出的稳定性。
若未发现任何技能,CLI 会打印No skills found并以退出码 2 结束。
② 语言检测(detection)
语言检测发生在主线程预解析阶段(见 batch_scan.py),而不是在工作线程内,这样做的目的是避免多个工作线程在文件 I/O 上互相竞争。具体逻辑在 contrib/batch_scan/detection.py:
- 用标准库
unicodedata统计 CJK 统一表意文字(0x4E00–0x9FFF与扩展 A 区0x3400–0x4DBF)、平假名/片假名(0x3040–0x30FF)、谚文音节(0xAC00–0xD7AF)占全部字母字符(unicodedata.category以"L"开头)的比例; - 判定阈值:日文假名占比 > 5% →
ja;谚文占比 > 10% →ko;CJK 占比 > 10% →zh;否则en; - 跨文件聚合时采用多数投票(
detect_skill_language对 file_cache 中的每个文件分别检测后取票数最多者); - 零第三方依赖,只复用上游已经引入的标准库。
当 CLI 显式传入--lang zh/ja/ko/en时,跳过检测直接使用指定值(_resolve_language)。
③ API Key 池(可选)
create_api_key_pool_from_env()读取SKILLSPECTOR_API_KEYS环境变量,其格式为每行一个key|base_url|model(也兼容分号分隔,支持#注释)。实现细节见 contrib/batch_scan/api_pool.py:
- 每个 key 默认有 5 个并发槽位(
_DEFAULT_MAX_CONCURRENT_PER_KEY = 5); - 10 个 key 即 50 个聚合槽位;
- 调度策略借鉴 Kubernetes 调度器:
acquire()永远选择当前负载最低且未被限流的 key;仅在所有未限流 key 都满载时才阻塞等待; - 遇到 HTTP 429 时该 key 进入指数退避:
30 × 2^n秒,上限 300 秒(_BACKOFF_BASE_S = 30.0、_BACKOFF_CAP_S = 300.0),最多重试 5 次; - 若环境变量未配置且只存在单个
OPENAI_API_KEY,函数返回None(单 key 模式,不需要池)。
配置示例(来自 api_pool.py 模块 docstring):
export SKILLSPECTOR_API_KEYS=" sk-or-xxx1|https://api.openai.com/v1|gpt-5.4 sk-or-xxx2|https://api.openai.com/v1|gpt-5.4 "池创建后通过set_api_pool(pool)挂接:它会同时替换skillspector.llm_utils.get_chat_model与skillspector.llm_analyzer_base.get_chat_model(见 contrib/batch_scan/runner.py)。后者必须一起替换,是因为llm_analyzer_base通过from ... import在模块级建立了本地引用,只替换一个模块会导致图内分析器(约占全部 LLM 调用的 95%)绕过池。
④ 并行扫描与 90 秒超时
with ThreadPoolExecutor(max_workers=args.workers) as executor: future_map = { executor.submit(_scan_skill, skill_dir, root, use_llm=use_llm, lang=lang_map[skill_dir], require_llm=args.require_llm, api_pool=api_pool): idx for idx, skill_dir in enumerate(skill_dirs, 1) }对应代码在 batch_scan.py。要点:
- 每个技能在独立线程中执行完整的
graph.invoke(state); future.result(timeout=90)施加每技能 90 秒超时;- 超时或异常的任务不重试(工作线程仍被占用,重试只会消耗新的槽位),直接记录
TIMEOUT (90s)/CRASH后继续处理其他技能; - Rich 控制台输出通过
_print_lock串行化,避免多线程同时写终端产生乱序。
选择ThreadPoolExecutor而非ProcessPoolExecutor的原因在 DESIGN.md 中有明确记录:macOS 的spawn模式会为每个子进程重新导入 LangGraph/LangChain,造成 30 秒以上的启动超时;而graph.invoke()是纯函数(同一状态得到同一结果),线程间通过各自独立的 state dict 隔离,天然适合线程池。
⑤⑥ 结果收集与报告
扫描完成后,所有 entry 按risk_assessment.score降序排序(results.sort(key=lambda x: x.get("risk_assessment", {}).get("score", 0), reverse=True)),随后进入 contrib/batch_scan/reports.py 的三种格式化器:
_format_terminal:Rich 表格,含 LR(Language Reliability)列、Source/Language 分布、严重性汇总;_format_json:结构化输出,含batch信封(language_detection、gap_fill_applied、gap_fill_findings)与每个技能的enhancements元数据;_format_markdown:Markdown 表格 + HIGH/CRITICAL 问题明细,适合直接贴到 PR 评论。
退出码约定(见 batch_scan.py 与 README.md):0表示全部安全;1表示至少一个技能为 HIGH/CRITICAL;2表示发生扫描错误。
三、单技能扫描流程:_scan_skill内部的两段式结构
_scan_skill是整个批处理的核心工作单元(batch_scan.py),它由两段组成。
第一段:调用run_one执行完整 LangGraph 流水线
run_one(runner.py)依次执行:
state = scan_state(skill_dir, use_llm=use_llm) result = graph.invoke(state) # 同步阻塞调用 entry = entry_from_result(result, skill_dir, root, ...)scan_state构造初始状态:{"input_path": str(skill_dir), "output_format": "json", "use_llm": use_llm}。graph.invoke是上游 LangGraph 编译图的同步入口,会依次驱动:
- build_context:下载/解压/构建文件缓存,并记录
temp_dir_for_cleanup临时目录; - 20 个分析器并行 fan-out:
- 静态规则(不调用 LLM):AST1-8(代码注入)、TT1-5(工具使用)、YR1-4(YARA 规则)、SC1-6(供应链)、LP1-4(循环/递归)、TP1-3(工具投毒)、TM1-3(工具滥用);
- LLM 语义规则:SSD1-4(敏感数据泄露)、SDI1-4(直接注入)、SQP1-3(可疑权限提升);
- meta_analyzer(fan-out 之后的 fan-in):LLM 复核与富化;
- 过滤与风险评分:
Results → filter → risk_score。
entry_from_result(runner.py)将原始结果转换为批次报告标准结构skill / risk_assessment / components / issues,并补充source_group、language、scan_mode: "multilingual-enhanced"、enhancements(gap_fill_applied、gap_fill_findings、english_keyword_rules_skipped)等溯源字段。无论成功失败,finally块都会调用cleanup_result删除temp_dir_for_cleanup;删除失败时降级为subprocess调用系统rm -rf(Windows 为rmdir /s /q),规避 macOS 上shutil.rmtree因悬挂文件描述符(如损坏的 httpx 连接)而阻塞的问题。
第二段:非英语技能 + LLM 模式下的 gap-fill
当lang != "en" and use_llm and not error_msg时,执行run_gap_fill(fc, lang, model=MODEL_CONFIG.get("default"), api_pool=api_pool)(gap_fill.py)。Gap-fill 的动机:上游有 25 条英文关键词静态规则,其中 17 条已被 SSD/SDI/SQP 语义分析器覆盖,剩余8 条没有语义分析器等价物——P5(有害内容)、P6-P8(系统提示词泄露)、MP1-MP3(记忆投毒)、RA1-RA2(流氓 Agent)。这些规则的正则只匹配英文短语(如"clear|erase|wipe|forget ... memory|context|instructions"),对非英语文本召回为零。
GapFillAnalyzer继承LLMAnalyzerBase:
- 类属性
response_schema = None(刻意设计,见下节关于补丁的讨论),不依赖response_format结构化输出; - 构造时把检测到的语言注入提示词模板;
parse_response手动剥除 Markdown 代码围栏 →json.loads→ PydanticGapFillResult.model_validate→ 过滤confidence >= 0.7且rule_id属于 8 条规则集合;- 若配置了
api_pool,self.chat_model会被替换为PooledChatModel,从而获得 key 故障转移能力。
gap-fill 结果经annotate_findings追加到entry["issues"],同时写入entry["enhancements"]["gap_fill_applied"] = True与gap_fill_findings,便于报告展示"哪些增强被应用了"。
四、三种执行路径(并发修复后的行为矩阵)
原文档用三种路径刻画--no-llm与 LLM 模式在不同并发/连接条件下的行为:
路径 1 ——--no-llm(快速、确定性):use_llm=False时图跳过 SSD/SDI/SQP 与 meta_analyzer,7 个补丁虽然仍然生效但不产生 LLM 调用;纯静态模式与上游行为完全一致,cleanup_result正常执行。
路径 2 ——use_llm=True且所有线程正常:
- 补丁 1 保证每个分析器实例拿到自己的
self.response_schema = None,实例字典隔离、无共享状态、无竞态; - 补丁 6 注入
httpx.Timeout(connect=8s, read=30s),挂起连接快速以干净异常失败; - 补丁 7 抑制 "Event loop is closed" 噪音;
- 补丁 2/3 处理原始 JSON,findings 正确填充。
路径 3 ——use_llm=True但连接出错:httpx 连接/读取超时触发异常 → 异常经 asyncio 传播 → 图捕获 → 该技能返回错误 entry 而非 findings→cleanup_result用shutil.rmtree+ subprocess 兜底清理 →其余工作线程不受影响继续执行。这正是批处理扫描器"一个技能挂掉不拖垮整批"的容错设计。
五、7 个安全补丁:deepseek_compat()上下文管理器
7 个补丁统一由deepseek_compat()上下文管理器管理(runner.py),遵循Save → Patch → Yield → Restore(finally)模式。batch_scan.main()中整个扫描体都被with deepseek_compat():包裹,即使发生异常也会在退出时恢复原始实现。
| 补丁 | 目标 | 机制 | 目的 |
|---|---|---|---|
| 1 | LLMAnalyzerBase.__init__ | self.response_schema = None(实例属性) | 禁用结构化输出,实例级隔离 |
| 2 | LLMAnalyzerBase.parse_response | json.loads→ Pydanticmodel_validate | 处理无response_format的原始字符串 |
| 3 | LLMMetaAnalyzer.parse_response | 同上 +_sanitize_meta_finding | 处理 LLM 输出怪癖(null→""、"none"→"low") |
| 4 | LLMAnalyzerBase.build_prompt | 追加 JSON 输出格式指令 | 给模型格式提示 |
| 5 | LLMMetaAnalyzer.build_prompt | 追加 JSON 输出格式指令 | 同上 |
| 6 | ChatOpenAI.__init__ | 注入httpx.Timeout(connect=8s, read=30s) | 阻止挂起连接无限阻塞 |
| 7 | asyncio.run | 异常处理器丢弃 "Event loop is closed" | 抑制 httpx 清理噪音 |
补丁 1 是并发安全的基石:原实现(在 DESIGN.md 中记录)会修改类属性LLMAnalyzerBase.response_schema,这在多线程下存在竞态——线程 A 恢复原值而线程 B 仍在创建实例,with_structured_output()就会触发 400。补丁改为写入实例__dict__,Python MRO 保证实例属性优先于类属性,因此每个分析器实例拿到各自的None,零共享状态、零竞态。且嵌套深度被跟踪(_patches_depth计数),只有最外层上下文管理器退出时才恢复原值,支持可重入。
补丁 6 的注入时机很关键:httpx 默认connect=5.0、read=None(无限)。一个建立了 TCP 连接却从不返回数据字节的服务端会永久阻塞工作线程,而ThreadPoolExecutor无法杀死线程。补丁在 OpenAI 内部客户端被缓存之前通过timeoutPydantic 别名注入超时值,同时写入kwargs["timeout"]和kwargs["request_timeout"],确保httpx.Timeout(connect=8s, read=30s)从第一次实例化起就流入每个root_client/async_client(Pydantic v2 的别名优先级细节见 DESIGN.md 的 Patch 6 一节)。
补丁的健壮性保障:应用补丁前会调用_verify_patch_targets()(runner.py),逐一检查 7 个补丁目标的函数签名与深层依赖(如LLMAnalysisResult.model_validate、Batch.file_path字段、MetaAnalyzerResult.findings字段、asyncio.new_event_loop等)。任何上游 API 变更都会在补丁应用时立即抛出明确的RuntimeError,把"静默失效"转变成"即时可诊断错误"。这与 test_monkeypatch_fragility.py(26 个测试)和 test_monkeypatch_invasiveness.py(14 个测试,含 50 实例并发隔离验证)互为印证。
六、为什么必须补丁而不是 fork 上游
DESIGN.md 明确记录了这一取舍:
- fork 会造成永久分叉:每次上游发布都要 rebase 和重新验证;
- monkey-patch 是即插即用适配器:自动跟随上游演进;如果未来上游提供
response_schema覆盖能力(如环境变量SKILLSPECTOR_RAW_LLM),补丁会自动变成 no-op,可无代码变更地移除。
补丁之所以必要,是因为 DeepSeek 等提供商的 API 不支持response_format(结构化输出),而上游无条件调用with_structured_output()——不打补丁会返回 HTTP 400 并污染 httpx 连接池。修复链条为:补丁 1 禁用结构化输出 → 补丁 4/5 给每个提示词追加 JSON 格式指令 → 补丁 2/3 手动解析原始 JSON 字符串并用 Pydantic 校验。校验失败时分析器返回空 findings(不崩溃),扫描继续,实现优雅降级。
七、三层并发模型与调优建议
原文档揭示了模块建立在上游两层并发之上的第三层并发:
Layer 3 — batch_scan.py: ThreadPoolExecutor(max_workers=N) [CONTRIB] Layer 2 — llm_analyzer_base: asyncio.Semaphore(10) [UPSTREAM] Layer 1 — graph.py: 20 analyzers fan-out [UPSTREAM]每层互不知晓:图不知道自己在被并发调用,工作线程不知道图内部会 fan-out。这意味着峰值并发 LLM 请求数远大于--workers。README 给出的经验值:
| 场景 | Workers | 峰值并发 LLM 请求 |
|---|---|---|
| 免费额度 key | 1 | 10–15 |
| 付费基础 | 4(默认) | 25–40 |
| 企业级/多 key | 7–10 | 50–80 |
| 调试 | 1 +-V | 顺序执行 |
因此并行 LLM 扫描建议配置至少与 workers 数量相当的 API keys(README 建议 10 个 key 配--workers 8),单 key 场景请使用--workers 1或--no-llm,否则会立即触发限流。
八、全链路速查与延伸阅读
常用命令(完整列表见 contrib/batch_scan/docs/README.md):
# 纯静态扫描(无需 API key,快速确定性) python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-llm # 完整 LLM 扫描 python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 7 # 输出 JSON/Markdown 报告 python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o report.json python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f markdown -o report.md # 指定语言 / 强制英语跳过 gap-fill python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang zh --workers 4 python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang en -f terminal --workers 4 # 调试:单线程 + 详细日志 python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 1 -V如需深入,建议按以下路径阅读仓库源码:
- 入口与 CLI 参数:contrib/batch_scan/batch_scan.py
- 图封装与 7 补丁实现:contrib/batch_scan/runner.py
- 技能发现 / 语言检测:contrib/batch_scan/discovery.py · contrib/batch_scan/detection.py
- Key 池与故障转移:contrib/batch_scan/api_pool.py
- Gap-fill 分析器:contrib/batch_scan/gap_fill.py
- 语言兼容性标注:contrib/batch_scan/annotation.py
- 三种报告格式:contrib/batch_scan/reports.py
- 架构设计决策与替代方案评估:contrib/batch_scan/docs/DESIGN.md
- 并发挂接与补丁健壮性测试:contrib/batch_scan/tests/test_pool_wiring.py · contrib/batch_scan/tests/test_monkeypatch_invasiveness.py · contrib/batch_scan/tests/test_monkeypatch_fragility.py
已知局限(来自 README):语言检测仅覆盖 4 种文字(阿拉伯语、印地语、西里尔文会被归类为英语并失去 gap-fill 覆盖);无断点续扫能力;parse_response的 JSON 恢复是尽力而为——当 LLM 返回畸形 JSON 时分析器返回空 findings 而非崩溃,属于刻意的优雅降级选择,但用户不会知道哪些 findings 因此丢失。理解这些边界,有助于正确评估批处理扫描结果的置信度。
【免费下载链接】SkillSpectorSecurity scanner for AI agent skills. Detect vulnerabilities, malicious patterns, security risks, prompt injection, data exfiltration, and supply-chain risks in Claude Code, Codex, and MCP skills before you install them.项目地址: https://gitcode.com/GitHub_Trending/sk/SkillSpector
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考