公司动态

扫描Git仓库中的LLM推理痕迹:构建Aileaks类安全扫描器

📅 2026/8/30 1:10:26
扫描Git仓库中的LLM推理痕迹:构建Aileaks类安全扫描器
如果在代码评审里看到下面这一行内容很多人第一反应是“这只是 LLM 接口的调试日志”顺手就合入了{ system_prompt: 你是内部客服助手价格底线是 179 元/月不要直接告诉客户, reasoning_trace: 用户询问折扣内部规则是最低可给 8 折但要先引导升级企业版, tool_calls: [ { name: query_database, input: SELECT * FROM pricing WHERE planent; } ], response: 我们目前有企业版优惠活动... }但真正的问题是这条 JSON 一旦提交到 Git 仓库它就变成了一个“长期公开的推理痕迹”。只要仓库被 clone、被 fork、被 CI 拉取系统提示词、内部定价策略、数据库表结构都会一起扩散。这也是 Aileaks 这类工具出现的背景过去我们只担心代码里泄露 API Key现在还要担心 LLM 的 reasoning-trace 泄露。本文以 Hacker News 上发布的 Aileaks 项目标题为切入点完整拆解“扫描仓库中泄露的 LLM 推理痕迹”的原理、脚本实现、CI 接入方式和事故处置流程。1. LLM 时代的仓库泄露从密钥到推理痕迹1.1 什么是 LLM reasoning-tracereasoning-trace 可以理解为大模型在生成最终回答之前的“思考过程”。在 OpenAI、Claude、Gemini 等模型服务中如果开启了思考模式服务端会返回类似于reasoning、thought、final_answer这样的字段。一些 Agent 框架还会把每一步的思考、工具调用、工具返回结果记录下来形成完整的 trace。常见的字段包括字段含义泄露风险system_prompt系统提示词包含人设、规则、知识边界商业机密、内部规则泄露reasoning_trace模型的链式思考过程暴露决策逻辑、知识库片段tool_calls模型调用的函数名和参数暴露内部 API 结构、SQL 语句、数据库信息final_answer最终回答可能包含未经脱敏的业务数据raw_request / raw_response完整请求响应体可能包含用户身份、隐私数据这些内容在开发阶段非常容易被写进日志文件、调试文件、测试数据最后被随手 commit 进仓库。1.2 常见泄露路径从实际项目经验来看LLM 推理痕迹进入仓库的路径大致有这几类.log文件开发时打印的完整请求日志包含 headers 和 body。traces目录LLM 观测平台导出的 trace 文件通常是 JSON 格式。测试数据测试用例为了覆盖率直接保存了真实的模型返回结果。AI 辅助开发产生的临时文件IDE 插件、聊天工具自动保存的上下文文件。dump.json、error.json异常排查时导出的现场数据。很多仓库出现泄漏不是开发者故意提交密钥而是日志体系设计不合理把敏感内容当成普通文本输出了。1.3 为什么传统 Secret Scanning 不够用传统的密钥扫描工具比如 gitleaks、trufflehog擅长识别固定模式的密钥比如sk-xxxxxxxxxxxxxxxxxxxxxxxx AKIAXXXXXXXXXXXXXXXX ghp_xxxxxxxxxxxxxxxx但 LLM reasoning-trace 的泄露往往不是标准密钥而是自然语言。例如你是内部客服价格底线是 179 元/月不要告诉用户这段文字没有密钥模式正则表达式很难命中。而且很多敏感片段会被 JSON 转义、Base64 编码、压缩打包传统工具只能看到一串无意义的字符。所以我们需要一套面向 LLM 场景的检测思路识别密钥特征识别 reasoning 相关字段名识别系统提示词和内部规则的关键词识别高熵字符串结合 Git 历史扫描哪怕是已经删除的文件也能被找回来。2. Aileaks 这类工具的核心思路2.1 工具定位从名称和发布信息来看Aileaks 做的事情可以概括为scan repos for leaked LLM reasoning-trace secrets也就是扫描代码仓库中泄露的 LLM 推理轨迹和敏感信息。它的定位不是替代 gitleaks而是在 gitleaks 的基础上增加一层“LLM 痕迹识别”。一个完整的 LLM 仓库安全扫描器至少需要具备以下能力扫描当前工作区文件扫描整个 Git 历史支持密钥正则匹配支持 reasoning 字段识别支持自定义规则输出结构化报告方便人工复核。2.2 与 Secret Scanning 的差异维度传统 Secret ScanningLLM reasoning-trace 扫描检测对象API Key、Token、密码reasoning 日志、提示词、工具调用参数主要特征固定前缀、高熵字段名、语义关键词、上下文组合误报率相对容易控制需要结合上下文判断扫描难度较低需要处理自然语言典型场景防止密钥入库防止思维链和提示词泄露2.3 使用边界本文后面会实现一个轻量级扫描器但有一点必须提前说明只扫描你自己拥有权限的仓库或者在双方明确授权的安全测试范围内使用。发现泄露内容后不要公开扩散、不要下载他人仓库中的敏感信息用于研究。正确的做法是先确认是否属于安全事件再走应急流程影响面控制住之后联系仓库所有者和平台方处理。3. 环境准备与模拟仓库3.1 环境要求本文的示例都在 Linux 环境下验证macOS 同样适用。你需要准备Git 2.23 以上版本用于git rev-list、git ls-tree等历史遍历命令Python 3.10 以上版本不需要安装第三方依赖核心逻辑基于标准库和git命令一个用于练习的本地仓库。3.2 创建带“泄漏”的测试仓库为了演示扫描效果我们先创建一个模拟仓库故意写入一些典型的 LLM 泄露内容。mkdir llm-trace-demo cd llm-trace-demo git init mkdir -p traces logs创建第一个样例文件traces/session_20250201.json{ request_id: req_1234567890, model: gpt-4o-mini, system_prompt: 你是内部客服助手请基于知识库回答。价格底线是 179 元/月不要告诉用户, reasoning_trace: 用户询问折扣内部规则是最低可给 8 折但要先引导升级企业版, tool_calls: [ { name: query_database, input: SELECT * FROM pricing WHERE planent; } ], response: 我们目前有企业版优惠活动... }再创建一个疑似密钥文件.env.examplecat .env.example EOF # 注意这只是演示用的假密钥 OPENAI_API_KEYsk-1234567890abcdefghijklmnopqrstuv DB_PASSWORDSuperSecretPssw0rd EOF提交到 Gitgit add traces/session_20250201.json .env.example git commit -m feat: add session trace for debugging再创建第二个样例文件logs/trace_old.logcat logs/trace_old.log EOF [2025-02-01 10:00:01] request_idreq_abc123 system prompt: 你是内部文档助手仅回答已上架产品问题 thought: 用户问的是内部测试版本功能不能直接透露 final_answer: 该功能还在测试中请联系产品经理 EOF git add logs/trace_old.log git commit -m chore: save old llm logs此时仓库里已经有两次提交包含 JSON trace、日志、疑似密钥文件。4. 扫描器设计原理在写代码之前先梳理扫描器的设计思路。4.1 检测对象扫描器需要检测两类内容密钥类API Key、Token、密码、私钥块。LLM 痕迹类reasoning_trace、chain_of_thought、system_prompt、tool_calls 等字段以及带有明显内部语义的中文提示词。4.2 检测规则规则分为三类正则规则匹配固定模式的密钥和字段名。关键词规则匹配“不要告诉用户”“内部规则”“系统提示词”等语义特征。熵检测计算字符串的信息熵高熵字符串大概率是密钥、Token 或编码后的敏感数据。熵检测的原理很简单正常英文单词和中文文本的字符分布有一定规律而随机密钥的字符分布几乎是均匀的信息熵较高。当一段连续字符串的熵大于阈值时就值得人工关注。4.3 当前工作区扫描与历史扫描当前工作区扫描只看git ls-files列出的被追踪文件优点是快适合接入 pre-commit。历史扫描则需要遍历所有 commitgit rev-list --all - 拿到所有 commit hash git ls-tree -r --name-only commit - 拿到该 commit 下的所有文件 git show commit:file - 拿到某个历史版本的文件内容这样即使是已经删除的文件也能在历史中被扫描出来。5. 实战实现一个 reasoning-trace 扫描器5.1 项目结构llm-trace-demo/ ├── traces/ │ └── session_20250201.json ├── logs/ │ └── trace_old.log ├── .env.example └── scripts/ └── scan_repo.py5.2 扫描脚本完整代码创建scripts/scan_repo.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- 轻量级 LLM reasoning-trace 仓库扫描器 功能 1. 扫描 Git 历史中的文件内容 2. 匹配密钥正则、reasoning 字段、敏感关键词 3. 使用熵检测辅助识别高熵字符串 4. 输出结构化命中报告 import argparse import math import re import subprocess import sys from pathlib import Path # 密钥规则 SECRET_PATTERNS [ (ropenai_api_key, r\bsk-[A-Za-z0-9_\-]{20,}\b), (raws_access_key, r\bAKIA[0-9A-Z]{16}\b), (rprivate_key_block, r-----BEGIN (?:RSA|EC|OPENSSH) PRIVATE KEY-----), (rgeneric_api_secret, r(?i)\b(?:api[_-]?key|secret|token|password)\b[\\s:][\]?[A-Za-z0-9_\-/]{16,}), ] # LLM reasoning 相关规则 REASONING_PATTERNS [ (rreasoning_trace_key, r\reasoning(_| )?trace\\s*:), (rchain_of_thought, r(?i)\bchain[_-]?of[_-]?thought\b), (rfinal_answer_key, r\final[_ ]answer\\s*:), (rsystem_prompt_snippet, r(?i)(system\sprompt|你是内部|内部规则|不要告诉用户|指令词)), (rtool_call_key, r\tool[_ ]?calls?\\s*:), (rthought_observation, r(?i)\b(thought|observation)\s*:), ] # 跳过二进制、依赖锁文件、压缩包 SKIP_SUFFIXES { .png, .jpg, .jpeg, .gif, .webp, .ico, .woff, .woff2, .pdf, .zip, .gz, .jar, .class, .so, .dll, .exe, .pyc, .pyo, .min.js, .map, .lock, } HIGH_ENTROPY_THRESHOLD 3.5 MAX_MATCH_CHARS 160 MAX_FILE_SIZE 5 * 1024 * 1024 def shannon_entropy(text: str) - float: 计算字符串的信息熵熵越高越像随机密钥。 if not text: return 0.0 length len(text) freq {} for ch in text: freq[ch] freq.get(ch, 0) 1 entropy 0.0 for count in freq.values(): p count / length entropy - p * math.log2(p) return entropy def should_skip(path: str) - bool: return any(path.endswith(suffix) for suffix in SKIP_SUFFIXES) def scan_text(text: str, location: str, hits: list): 在单段文本中执行规则匹配。 for rule, pattern in SECRET_PATTERNS REASONING_PATTERNS: try: matches re.finditer(pattern, text, flagsre.IGNORECASE) except re.error as e: print(f[rule error] {rule}: {e}, filesys.stderr) continue for m in matches: start max(0, m.start() - 40) end min(len(text), m.end() 120) snippet text[start:end].replace(\n, ).replace(\r, ) hits.append({ location: location, rule: rule, snippet: snippet[:MAX_MATCH_CHARS], }) # 高熵字符串检测 for token in re.findall(r[A-Za-z0-9_\-/]{20,}, text): if shannon_entropy(token) HIGH_ENTROPY_THRESHOLD: hits.append({ location: location, rule: high_entropy_token, snippet: token[:80], }) def scan_working_tree(repo: Path): 扫描当前工作区中被 Git 追踪的文件。 hits [] try: out subprocess.check_output( [git, ls-files], cwdrepo, textTrue, errorsignore ) except subprocess.CalledProcessError as e: print(fgit ls-files 失败: {e}, filesys.stderr) return hits for rel in out.strip().splitlines(): if not rel: continue if should_skip(rel): continue path repo / rel if not path.exists(): continue try: data path.read_text(encodingutf-8, errorsignore) except Exception as e: print(f[skip] {path}: {e}, filesys.stderr) continue scan_text(data, fworking_tree:{rel}, hits) return hits def scan_history(repo: Path): 扫描所有历史提交中的文件内容。 hits [] try: commits subprocess.check_output( [git, rev-list, --all], cwdrepo, textTrue, errorsignore ).strip().splitlines() except subprocess.CalledProcessError as e: print(fgit rev-list 失败: {e}, filesys.stderr) return hits for commit in commits: try: files subprocess.check_output( [git, ls-tree, -r, --name-only, commit], cwdrepo, textTrue, errorsignore ).strip().splitlines() except subprocess.CalledProcessError: continue for rel in files: if not rel or should_skip(rel): continue try: data subprocess.check_output( [git, show, f{commit}:{rel}], cwdrepo, textTrue, errorsignore, stderrsubprocess.DEVNULL ) except subprocess.CalledProcessError: # 可能是子模块或 LFS 文件跳过 continue if len(data) MAX_FILE_SIZE: continue scan_text(data, f{commit}:{rel}, hits) return hits def deduplicate(hits): 按 location rule snippet 去重。 seen set() result [] for hit in hits: key (hit[location], hit[rule], hit[snippet]) if key not in seen: seen.add(key) result.append(hit) return result def print_report(hits): if not hits: print(未发现疑似泄露内容。) return print(f发现 {len(hits)} 处疑似泄露\n) print(f{序号:4} {位置:50} {规则:25} {命中片段}) print(- * 120) for idx, hit in enumerate(hits, 1): print(f{idx:4} {hit[location][:48]:50} {hit[rule]:25} {hit[snippet][:40]}) def main(): parser argparse.ArgumentParser(descriptionScan LLM reasoning-trace secrets) parser.add_argument(--repo, typestr, default., help仓库路径) parser.add_argument(--history, actionstore_true, help是否扫描 Git 历史) args parser.parse_args() repo Path(args.repo).resolve() if not (repo / .git).exists() and not (repo / HEAD).exists(): print(当前目录不是 Git 仓库。, filesys.stderr) sys.exit(1) hits [] print(f[*] 扫描仓库: {repo}) print([*] 正在扫描当前工作区...) hits.extend(scan_working_tree(repo)) if args.history: print([*] 正在扫描 Git 历史可能较慢...) hits.extend(scan_history(repo)) hits deduplicate(hits) print_report(hits) if __name__ __main__: main()这个脚本自包含没有第三方依赖核心思路如下scan_working_tree只扫描当前追踪文件速度快适合日常检查。scan_history通过git rev-list --all拿到所有提交再逐个提取文件内容适合做全量历史检查。scan_text同时做正则匹配和熵检测命中结果统一保存。deduplicate避免同一内容在多个版本里重复出现让报告更清晰。5.3 运行扫描器先扫描当前工作区cd llm-trace-demo python3 scripts/scan_repo.py --repo .预期输出大致如下[*] 扫描仓库: /home/user/llm-trace-demo [*] 正在扫描当前工作区... 发现 5 处疑似泄露 序号 位置 规则 命中片段 1 working_tree:traces/session_20250201.json system_prompt_snippet 你是内部客服助手请基于知识库回答。价格底线是 179 元/月不要告诉用户 2 working_tree:traces/session_20250201.json reasoning_trace_key reasoning_trace: 用户询问折扣内部规则是最低可给 8 折... 3 working_tree:traces/session_20250201.json tool_call_key tool_calls: [ { name: query_database, input: SELECT * FROM... 4 working_tree:.env.example openai_api_key sk-1234567890abcdefghijklmnopqrstuv 5 working_tree:.env.example generic_api_secret DB_PASSWORDSuperSecretPw0rd再扫描历史python3 scripts/scan_repo.py --repo . --history由于之前已经记录了两次提交历史扫描会额外扫到logs/trace_old.log中的 thought 和 final_answer 内容。5.4 为什么需要 history 扫描只扫描当前工作区会漏掉一类重要情况开发者发现文件敏感后用git rm删除文件并提交但历史中仍然保留了旧版本。只要仓库没有被重写任何人都能通过git log、git show找回被删除的内容。这也是 GitHub 上一些“已删除密钥”仍然被爬虫扫描到的原因。所以真正有效的仓库敏感信息排查必须把历史扫描纳入检查范围。6. 把扫描接入 pre-commit 和 CI6.1 pre-commit 本地钩子本地开发阶段最好在提交前就拦截敏感信息。使用 pre-commit 框架时可以在.pre-commit-config.yaml中配置一个本地钩子repos: - repo: local hooks: - id: scan-reasoning-trace name: scan-reasoning-trace entry: python3 scripts/scan_repo.py --repo . language: system pass_filenames: false verbose: truepass_filenames: false表示不把文件名逐个传进去而是由脚本自己扫描整个仓库。这样每次git commit之前pre-commit 会自动运行扫描。如果发现命中提交会被中断开发者需要先处理敏感内容。注意在本地钩子中扫描整个历史会导致提交变慢。建议 pre-commit 阶段只扫描当前工作区历史全量扫描放到 CI 或定时任务中执行。6.2 GitHub Actions 定时全量扫描CI 环境中可以配置一个工作流做全量历史扫描name: llm-secret-scan on: push: branches: [ main ] pull_request: schedule: - cron: 0 2 * * 1 jobs: scan: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkoutv4 with: fetch-depth: 0 - name: Run reasoning-trace scanner run: python3 scripts/scan_repo.py --repo . --history这里有两个关键点fetch-depth: 0表示克隆完整历史。如果不加这个参数GitHub Actions 默认只拉取最新一次提交历史扫描会没有数据。schedule用于每周自动执行一次全量扫描弥补 push 阶段扫描可能漏掉的情况。实际团队落地时还可以把扫描脚本做成独立 Docker 镜像或者接入内部的代码安全平台。7. 发现泄露后的处置流程扫描器发现命中只是开始真正的难点在于处置。7.1 先判断是否为真实命中很多命中是误报示例代码中的假密钥测试用例里故意构造的字段文档中引用了公开的密钥正则数据样本来自公开数据集。所以第一步永远是人工复核确认命中内容是否真的包含可用的密钥未公开的系统提示词内部业务规则用户隐私数据数据库连接信息。不要一看到命中就直接改写历史那样会造成不必要的团队协作成本。7.2 立即撤销密钥如果命中内容包含真实的 API Key、Token、数据库密码第一步不是删 Git 历史而是马上让密钥失效OpenAI/云服务厂商的 Key 直接在控制台 revoke数据库密码立即修改内部 API Token 重新生成。原因是仓库一旦被推送过远程任何有权访问该仓库的人员都可能已经拿到密钥。单纯删除仓库历史并不能保证密钥没有被复制。密钥撤销后再考虑清理历史。7.3 使用 git filter-repo 清理历史清理历史时推荐使用git-filter-repo它比 filter-branch 更快、更安全。安装方式pip install git-filter-repo移除单个文件git filter-repo --path traces/session_20250201.json --invert-paths移除包含特定文本的提交git filter-repo --replace-text (echo sk-1234567890abcdefghijklmnopqrstuvxxx)运行前必须备份整个仓库通知所有合作者历史重写后需要重新 clone确认远程端的保护规则必要时先关闭 force push 保护清理本地的 reflog 和垃圾对象。rm -rf .git/refs/original/ git reflog expire --expirenow --all git gc --prunenow --aggressive7.4 平台缓存的处理如果仓库托管在 GitHub、GitLab 等平台历史重写后平台侧可能还有fork 副本CI 缓存平台安全扫描器的缓存已生成的工单、评论中的链接。这些内容不一定能通过本地git push --force清除。必要时应联系平台支持团队或者由平台安全响应流程介入。8. 常见问题与排查思路问题现象可能原因解决思路扫描结果大量误报规则太宽比如通用 token 正则匹配到了示例代码增加白名单、提高熵阈值、要求同时命中字段名和值明明文件还在但扫描不到文件被 .gitignore 忽略不在git ls-files输出中检查.gitignore如果文件本身未入库风险相对较低history 扫描结果为空本地 clone 用了--depth1没有完整历史执行git fetch --unshallow后再扫描git show 报错跳过文件文件可能是子模块或 Git LFS 指针在错误日志中单独记录 LFS 文件使用 LFS 工具检查扫描太慢每个 commit 的每个文件都调用一次 git show使用git log -p流式扫描或建立文件指纹缓存pre-commit 误拦截正常提交测试数据中包含 demo key在规则中忽略test/、fixtures/目录或使用短标记{{SECRET}}二进制文件里的敏感信息扫不到脚本默认跳过二进制文件增加strings提取流程单独扫描二进制制品删除文件后重新扫描仍然命中历史记录中仍保留旧 blob历史重写后执行 gc 和 reflog 清理这里需要重点说明的是扫描工具的意义是发现问题而不是证明“没有风险”。一次全量扫描没有命中不代表仓库绝对安全只能说明当前规则覆盖范围内的内容没有被检测到。随着 LLM 应用形态变化检测规则也需要持续更新。9. 最佳实践与工程建议9.1 从日志源头脱敏很多泄露问题在代码提交前就已经发生了。最有效的手段不是扫描而是让敏感内容不进入日志。建议在 LLM 网关层或日志输出层做统一脱敏请求体中的 system_prompt 默认只记录 hashreasoning_trace 在生产环境默认关闭tool_calls 参数中的 SQL、文件路径、用户 ID 做掩码日志输出 PII 字段统一替换为占位符。def mask_sensitive_fields(data: dict) - dict: mask_keys {system_prompt, reasoning_trace, api_key, password, token} for key, value in data.items(): if key in mask_keys and isinstance(value, str): data[key] value[:4] **** value[-4:] return data这个方案比扫描更靠前成本也最低。9.2 分层扫描策略实际工程中建议把扫描分成三个层级开发阶段pre-commit 扫描当前工作区快速反馈合并阶段CI 扫描新增提交内容结合 gitleaks 类工具周期阶段每周全量扫描 Git 历史排查长期积累的泄露。三个层级使用同一套规则库但执行频率不同避免每次提交都被历史扫描拖慢。9.3 规则库要持续迭代LLM 的迭代速度很快新的字段名、新的日志格式不断出现。建议把规则抽成独立的 YAML 或 JSON 文件而不是写死在代码里。rules: - id: reasoning_trace_key pattern: reasoning(_| )?trace\s*: category: llm_trace - id: system_prompt_keyword pattern: (?i)(system\sprompt|你是内部|不要告诉用户) category: prompt_leak这样当 OpenAI 发布新字段、新 Agent 框架引入新的 trace 格式时可以直接通过配置更新不需要改动扫描主程序。9.4 权限和敏感数据隔离除了扫描还要从权限上限制敏感内容的传播范围训练数据、评测集、LLM trace 文件不要放在应用仓库中提示词模板如果属于商业机密单独建私有仓库管理仓库访问权限遵循最小权限原则为外部协作者配置只读或受限访问。9.5 关注 LLM 应用的供应链安全当前 LLM 应用开发中大家更关注模型精度fp16/fp32/bf16 的选择、Agent 编排框架、RAG 知识库效果但供应链安全同样不能忽视。reasoning-trace 泄露只是其中一个入口。如果 Agent 框架的配置文件中包含了内部工具的 API 地址、服务账号信息同样会被扫描工具识别为高价值目标。建议在做模型选型和框架选型时把“日志脱敏能力”“trace 导出权限控制”纳入评估维度。10. 总结与进一步学习本文围绕 Aileaks 的发布话题把“扫描仓库中泄露的 LLM reasoning-trace secrets”这条链路完整拆解了一遍。你可以从以下几个方面继续深入先跑通本文的扫描脚本在测试仓库里观察命中结果阅读 gitleaks 的规则库设计把其中针对密钥的正则合并进来学习 git-filter-repo 的使用方式在测试环境中模拟一次历史重写结合公司的日志规范把系统提示词和推理过程的脱敏能力前置到日志输出层。有一点需要特别提醒扫描工具可以帮你发现历史遗留问题但真正解决问题的关键是让开发团队意识到“LLM 推理过程本身可能就是敏感数据”。把扫描加入日常开发流程比事后清理更省成本。安全建设不需要一开始就做得很重先从一条最简单的规则开始把误报率调到可接受范围再逐步扩大覆盖范围即可。