从零构建 security-audit-skill:Agent 安全审计技能的设计与落地 1. 从security-audit-skill这个名字说起它到底在解决什么问题第一次看到security-audit-skill这个命名我的直觉是这是一个把安全审计能力封装成技能的模块大概率跑在某个 agent 框架里输入是待审计的目标输出是一份结构化的审计结果。关键词里出现的findings.json和report-schema.json基本印证了这个判断——前者是审计发现的原始数据后者是报告的格式契约。为什么这件事值得单独做成一个 skill因为安全审计这件事本质上是一个流程高度固定、但细节极度依赖经验的工作。固定的是你要枚举攻击面、要逐项检查、要记录证据、要给出风险等级、要输出可读报告。依赖经验的是哪些点值得深挖、什么样的配置算高危、误报怎么排除、报告怎么写才能让开发愿意改。传统做法是写一个脚本把所有检查项硬编码进去。问题是脚本一旦写完就僵化了新增检查项要改代码输出格式变了要改代码换个项目上下文又要改代码。而把它做成 skill本质上是把审计的方法论和审计的执行解耦——skill 定义的是能力和流程agent 负责调度和推理report-schema.json负责约束输出。这三者各司其职才是这个设计真正聪明的地方。这篇文章我会从零拆解一个security-audit-skill应该怎么设计、怎么落地、怎么避坑。不管你是刚接触 agent 开发的新手还是已经写过几个 skill 的老手都能从中拿到可以直接抄的配置和踩过的坑。核心会围绕四个东西展开skill 的目录结构与契约设计、审计流程的编排逻辑、findings.json到report-schema.json的数据流转、以及实测中最容易翻车的那几个点。先说结论一个能用的安全审计 skill难点从来不在检查什么而在怎么让 agent 稳定地按你的意图执行并且输出可被程序消费的结果。下面逐层展开。2. skill 的目录结构与契约设计先定接口再写逻辑2.1 为什么 skill 不是一个脚本而是一份契约很多人第一次写 skill习惯性地把它当成一个函数给输入、跑逻辑、返回输出。这个理解在简单场景下没错但在 agent 场景下会出大问题。因为 agent 不是被动调用者它会读你的 skill 描述、理解你的意图、然后自主决定怎么执行。这意味着 skill 的描述文本和输出格式本身就是接口的一部分而且是最重要的部分。我见过太多 skill 失败在第一步描述写得含糊agent 理解偏了跑出来的结果驴唇不对马嘴。比如你写检查配置安全问题agent 可能去检查了环境变量也可能去检查了依赖版本还可能去检查了文件权限——它没错是你没说清。所以security-audit-skill的第一件事是把契约定死。契约包含三部分输入契约skill 接受什么。是一个代码仓库路径一个配置文件一个运行中的服务地址还是多个的组合输出契约skill 产出什么。这里就是findings.json和report-schema.json登场的地方。行为契约skill 在什么条件下做什么、不做什么。比如只读不写、不执行目标代码、遇到不确定的项标记为 needs-review 而非直接判定。把这三部分写清楚agent 才知道边界在哪。我个人的经验是行为契约比前两个更容易被忽略但恰恰是它决定了 skill 的安全性——一个安全审计 skill 自己去执行了可疑代码这就成了最大的笑话。2.2 推荐的目录布局一个可维护的 skill 目录我一般会这样组织security-audit-skill/ ├── SKILL.md # skill 的主描述agent 读这个理解能力 ├── schemas/ │ ├── findings.schema.json # findings.json 的结构定义 │ └── report.schema.json # report-schema.json 的结构定义 ├── checks/ │ ├── secrets.yaml # 敏感信息检查项 │ ├── deps.yaml # 依赖与版本检查项 │ ├── config.yaml # 配置项检查 │ └── code-patterns.yaml # 代码模式检查 ├── templates/ │ └── report.md.tpl # 人类可读报告的模板 └── scripts/ └── validate.py # 校验 findings 是否符合 schema这个布局的核心思路是描述与数据分离、检查项与执行逻辑分离、机器格式与人类格式分离。SKILL.md只讲这个 skill 能做什么、怎么用、边界在哪不掺杂具体检查项检查项全部放在checks/下的 YAML 里新增检查项不用改任何代码schemas/定义数据契约templates/定义展示层。为什么检查项要用 YAML 而不是直接写进代码因为安全审计的检查项是高频变动的——今天加一条检测硬编码的云厂商密钥明天加一条检测过期的 TLS 协议版本。如果每条都要改 Python 代码、跑测试、发版维护成本会劝退所有人。YAML 让非开发人员也能贡献检查项这是可持续的关键。2.3 SKILL.md 里必须写清楚的几件事SKILL.md是 agent 的入口它的质量直接决定 skill 的可用性。我总结下来必须包含以下内容缺一不可第一能力边界。明确写本 skill 执行静态审计不执行目标代码不发起网络请求不修改任何文件。这句话看着像免责声明实际上是给 agent 的行为约束。没有这句话agent 可能会聪明地去跑一下目标程序看看行为这就越界了。第二输入说明。说清楚需要哪些输入、格式是什么、哪些是必填哪些是可选。比如inputs: target_path: type: string required: true description: 待审计的代码仓库根目录绝对路径 scope: type: array required: false default: [secrets, deps, config] description: 审计范围可选值见 checks/ 目录 severity_threshold: type: string required: false default: low description: 报告包含的最低风险等级第三输出说明。明确产出findings.json机器消费和report.md人类阅读并指向 schema 文件。这里有个细节一定要告诉 agent findings.json 必须通过 schema 校验才算完成否则 agent 可能生成一个结构对但字段缺失的文件就交差了。第四执行流程。用编号列出 skill 的标准执行步骤让 agent 有章可循。比如加载检查项 → 逐项执行 → 收集原始发现 → 归一化去重 → 按 schema 生成 findings.json → 渲染报告 → 自校验。第五失败处理。明确当某项检查无法执行时如文件不存在、解析失败记录为 skipped 并附原因不得中断整体流程。这条极其重要我后面会专门讲为什么。2.4 一个容易踩的坑描述过度承诺新手写 SKILL.md 时总想把能力写得越强越好于是出现本 skill 可检测所有安全漏洞这种话。这是大忌。agent 会当真然后在你没覆盖的场景下强行给出结论产生大量误报。正确的做法是明确列出覆盖范围并主动声明未覆盖的场景不在本 skill 职责内。宁可显得能力有限也不要让 agent 越界发挥。3. 审计流程的编排让 agent 稳定执行而不是自由发挥3.1 为什么流程编排比检查逻辑更难检查逻辑本身不难——检测硬编码密钥就是正则匹配检测过期依赖就是版本比对。难的是让 agent 每次都按同样的顺序、同样的标准执行而不是这次检查了 A 和 B下次只检查了 A再下次把 B 和 C 混在一起。这个问题的根源在于agent 是概率性的它倾向于走捷径。如果你不把流程约束死它会自己判断哪些检查重要然后跳过它认为不重要的。对安全审计来说这是灾难——漏掉一项检查可能就意味着漏掉一个高危漏洞。所以流程编排的核心目标是把 agent 的自由度压缩到执行层面而不是决策层面。决策检查什么、什么算高危由 skill 预先定义agent 只负责执行和记录。3.2 五阶段流程设计我把security-audit-skill的流程拆成五个阶段每个阶段有明确的输入输出和退出条件阶段名称输入输出退出条件1环境探测target_path项目类型、语言、框架清单至少识别出一种项目类型2检查项加载scope待执行的检查项列表列表非空3逐项执行检查项列表原始发现列表所有项执行完毕或标记 skipped4归一化去重原始发现标准化 findings无重复项5报告生成findingsfindings.json report.mdschema 校验通过阶段一环境探测。这一步经常被跳过但它决定了后续检查项的适用性。比如一个纯前端项目你去检查服务端配置就是浪费时间。探测的方式很简单看有没有package.json、requirements.txt、go.mod、pom.xml这些标志性文件看目录结构里有没有src/、config/、deploy/。探测结果直接决定阶段二加载哪些检查项。阶段二检查项加载。根据 scope 和环境探测结果从checks/目录加载适用的检查项。这里有个设计要点检查项要支持条件加载。比如deps.yaml里的检查项可以标注applies_to: [python, node]只有探测到对应语言时才加载。这样既避免了无效检查也让 skill 能适配多种项目。阶段三逐项执行。这是核心阶段。每个检查项执行后产出一条或多条原始发现。原始发现的结构可以很松散因为阶段四会归一化。但有一条铁律任何检查项失败都不能中断整体流程。文件读不了就记 skipped正则写错了就记 error但流程必须走完。原因很简单——安全审计的价值在于尽可能全面地覆盖而不是在第一个错误处停下。阶段四归一化去重。不同检查项可能报出同一个问题。比如硬编码密钥可能被 secrets 检查和 code-patterns 检查同时命中。归一化的目标是同一位置、同一类型的问题只保留一条并合并证据。去重的键一般是(file_path, line_number, category)三元组。阶段五报告生成。把归一化后的 findings 按report-schema.json输出为findings.json再按模板渲染成report.md。最后一步是自校验——用scripts/validate.py校验 findings.json 是否符合 schema不符合就报错重来。3.3 用状态机约束 agent 的行为光有流程描述还不够agent 可能理解了流程但执行时偷懒。我的做法是在 SKILL.md 里显式定义一个状态机让 agent 按状态转移执行INIT - PROBE - LOAD_CHECKS - EXECUTE - NORMALIZE - REPORT - DONE | | v v (empty) (error) | | v v DONE RECORD_SKIP - EXECUTE这个状态机的关键约束是EXECUTE状态只有在所有检查项都处理过成功或 skipped之后才能转移到NORMALIZE。agent 不能从EXECUTE直接跳到REPORT这就堵死了跳过部分检查的捷径。实测下来加了状态机约束之后agent 的执行一致性提升非常明显。之前十次执行可能有三次漏检查现在基本稳定。代价是 SKILL.md 变长了但这点长度换来稳定性完全值得。3.4 一个反直觉的经验不要给 agent 太多智能很多人设计 skill 时喜欢给 agent 留自主判断的空间比如如果发现可疑项可以深入分析。听起来很美好实际是灾难。因为 agent 的深入分析不可控可能分析出完全错误的结论还可能陷入无限循环。我的做法是把深入分析也变成显式的检查项。比如检测到疑似密钥后检查其上下文 5 行内是否有test、example、dummy等标记若有则降级为 info。这样深入分析就变成了可预测、可复现的规则而不是 agent 的即兴发挥。安全审计这个场景可预测性远比智能重要。一个每次都给出相同结果的笨 skill比一个时好时坏的聪明 skill 有价值得多。4. findings.json 与 report-schema.json数据契约怎么定才不返工4.1 findings.json 的字段设计findings.json是整个 skill 的核心产物它的结构设计直接决定了后续能不能被程序消费、能不能做趋势分析、能不能对接其他工具。我踩过的坑是第一版设计得太简单只有title和severity结果想做去重和统计时发现信息不够只能推倒重来。现在我的字段设计是这样的{ schema_version: 1.0, scan_metadata: { target: /path/to/repo, started_at: 2025-01-15T10:00:00Z, finished_at: 2025-01-15T10:02:30Z, scope: [secrets, deps, config], tool_version: 1.2.0 }, findings: [ { id: SEC-001, category: secrets, severity: high, confidence: high, title: 硬编码的数据库密码, description: 在配置文件中发现明文数据库密码, location: { file: config/database.yml, line: 12, column: 8, snippet: password: \prod_pass_123\ }, evidence: [正则匹配命中, 上下文无 test/example 标记], remediation: 改用环境变量注入并将该密码从版本历史中移除, references: [CWE-798], status: open } ], summary: { total: 1, by_severity: {high: 1, medium: 0, low: 0, info: 0}, by_category: {secrets: 1} } }几个关键字段值得单独说confidence置信度是我强烈建议加的字段。安全审计的误报率天然高光有 severity 不够还要告诉消费者这条有多确定。high表示规则精确匹配、上下文确认medium表示规则匹配但上下文存疑low表示启发式判断、需要人工复核。有了这个字段下游就能决定high 置信度的直接修low 置信度的先人工看。location.snippet存的是命中位置的代码片段。这个字段的价值在于报告读者不用打开文件就能看到问题现场。但要注意脱敏——如果 snippet 里包含密钥本身要打码否则报告本身就成了泄露源。这是个很容易忽略的坑。evidence是数组记录判定依据。为什么要有这个因为审计结论需要可追溯。当有人质疑你凭什么说这是高危时evidence 就是答案。它也让误报排查变得容易——看 evidence 就知道是哪条规则误判了。status字段为后续工作流留了口子。open表示待处理fixed表示已修复false_positive表示误报。这样 findings.json 就不只是一次性产物而是可以持续跟踪的状态文件。4.2 report-schema.json 与 findings.schema.json 的分工很多人会疑惑为什么要有两个 schema直接一个不行吗不行因为它们的消费者不同。findings.schema.json约束的是机器消费的数据字段要全、要结构化、要稳定。report-schema.json约束的是人类阅读的报告字段可以少、可以有嵌套的展示结构、可以随版本调整。我的分工是这样的findings.schema.json定义 findings.json 的完整结构是所有下游工具的契约。一旦发布字段只增不减保证向后兼容。report-schema.json定义 report.md 的元数据部分比如报告头部信息、章节结构以及从 findings 到报告的映射规则。它更灵活可以随展示需求调整。具体来说report-schema.json里我会定义{ report_schema_version: 1.0, sections: [ {id: overview, title: 审计概览, source: summary}, {id: high, title: 高危问题, filter: {severity: high}}, {id: medium, title: 中危问题, filter: {severity: medium}}, {id: low, title: 低危与提示, filter: {severity: [low, info]}}, {id: skipped, title: 未执行的检查, source: skipped_checks} ], finding_template: ### {{title}}\n\n- 位置{{location.file}}:{{location.line}}\n- 置信度{{confidence}}\n- 说明{{description}}\n- 修复建议{{remediation}}\n }这样报告生成就变成了按 schema 过滤 套模板的机械操作不需要 agent 发挥创意。报告结构稳定读者每次看到的格式都一样认知负担低。4.3 schema 校验必须作为完成条件我在前面反复强调schema 校验通过才算完成这里展开说为什么。agent 生成 JSON 时最常见的错误有三类字段名拼错severity写成serverity、类型错误line应该是数字却写成字符串、必填字段缺失漏了location。这些错误在人工看的时候可能不明显但下游程序一消费就崩。把 schema 校验作为完成条件意味着 agent 生成 findings.json 后必须跑一次校验不通过就修正重来。这一步能拦掉 90% 的格式问题。实现上scripts/validate.py用jsonschema库几行就能搞定import json import jsonschema import sys def validate(findings_path, schema_path): with open(findings_path) as f: findings json.load(f) with open(schema_path) as f: schema json.load(f) try: jsonschema.validate(findings, schema) print(VALID) return 0 except jsonschema.ValidationError as e: print(fINVALID: {e.message}) print(fPATH: {list(e.path)}) return 1 if __name__ __main__: sys.exit(validate(sys.argv[1], sys.argv[2]))注意错误输出里要带上e.path这样 agent 知道具体是哪个字段出了问题修正起来有的放矢。只报校验失败而不报位置agent 会瞎猜来回好几轮都修不对。4.4 版本演进策略怎么改 schema 不破坏下游schema 一旦被下游消费就不能随便改。我的策略是加字段安全直接加下游忽略未知字段即可。改字段类型危险必须升schema_version并保留旧字段一段时间。删字段最危险先标记 deprecated至少保留两个大版本再删。改字段语义最隐蔽的危险比如severity从字符串变成对象下游不报错但逻辑全错。这种必须升大版本并在文档里高亮。我吃过一次亏把line字段从数字改成字符串以支持范围如12-15结果下游一个统计脚本直接崩了。后来改成新增line_range字段line保持数字不变才解决问题。教训是宁可加字段不要改字段。5. 实测中最容易翻车的五个点5.1 误报淹没真问题安全审计 skill 最大的敌人不是漏报而是误报。一个报告里 200 条发现其中 180 条是误报开发看一眼就关掉了剩下 20 条真问题也跟着被忽略。这是狼来了效应。误报的主要来源是正则匹配太宽泛。比如检测密钥用[A-Za-z0-9]{32,}这会把哈希值、UUID、base64 编码的普通数据全命中。我的应对策略是三层过滤第一层白名单路径。test/、tests/、fixtures/、examples/、docs/下的文件默认跳过密钥检查因为这些地方出现假密钥是正常的。第二层上下文标记。命中位置前后 5 行内如果有example、dummy、fake、placeholder、your_、xxx等标记降级为 info。第三层熵值判断。对疑似密钥的字符串计算香农熵熵值低于阈值的比如纯数字、重复模式降级。真实密钥的熵通常较高。这三层过滤下来误报率能降一个数量级。代价是可能漏掉一些真密钥比如熵值恰好低的但相比误报淹没真问题这个取舍是值得的。5.2 大仓库扫描超时在几万文件的大仓库上跑审计如果不做限制skill 可能跑几个小时甚至卡死。我踩过的坑是agent 会耐心地遍历所有文件直到超时被框架杀掉然后什么都没输出。解决方案是加三重限制文件数上限。默认最多扫描 5000 个文件超过就按修改时间排序取最近的并在报告里注明因文件数超限仅扫描了最近修改的 5000 个文件。单文件大小上限。超过 1MB 的文件跳过除非是明确的配置文件因为大文件通常是打包产物或数据文件不是审计目标。总时长上限。默认 5 分钟到点就停止扫描已完成的检查正常输出未完成的标记为 skipped。这些限制要写进 SKILL.md让 agent 知道超时是预期行为不是错误。否则 agent 可能把超时当成失败重试一遍又超时陷入循环。5.3 检查项之间的相互干扰多个检查项同时跑可能互相干扰。最典型的是secrets 检查读取了文件内容code-patterns 检查又读了一遍deps 检查还要读一遍。在文件多的时候重复 IO 会成为瓶颈。我的做法是引入一个文件内容缓存层第一次读文件时缓存内容后续检查项从缓存取。缓存要设上限比如最多缓存 100MB超过就 LRU 淘汰。这样既避免了重复 IO又不会撑爆内存。另一个干扰是发现重复。前面提过归一化去重但去重本身也有坑如果两个检查项报的是同一位置但描述不同去重时保留哪条我的规则是保留 severity 更高的那条如果 severity 相同则保留 confidence 更高的再相同则保留先执行的。规则要写死不能让 agent 临场决定。5.4 agent 擅自优化检查项这个坑最隐蔽。agent 在执行过程中可能觉得某个检查项没必要或者可以合并然后擅自跳过或修改。比如它觉得检查 TLS 版本和检查加密套件是一回事就只跑了一个。应对方法是在 SKILL.md 里明确禁止修改检查项列表并规定每个检查项的执行结果必须单独记录不得合并。同时在状态机里加约束EXECUTE状态的退出条件是检查项列表中的每一项都有对应的执行记录缺一项就不能退出。实测中这个约束能有效防止 agent 的自作聪明。偶尔 agent 还是会尝试合并但状态机检查会拦住它让它补跑。5.5 报告里的敏感信息二次泄露这是最容易被忽略、后果最严重的问题。审计报告里如果原样贴出了发现的密钥那这份报告本身就成了泄露源。一旦报告被分享、被提交到仓库、被发到群里密钥就扩散了。我的处理规则是location.snippet里如果包含疑似密钥只保留前 4 位和后 4 位中间用****替代。description里不重复密钥内容只说发现疑似 XX 类型的密钥。evidence里不包含密钥原文只记录正则命中、熵值 4.2这类元信息。报告头部加一行提示本报告已对敏感信息脱敏如需查看原文请直接在目标文件中定位。脱敏逻辑要放在归一化阶段统一处理不能指望每个检查项自己脱敏——总有人会忘。6. 把 skill 用起来集成、扩展与日常维护6.1 在 agent 框架里怎么挂载这个 skill不同 agent 框架挂载 skill 的方式不同但核心逻辑一致把 skill 目录放到框架约定的 skills 路径下框架启动时扫描并注册。以常见的目录约定为例通常是~/.agent/skills/security-audit-skill/或项目内的.agent/skills/。挂载后要验证三件事skill 被正确识别。让 agent 列出可用 skill确认security-audit-skill在列表里且描述正确。输入被正确解析。给一个测试仓库路径看 agent 是否正确提取了target_path。输出被正确生成。跑一次完整流程确认findings.json和report.md都生成了且 schema 校验通过。这三步任何一步失败都要先解决再往下走。我见过有人 skill 没注册成功就开始调参数白折腾半天。6.2 新增检查项的标准流程检查项是 skill 的生命力所在要让它容易加。标准流程是在checks/下对应的 YAML 文件里新增一条检查项。定义id、category、severity、pattern或logic、applies_to、remediation。如果涉及新的输出字段同步更新findings.schema.json。跑一次测试仓库确认新检查项能命中预期目标且不产生大量误报。更新 SKILL.md 里的覆盖范围说明。一条检查项的 YAML 大概长这样- id: SEC-010 category: secrets severity: high confidence: high applies_to: [*] pattern: (?i)(api[_-]?key|secret[_-]?key|access[_-]?token)\s*[:]\s*[]([A-Za-z0-9/]{20,})[] exclude_paths: [test/, tests/, fixtures/, examples/] title: 硬编码的 API 密钥 remediation: 改用密钥管理服务或环境变量注入 references: [CWE-798]注意exclude_paths和applies_to这两个字段它们让检查项自带过滤逻辑不用在代码里写 if-else。这是 YAML 驱动的好处。6.3 定期回归怎么知道 skill 没退化skill 用久了容易悄悄退化——某次改动让某个检查项失效了但没人发现。所以要建立回归机制。我的做法是维护一个基准仓库里面故意埋了各种已知问题硬编码密钥、过期依赖、危险配置等每次改动 skill 后跑一遍对比 findings 数量和内容。如果某个已知问题没被检出说明对应的检查项退化了。基准仓库要覆盖所有检查项且每个问题只埋一处这样对比起来清晰。我一般用一个小型项目几十个文件做基准跑一次几十秒适合频繁回归。6.4 和其他工具的协作边界security-audit-skill不是万能的它做的是静态审计。动态扫描、依赖漏洞库比对、运行时行为监控这些是其他工具的活。skill 的定位是快速、轻量、可集成到开发流程的静态检查不是替代专业安全扫描器。明确这个边界很重要否则会陷入什么都想自己做的陷阱最后什么都做不好。我的做法是在 SKILL.md 里明确写本 skill 不覆盖动态测试、依赖 CVE 比对、运行时监控并建议用户在这些场景下使用专业工具。skill 的输出findings.json设计成标准格式也方便和其他工具的结果合并。6.5 一个实用的小技巧让报告可操作审计报告最怕的是看完不知道干什么。我的做法是在每条 finding 的remediation里给出具体到命令或代码的修复建议而不是泛泛的请修复此问题。比如检测到硬编码密码remediation 不写改用环境变量而写将 config/database.yml 第 12 行的 password 改为 % ENV[DB_PASSWORD] % 并在部署环境中设置 DB_PASSWORD 环境变量。 注意该密码已进入版本历史需同时执行密钥轮换。这样开发拿到报告就能直接动手不用再研究怎么改。报告的可操作性直接决定了问题被修复的速度。7. 写在最后几个我反复验证过的判断关于security-audit-skill这类项目我有几个判断是踩了足够多坑之后才形成的分享出来供参考。第一skill 的价值在于约束不在于能力。一个能力很强但行为不可预测的 skill不如一个能力有限但每次结果一致的 skill。安全审计尤其如此可复现性比覆盖面更重要。第二数据契约要早定、定死。findings.json和report-schema.json一旦被下游消费改动成本极高。前期多花时间设计字段后期省无数返工。宁可字段多几个用不上也不要少几个不够用。第三误报控制是 skill 能否被长期使用的分水岭。一个误报率高的 skill用两次就没人用了。三层过滤白名单、上下文、熵值是底线配置不能省。第四脱敏必须内建不能靠自觉。审计报告泄露密钥这种事发生一次就够毁掉整个 skill 的信任。脱敏逻辑放在归一化阶段统一处理是最稳妥的做法。第五检查项要 YAML 化让非开发也能贡献。安全审计的检查项更新频率很高如果每次都要改代码维护会跟不上。YAML 驱动是可持续的关键。这套设计我在几个不同规模的项目上跑过从几千行的小项目到几十万行的大仓库基本都能稳定工作。大仓库需要调高文件数和时长上限小项目可以调低以加快速度。核心逻辑不变参数按需调整即可。如果你正在做类似的 skill建议先从最小可用版本开始一个检查项、一个 schema、一个报告模板跑通全流程再逐步扩展。不要一上来就追求大而全那样大概率会在细节里迷失最后连一个能跑的版本都出不来。