1. 项目缘起与整体设计思路
第一次接触security-audit-skill这个项目,是在给一个内部 Agent 平台做安全加固的时候。当时团队里几个 Agent 各自跑各自的检查脚本,输出格式五花八门,有人用纯文本,有人用 CSV,还有人直接往聊天窗口里贴一大段 JSON。每次做安全评审,光是把这些结果对齐、去重、归类,就要耗掉大半天。后来我意识到,问题不在于检查本身,而在于缺少一个统一的“技能封装层”——把安全审计这件事,做成一个可被 Agent 调用、可复用、可编排的 skill。
security-audit-skill要解决的核心问题就是这个:让安全审计从“一堆散落的脚本”变成“一个标准化的技能单元”。它接收目标信息,执行一系列检查,最终产出两个关键文件——findings.json和report-schema.json。前者是审计发现的原始数据,后者是报告的结构定义。这两个文件一出来,后续无论是接入 CI 流水线、生成可视化报告,还是喂给另一个 Agent 做二次分析,都有了统一的接口。
这个项目适合谁?如果你正在做 Agent 开发,尤其是需要给 Agent 赋予“安全审计”能力的场景,这个 skill 的设计思路可以直接参考。如果你只是想让自己的安全检查流程更规范,哪怕不用 Agent 框架,findings.json和report-schema.json这套数据契约也值得借鉴。甚至你是个刚入门的安全小白,想理解一个审计工具从输入到输出该怎么设计,这篇文章也能给你一条清晰的路径。
我先把整体设计思路拆开讲。一个 skill 的本质,是“能力封装 + 接口定义 + 执行契约”。security-audit-skill在这三件事上分别做了这样的取舍:
- 能力封装:把常见的审计动作(依赖检查、配置检查、权限检查、敏感信息扫描)打包成独立的检查器,每个检查器只负责一件事,通过统一的调度器串联。
- 接口定义:对外暴露的输入是一个目标描述(可以是代码仓库路径、配置文件路径、或者一个服务端点),输出固定为
findings.json和report-schema.json。 - 执行契约:每个检查器必须按照
report-schema.json定义的字段输出结果,保证不同检查器的发现可以合并、去重、排序。
为什么这么设计?因为 Agent 调用 skill 的时候,最怕的就是“不确定性”。如果每次调用的输出格式都不一样,Agent 就没法可靠地解析和决策。把输出 schema 固定下来,相当于给 Agent 一个稳定的“合同”,它知道调用完这个 skill 之后,一定能拿到什么结构的数据。这一点在 Agent 编排场景里尤其重要——多个 skill 串联时,上游的输出就是下游的输入,格式不稳定,整条链路就断了。
还有一个考量是“可扩展性”。安全审计的检查项是会不断增加的,今天查依赖漏洞,明天可能要查容器配置,后天可能要查 API 鉴权。如果每加一个检查项就要改主流程,这个 skill 很快就会变得难以维护。所以我把检查器设计成插件式的,每个检查器实现统一的接口,注册到调度器里就行。report-schema.json里预留了category和severity字段,新检查器只要按这个 schema 输出,就能无缝接入。
2. 核心文件解析与数据契约设计
2.1 findings.json 的字段设计与取舍
findings.json是整个 skill 的核心产出。它的结构设计直接决定了后续所有消费方能不能高效使用审计结果。我见过太多审计工具的输出是一坨没有结构的文本,导致每次都要写正则去解析,痛苦不堪。所以在这个项目里,我把findings.json设计成一个顶层为数组、每个元素是一条 finding 的结构。
一条 finding 包含这些字段:
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| id | string | 唯一标识,建议用 UUID 或 hash | 是 |
| title | string | 简短描述,一句话说清问题 | 是 |
| description | string | 详细说明,包含上下文 | 是 |
| severity | string | 严重级别:critical/high/medium/low/info | 是 |
| category | string | 分类:dependency/config/permission/secret 等 | 是 |
| location | object | 问题位置,包含 file、line、column 等 | 否 |
| evidence | string | 证据片段,比如匹配到的敏感字符串 | 否 |
| remediation | string | 修复建议 | 否 |
| references | array | 参考链接 | 否 |
| detected_at | string | 检测时间,ISO 8601 格式 | 是 |
| checker | string | 产生这条 finding 的检查器名称 | 是 |
为什么severity用字符串而不是数字?因为数字级别在不同团队里含义不一样,有人觉得 1 是最高,有人觉得 5 是最高。用字符串critical/high/medium/low/info是业界比较通用的做法,CVSS 和很多漏洞库都这么用,认知成本低。而且字符串在 JSON 里可读性更好,人直接看也能看懂。
location设计成对象而不是字符串,是为了兼容不同场景。代码扫描有文件和行号,配置检查可能只有文件路径,服务检查可能只有端点 URL。用对象可以灵活扩展,比如加endpoint、parameter等字段。这里有个经验:不要为了省事把 location 写成"file.js:42"这种字符串,后期想按文件聚合、按行号排序的时候,解析字符串会让你抓狂。
evidence字段要特别注意。它可能包含敏感信息,比如扫到的密钥片段。所以在写入findings.json之前,一定要做脱敏处理。我的做法是只保留前 4 位和后 4 位,中间用****替代。这样既能证明问题存在,又不会把敏感信息二次泄露。这个细节很多审计工具都忽略了,结果审计报告本身成了泄露源。
2.2 report-schema.json 的作用与编写要点
report-schema.json是findings.json的结构定义,用 JSON Schema 格式编写。它的作用有三个:第一,校验findings.json是否符合预期结构,防止检查器输出脏数据;第二,给消费方提供文档,他们看 schema 就知道数据长什么样;第三,在 Agent 场景里,schema 可以作为工具调用的参数定义,让 Agent 知道这个 skill 的输出契约。
编写 schema 的时候,有几个坑我踩过。第一个坑是required字段设得太严。一开始我把所有字段都设为必填,结果有些检查器拿不到location信息,直接报错。后来改成只有核心字段必填,可选字段用required数组里不包含的方式处理。第二个坑是severity没有用enum约束,导致有检查器输出了"Critical"大写开头,和其他的"critical"不一致,聚合的时候对不上。加上enum之后,schema 校验会直接拦住这种问题。
一个精简版的report-schema.json大概长这样:
{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Security Audit Findings", "type": "array", "items": { "type": "object", "required": ["id", "title", "severity", "category", "detected_at", "checker"], "properties": { "id": { "type": "string" }, "title": { "type": "string", "minLength": 1 }, "description": { "type": "string" }, "severity": { "type": "string", "enum": ["critical", "high", "medium", "low", "info"] }, "category": { "type": "string" }, "location": { "type": "object", "properties": { "file": { "type": "string" }, "line": { "type": "integer", "minimum": 1 }, "column": { "type": "integer", "minimum": 1 }, "endpoint": { "type": "string" } } }, "evidence": { "type": "string" }, "remediation": { "type": "string" }, "references": { "type": "array", "items": { "type": "string", "format": "uri" } }, "detected_at": { "type": "string", "format": "date-time" }, "checker": { "type": "string" } } } }这个 schema 可以直接被 Python 的jsonschema库、Node 的ajv等工具使用。在 skill 的执行流程里,每个检查器输出结果后,先过一遍 schema 校验,不通过的就打日志并丢弃,避免脏数据污染最终报告。这个校验步骤看起来多余,但实际跑起来能省掉大量排查时间。
2.3 两个文件如何配合工作
findings.json和report-schema.json的关系,类似于“数据”和“数据字典”。执行流程是这样的:调度器启动所有检查器,每个检查器返回一个 finding 数组,调度器把所有数组拼在一起,去重,按 severity 排序,然后写入findings.json。写入之前,用report-schema.json做一次全量校验。校验通过,skill 执行成功;校验失败,skill 返回错误,并附带具体的校验失败信息。
这里有个设计决策值得说:为什么不在每个检查器内部直接写文件,而是统一由调度器写?因为并发写同一个文件会有竞争问题,而且去重和排序需要全局视角。统一由调度器写,逻辑清晰,也方便做事务性处理——要么全部成功,要么全部失败,不会出现写了一半的findings.json。
去重逻辑我用了(title, location.file, location.line)三元组作为 key。同一个问题在不同检查器里可能被重复发现,比如依赖检查器和配置检查器都可能报某个库的版本问题。用这个三元组去重,能去掉大部分重复。但要注意,如果两个检查器报的是同一个文件同一行但不同类别的问题,不应该去重。所以 key 里其实还应该加上category。这个细节我在第一版里漏了,导致有些真实问题被误删,后来补上才正常。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
这个 skill 我用 Python 实现,因为安全审计生态里 Python 的库最全,而且 Agent 平台对 Python 的支持通常也最好。基础环境是 Python 3.9 以上,推荐 3.11,因为 3.11 的异常处理和性能都有明显提升。
依赖方面,核心的只有几个:
pip install jsonschema pyyaml requestsjsonschema用来做report-schema.json的校验。pyyaml用来读配置文件,很多审计目标本身就是 YAML 格式。requests用来做服务端点的检查,比如探测 HTTP 头里的安全配置。
如果你要做依赖漏洞检查,还需要装pip-audit或者safety。这两个工具各有优劣:pip-audit是 PyPA 官方维护的,和 pip 集成好;safety的漏洞库更新更频繁,但免费版有调用次数限制。我一般两个都装,交叉验证。
pip install pip-audit safety目录结构我建议这样组织:
security-audit-skill/ ├── skill.py # 入口,调度器 ├── checkers/ │ ├── __init__.py │ ├── base.py # 检查器基类 │ ├── dependency.py # 依赖检查 │ ├── config.py # 配置检查 │ ├── permission.py # 权限检查 │ └── secret.py # 敏感信息扫描 ├── schemas/ │ └── report-schema.json ├── output/ │ ├── findings.json │ └── report-schema.json └── config.yaml # skill 自身配置checkers/base.py里定义基类,所有检查器继承它,实现run(target)方法,返回 finding 列表。基类里做公共的事情,比如生成detected_at时间戳、填充checker字段、做 evidence 脱敏。这样每个检查器只需要关注自己的检查逻辑,不用重复写这些样板代码。
3.2 检查器基类与调度器实现
基类的设计我改过三版。第一版没有基类,每个检查器各写各的,结果字段命名不一致,有的用checker_name,有的用source,聚合的时候要写一堆映射。第二版加了基类,但把脱敏逻辑放在调度器里,导致每个检查器返回的 evidence 是原始的,调度器统一处理。第三版把脱敏下沉到基类,检查器返回之前就已经脱敏好了,调度器只负责聚合。
基类的核心代码大概是这样:
import uuid import re from datetime import datetime, timezone class BaseChecker: name = "base" def run(self, target): raise NotImplementedError def make_finding(self, title, severity, category, **kwargs): finding = { "id": str(uuid.uuid4()), "title": title, "severity": severity, "category": category, "detected_at": datetime.now(timezone.utc).isoformat(), "checker": self.name, } finding.update(kwargs) if "evidence" in finding: finding["evidence"] = self._mask(finding["evidence"]) return finding def _mask(self, text): # 保留前4后4,中间打码 if len(text) <= 8: return "****" return text[:4] + "****" + text[-4:]调度器的逻辑是:读配置,实例化所有启用的检查器,依次调用run,收集结果,去重,排序,校验,写文件。排序规则是 severity 优先级从高到低,同级别按detected_at升序。去重 key 用(title, category, location.file, location.line)。
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4} def dedupe(findings): seen = set() result = [] for f in findings: loc = f.get("location", {}) key = (f["title"], f["category"], loc.get("file"), loc.get("line")) if key not in seen: seen.add(key) result.append(f) return result def sort_findings(findings): return sorted(findings, key=lambda f: (SEVERITY_ORDER[f["severity"]], f["detected_at"]))写完findings.json之后,还要把report-schema.json也复制到输出目录。为什么?因为消费方拿到findings.json的同时,应该能拿到对应的 schema,这样它才能自己校验。如果 schema 只放在 skill 内部,消费方就不知道数据契约是什么。这个做法在 Agent 场景里特别有用——Agent 可以把 schema 作为工具定义的一部分,直接告诉模型输出应该长什么样。
3.3 敏感信息扫描检查器的实现细节
敏感信息扫描是使用频率最高的检查器,也是最容易出问题的。我实现的时候用了正则加熵值双重判断。纯正则误报太多,比如一个普通的 base64 字符串可能被误判成密钥。加上熵值判断之后,误报率明显下降。
熵值计算用 Shannon 熵:
import math from collections import Counter def shannon_entropy(s): if not s: return 0 counts = Counter(s) length = len(s) entropy = 0 for count in counts.values(): p = count / length entropy -= p * math.log2(p) return entropy经验阈值是 4.5。低于这个值的字符串,大概率是普通文本,不是随机生成的密钥。高于这个值,再结合正则匹配(比如AKIA[0-9A-Z]{16}这种 AWS key 的模式),才判定为敏感信息。
扫描的时候要排除一些目录,比如.git、node_modules、venv、__pycache__。这些目录里要么是二进制文件,要么是第三方代码,扫了只会产生噪音。排除逻辑用pathlib的rglob配合过滤:
EXCLUDE_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build"} def iter_files(root): for p in Path(root).rglob("*"): if p.is_file() and not any(part in EXCLUDE_DIRS for part in p.parts): yield p还有一个坑是文件编码。有些文件不是 UTF-8,直接读会抛UnicodeDecodeError。我的处理是errors="ignore",跳过无法解码的字符。虽然可能漏掉一些内容,但总比整个扫描崩掉好。对于二进制文件,先判断扩展名,常见的二进制扩展名直接跳过。
注意:敏感信息扫描的结果里,evidence 字段一定要脱敏。我见过有团队把扫到的完整密钥写进报告,然后报告被上传到共享盘,等于把密钥公开了。脱敏不是可选项,是必选项。
4. 常见问题与排查技巧实录
4.1 findings.json 校验失败的排查思路
校验失败是最常见的问题,表现是 skill 执行到最后一步报错,提示某个字段不符合 schema。排查的时候按这个顺序来:
第一,看错误信息里的path。jsonschema的报错会告诉你具体是哪个字段、哪个位置出了问题。比如$.items[3].severity说明是第 4 条 finding 的 severity 字段有问题。先定位到具体 finding,再看它的值是什么。
第二,检查severity的值。最常见的错误是大小写不一致,或者用了 schema 里没有定义的值。比如写了"moderate"但 enum 里只有critical/high/medium/low/info。这种问题在自定义检查器里特别容易出,因为每个人对严重级别的叫法不一样。
第三,检查detected_at的格式。schema 里用了format: date-time,要求是 ISO 8601 格式。如果检查器里用了datetime.now().isoformat()但没有带时区,某些严格的校验器会报错。统一用datetime.now(timezone.utc).isoformat()最稳妥。
第四,检查location.line的类型。schema 里定义的是integer,但有些检查器从文件里读行号的时候读成了字符串。Python 的jsonschema对类型检查很严格,"42"和42是两回事。
我整理了一个速查表:
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
'severity' is not one of [...] | 严重级别值不在 enum 里 | 统一用小写,对照 enum 修正 |
'detected_at' is not a 'date-time' | 时间格式不对 | 用datetime.now(timezone.utc).isoformat() |
'line' is not of type 'integer' | 行号是字符串 | 用int()转换 |
'id' is a required property | 缺少 id 字段 | 检查 make_finding 是否被正确调用 |
Additional properties are not allowed | 有多余字段 | 要么删掉,要么在 schema 里加additionalProperties: true |
4.2 检查器执行超时的处理
依赖检查器调用pip-audit的时候,如果网络不好,可能会卡很久。默认没有超时的话,整个 skill 就挂在那里了。我的做法是给每个检查器加超时控制,用concurrent.futures的ThreadPoolExecutor配合future.result(timeout=...)。
from concurrent.futures import ThreadPoolExecutor, TimeoutError def run_checker(checker, target, timeout=60): with ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit(checker.run, target) try: return future.result(timeout=timeout) except TimeoutError: return [checker.make_finding( title=f"Checker {checker.name} timed out", severity="info", category="meta", description=f"检查器 {checker.name} 执行超过 {timeout} 秒,已跳过" )]超时之后不是直接丢弃,而是生成一条info级别的 finding,说明这个检查器没跑完。这样消费方知道结果是不完整的,不会误以为“没发现问题就是没问题”。这个设计很重要,安全审计里“漏报”比“误报”危险得多。
超时时间设多少?依赖检查建议 120 秒,因为要下载漏洞库。配置检查和权限检查 30 秒足够。敏感信息扫描看仓库大小,小仓库 60 秒,大仓库可以放到 300 秒。这些值放在config.yaml里,不同项目可以覆盖。
4.3 误报治理的实操经验
误报是安全审计工具的通病。误报太多,开发团队就不信任报告了,最后没人看。我在这个项目里用了三种手段治理误报。
第一种是白名单机制。在config.yaml里允许配置ignore_rules,每条规则包含checker、title_pattern、location_pattern。匹配到的 finding 直接不写入报告。比如某个测试文件里故意放了假密钥,就可以加白名单。
第二种是置信度分级。每条 finding 除了severity,还可以加一个confidence字段,值域是high/medium/low。正则匹配到的敏感信息,如果熵值刚过阈值,标为low;如果熵值很高且匹配了明确的模式,标为high。消费方可以根据置信度决定要不要处理。
第三种是上下文判断。比如扫到password = "test123",如果这个文件在tests/目录下,大概率是测试数据,降级为info。如果是在src/目录下,保持原级别。这个逻辑写在检查器里,根据文件路径做判断。
提示:白名单要定期审查。我见过有团队把白名单当垃圾桶,什么问题都往里扔,最后白名单比真实发现还多。建议每个月过一遍白名单,确认每条豁免仍然合理。
4.4 Agent 调用场景下的特殊问题
当这个 skill 被 Agent 调用时,有几个额外的问题要注意。
第一个是输出大小。findings.json如果太大,比如几万条 finding,Agent 的上下文窗口可能放不下。我的做法是在 skill 层面做聚合,如果 finding 数量超过阈值(比如 500 条),就按category和severity分组,每组只保留前 N 条,其余用一条汇总 finding 代替。汇总 finding 的description里说明“还有 X 条同类问题未列出”。
第二个是执行时间。Agent 调用工具通常有超时限制,如果 skill 跑太久,Agent 会认为调用失败。所以 skill 要支持“快速模式”,只跑关键检查器(敏感信息、高危配置),跳过耗时的依赖检查。快速模式通过参数控制,Agent 可以根据场景选择。
第三个是错误处理。Agent 调用 skill 失败时,需要知道失败原因才能决定下一步。所以 skill 的错误输出也要结构化,不能只抛一个异常。我的做法是返回一个包含status、error_code、message的 JSON,Agent 解析这个 JSON 就能知道是超时、校验失败还是其他问题。
{ "status": "error", "error_code": "SCHEMA_VALIDATION_FAILED", "message": "findings.json 校验失败:$.items[3].severity 值 'Critical' 不在 enum 中", "partial_findings_count": 12 }这个错误结构让 Agent 可以做针对性处理。比如SCHEMA_VALIDATION_FAILED可以重试,TIMEOUT可以降级到快速模式,TARGET_NOT_FOUND则应该直接报给用户。
5. 扩展方向与个人实践体会
这个 skill 目前覆盖了依赖、配置、权限、敏感信息四类检查,但安全审计的范畴远不止这些。我后续打算加两个检查器:一个是容器镜像检查,扫描 Dockerfile 里的最佳实践问题,比如用了latest标签、以 root 运行、没有多阶段构建;另一个是 API 鉴权检查,针对 OpenAPI 规范文件,检查哪些端点缺少鉴权声明。
容器镜像检查的实现思路是解析 Dockerfile,逐条指令做规则匹配。比如FROM指令如果基础镜像没有指定版本标签,报medium;USER指令如果缺失或者设为root,报high。这些规则不复杂,但很实用,能在构建阶段就拦住一批常见问题。
API 鉴权检查稍微复杂一点,需要解析 OpenAPI 的 YAML 或 JSON,遍历所有 path 和 method,检查security字段是否存在。如果某个端点没有security定义,且全局也没有默认安全策略,就报high。这个检查器对微服务项目特别有价值,因为端点多了之后,很容易漏掉某个端点的鉴权配置。
关于report-schema.json的版本管理,我建议加一个schema_version字段。当 schema 发生不兼容变更时,版本号递增,消费方可以根据版本号决定怎么解析。这个字段放在 schema 的顶层,和$schema并列。虽然现在还没用到,但提前留好位置,以后改起来不会手忙脚乱。
最后分享一个我在实际使用中总结的小技巧:把findings.json和上一次的审计结果做 diff,只输出新增的 finding。这个功能在 CI 场景里特别有用——每次提交都跑全量审计,但只对新增问题报警,存量问题不打扰开发。实现方式是保存上一次的findings.json,用(title, category, location.file, location.line)做 key 做集合差。这个 diff 逻辑我封装成了一个独立的函数,放在调度器里,通过配置开关控制。
踩过几次坑之后,我越来越觉得安全审计工具的价值不在于“查得多全”,而在于“结果可信、可操作”。一个查了一百项但误报八十项的工具,不如一个只查二十项但每项都准的工具。security-audit-skill的设计里,schema 校验、去重、脱敏、置信度分级这些“非检查逻辑”,其实才是让结果可信的关键。检查逻辑决定能发现什么,而这些工程细节决定发现的东西有没有人愿意看。