跳到主内容
快讯直播
AI智模界
教程

用 Claude 缩减代码攻击面:从入口盘点到回归验证

这篇能做出什么

读完并跟着做,你会在自己的真实仓库里得到五样东西:

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:42POST /api/v1/usersJWTbody 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 当作发布评审材料的一部分。攻击面是会长回来的,守卫要一直在。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。