你接手的代码库不是你写的,业务逻辑又绕又长,安全测试排期还要三天后,可线上版本已经在灰度了。这时候与其干等人工审计,不如把活儿拆成机器能干的:先用规则把代码翻个底朝天,再用大模型把规则的误报筛一遍、把老代码里那些“一眼看不出来”的逻辑漏洞挑出来。这套东西,我最近用AI代码审查:security-audit-skill在内部跑通了,从扫描到报告,全程不需要安全工程师守在旁边。这篇就把整个技能的设计、落地坑位和跑批经验一次说清楚。
这个security-audit-skill本质上是一个可复用的代码安全审计技能包,做两件事:第一,把能机器化的静态扫描变成一套可重复执行的流程;第二,把大模型的语义理解能力接在扫描结果后面,代替人去看告警、判误报、补上下文。它不是要替代安全工程师,而是把审计里最耗时间的部分压下来,适合研发团队自检、安全团队做初筛、以及任何想在做完代码 review 之后再加一道自动化防线的人。
1. 技能的整体设计和思路拆解
1.1 为什么不是拿大模型直接“裸审”代码
一开始我也试过直接拿大模型跑整个代码仓库,把文件路径和源码一股脑塞进对话窗口,让它“找出安全问题”。结果喜忧参半:单文件、几百行的小项目它说得头头是道,但一旦文件多、跨函数调用多,它普遍开始“捡了芝麻丢西瓜”——要么抓住一个中风险问题反复展开,要么干脆漏掉真正埋着的高危逻辑。
问题不在模型能力,而在任务形态。安全审计本质上是一个高召回率优先的检索任务,你得先把所有可疑的点找出来,再人工或模型逐个判定。而大模型擅长的是“给定上下文做判断”,并不擅长“从一段很长的代码里自己发现所有问题”。所以裸审这种用法,天然违背了模型的工作方式。
换成安全审计的思路之后,事情就顺了:先静态扫描把代码变成一份“可疑点清单”,这一步要求的是规则覆盖和可重复执行,而不是聪明;然后把清单交给大模型,让它针对每个点去读上下文、判断是否真的危险,这一步要求的是推理和解释能力。两件事分开做,每一件都能做到极致。
1.2 技能包需要覆盖哪些审计场景
设计这个 Skill 的时候,我按业界常见的安全审计面来切,尽量保证覆盖度而不是深度。大致分成了四条线:
- 注入与命令执行类:SQL 注入、命令注入、代码注入、模板注入,这类问题通常能在代码里找到显式的“拼接”特征。
- 认证与会话管理类:硬编码密钥、弱口令、JWT 校验缺失、Session 固定、越权接口。这类问题除了特征,更要看调用链。
- 敏感信息泄露类:日志里打印凭证、前端代码里内嵌密钥、接口返回多余字段、硬编码邮箱密码。
- 依赖与配置类:使用了有已知 CVE 的依赖版本、容器镜像未固定版本、开放了不该开放的端口、debug 模式下线。
另外我还会看一眼业务逻辑漏洞,比如支付流程里能不能负数金额、登录接口有没有做频率限制。这类问题静态规则经常抓不到,但大模型偶尔能通过“读代码往前走几步”发现苗头,所以我把它们放在规则扫描之后、大模型研判阶段的补充任务里。
1.3 审计技能的技术选型:规则库、扫描器与大模型的搭配
真正动手做技术选型的时候,我碰到了第一个重要决定:底层用现成的扫描器,还是自己写规则引擎?
用现成的 SAST 工具(比如 Semgrep、CodeQL、SonarQube)好处显而易见——规则积累厚、社区生态好、误报率调过一轮。但问题也实在:在企业内部环境里,安装依赖、配置 license、引入重量级 CI 插件,每一步都可能卡在流程上。而且它们的规则普遍偏“代码特征”,对业务逻辑的感知几乎为零。
我这个技能更想要一种“轻量可控”的状态:扫描逻辑浅但是透明,规则自定义成本低,结果能直接喂给大模型做二次研判。所以最后选了半自制方案:底层解析用通用 AST 工具,匹配层自己写规则集,规则用 YAML 维护,这样既能结合静态规则的速度,又能保留大模型做语义判断的灵活性。
如果你在国内的网络环境或者内网环境做部署,这个方案还有一个额外的好处:引擎层完全离线可用,不需要每次跑审计都去请求外部依赖库,规避了很多网络和合规的不确定性。
2. 核心细节解析与实操要点
2.1 Skill 的目录结构和输入输出协议
一个合格的技能包,结构必须能让机器读、能让人维护。
security-audit-skill/ ├── SKILL.md # 技能主定义:角色、能力边界、调用说明 ├── rules/ # 规则集,按语言/漏洞类型拆分 │ ├── python_sql_injection.yaml │ ├── python_cmd_exec.yaml │ ├── javascript_xss.yaml │ ├── hardcoded_secrets.yaml │ └── dependencies_cve.yaml ├── scanners/ # 扫描引擎入口 │ ├── grep_scanner.py # 基于特征的正则扫描器 │ ├── ast_scanner.py # 基于AST的代码结构分析器 │ └── semgrep_wrapper.py # 可选的 Semgrep 适配器 ├── llm_reviewer/ # 大模型研判模块 │ ├── prompt_templates.py # 每个漏洞类型的分析提示词模板 │ ├── code_reader.py # 上下文读取/裁剪工具 │ └── report_builder.py # 汇总规则命中 + LLM 分析结果,生成报告 ├── reports/ # 输出目录,按时间戳生成 └── config.yaml # 技能级配置:语言、忽略路径、模型 API 参数调用协议不需要花哨,做到两件事就够:输入一个代码仓库路径(或 diff 文件),输出一份审计报告。报告要区分机器可读部分(JSON)和人可读部分(Markdown/HTML),这样既能接入流水线自动归档,又能直接开会评审用。
2.2 规则引擎设计:YAML 规则到底怎么写
规则是整个技能的核心资产,我对规则的要求就两条:要快、要结果可解释。所以每条规则都尽量独立成文件,并且要备注为什么这条规则存在、它的误报率预期是多少。
拿 SQL 注入来举例,一份精简版规则长这样:
id: python_sql_injection language: python severity: high category: injection description: 检测通过字符串拼接或格式化构造 SQL 查询的情况 patterns: - type: subprocess_call regex: "execute\s*\((.*?)\)" - type: string_concat regex: "SELECT.*\+|SELECT.*%|SELECT.*\.format|f['\"].*SELECT" - type: direct_query regex: "cursor\.execute\(.*%s" allowlist: - "execute_many" - "参数化查询"规则里的patterns就是扫描器要匹配的特征。我用正则作为第一道粗筛,原因是快——一个十万行代码的仓库,纯正则扫描跑完也就几十秒。粗筛结果再进入 AST 扫描器,判断拼接点是否真的落到了 SQL 函数参数上,这步能滤掉不少“看着像、其实不是”的误报。
然后allowlist是我后来才加的,专门用来记录那些“命中规则但内部确认安全”的例外。比如代码里有大量封装好的参数化查询方法,函数名本身含SELECT,正则必然会扫到,加进允许名单之后,后续审计就再也不会被这批合法代码干扰。
2.3 大模型研判阶段:提示词模板怎么写才不跑偏
规则命中之后,一个大难题摆在面前:如何让大模型只做“确认”而不做“发散”。
实验过几轮之后,我发现最稳的提示词结构是固定三段式:
- 给结论框架:先让模型输出“高危 / 中危 / 低危 / 误报”四选一。
- 给分析边界:要求模型只能基于提供的代码片段做判断,不许猜测未展示的代码逻辑。
- 给输出格式:JSON,必须包括
reason、confidence、suggested_fix三个字段。
review_prompt_base = """ 你是资深安全工程师。审查以下代码片段,并判断规则 {rule_id} 的命中是否构成真实安全风险。 【代码片段】 {code_snippet} 【命中说明】 {rule_hit_detail} 【输出要求】 只输出 JSON,不要多余文字: {{ "verdict": "high | medium | low | false_positive", "reason": "不超过120字的中文分析", "confidence": 0.0-1.0, "suggested_fix": "如果是误报可以不填,否则给出一行修改建议" }} """有了这个模板,一个命中点从“规则扫到”到“大模型给出判定”,单条平均耗时 2 到 3 秒。一批几百个告警,半小时内就能清洗完,这相比人工逐个看快了一个数量级。而且整个研判过程保留 JSON 原始输出,后续可以拿去继续训练内部模型,或者给安全组做回溯审计用。
2.4 报告输出与安全评级:给开发看的结论,不是给机器看的日志
审计结果最怕的就是扔给开发一个大 JSON 文件,那跟没审计没什么区别。我的做法是双层报告:
- JSON 层:保留完整流水线数据,包括规则 ID、命中行号、代码片段、LLM 评级和置信度,方便持续集成和自动归档。
- Markdown/HTML 层:面向人阅读,只保留两类结论——确定为风险的问题、疑似风险但需要人工确认的问题。前者给出修复建议和优先级排序,后者标注为什么存疑。
报告中还要生成一个“风险概览表”,这是让安全团队和管理层快速拍板的利器:
| 风险级别 | 数量 | 优先级 | 一句话定性 |
|---|---|---|---|
| 高危 | 6 | P0 | 存在可直接利用的注入或认证绕过风险,建议 24h 内介入 |
| 中危 | 23 | P1 | 大概率可被利用,但需要特定触发条件 |
| 低危 | 41 | P2 | 合规性风险或代码质量问题为主 |
| 误报 | 58 | - | 模型与规则复核后判定为安全,已归档 |
3. 实操过程与核心环节实现
3.1 环境准备:装什么、配什么、怎么跑通
环境部分力求简单,我这边实际用到的依赖一共就三类:Python 3.10+、一个代码解析库(tree-sitter 或 ast 标准库都行)、以及一个大模型 API 接口(OpenAI 兼容协议,也可以是本地部署的模型服务)。
先看项目的配置文件,所有可调参数都集中在config.yaml:
# config.yaml repo_path: "./sample_repo" # 待审计的代码仓库路径 exclude_paths: # 扫描排除的路径,避免噪音 - "node_modules/" - "venv/" - "dist/" - "tests/" languages: - python - javascript - go rulesets: - "python_sql_injection" - "python_cmd_exec" - "hardcoded_secrets" - "javascript_xss" - "dependencies_cve" llm: api_base: "" # 模型 API 地址 model: "deepseek-chat" # 或内部部署模型名 temperature: 0.1 # 安全研判必须低温,减少随机性 max_tokens_per_call: 800 # 单次调用上限,控制成本 scan_depth: "full" # full=全量代码, diff=仅变更代码这里有个被我反复验证过的使用习惯:做全量审计时用full,做日常流水线时用diff。全量审计适合发版本前、接外包代码后、以及新成员交接老项目时跑一次;而日常每次提交触发的话,只扫描变化的部分就够,速度快好几倍,告警量也更容易控制。
3.2 跑通一次完整审计:从扫描到出报告的执行细节
下面的调度代码是整个技能的引擎核心,我拆成几个函数来讲清楚每个环节是干嘛的。
# audit_runner.py import yaml import json import pathlib from scanners.grep_scanner import GrepScanner from scanners.ast_scanner import ASTScanner from llm_reviewer.prompt_templates import LLMReviewer def load_config(config_path="config.yaml"): """加载配置,并做基本的完整性校验""" with open(config_path, "r", encoding="utf-8") as f: config = yaml.safe_load(f) if not config.get("repo_path"): raise ValueError("repo_path 必填") return config def run_scan_phase(config): """第一阶段:规则扫描""" scanner = GrepScanner(config) pattern_hits = scanner.scan_all() ast_scanner = ASTScanner(config) ast_hits = ast_scanner.verify(pattern_hits) # 用 AST 复核正则粗筛结果 return ast_hits def run_review_phase(config, hits): """第二阶段:大模型研判""" reviewer = LLMReviewer(config) review_results = [] for hit in hits[:200]: # 单次最多研判 200 个命中,防止成本失控 result = reviewer.review(hit) review_results.append(result) return review_results def build_report(config, hits, reviews): """第三阶段:产出双层报告""" report_builder = ReportBuilder(config) report_builder.write_json(hits, reviews) report_builder.write_markdown(hits, reviews) log.info("审计报告已输出至 reports/") if __name__ == "__main__": cfg = load_config() scan_hits = run_scan_phase(cfg) reviews = run_review_phase(cfg, scan_hits) build_report(cfg, scan_hits, reviews)这段代码非常直观,你甚至可以不用框架,直接照着这个逻辑在 CI 里挂两个脚本:一个做扫描,一个做研判。实际跑的时候,注意第二阶段的[:200]切片,这个数字不是拍脑袋定的,它对应的是单次任务成本上限。举个例子,一次扫描命中 800 个点,全部丢给大模型研判,按每条 800 token 算就是 64 万 token,既不便宜也没必要——真正常见的真阳性不会超过总量的 10%,第一次研判权当筛选,把高置信的挑出来就够了。
3.3 命中结果的清洗与去重:哪些该删、哪些该留
跑过真实仓库之后你一定会发现,扫描结果里大量命中有几个共同特征:测试代码、脚手架文件、自动生成的代码。这些不做清洗,后面大模型研判也会被带偏,浪费 token 和时间。
我在清洗阶段做了三层过滤:
- 路径过滤:
tests/、mock/、fixtures/、*.min.js、*.generated.*一律不算。 - 结构过滤:AST 层确认命中点是局部函数引用而非外部可触达入口时,自动降级为“参考信息”。
- 语义过滤:大模型判定为“误报”或“疑似但不影响安全”的结果,单独归档,不进入风险清单。
这三层走完,告警量通常能降个六七成,剩下的才值得安全组成员人肉看。用一套简单的话来概括:扫描是捞鱼,清洗是挑鱼,大模型是掂量每条鱼的斤两,最后报告只给能上桌的那几条。
3.4 调用大模型做研判时的上下文裁剪技巧
大模型研判阶段最懊恼的事不是模型答错,而是你塞进去的代码它看不懂——上下文太短缺前因后果,太长钱烧得快还容易跑题。我总结出一个“三明治”裁剪法:
- 面包层:类的声明和构造函数,让模型知道这段代码在哪个类里、依赖哪些成员变量。
- 肉饼层:命中点所在的函数体,前后各保留 20 到 30 行,让模型看清楚执行流。
- 芝士层:如果是跨函数调用,就再附加一个被调函数的签名和很短的主体摘要。
def build_context_for_hit(hit, codebase, before=20, after=30): ctx_lines = [] # 面包层:类声明 cls_decl = codebase.get_class_at(hit["file"], hit["line"]) if cls_decl: ctx_lines.append(cls_decl) # 肉饼层:命中点前后代码 lines = codebase.read_lines(hit["file"], hit["line"] - before, hit["line"] + after) ctx_lines.extend(lines) # 芝士层:被调用函数签名 callee = codebase.find_callee(lines, hit["file"]) if callee: callee_sig = codebase.get_function_signature(callee) ctx_lines.append(callee_sig) return "\n".join(ctx_lines)这个裁剪思路有一个隐藏收益:因为喂给模型的代码可控,max_tokens_per_call也可以压得很低,单次调用成本从原来的可能几美分降到零点几美分,审计几千个文件也能控制在预算内。
4. 常见问题与排查技巧实录
4.1 告警多到无法处理:先把“规则召回率”降下来
我第一次用这套 skill 跑一个两万行 Python 项目,粗扫告警 600 多条,直接看懵了。后来排查发现,问题出在正则规则上——一个SELECT关键字匹配就把所有包含 select 的日志语句、注释、字符串全捞了出来。
我的调参顺序是这样的:先加allowlist,把已确认安全的封装函数列表维护进去;然后再提高patterns的精度,要求execute(函数名必须伴随“字符串拼接或格式化”,单纯的传参不算。一轮调下来,规则命中数从 600 降到 90,实际风险点一个没漏。
如果你发现自己某个规则命中上千条,不要急着让模型去筛。先回头审视规则本身——规则应是外科手术刀,不是大范围扫射的加特林。
4.2 大模型的判定结果忽高忽低,不稳定怎么办
温度(temperature)是最先要查的,这个参数我强制设到 0.1,目的就是让输出的随机性降到最低。别小看这个细节,我做过对照实验:同样的代码和规则,temperature=0.7跑两轮,一条命中一次被判“高危”、一次被判“误报”,这结果直接没法用;降到 0.1 之后,同一个命中点的判断一致性从不到 50% 提升到接近 90%。
其次是把置信度(confidence)拆出来单看。即使模型判断“高危”,置信度只有 0.4,说明模型自己也很犹豫,这种命中就不要直接进 P0 清单,先标记为“待人工复核”。在流程里加一个置信度阈值,比事后靠人逐条看靠谱得多。
4.3 想接进 CI 流水线,又怕拖慢速度怎么办
如果你把整套全量扫描铺在每次提交上,MR 体感时间大概率会爆炸。我在团队里的做法是拆成两个 Job:
- 快轨(每次 MR 必跑):
diff模式 + 只跑高危规则集,例如注入类和硬编码密钥类,跑完自动判断这几个风险是否新增,目标时间是 2 分钟内完成。 - 慢轨(每晚一次):
full模式 + 全量规则 + 大模型研判,输出完整审计报告,第二天上班安全组直接看报告,不阻塞开发流程。
两条轨共用一套规则库和技能包,只是入口参数不同。想接 GitLab CI 或 GitHub Actions,核心其实是把调用逻辑写成命令行入口,python audit_runner.py --mode diff --since main这样,平台侧只负责装饰。
4.4 大模型审计出现幻觉怎么办:如何锁定它“瞎编”的问题
大模型在研判阶段偶尔会“编”一个风险,尤其当代码片段本身信息量少的时候。我遇到过的最典型情况:模型把一个普通字典读取判定成了“路径穿越漏洞”,理由是 key 可能来自外部用户输入——但它其实根本没看到 key 的来源。
要解决这一步,不能依赖模型自觉,必须在流程里加“证据校验”。做法是要求模型输出它参考的行号范围,代码里把这个行号范围和命中点附近代码做比对,如果模型引用行号超出给定范围,则自动将这条判定降级为“低置信”。
def verify_review_evidence(review, hit): ref_lines = review.get("reference_lines", []) hit_start, hit_end = hit["start_line"], hit["end_line"] # 扩展 50 行允许范围 ok = all(hit_start - 50 <= ln <= hit_end + 50 for ln in ref_lines) if not ok: review["confidence"] = min(review["confidence"], 0.3) review["verdict"] = "low" review["reason"] += " [证据引用超出范围,置信度自动下调]" return review加了证据校验之后,幻觉类误判大幅下降,这是我们内部对抗大模型“一本正经地胡说八道”最管用的一个补充机制。
5. AI 代码审查在团队里怎么落地不翻车
5.1 它不是给安全工程师的玩具,而是给开发同学的工具
如果整个技能包只有安全组在用,那这个方案实际上已经失败了一半——因为最核心的收益是开发在提交代码的时候自己先把问题拦下来。我的建议是把报告链接直接发给对应提交人,并在 MR 里留一条评论,附上命中行号和修复建议。与安全组的事后追责完全相反,这套工具要做的是在代码合入之前提醒,把安全问题前置到源头。
开发同学不一定要懂漏洞原理,只要按报告里的suggested_fix字段改就行。比如“调用cursor.execute时改用参数化查询,不要拼接字符串”,这类建议已经足够让一个后端新人自查自改。而安全工程师的精力则完全留给高置信、高危级别的疑难杂症。
5.2 规则库怎么持续迭代:每个误报都是资产
用这套技能跑审计,本质上是在持续地喂它数据。每次人工复核发现某条规则命中是误报,就应该把样本追加进对应规则的allowlist或misleading_cases里。慢慢你会发现,真正的排查能力等于“匹配规则 + 忽略清单”的积累,而不是模型有多聪明。
我习惯每两周抽半小时做一次规则更新:翻一遍本周所有被人工标为误报的样本,挑出共性,要么加 allowlist,要么改正则;同时看看有没有新出现的真实漏洞值得一提。规则库的成长是非常缓慢但扎实的,三个月后你再看最初的规则文件,会明显觉得最初那个版本有多粗糙。
5.3 这个技能包兜住了谁的安全底线
最终这套security-audit-skill在内部承担的角色是“三层防线”的一环:开发自测时用 IDE 插件做快速检查;MR 合入时用快轨流水线做高危规则体检;每晚全量审计出一份安全健康报告,供安全组做第二天的晨会输入。它不是万能的——业务逻辑漏洞依然会漏,0day 攻击链依然挡不住,但它实打实把那些最常见的注入、密钥泄露、依赖带病问题,挡在了上线之前。
我在实际操作中最深的感受是:做代码安全审计,不要追求“模型发现未知漏洞”,而要追求“已知漏洞无处遁形”。把已知问题管好,未知问题自然也就少了藏身之地。后面如果你想把这个技能扩展成支持 Java 和 Kotlin,或者加上对容器镜像和 IaC 模板的审计,其实只要往rules/里加对应的 YAML 规则文件就能跑起来——路已经铺平了,剩下的就是坚持下去,持续喂规则、盯告警、调误报。