SkillSpector Batch Scan 架构流程详解:从 CLI 入口到多线程 LangGraph 扫描流水线
2026/9/13 23:21:39 网站建设 项目流程

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_modelskillspector.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_detectiongap_fill_appliedgap_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 编译图的同步入口,会依次驱动:

  1. build_context:下载/解压/构建文件缓存,并记录temp_dir_for_cleanup临时目录;
  2. 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(可疑权限提升);
  3. meta_analyzer(fan-out 之后的 fan-in):LLM 复核与富化;
  4. 过滤与风险评分Results → filter → risk_score

entry_from_result(runner.py)将原始结果转换为批次报告标准结构skill / risk_assessment / components / issues,并补充source_grouplanguagescan_mode: "multilingual-enhanced"enhancementsgap_fill_appliedgap_fill_findingsenglish_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.7rule_id属于 8 条规则集合;
  • 若配置了api_poolself.chat_model会被替换为PooledChatModel,从而获得 key 故障转移能力。

gap-fill 结果经annotate_findings追加到entry["issues"],同时写入entry["enhancements"]["gap_fill_applied"] = Truegap_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 而非 findingscleanup_resultshutil.rmtree+ subprocess 兜底清理 →其余工作线程不受影响继续执行。这正是批处理扫描器"一个技能挂掉不拖垮整批"的容错设计。

五、7 个安全补丁:deepseek_compat()上下文管理器

7 个补丁统一由deepseek_compat()上下文管理器管理(runner.py),遵循Save → Patch → Yield → Restore(finally)模式。batch_scan.main()中整个扫描体都被with deepseek_compat():包裹,即使发生异常也会在退出时恢复原始实现。

补丁目标机制目的
1LLMAnalyzerBase.__init__self.response_schema = None(实例属性)禁用结构化输出,实例级隔离
2LLMAnalyzerBase.parse_responsejson.loads→ Pydanticmodel_validate处理无response_format的原始字符串
3LLMMetaAnalyzer.parse_response同上 +_sanitize_meta_finding处理 LLM 输出怪癖(null→""、"none"→"low")
4LLMAnalyzerBase.build_prompt追加 JSON 输出格式指令给模型格式提示
5LLMMetaAnalyzer.build_prompt追加 JSON 输出格式指令同上
6ChatOpenAI.__init__注入httpx.Timeout(connect=8s, read=30s)阻止挂起连接无限阻塞
7asyncio.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.0read=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_validateBatch.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 请求
免费额度 key110–15
付费基础4(默认)25–40
企业级/多 key7–1050–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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询