这篇能做出什么
读完并跟着做,你会在自己的真实仓库里得到五样东西:
1. 一份入口清单:ATTACK_SURFACE.md,写清楚"外部到底能通过哪几种方式碰到这段代码",附文件行号证据。
2. 一张可达性判断表:把每个符号标成"外部可达 / 仅测试可达 / 无引用/动态可达待确认"。
3. 一批小步删除的提交:每条提交只删一个符号或一组同源代码,可单独回滚。
4. 一条证据链:单元测试 + 集成测试 + 请求回放对拍(改动前后跑同一批输入比输出),证明删除没有改变外部行为。
5. 一个守卫:CI 里检查入口清单有没有被悄悄改动,防止攻击面重新扩大。
关键点在于顺序:先盘点,再裁剪,最后验证。跳过盘点直接删,很容易删掉反射或配置里调用的代码;跳过对拍直接删,测试没覆盖到的地方坏了你也不知道。
前置条件清单
开工前确认这些,缺哪项就先补哪项:
- 仓库能在本地构建,测试能在本地跑通。跑不通的话,先修这个,否则后面所有验证都失去意义。
- 一个干净的分支和可用的 CI,能对每个提交跑测试。
- 编码智能体的运行环境:能读整个仓库、能执行测试命令。建议放在容器或独立工作区里,用只读挂载加可写副本的方式,避免它误改你正在开发的分支。
- 一批可回放的请求样本(真实流量脱敏、或者现有的集成测试用例)。没有的话,先用契约测试的输入凑一批。
- 记录基线的手段:测试通过数、构建产物体积、公开导出符号数量、依赖清单。
工具版本一律以官方文档当前版本为准,本文只给通用命令。
分步骤
步骤 1:开分支,冻结基线
先留一份"改动之前长什么样"的快照,后面每一步都要拿它对比。
```bash
git switch -c chore/attack-surface-20250101
{
echo "commit: $(git rev-parse --short HEAD)"
echo "date: $(date -u +%FT%TZ)"
echo "--- tests ---"
pytest -q 2>&1 | tail -n 5
echo "--- exported symbols ---"
rg -n --no-heading -g '!vendor' -g '!node_modules' '^def |^class |^func |^export ' src/ | wc -l
} | tee baseline.txt
```
pytest 换成你项目里的测试命令即可(go test ./...、npm test、mvn -q test)。这份 baseline.txt 提交进分支,后面每一步都和它比。
步骤 2:让智能体做入口点盘点
这一步只让它读、不让它写。给它的提示词要写死分类口径和证据要求,否则它会凭文件名猜。
```text
你是代码审计助手。仓库根目录 <path>。
任务:只做盘点,不修改任何文件。列出所有"外部可触达的入口点"。
按以下类别分组,每条给出:类别 / 文件:行号 / 触发方式 / 是否需要鉴权 / 输入来源。
类别:
1. 网络入口:HTTP、RPC、WebSocket 的路由注册点与中间件挂载点
2. 命令入口:CLI 子命令、脚本 main、Makefile 目标
3. 消息入口:队列消费者、事件订阅、webhook 回调
4. 定时入口:cron、调度器注册
5. 文件与系统入口:文件监听、上传落盘、反序列化、模板求值
6. 隐式入口:反射、动态 import、插件注册表、配置驱动的 handler 映射、原生 SQL 拼接
约束:
- 先用 ripgrep 找注册点,再顺着注册表追到处理函数,不要靠文件名推断。
- 每条结论必须附证据:文件:行号 + 关键代码一行。
- 确认不了的写"待人工确认",不要编造。
- 最后输出一张 Markdown 表格,并单独列出"注册被环境变量或配置开关控制"的位置。
```
它动手之前,你自己也跑一遍检索,方便校验它的结论:
```bash
rg -n --no-heading -g '!vendor' -g '!node_modules' -g '!dist' \
-e '@(app|router|bp|api)\.(route|get|post|put|delete|patch)' \
-e '\.(GET|POST|PUT|DELETE|PATCH)\(' \
-e 'HandleFunc|Handle\(|AddRoute|RegisterService|register_handler' \
-e 'ListenAndServe|listen\(|createServer|uvicorn|gunicorn' \
-e 'consume|subscribe|on_message|@Scheduled|setInterval|cron' \
. | head -n 200
```
框架不同,模式要换,上面只是起点。
步骤 3:人工校验,把清单变成事实源
智能体给的清单一定会有噪音,重点核这几类:
- 代码里有注册,线上未必暴露:路由前缀在网关层被拦截、管理端口只绑内网、特性开关默认关闭。这些属于"可以先删"的。
- 代码里没有显式注册,但确实可达:框架按目录约定扫描、配置里写 handler 名、插件目录动态加载。
- 间接入口:反序列化、模板渲染、表达式求值、文件路径拼接点。这类是攻击面的重点,不在路由表里但同样对外。
校验完写成 ATTACK_SURFACE.md,和代码同仓,作为后续评审的唯一事实源:
```markdown
| 类别 | 位置 | 触发方式 | 鉴权 | 输入来源 | 备注 |
|---|---|---|---|---|---|
| 网络 | api/routes/user.py:42 | POST /api/v1/users | JWT | body JSON | 有 schema 校验 |
| 消息 | worker/consumer.py:88 | 订阅 order.created | 无 | 队列 | 内部队列,仅内网 |
| 定时 | jobs/cleanup.py:15 | 每日 03:00 | 无 | 无 | 扫描全表,注意限流 |
| 隐式 | utils/template.py:31 | 模板字符串 | 取决于调用方 | 用户可控 | 待确认是否走沙箱 |
```
步骤 4:做可达性裁剪
两条腿走路:静态找"没人引用",动态找"运行时没走到"。
静态一条腿:语言生态里通常有现成的死代码检测工具(Go 的 deadcode、Python 的 vulture、JS 的 knip 之类),以官方文档当前版本为准。没有合适的工具时,用引用计数做个粗筛:
```python
tools/refcount.py —— 只统计模块顶层定义,方法名同名的概率太高
import ast, pathlib, re, sys
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "src")
skip = {".venv", "venv", "node_modules", "build", "dist", ".git"}
files = [p for p in root.rglob("*.py") if not (set(p.parts) & skip)]
texts = {p: p.read_text(encoding="utf-8", errors="ignore") for p in files}
defs = []
for p, src in texts.items():
try:
tree = ast.parse(src)
except SyntaxError:
continue
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
defs.append((node.name, f"{p}:{node.lineno}"))
for name, loc in sorted(defs, key=lambda x: x[1]):
hits = sum(len(re.findall(rf"\b{re.escape(name)}\b", s)) for s in texts.values())
if hits <= 1:
print(f"{loc}\t{name}\t只出现一次")
```
```bash
python tools/refcount.py src | tee candidates.txt
```
动态一条腿:跑测试收覆盖率,把从没执行过的模块捞出来。
```bash
Python
coverage run -m pytest -q && coverage report --show-missing --skip-covered
Go
go test -coverprofile=cover.out ./... && go tool cover -func=cover.out | sort -k3 -n | head -n 40
```
注意:覆盖率只说明"测试没走到",不说明"线上没走到"。生产流量的路径可能压根没进测试,所以覆盖率低只能作为线索,不能作为删除依据。
步骤 5:三分类,只删 A 类
把候选分成三堆,写进同一张表:
- A 类 真死:任何入口都不可达、无测试引用、无反射/配置/模板引用。
- B 类 仅测试可达:只被测试用到。要么保留,要么和测试一起删,别只删一半。
- C 类 动态可达:出现在配置值、字符串表、插件注册、模板里。不删,标记待确认。
然后让智能体只处理 A 类,提示词里禁止它自由发挥:
```text
现在只处理 A 类候选,逐个删除。规则:
1. 删之前先贴出证据:入口清单里的位置、grep 引用结果、动态引用排查结果。
2. 只改必要文件,不重命名、不格式化、不优化、不加注释。
3. 每删一个符号,立刻运行 <你的测试命令>;失败就回滚这一条,把原因记到 candidates.txt 对应行。
4. 一次提交只删一个符号,或一组必须同删的符号(比如类 + 它的私有辅助函数)。
5. 提交信息格式:refactor: drop unreachable <名字>。
6. 删完代码后再单独提交一次依赖清理,不要混在删除提交里。
```
小步提交的价值在于:一旦对拍失败,git bisect 能精确定位到是哪一次删除动了行为。
步骤 6:回归验证
三层,从便宜到贵:
第一层,测试套件。 删除前后必须一致地通过,且通过数不减。
```bash
pytest -q 2>&1 | tail -n 3
```
第二层,契约测试。 对外接口的响应结构、状态码、字段名不变。用 schema 快照比对:
```bash
采集所有公开接口的响应结构,存成基线
curl -s http://127.0.0.1:8000/openapi.json | jq -S . > contract-before.json
改动后再采一次
curl -s http://127.0.0.1:8000/openapi.json | jq -S . > contract-after.json
diff contract-before.json contract-after.json && echo "契约无变化"
```
第三层,请求回放对拍。 把一批真实请求分别打到改动前和改动后的服务上,比输出。
```python
tools/replay.py
import json, sys, urllib.request, urllib.error
host = sys.argv[1]
cases = json.load(open("fixtures/requests.json", encoding="utf-8"))
def normalize(v):
"""剔除易变字段,避免时间戳、自增 ID 造成误报"""
if isinstance(v, dict):
return {k: normalize(x) for k, x in v.items() if k not in {"ts", "trace_id", "request_id"}}
if isinstance(v, list):
return [normalize(x) for x in v]
return v
def call(case):
req = urllib.request.Request(
host + case["path"],
method=case.get("method", "GET"),
data=(case.get("body") or "").encode() or None,
headers=case.get("headers", {}),
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
body = r.read()
try:
body = normalize(json.loads(body))
except Exception:
body = body.decode("utf-8", "replace")
return {"status": r.status, "body": body}
except urllib.error.HTTPError as e:
return {"status": e.code, "body": None}
out = {c["name"]: call(c) for c in cases}
json.dump(out, open(sys.argv[2], "w", encoding="utf-8"), ensure_ascii=False, indent=2, sort_keys=True)
```
```bash
在改动前的提交上跑一遍
git stash && git switch main && python tools/replay.py http://127.0.0.1:8000 before.json
回到改动后的分支再跑一遍
git switch chore/attack-surface-20250101 && python tools/replay.py http://127.0.0.1:8000 after.json
diff before.json after.json && echo "对拍通过"
```
差异必须逐条解释:如果某个接口的响应变了,要么是 bug(回滚该次删除),要么是这个变化本来就在预期内(写进 PR 描述)。
步骤 7:收口,让攻击面别长回来
改完不是终点。加一道 CI 检查,入口清单变动必须人工确认:
```bash
#!/usr/bin/env bash
ci/check-attack-surface.sh
set -euo pipefail
base="${1:-origin/main}"
if git diff --name-only "$base"...HEAD | grep -qx 'ATTACK_SURFACE.md'; then
echo "::warning::入口清单有变更,请确认 PR 描述已说明新增/删除的入口"
git diff "$base"...HEAD -- ATTACK_SURFACE.md
fi
顺手拦一下新增的注册点,数量只减不增
new_routes=$(git diff "$base"...HEAD -- '*.py' '*.go' '*.ts' | grep -c '^+.*@app\.route\|^+.*HandleFunc\|^+.*AddRoute' || true)
echo "本次新增路由注册点:$new_routes"
```
再补一条 lint 规则,禁止新增未使用的导出符号(各语言生态都有对应工具,以官方文档当前版本为准)。入口清单和代码放在同一个仓库、同一次评审里,才有人真的看。
常见坑与排错
grep 不到就当死代码。 反射、字符串拼出来的函数名、配置里写的 handler 名、模板里的调用、其他服务的 RPC 调用——这四种 grep 全都看不见。凡是出现在配置值、字符串常量、注册表里的名字,一律归到 C 类,不删。
只看覆盖率删代码。 覆盖率高不代表入口盘点全,覆盖率低不代表死。两个信号要交叉:静态无引用 + 动态未执行 + 不在入口清单里,三条同时满足才算 A 类。
删了函数但测试还在调它。 测试失败后误判成"误删了活代码"然后回滚,其实该做的是把对应的测试一并删掉或改掉。先判断是"测试引用死代码"还是"生产代码引用"。
一次删太多,diff 无法评审。 一个提交删十几个文件,出问题只能整体回滚。按符号拆提交,单次 diff 控制在能一眼看完的规模。
智能体顺手重构。 它会"顺便"帮你改命名、抽函数、加类型注解。这些改动混进来后,对拍出现差异你分不清是删除引起还是重构引起。提示词里明确禁止,并在评审时拒绝超范围的 diff。
对拍误报淹没真问题。 时间戳、自增 ID、列表顺序、浮点精度都会造成假阳性。先做归一化再比,实在不稳定就只比状态码和字段名集合。
忘了删配置、环境变量、权限。 代码删了,部署配置里还留着一个指向空 handler 的路由,或者 IAM 里还留着一条不再需要的权限。攻击面不只在代码里,收尾时把配置和权限一起清。
删掉"看起来没用"的兼容分支。 老客户端还在发旧字段、上游还在发旧版本消息。这类分支没有本地引用,但有外部契约。动之前先查日志里这个分支最近有没有被触发。
下一步建议
流程跑顺之后,往三个方向延伸:
1. 把入口清单变成威胁建模的输入。 给每个入口补三列:鉴权方式、限流策略、输入校验位置。空缺的就是待办项。
2. 在入口层做统一收口。 逐个 handler 加校验一定会漏,把鉴权、限流、请求体大小限制、schema 校验收到中间件里,新入口自动继承。
3. 定期重跑。 每季度或每次大版本前重跑一遍步骤 2 到步骤 6,把入口清单的 diff 当作发布评审材料的一部分。攻击面是会长回来的,守卫要一直在。
