影子 API 指的是「线上真实存在、但资产台账里没有登记」的接口。它可能来自三处:开发联调时留下的测试路由、旧版本未彻底下线的兼容接口、第三方 SDK 回调里被遗忘的 webhook。这些接口通常没有纳入鉴权与限流,也没有进过安全评审,却是攻击者最喜欢找的入口——因为扫描器只要遍历到一条能返回 200 的可写接口,就等于绕过了整套防护设计。
找影子 API 不需要买资产测绘产品,一条最朴素的路子是:把访问日志里出现过的真实路径全部归一化,再和代码仓库或网关里登记的接口清单做差集,多出来的就是影子 API。本文给出这套流程的完整命令,从日志格式改造到差集比对再到收敛处置。
适用场景
- 业务迭代快,接口文档长期滞后,没人能说清线上到底开了多少个接口。
- 做过微服务拆分或网关迁移,旧网关遗留路由未清理。
- 等保或渗透测试报告里出现「发现未授权访问接口」,需要系统性排查而不是逐个修。
- 准备接入 WAF 或 API 网关做统一鉴权,需要先摸清接口清单才能定规则。
- 发生过数据泄露,需要确认泄露渠道是否来自某个未登记的对外开放接口。
- 前后端分离项目,前端只调用了 30 个接口,后端实际暴露了 80 个。
前置条件
- Nginx 访问日志可用,且包含
$request_method、$uri、$status至少三个字段。 - 日志留存时间足够覆盖一个完整业务周期(建议 30 天),否则季节性接口会被误判为下线。
- 能拿到登记侧数据:OpenAPI/Swagger 文件、网关路由导出、或框架路由表(Laravel
route:list、Spring/actuator/mappings、Express 路由文件)。 - Python 3.8 以上,仅用标准库;
jq用于处理 JSON。 - 一份接口责任人清单,否则扫出来的影子接口没人认领,最后只能烂在报告里。
原理说明
整套方法建立在「两个独立数据源必然存在差异」这个前提上。流量侧反映的是事实:什么路径真的被请求过、返回了什么状态码。登记侧反映的是意图:开发者认为系统暴露了哪些接口。两者的差集分两类:
- 流量有、登记无:这就是影子 API,也是本文要找的目标。
- 登记有、流量无:可能是废弃接口未清理,也可能是低频接口,需要按状态码区分。
难点不在比对,而在路径归一化。/api/order/10086 和 /api/order/10087 是同一个接口的两次调用,如果直接按原始路径统计,日志里会出现成千上万个「接口」,差集结果毫无意义。归一化就是把路径里的变量段替换成占位符:数字换成 {id}、UUID 换成 {uuid}、24 位十六进制换成 {objectid}、日期换成 {date}。归一之后,同一接口的所有调用收敛成一行。
归一化必须结合状态码看。一个路径返回 404 说明它压根不存在,属于扫描器探测噪音,不能算影子接口;返回 401/403 说明接口存在但被拦住了,属于「受保护但未登记」,优先级中等;返回 200/201 且是写方法(POST/PUT/DELETE),才是真正需要立刻处理的高危项。
操作步骤
步骤 1:改造 Nginx 日志格式
默认的 combined 格式把方法、路径、协议挤在一个引号字段里,解析起来容易出错。单独定义一份 API 日志格式,字段拆开,便于后续 awk 与 Python 处理。
# /etc/nginx/conf.d/log-format.conf
log_format api '$remote_addr|$remote_user|$time_iso8601|$request_method|$uri|'
'$server_protocol|$status|$body_bytes_sent|$request_time|'
'$upstream_addr|$http_user_agent|$http_x_request_id';
# 在 server 块内引用,单独落盘便于分析
access_log /var/log/nginx/api-access.log api;
sudo nginx -t && sudo systemctl reload nginx
# 确认新日志开始写入,字段数为 12
sudo tail -n 3 /var/log/nginx/api-access.log
sudo tail -n 100 /var/log/nginx/api-access.log | awk -F'|' '{print NF}' | sort -u
字段数应恒为 12。若出现 13 或 11,说明 UA 或 referer 里带了竖线,需要换分隔符(例如 \x1F)或对字段做转义。
步骤 2:路径归一化并聚合
下面的脚本只依赖标准库,读日志、归一化、按「方法 + 归一化路径」聚合,输出每个接口的调用次数、状态码分布与首末次访问时间。
# /opt/api-audit/normalize.py
import re, sys, json
from collections import defaultdict
RULES = [
(re.compile(r'/[0-9a-fA-F]{24}(?=/|$)'), '/{objectid}'),
(re.compile(r'/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?=/|$)'), '/{uuid}'),
(re.compile(r'/\d{4}-\d{2}-\d{2}(?=/|$)'), '/{date}'),
(re.compile(r'/\d{6,}(?=/|$)'), '/{id}'),
(re.compile(r'/\d+(?=/|$)'), '/{n}'),
(re.compile(r'/[A-Za-z0-9_-]{32,}(?=/|$)'), '/{token}'),
]
def norm(path):
p = path.split('?')[0].rstrip('/') or '/'
for rx, rep in RULES:
p = rx.sub(rep, p)
return p
stat = defaultdict(lambda: {"cnt": 0, "codes": defaultdict(int), "first": None, "last": None})
for line in open(sys.argv[1], encoding='utf-8', errors='replace'):
f = line.rstrip('\n').split('|')
if len(f) != 12:
continue
ip, user, ts, method, uri, proto, status, size, rt, upstream, ua, rid = f
key = (method.upper(), norm(uri))
s = stat[key]
s["cnt"] += 1
s["codes"][status] += 1
s["first"] = s["first"] or ts
s["last"] = ts
out = [{"method": k[0], "path": k[1], "count": v["cnt"],
"codes": dict(v["codes"]), "first": v["first"], "last": v["last"]}
for k, v in stat.items()]
out.sort(key=lambda x: -x["count"])
json.dump(out, open('/opt/api-audit/observed.json', 'w', encoding='utf-8'),
ensure_ascii=False, indent=1)
print("interfaces:", len(out))
for r in out[:15]:
print(f'{r["method"]:6} {r["path"]:50} {r["count"]:8} {r["codes"]}')
# 处理最近 7 天日志,先合并再分析
sudo zcat -f /var/log/nginx/api-access.log* \
| grep -E ' (GET|POST|PUT|DELETE|PATCH) ' > /tmp/api.log
python3 /opt/api-audit/normalize.py /tmp/api.log
注意 /n 与 /id 两条规则的顺序:先匹配 6 位以上的长数字,再匹配任意数字,否则 /order/20261029 会被过早截成 /order/{n},丢失「这是订单号还是日期」的区分度。规则顺序就是精度顺序,写反了差集结果会偏。
步骤 3:抽取登记侧接口清单
登记侧的来源按可靠性排序:代码路由表 > 网关路由 > OpenAPI 文件。文件容易过期,代码最真实。下面给出三种常见来源的抽取方式,统一输出「方法 路径」两列文本。
# A. OpenAPI 3.x / Swagger:提取所有 path 与 method
jq -r '.paths | to_entries[] | .key as $p | (.value | keys[]) as $m
| select($m=="get" or $m=="post" or $m=="put" or $m=="delete" or $m=="patch")
| "\($m|ascii_upcase) \($p)"' openapi.json \
| sed 's/{[^}]*}/X/g' | sort -u > /opt/api-audit/registered.txt
# B. Laravel:路由表直接导出
php artisan route:list --json \
| jq -r '.[] | .method as $m | .uri as $u
| ($m|split("|")[]) as $one | "\($one) /\($u)"' \
| sed 's/{[^}]*}/X/g' | sort -u >> /opt/api-audit/registered.txt
# C. 网关路由(以 JSON 导出为例)
jq -r '.routes[] | "\(.methods[0]|ascii_upcase) \(.paths[0])"' gw-routes.json \
| sed 's/[^/]*{/X{/g' | sort -u >> /opt/api-audit/registered.txt
wc -l /opt/api-audit/registered.txt
关键在 sed 's/{[^}]*}/X/g' 这一步:把登记侧的 {orderId} 统一替换成占位符 X,与流量侧的 {id}、{n} 对齐。两边占位符名字不必相同,比对时统一抹成 X 即可,切忌直接拿 {id} 与 {orderId} 做字符串比较,那会得到 100% 的差集。
步骤 4:求差集,锁定影子接口
# 流量侧归一化后转成「方法 X路径」格式
jq -r '.[] | select(.codes["200"] or .codes["201"] or .codes["204"]
or .codes["401"] or .codes["403"])
| "\(.method) \(.path | gsub("\\{[^}]*\\}"; "X"))"' \
/opt/api-audit/observed.json | sort -u > /opt/api-audit/observed.txt
# 差集:在流量里出现、但登记表里没有
comm -23 /opt/api-audit/observed.txt /opt/api-audit/registered.txt \
> /opt/api-audit/shadow.txt
echo "影子接口数量: $(wc -l < /opt/api-audit/shadow.txt)"
cat /opt/api-audit/shadow.txt
comm -23 要求两个文件都已排序且编码一致,否则结果会错乱。上一步的 sort -u 不能省。输出里若出现大量 /wp-login.php、/.env、/phpmyadmin 这类路径,说明日志混入了扫描器噪音,先用状态码过滤(这些通常返回 404),再做一次差集。
# 排除扫描噪音:只保留返回 2xx 的路径,再比对
jq -r '.[] | select(.codes["200"] or .codes["201"] or .codes["204"])
| "\(.method) \(.path | gsub("\\{[^}]*\\}"; "X"))"' \
/opt/api-audit/observed.json | sort -u > /opt/api-audit/observed_2xx.txt
comm -23 /opt/api-audit/observed_2xx.txt /opt/api-audit/registered.txt
步骤 5:分级处置与收敛
扫出来的影子接口不能一刀切下线,按风险分三档处理,优先级从高到低:
- 写方法 + 2xx + 无鉴权:最高危,立即加鉴权或临时返回 403,再排期下线。
- 读方法 + 2xx + 返回业务数据:评估是否泄露敏感字段,先加限流与鉴权,再补文档纳入台账。
- 返回 401/403 的未登记路径:接口存在但被拦截,确认拦截规则是否覆盖全路径,补登记即可。
同时做三件兜底,防止新的影子接口再冒出来:关闭 API 文档的公开访问、给未匹配路径统一返回 404 而不是落到默认站点、在网关层开启默认拒绝。
# 1. 关闭 Swagger / OpenAPI 文档的公网访问
location ~ ^/(swagger-ui|swagger-resources|v3/api-docs|openapi\.json|api-docs) {
allow 10.0.99.0/24;
deny all;
return 403;
}
# 2. 未匹配任何 server_name 的请求直接断开,避免落到默认站点暴露目录
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on;
return 444;
}
# 3. API 前缀下的未知路径统一 404,不交给后端框架兜底路由
location /api/ {
try_files $uri @backend;
}
location @backend {
proxy_pass http://app_pool;
proxy_intercept_errors on;
error_page 404 = @api_notfound;
}
location @api_notfound {
return 404 '{"code":404,"msg":"not found"}';
add_header Content-Type application/json;
}
步骤 6:固化为每日巡检
影子接口是持续产生的,一次性扫描没有意义。把流程挂到 cron,每日凌晨跑一次,与前一天结果做增量对比,只推送新增项。
# /etc/cron.d/api-audit
0 3 * * * root /bin/bash -c '/usr/bin/zcat -f /var/log/nginx/api-access.log* \
| grep -E " (GET|POST|PUT|DELETE|PATCH) " > /tmp/api.log; \
/usr/bin/python3 /opt/api-audit/normalize.py /tmp/api.log >> /var/log/api-audit.log 2>&1; \
/opt/api-audit/diff.sh'
#!/bin/bash
# /opt/api-audit/diff.sh
set -e
D=/opt/api-audit
cp -f $D/shadow.txt $D/shadow.prev 2>/dev/null || true
jq -r '.[] | select(.codes["200"] or .codes["201"] or .codes["204"])
| "\(.method) \(.path | gsub("\\{[^}]*\\}"; "X"))"' \
$D/observed.json | sort -u > $D/observed_2xx.txt
comm -23 $D/observed_2xx.txt $D/registered.txt > $D/shadow.txt
NEW=$(comm -13 $D/shadow.prev $D/shadow.txt || true)
if [ -n "$NEW" ]; then
echo "新增影子接口:"; echo "$NEW"
curl -s -X POST "$WEBHOOK" -H 'Content-Type: application/json' \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"新增影子接口\\n$NEW\"}}" >/dev/null
fi
配置验证
# 日志格式与字段数
sudo tail -n 50 /var/log/nginx/api-access.log | awk -F'|' '{print NF}' | sort -u
# 归一化正确性:同一接口的不同 ID 应归到同一行
grep -o '/api/order/[0-9]*' /tmp/api.log | head -5
jq -r '.[] | select(.path|startswith("/api/order")) | .path' /opt/api-audit/observed.json | sort -u
# 登记表与差集
wc -l /opt/api-audit/registered.txt /opt/api-audit/observed.txt /opt/api-audit/shadow.txt
# 兜底是否生效:未登记路径应返回 404,未知 Host 应断连
curl -s -o /dev/null -w "%{http_code}\n" https://www.wafai.cn/api/__probe__
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/ -H 'Host: unknown.local'
验收看三个数字:归一化后的接口总数应远小于原始日志唯一路径数(差一个数量级以上才算归一化有效);影子接口数量在收敛后应逐日下降;探测路径 /api/__probe__ 必须返回 404 而不是 200。
常见问题
FAQ 1:灰度与版本前缀导致同一个接口被算成多个影子 API
/api/v1/user、/api/v2/user、/api-inner/user 在归一化后是三行,登记表里可能只写了 v2,于是 v1 被误报成影子接口。处理办法是在归一化阶段把版本段也抹平(re.sub(r'/v\d+(?=/)', '/{ver}', p)),在报告里单独列出实际版本号;或者维护一份「前缀白名单」,比对前先剔除已知前缀。判断标准是「同一个 controller 的不同版本算一个资产」,而不是按路径字面判断。
FAQ 2:日志采样或留存不足,低频接口完全扫不出来
如果 Nginx 开了 access_log ... buffer= 或上游采样,短周期日志会漏掉低频接口;留存不足 30 天则会漏掉季度性任务接口。两个补救手段:一是把分析窗口拉长到 60-90 天,用 zcat -f 把归档日志全量喂进去;二是补一个「代码侧反查」——把代码里的路由表当成全集,反向去日志里找「从未被访问过的已登记接口」,这类接口往往就是该下线的废弃入口,同样属于收敛对象。
FAQ 3:网关做了路径重写,日志里的路径和登记表对不上
重写会让流量侧看到 /svc/order/detail,而登记表写的是 /api/order/{id},差集直接爆表。解决方式是在比对前加一层映射表,把网关的重写规则(rewrite / strip_prefix)转成前缀替换规则,先映射到统一命名空间再比对。更省事的做法是让后端在响应头里回传 X-Route-Id,日志用 $upstream_http_x_route_id 记录,直接拿到逻辑路由标识,绕开路径差异。
总结
影子 API 测绘的本质是一次「事实与台账的对账」:流量侧提供事实,登记侧提供意图,归一化是让两者可比的翻译层,差集是最终产出。整条链路用 Nginx 日志加一个百行 Python 脚本就能跑通,成本极低,但没有替代品——任何自动化资产平台的第一步也是干这件事。
真正决定成效的不是脚本写得多漂亮,而是三点执行细节:归一化规则顺序要对,否则接口数量虚高;差集必须按状态码分级,否则会淹没在扫描器噪音里;扫出来的结果必须落到责任人,否则报告只会越来越厚、接口一个都不会少。把每日增量巡检固化下来,影子接口才会从「越积越多」变成「发现即收敛」。