如何加固一个你并不完全了解的 Web 应用
某处有一个由你负责的 Web 应用,而没人能一本正经地告诉你,把它的登录表单暴露到互联网上是否安全。 也许这个应用是你接手的。也许某位同事用一个周末「凭感觉」写了出来。也许它有十年历史,而当初那支团队早已散去。 这些都是同一个问题:你需要加固一个内部你并不完全了解的 Web 应用。 你没法把每条路由、每处鉴权检查、每个依赖、每次数据库调用都走一遍,然后亲自为它们担保。重写要花掉你没有的几个季度,而放任它暴露又不可接受。本文讲的就是这中间那条路。
如果把手上的应用当成一个黑盒,我们就得从外面、一层一层地防守它。没有哪个单一工具能保护整体,但把几个互补的工具叠起来,就能覆盖它被拿下的大多数显然路径。三个核心层,大致按你应当添加的顺序:
| 层 | 做什么 |
|---|---|
| 密钥扫描 | 找出泄露进仓库及其历史的凭据。 |
| 边缘过滤 | 在应用前面放一个 WAF,过滤明显恶意的流量。 |
| 机器人防护 | 在那些必须保持公开的表单上做 CAPTCHA 或基于分值的机器人检测。 |
上面这三层都坐在边缘——你的应用与外部世界之间的边界。密钥扫描不让凭据从仓库漏出去,WAF 过滤进来的恶意流量,机器人防护对那些必须保持公开的表单上的非人类访客发出挑战。它们没有一个需要理解应用的内部才能工作,而这正是内部情况不确定时它们如此有用的原因。
OWASP 给这套套路起了个名字:虚拟打补丁——在底层代码仍不确定的时候,用防护层降低暴露面。它们都不能替代一次正经的重写或一次正经的审计。但它们都能给你争取时间。
本文大部分篇幅花在边缘上,但我们也会讲一点边缘之外能做的事——转而向内看:看应用实际运行的那些库、看它的源代码、看它运行时的行为。依赖扫描找出应用已经带在身上的已知易受攻击的库,而文末的 SAST 与 DAST 抓住任何边缘层都看不见的代码级模式。
这些层大多都有一个可靠的自托管选项,在任何主机上都能用——密钥用 gitleaks,WAF 用 ModSecurity,依赖用 OSV-Scanner,SAST 用 Semgrep,DAST 用 ZAP。全文以它们为主。至于托管的云端等价物——你用一部分配置工作换取厂商调好的规则集和它的威胁情报——每家大型云都有自己的一套。当托管示例有助于说明时,我们会拿 Google Cloud Platform 作为具体参照:WAF 用 Cloud Armor,容器扫描用 Artifact Analysis,机器人防护用 reCAPTCHA Enterprise 和 Adaptive Protection。选定一家云能让这些例子具体,而不是挥挥手了事;AWS、Azure 和 Cloudflare 都有对应产品,我们会简要提及,但不深入。
三类未知
在动工具之前,值得先说清你到底在防什么,因为这决定了哪一层最重要。未知分成三组。
代码和仓库里有什么。 你不知道存在哪些路由——遗留应用是有机生长出来的,里面很可能有没人提过的管理端点。你也不知道代码究竟在做什么,要么因为你来不及读完(遗留),要么因为一开始就没人认真读过(AI 生成)。而且你不知道什么东西已经泄进了 git 历史。DAST 从外部探测运行中的应用,摸清究竟哪些东西被暴露了;SAST 给你一份有损但有用的风险代码模式地图;密钥扫描抓住那些被遗忘在旧提交里的凭据。
代码旁边跑着什么。 你不知道哪些依赖是新的——一个十年的应用会有几十个带公开 CVE(Common Vulnerabilities and Exposures,已知安全缺陷的公开目录,每条都有形如 CVE-2024-12345 的标准化标识符)的库。依赖扫描是针对这一点杠杆率最高的单一层。
生产环境里的应用正在被怎样对待。 你不知道是谁在攻击你、怎么攻击——WAF 的审计日志会在上线一天内告诉你。你也不知道流量里有多少是机器人,而在公开的注册、登录和密码重置表单上,那往往是大多数。机器人防护把这一点摊开,并让你能对它采取行动。
没有工具能解决全部这些。下面三层各自应对其中一项或几项;依赖扫描(在这些层之后讲)和 SAST/DAST(在文末)在基础到位之后把深度继续推进。
密钥扫描
仓库会漏凭据——遗留的因为多年有机生长,「凭感觉写」的因为 LLM 会为了「方便」欢欢喜喜地把密钥写死,而 diff 没人看。API 密钥、AWS 访问密钥、数据库密码、内部服务令牌——它们最后落在提交里、配置文件里、旧测试固件里、文档里,以及 AI 生成的示例里(那些示例会悄悄用真实密钥替换掉占位符)。而且因为 git 永远保留历史,即便那些「删掉了」密钥的提交,仍然含着它。
对一个黑盒应用来说,回报最快的做法是:扫描所有相关仓库的完整 git 历史,轮换掉找到的一切,然后在 CI 里常驻一个扫描器,让新的泄露能被抓住。
工具
- gitleaks——快,Go 写的,默认规则出色,覆盖大多数提供方(AWS、GCP、Azure、Stripe、Twilio、GitHub 令牌、私钥、JWT)。要对着完整历史跑,而不只是 HEAD。
- trufflehog——检测类似,但多了一步验证:它会测试检测到的密钥是否真的还活着(例如对 AWS 密钥发起一次
sts:GetCallerIdentity调用)。输出信噪比更高,多出来的延迟值得。 - git-secrets——面向 AWS,轻量,适合当 pre-commit 钩子。
- GitHub Secret Scanning——公开仓库免费,私有仓库走 Advanced Security。它与提供方有合作,所以泄露的令牌会被自动吊销。
跑一次扫描并读懂输出
gitleaks 有几种安装方式——预编译二进制、Homebrew、go install,或者通过 Docker。这里我们图方便用 Docker:主机上什么都不用装,同一条命令在任何有 Docker 守护进程的机器上都能用,而 CI runner 拿到的调用和你的笔记本一模一样。
我刚刚对着这篇文章所在的仓库跑了这个:
docker run --rm -v "$(pwd):/repo" zricethezav/gitleaks:latest \
detect --source=/repo --log-opts="--all" --redact --verbose--log-opts="--all" 会走遍每个分支上的每个提交,而不只是 HEAD;--redact 在输出中把密钥值遮掉,好让报告本身不成为一次新的泄露;--verbose 打印每条发现的完整字段块,而不只是一个汇总计数。
gitleaks 为它识别出的每个可能的密钥字符串给出一条发现——不论触发的是哪条规则,字段块都是同一套。一条典型的发现长这样:
Finding: STRIPE_SECRET_KEY=REDACTED
Secret: REDACTED
RuleID: stripe-access-token
Entropy: 4.175736
File: blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx
Line: 225
Commit: 49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa
Fingerprint: 49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:stripe-access-token:225真正要紧的五个字段:
| 字段 | 它告诉你什么 |
|---|---|
| RuleID | 触发的是哪条检测规则(stripe-access-token、aws-access-token、generic-api-key、private-key 等)。完整的默认规则集在 gitleaks 仓库的 config/gitleaks.toml 里——每条规则都带着自己的正则、描述和任何关键词过滤条件。 |
| File + Line | 匹配在哪里。 |
| Commit | 是哪个提交引入的——不一定是最新那个。gitleaks 会在它于历史中首次出现的地方找到它。 |
| Entropy | 匹配字符串的香农熵,单位是每字符比特。越高=看起来越随机,而这通常意味着更可能是真密钥。通用 API 密钥类规则把熵当作主要信号;针对特定提供方的规则(Stripe、AWS)匹配前缀,不需要熵。 |
| Fingerprint | 一个稳定标识符;如果这条发现最终是可接受的,你就用它来抑制。下面详述。 |
熵究竟测量什么
香农熵测量的是一个字符串中每个字符平均有多不可预测。如果一个字符串里每个字符都同等可能是 N 个取值中的任一个,它就有最大可能的熵:每字符 log₂(N) 比特。如果某个字符出现得远多于其他字符,或者下一个字符可以从上一个预测出来,熵就更低。
对密钥扫描来说,熵试图回答的问题是:这个字符串看起来像是随机源生成的,还是像人写的? 真密钥——API 密钥、哈希、base64 编码的令牌——被设计成不可预测的,所以它们把熵推向该字母表的上限。人名、英文单词、日期、版本串、文件路径和样板文字则低得多。熵是那个便宜的测试,用来区分「这是 CSPRNG 摇出来的」和「这是人敲的、或者由带套路的代码生成的」。
粗略的刻度:
- 低于 ~3.0——看起来像英文文本、标识符、版本串,或者重复结构。诸如
password123、[email protected]、v1.2.3-beta、文件路径。 - ~4.0——接近纯十六进制的上限(
log₂(16) = 4)。MD5/SHA 哈希、十六进制编码的令牌、UUID。 - ~5.0——随机字母数字令牌的典型值。带固定前缀和高熵随机尾巴的 API 密钥。
- ~5.95——完全随机的 62 字符
[a-zA-Z0-9]字母表的天花板。较长的类 base64 或纯字母数字密钥。
上面那条发现得分 4.175736——舒舒服服地高于 gitleaks 对通用规则的默认阈值 ≈3.5,而且正落在真实提供方密钥所处的那一带。固定前缀(类似 sk_live_)把平均熵往下拽了一点;随机尾巴又把它推了回去。这个组合恰恰就是一个真 API 密钥的样子。
分诊发现
当你真的对着一个真实仓库跑 gitleaks,你会得到三类发现——而同一种反应并不适合这三类:
- 真泄露。 一个不小心提交上去的凭据——一个
.env文件、一个写死了 API 密钥的配置文件、一个带真令牌的测试固件。立刻轮换。 就算仓库是私有的,也当这个凭据已被泄露。前承包商和旧笔记本备份不是理论问题。然后再决定是否重写历史(只对生产数据库密码或签名密钥这类高敏感情形——重写很有破坏性,要慎重决定)。 - 有意收录。 这个字符串确实匹配了密钥的模式,但它本来就应该在仓库里:文档在安全分析中把攻击者控制的凭据作为证据引用出来、故意造假但看起来很真的测试固件值、示例配置文件。 这篇文章所在的仓库正有这种情形——那篇供应链攻击分析引用了恶意项目里写死的 Stripe 和 API 密钥,以展示攻击者用的是什么。 gitleaks 把它们标出来是对的,但那些不是真凭据,所以没有什么要轮换。
- 误报。 模式匹配上了,但那个字符串其实不是密钥——哈希里的随机 base64、一个类 UUID 的长值、提交信息里一个熵很高的词。用针对特定提供方的规则时较少见,用通用规则时更常见。
对第 2 和第 3 类,解决办法是在仓库根目录放一个 .gitleaksignore 文件,列出你已审阅并批准的那些发现的标识符——也就是上表中的 Fingerprint 字段:
# 有意收录:在供应链攻击分析中作为证据引用的密钥
49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:stripe-access-token:225
49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:generic-api-key:193后续扫描时 gitleaks 会跳过这些具体的发现。注释写得大方些——未来的你需要知道每一条是因为「有意收录」而被批准,还是因为「误报」而被批准,而注释是这个决定唯一持久的记录。
在提交那一刻挡住下一次泄露
一旦历史扫描干净了(或者已取指纹并抑制),就把 gitleaks 接进:
- CI——任何新发现都会卡住构建。同一条 Docker 调用在任何 CI runner 里都能用。
- pre-commit 钩子——在泄露连你的本地仓库都还没进入之前就抓住它。为此有一个官方的
gitleaks protect模式,或者用 pre-commit 框架 配上 gitleaks 钩子。
两者合起来意味着一次新的泄露现在需要主动绕过两层——不小心做到这件事,比刻意做到要难得多。
用 WAF 做边缘过滤
Web 应用防火墙(WAF) 是一个反向代理,它拿规则集来检查 HTTP(S) 请求。在请求到达你的应用之前,它决定放行、记录、拦截还是发出挑战。WAF 对以下事情有用:
- 抓住若干攻击类别的显然载荷:SQL 注入、XSS、本地/远程文件包含、命令注入、路径穿越。
- 拦掉已知的坏路径(
/wp-admin/、.git/config、.env)。 - 限流、按 IP 信誉拦截、按地域过滤。
- 当某个你今天无法升级的依赖爆出 CVE 时,为你争取时间。
它对坏掉的业务逻辑、坏掉的授权或糟糕的会话设计没用。它是补偿性控制,不是治愈手段。
通往 WAF 有两条运维路径。托管的 WAF 由你的云提供商运行;自托管的 WAF 则是你自己运行的那种。两者底下的检测规则集本质上是同一套——开源的 ModSecurity v3 引擎搭配 OWASP Core Rule Set(CRS),或者厂商换了牌子的同一套东西。CRS 大约有 200 条由社区维护的检测规则,覆盖 SQL 注入、XSS、RCE、路径穿越以及 OWASP Top 10 的其余部分。它用的是异常计分——各条规则往一个分值上累加,只有总分越过阈值时请求才会被拦——这让调优比逐条规则拦截来得温和。
所以托管与自托管之间的选择主要是运维层面的,而不是检测质量层面的。两条路跑的本质上是同一个引擎、同一套规则。
托管:Cloud Armor
托管 WAF 几乎总是正确的起点:要运维的基础设施更少,部署更快(一个下午,而不是一周),没有引擎要更新和调优。除非你有具体理由不这么做,就先选它。
GCP 原生的选项是 Google Cloud Armor。它挂在 GCP 的 HTTP(S) 负载均衡器上,其预置规则组(sqli-v33-stable、xss-v33-stable、lfi-v33-stable、rce-v33-stable 等)直接源自 OWASP CRS——就是你用 ModSecurity 手工装的那套规则。
你可以通过 GCP 控制台、gcloud CLI、REST API,或者——我们下面要展示的——用 Terraform 声明式地配置 Cloud Armor。不论用哪种界面,模型都一样:一条 Cloud Armor 安全策略就是一串带匹配条件和动作(allow、deny(403)、rate_based_ban)的规则。一条启用预置 CRS 规则组、并带一个合理默认动作的最小策略长这样:
resource "google_compute_security_policy" "app" {
name = "app-waf"
rule {
action = "deny(403)"
priority = 1000
match {
expr { expression = "evaluatePreconfiguredWaf('sqli-v33-stable', {'sensitivity': 2})" }
}
description = "Block SQL injection"
}
rule {
action = "deny(403)"
priority = 1001
match {
expr { expression = "evaluatePreconfiguredWaf('xss-v33-stable', {'sensitivity': 2})" }
}
description = "Block XSS"
}
rule {
action = "allow"
priority = 2147483647
match { versioned_expr = "SRC_IPS_V1" config { src_ip_ranges = ["*"] } }
description = "Default allow"
}
}有一个模式值得早点提一下:Cloud Armor 的预览模式。开着它时,规则照常求值,并把每次匹配记录到 Cloud Logging——但并不真的拒绝流量。 这是把 ModSecurity 跑在仅检测模式的托管等价物,也是你在真实生产流量上安全地开启一条全新策略的方式,不必担心凌晨三点误拦自己的支付回调。 上线流程与自托管的相同:先预览一周,按触发情况调优,然后一条一条把规则从预览里放出来。
除了 WAF 规则之外,Cloud Armor 还捆绑或配套若干相邻能力:DDoS 防护(始终开启)、一个检测流量突增异常并自动建议规则的自适应防护 ML 层,以及机器人管理。后两者是付费附加项,不是免费的基础功能。
需要知道的权衡。 Cloud Armor 的自定义规则语言是 CEL 的一个子集——表达力不如 ModSecurity 完整的 SecRule 语法,所以非常曲折的自定义规则有时无法直接表达。CRS 的版本由 Google 定,不由你定,所以你没法停在某个特定修订上。而且在负载均衡器出网费之外,还有一笔按请求计的费用。
其他云提供商各有对应产品。AWS WAF 挂在 ALB/CloudFront/API Gateway 上,用 AWS 自己的规则语法加上托管规则组;Azure Front Door / Application Gateway WAF 也是基于 CRS;Cloudflare WAF 在架构上不同——它是反向代理型 CDN,而不是挂在负载均衡器上的过滤器,所以你把 DNS 指向 Cloudflare,你的源站藏在他们的网络后面。这三者都包含基于 CRS 的托管规则组,也都有仅检测模式的等价物。
自托管:ModSecurity + NGINX
自托管这条路在下列情况下是对的选择:你想完全掌控自定义规则、你不在一家有好托管产品的云上、在你的量级下按请求计费很要紧,或者合规约束让一台你自己运维的设备比厂商服务更容易讲清楚。
你把 ModSecurity v3 直接作为 NGINX 的动态模块来跑,用 OWASP CRS 规则来配置它。架构的形状是这样:
互联网 → NGINX + ModSecurity → 上游应用这三块是这样安排的:
- NGINX 是反向代理。它终结 TLS、接收进来的 HTTP 请求,并且——在没有东西拦住它们时——把它们代理到私有端口上的上游应用。
- libmodsecurity 是规则求值引擎。它是一个 C++ 库,与 NGINX 本身相互独立,自己没有任何网络代码——它只是把一个请求当输入,返回一个裁决。
- NGINX ModSecurity 连接器是一个动态模块(
.so文件),把这两者桥接起来。它挂进 NGINX 的请求处理管线,对每个进来的请求,把 URL、头部和主体交给 libmodsecurity 去求值。
这三块都跑在同一个 NGINX 进程里。 机制是 NGINX 的动态模块加载器——连接器和 libmodsecurity 最终都在启动时被载入每个 NGINX worker,没有独立的 ModSecurity 守护进程,没有 IPC,两者之间也没有套接字。
请求进来时,NGINX 调进连接器,连接器再调进 libmodsecurity,全都在共享的进程内存里发生。这就是为什么这套集成只增加几毫秒延迟,而不是一次去进程外安全网关往返的代价。这也是为什么连接器模块的 NGINX 版本必须与它被载入的那个 NGINX 版本完全一致——连接器是针对 NGINX 的内部 ABI 编译的,而那个 ABI 跨版本并不稳定。
对每个请求,NGINX 把 URL、头部和主体(通过连接器)传给 libmodsecurity。libmodsecurity 让它们过一遍已加载的规则——OWASP CRS 加上任何自定义规则——累积一个异常分值,并返回一个动作:allow、log 或 deny。如果裁决是 deny,NGINX 返回配置好的错误响应(通常是 403),且永不转发给上游。否则请求照常继续。
WAF 到位之后,确认下面这些都成立:
- 应用必须无法被直接访问。如果它还有公网 IP,攻击者会整个绕过 WAF。
- TLS 在 WAF 处终结。ModSecurity 无法检查它解不开的东西。
- WAF 现在是单点故障——如果可用性要紧,就在一个负载均衡器后面至少跑两个实例。
- 审计日志量会暴涨。规划好存储。
Debian/Ubuntu 上的安装形状(简略):
# libmodsecurity
git clone --depth 1 -b v3/master https://github.com/owasp-modsecurity/ModSecurity
cd ModSecurity && git submodule init && git submodule update
./build.sh && ./configure && make -j$(nproc) && make install
# NGINX 连接器模块(必须与你的 NGINX 版本一致)
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx
cd nginx-${NGINX_VERSION}
./configure --with-compat --add-dynamic-module=../ModSecurity-nginx
make modules && cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
# OWASP Core Rule Set
git clone --depth 1 https://github.com/coreruleset/coreruleset /etc/nginx/modsec/crs
cp /etc/nginx/modsec/crs/crs-setup.conf.example /etc/nginx/modsec/crs/crs-setup.conf然后在 nginx.conf 里:
load_module modules/ngx_http_modsecurity_module.so;
http {
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;
server {
listen 443 ssl;
server_name legacy-app.example.com;
ssl_certificate /etc/ssl/certs/app.crt;
ssl_certificate_key /etc/ssl/private/app.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}先以仅检测模式上线。 把 SecRuleEngine DetectionOnly 设上至少一周。读审计日志。你要找的是误报(CRS 规则在合法流量上触发——编辑器、管理后台、文件上传)和意料之外的真阳性(已经打在你身上的攻击)。CRS 用的是异常计分——各条规则累加一个分值,只有总分越过阈值时请求才会被拦——这让调优比逐条规则拦截来得温和。
发现误报时,先用限定到具体 location 的 SecRuleRemoveById,其次在某条路径上降低偏执级别,只有到最后一步才全局禁用该规则。等日志安静到有意义了,再把 SecRuleEngine On 打开。
对于自定义的虚拟补丁——比如你知道 /api/report?format=X 对命令注入是脆弱的——你可以在边缘丢弃白名单之外的一切:
SecRule REQUEST_URI "@beginsWith /api/report" \
"id:1000001,phase:1,chain,deny,status:400,\
msg:'virtual patch: /api/report format allowlist'"
SecRule ARGS:format "!@rx ^(pdf|csv|json)$"机器人防护与 CAPTCHA
这套体系里的其他层几乎没碰到一类几乎必定会打到公开遗留应用上的威胁:在那些不得不无需认证的端点上的机会主义机器人滥用——垃圾注册、对着你登录表单的撞库跑批、大规模抓取、自动化的优惠券滥用,以及触发大规模发邮件的密码重置洪流。
应用自己的认证在这里帮不上忙——注册、登录和密码重置表单必须对任何人可达。WAF 限流有帮助,但一个分布式僵尸网络每个 IP 只发一个看起来礼貌的请求,与一波合法注册看起来一模一样。真正能区分人与机器人的那一层,是机器人检测或 CAPTCHA 系统。
机器人防护更像一道光谱,而不是单个工具:
- 被动分类器——用信号(TLS 指纹、头部顺序、时序、行为遥测)给每个请求打分的 ML 模型,不给用户任何可见挑战。摩擦最小,对付大流量滥用效果最好。
- 基于分值的 CAPTCHA——分类器加上只在分值偏低时才升级为可见挑战。多数现代系统采用的混合做法。
- 始终可见的 CAPTCHA——经典的「点出所有红绿灯」。摩擦高,如今通常是错误的默认选择,但对特定高价值端点仍然有用。
下面三个小节覆盖了这道光谱中的两类——基于分值的 CAPTCHA(Turnstile、reCAPTCHA Enterprise)和被动分类器(Cloud Armor Adaptive Protection、Cloudflare Bot Management)。它们更像互补,而不是彼此的替代:
| 小节 | 光谱类别 | 需要客户端改动? | 最适合 |
|---|---|---|---|
| Cloudflare Turnstile | 基于分值的 CAPTCHA | 是——每个表单上一个 JS 组件 | 在注册/登录表单上快速见效 |
| reCAPTCHA Enterprise | 基于分值的 CAPTCHA | 是——每个表单上一个 JS 组件 | 更深的 GCP 集成,通过 Cloud Armor 在边缘执行 |
| 仅分类器 | 被动分类器 | 不需要——只用服务端信号 | 大流量滥用,以及你不能或不想加组件的流量 |
决定性的差别在于你是否必须加一个组件。Turnstile 和 reCAPTCHA Enterprise 都要求每个表单上有一段 JS——该组件收集浏览器层面的信号(时序、行为遥测、JS 挑战),并提交一个由你的服务器校验的令牌。仅分类器的工具只用网络本来就看得到的东西来评估每个请求:TLS 指纹、HTTP 头部顺序、IP 信誉、流量突增异常。没有东西要嵌入,应用代码不用改。 它们天然可以叠加:分类器像一张被子覆盖所有进来的流量;CAPTCHA 则专门瞄准高价值表单。
我们先从最容易搭起来的开始——Cloudflare Turnstile——然后再看那套更深的、GCP 原生的组合:reCAPTCHA Enterprise 加 Cloud Armor Adaptive Protection,给需要更多的团队。
Cloudflare Turnstile(最省事的路)
如果你今天只做一件事,就做这个。Cloudflare Turnstile 免费、默认隐形、对隐私友好(不像 reCAPTCHA 那样做行为指纹),而且与 reCAPTCHA v2 的 API 直接兼容——所以如果你将来长大到不够用了,可以在不重写任何表单代码的情况下迁移。
搭建确实就是一个下午的活,而且你不需要把应用其余部分托管在 Cloudflare 上——配置细节见 Turnstile 入门文档。搭建不需要改 DNS、不需要 Terraform,也不需要与你的 WAF 集成。
当你需要更精细的控制时,Turnstile 就不够了——比如需要可以据以行动的按请求分值(而不只是通过/不通过)、需要在负载均衡器而不是应用代码里执行,或者需要比「挑战通过/未通过」更细腻的规则。这正是下面 reCAPTCHA Enterprise + Cloud Armor 组合填上的缺口。
reCAPTCHA Enterprise(更深的 GCP 集成)
reCAPTCHA Enterprise 是 Google 的托管机器人检测服务——经典 reCAPTCHA 的后代,被重建为一个基于分值的 API,而不是通过/不通过的挑战。每个请求都会得到一个从 0.0(几乎肯定是机器人)到 1.0(几乎肯定是人)的分值,外加一份原因列表(AUTOMATION、UNEXPECTED_USAGE_PATTERNS、LOW_CONFIDENCE 等)。拿它做什么,由你决定。
有两点让它成为基于 GCP 的体系的自然选择:
- 默认隐形。 与旧版 reCAPTCHA v2 那个「点出所有红绿灯」的界面不同,Enterprise 通常在后台安静运行。除非分值偏低,用户看不到任何摩擦;而分值偏低时,你可以升级到一个可见挑战。
- 它与 Cloud Armor 集成。 Cloud Armor 可以把 reCAPTCHA 令牌作为安全策略规则表达式的一部分来求值,于是你可以在负载均衡器而不是应用里执行机器人分值:
rule {
action = "deny(403)"
priority = 500
match {
expr {
expression = "token.recaptcha_action_token.valid && token.recaptcha_action_token.score < 0.5"
}
}
description = "Block low-score bot traffic to sensitive endpoints"
}这套集成意味着:你的应用代码在表单上嵌入 reCAPTCHA JS 组件,随每个请求提交得到的令牌,而 Cloud Armor 在请求到达后端之前做分值检查。应用本身不需要知道这个请求是否可信。
Cloud Armor 里还有一个真正属于 WAF 的模式,叫机器人管理动作(redirect、googleRecaptcha),它在边缘内联地下发一个 reCAPTCHA 挑战——对「流量可疑激增,先对所有人下挑战直到平息」这种场景很有用。
Google 把 reCAPTCHA Enterprise 收进了更宽的 Cloud Fraud Defense 平台,后者增加了 AI 智能体分类、一个智能体式策略引擎,以及抗 AI 的挑战。已有的 reCAPTCHA Enterprise 集成继续照原样工作——Fraud Defense 是扩展而非替换,无需迁移,价格也不变。如果 AI 智能体流量在打到你表单上的东西里占到有意义的比例,这值得留意。
仅分类器(无需客户端集成)
更快的那一层——没有组件、不用嵌进表单、应用里什么都不用改——是一个纯分类器,它在边缘用网络本来就看得到的信号来评估请求:TLS 指纹(JA3/JA4)、HTTP 头部的顺序与大小写、请求时序、IP 信誉、流量突增异常。模型是一个机器人/人类的 ML 分类器;请求要么通过,要么不通过。
- Cloud Armor Adaptive Protection 是 GCP 原生的选项。它是 Cloud Armor 内部的一个 ML 层,盯着流向你每个后端服务的流量,检测出与任何单条 WAF 规则都不匹配的 L7 大流量攻击以及撞库/抓取模式,并自动生成一条候选 Cloud Armor 规则,你可以部署它(或者让它自动部署)来拦掉那些违规流量。持续运行,不需要与应用集成,也没有用户可见的摩擦。缺点:它基于流量异常的模式,所以对大流量滥用的抓取效果远好于来自单个执意行动者的低速慢攻。
- Cloudflare Bot Management 是 Cloudflare 上的对应产品——对每个请求做 ML 分类,分值从 1(机器人)到 99(人),可按分值配置规则。与 Cloudflare WAF 处于同一层。
- 应对高水平滥用的商业平台:DataDome、HUMAN Security(原 PerimeterX)、Kasada。它们把服务端分类器与客户端 JS 探针结合起来,是当你被具体针对时(黄牛抢票、规模化账号接管、竞争对手爬价)该上的档位。
对大多数应用来说,正确的结构是分类器在前,CAPTCHA 做后盾:让被动模型安静地处理绝大多数流量,只对分类器没把握的请求升级为可见挑战。reCAPTCHA Enterprise 原生就这么做;把 Adaptive Protection(大流量层)与 reCAPTCHA Enterprise(按请求打分、可升级)结合起来,你就同时有了边缘过滤器和挑战兜底。
其他替代方案
- hCaptcha——reCAPTCHA v2 的替代品,界面类似,更注重隐私,会为已解决的挑战向网站付费。Cloudflare 在自建 Turnstile 之前用的就是它。
- reCAPTCHA v2 / v3——免费的经典版(不是 Enterprise)。仍被广泛使用,但因可访问性和隐私原因越来越被回避,而且缺少与 Cloud Armor 的集成。
- Arkose Labs——商业产品,企业级,游戏式挑战,很难通过「代解即服务」的作坊农场破解。贵。只有当你的应用正被老练而执意的机器人运营者盯上时才选它。
CAPTCHA 是一种快速且相对便宜的机制,能滤掉绝大部分机会主义机器人流量——多数自动化滥用是无定向的,一碰到挑战就放弃,而任何跑着注册表单却不带 CAPTCHA 的生产应用,其注册量里都有不小一部分来自机器人。不过它们并非完整方案:一个执意的攻击者可以按大约每 1000 次挑战 $1–3 的价格购买 CAPTCHA 代解服务,这足以吓退机会主义滥用,但吓不退定向战役;而可见挑战会增加摩擦、损害转化,并且从可访问性角度对部分用户确实很不友好。
所以它们必要,但不充分。实用的做法:
- 把它们限定在高价值、易被滥用的端点上——注册、登录、密码重置、联系表单、优惠券兑换,以及任何应用会发邮件或创建资源的地方。别放在只读页面上。
- 基于分值的集成(reCAPTCHA Enterprise、Turnstile)在用户体验上远胜经典的通过/不通过挑战——大多数真实用户根本看不到挑战。
- 要与限流搭配,而不是取代它。 登录表单上的 CAPTCHA 加上激进的按 IP 限流,两者合力远强于任何单独一个。
- 不要给内部管理页面加 CAPTCHA。 把它们放到认证和 IP 白名单后面——机器人防护是给那些不得不保持公开的表单用的。
边缘之外——用 OSV-Scanner 做依赖扫描
前面那些层要么加固边缘(WAF、机器人防护),要么清理仓库(密钥)。它们没有一个碰到已经在你应用内部运行着的已知易受攻击的库——而遗留系统的多数实际入侵正是从那里开始的。这就是依赖扫描要修的东西。
为什么选 OSV-Scanner
OSV-Scanner 是 Google 基于 OSV(Open Source Vulnerabilities)数据库 构建的开源 CLI。OSV 就是 Dependabot、GitHub Security Advisories、GCP Container Analysis 以及另外几款扫描器在底下查询的那个漏洞数据源。它是一套规范化、机器可读的模式,聚合了来自 npm、PyPI、Go、Cargo、RubyGems、Maven、Packagist、NuGet、Debian/Alpine/Ubuntu 软件包、GitHub Actions 等等的公告。
它读取常见的 lock 文件(package-lock.json、yarn.lock、Pipfile.lock 等),把每个包解析到它精确钉住的版本,并报告漏洞,带上 CVE 标识、严重度,以及(在 OSV 有这项信息时)修复它的版本区间。
跑一次扫描并读懂输出
OSV-Scanner 可以通过 Go、Homebrew、预编译二进制安装,或者——和上面的 gitleaks 一样——通过 Docker 运行。为保持一致,我们展示 Docker。
我刚刚对着这篇文章所在的仓库跑了这个:
docker run --rm -v "$(pwd):/src" ghcr.io/google/osv-scanner:latest \
scan source --recursive /src--recursive 会走遍 /src 下的每个目录,找出嵌套项目里的 lock 文件(博客、几个演示、服务器、草稿实验)。不加它,扫描器只检查顶层——对单项目仓库没问题,但多数真实仓库的 lock 文件散布在好几个目录里。
一次运行末尾的汇总长这样:
Total 35 packages affected by 59 known vulnerabilities (2 Critical, 17 High, 37 Medium, 3 Low, 0 Unknown) from 2 ecosystems.
59 vulnerabilities can be fixed.完整输出是一张表,每行对应「CVE × 包 × 事实来源」。几行代表性的:
| OSV URL | CVSS | 生态 | 包 | 版本 | 修复版本 | 来源 |
|---|---|---|---|---|---|---|
| GHSA-xq3m-2v4x-88gg | 9.4 | npm | protobufjs | 7.5.4 | 7.5.5 | blog/…/transformers-js-demo/package-lock.json |
| GHSA-p9ff-h696-f583 | 8.2 | npm | vite | 7.3.1 | 7.3.2 | blog/package-lock.json |
| PYSEC-2025-40 | 7.5 | PyPI | transformers | 4.48.3 | 4.49.0 | blog/…/from-scratch/requirements.txt |
| GHSA-r5fr-rjxr-66jc | 8.1 | npm | lodash | 4.17.23 | 4.18.0 | server/package-lock.json |
每行六个字段,全都有用:
OSV URL——直通 osv.dev 上的公告(CVE 标识、攻击向量、受影响版本区间、参考资料)。CVSS——0–10 刻度上的严重度分值。9 以上是 Critical,7–8.9 是 High,4–6.9 是 Medium。Ecosystem——这个依赖来自哪个包注册中心。同一个包名可以存在于多个生态里,并拿到不同的 CVE。Package和Version——lock 文件里当前钉住的是什么。Fixed version——解决该问题的最低版本。常常是一个补丁号升级;有时是 minor 或 major。Source——这条发现来自哪个 lock 文件。对单体仓库至关重要:同一个包在不同子项目里可能钉在不同版本上,而这正是我们在实际扫描中对vite、protobufjs和picomatch看到的情形。
注意汇总把计数分成两种说法:总共 59 个已知漏洞,以及 59 个漏洞可以修复——也就是说每一个都有已知的升级目标。在这次扫描里它们恰好相等,但情况并非总是如此。
Fixed version 这一列告诉你每一行是否存在升级路径。
如果它有值,扫描器已经把调研做完了;剩下的工作纯粹就是升级——升版本、测试、发布。如果它是空的,你就碰上了一条尚无已知修复的发现,应对方式不同。下一节会讲怎么分诊这两类。
扫描器也可以针对某个具体的 lock 文件(--lockfile=/path/to/lockfile)、一份 SPDX 或 CycloneDX 格式的 SBOM(--sbom=/path/to/sbom.json),或者一个容器镜像(osv-scanner scan image my-app:latest)——镜像扫描需要本地 CLI 安装而不是 Docker,因为扫描器需要直接访问被检查镜像的文件系统。
拿这些发现怎么办
对一个遗留应用跑一次,你几乎肯定会得到一墙的发现——轻易几十条,有时上百条。 对每一条发现,决定怎么办的是两个问题:它有多严重,以及是否有可用的修复。
可修复的发现。 扫描翻出来的大部分通常落在这里,因为活跃维护的包会打补丁。按严重度分诊:
- Critical/High 且有已知公开利用代码——不论你是否认为自己用到了那条代码路径,先打这个补丁。公开利用不在乎你对代码的心智模型。
- Critical/High 但无已知利用代码——按正常节奏打补丁(下个迭代)。
- Medium/Low——在下一轮例行升级里升掉。别让它们卡住构建,否则你在遗留应用上永远看不到一次绿色 CI。
对这些,工作的形状都一样:把钉住的版本升到 Fixed version,跑测试,发布。像 Dependabot 和 Renovate(下面会讲)这类工具会把创建 PR 那一步自动化。
不可修复的发现。 它们需要不同的处理,通常有三种口味:
- 补丁尚未发布——漏洞已披露,但维护者还没发出修复。跟踪它,定期重新扫描,并盯着 OSV 公告页看更新。
- 无人维护的包——项目已死。要么替换这个依赖,要么有意识地与它共存,并在
osv-scanner.toml里加一条有文档记录的抑制,好让扫描不在每次 CI 运行时把它重新标出来。 - 你无法直接升级的传递依赖——某个易受攻击的包是被别的东西拽进来的,而那个东西还没升到更新的父版本。这恰恰是 OSV-Scanner 的输出与 WAF 配对的场合: 如果没有升级路径,而这个漏洞有已知的利用模式,那就写一条 WAF 规则拦掉那个模式,当作虚拟补丁,直到上游修复落地。
这个划分在运维上很要紧:可修复的发现是给你构建流水线的活(Dependabot 的 PR、自动升级)。不可修复的发现是给人的活——决定抑制、替换还是虚拟打补丁——而它们才是值得盯着的,因为它们不会自己消失。
替代方案,以及各自何时该选
- GCP Artifact Analysis——GCP 原生的选项,而且如果你已经在 GCP 上,它大概是你能加的摩擦最小的扫描器。Artifact Registry 在推送时用 OSV 数据库(与上面 OSV-Scanner 同一数据源)自动扫描镜像,而持续分析会随着新漏洞进入 OSV 而不断重扫已存储的镜像——于是你会知道几个月前发布的镜像里刚被披露的 CVE,而不必重新推送。发现会出现在 Artifact Registry 界面里,也会作为 Pub/Sub 通知发出。没有额外的工具要运维。
- Trivy——如果你想只用一个工具,它是名副其实的超集。扫描容器、文件系统、git 仓库、Kubernetes 配置、IaC(Terraform/CloudFormation)以及密钥。如果你还需要容器和 IaC 扫描,并且想要一个在任何地方都能跑的单一运维工具,就选 Trivy。
- Dependabot——如果你在 GitHub 上,它已经接好了。会自动开 PR 升级易受攻击的依赖。和 OSV-Scanner 搭着用:Dependabot 负责自动 PR,OSV-Scanner 在 CI 里按严重度卡构建。
一个合理的最小体系:CI 里的 OSV-Scanner(对新出现的高严重度发现卡构建)+ Dependabot(持续开升级 PR)。如果你本来就在发布容器,那就要么加上 Trivy 做镜像扫描,要么——如果你推送到云端注册中心——依靠注册中心自带的扫描器(GCP 上是 Artifact Analysis,AWS 上是 ECR + Inspector),而不是再运维一个工具。
扫描器不等于供应链卫生
有一个重要缺口值得说清:扫描器抓的是钉住依赖中的已知 CVE。它们抓不到供应链攻击——恶意包、被攻陷的维护者账号、抢注相似名(lodsh 对 lodash)、把凭据外传的 post-install 脚本。这是另一类威胁,需要另一套防御:强制使用 lock 文件、禁用安装期生命周期脚本、在添加新依赖前先审阅、给包注册中心账号开 2FA。
如果你的技术栈是 Node.js,OWASP 的 NPM Security Cheat Sheet 是一份有用的具体清单——涵盖 --ignore-scripts、npm audit、带 scope 的包、识别抢注名等等。OWASP 的 Vulnerable Dependency Management Cheat Sheet 以与语言无关的方式覆盖同一片地。两者都与上面基于扫描器的那一层配合得好:扫描器管已知的坏,卫生管未知的坏。
这套体系覆盖了什么,还能往哪走
如果你部署了三个核心层加依赖扫描,你到底给自己买到了什么?
覆盖得相当不错的:
- 机会主义的自动扫描器和大规模利用战役。
- 依赖中的已知 CVE——只要发现进来时你去打补丁。
- 仓库里泄露的凭据(当前的和历史的)。
- 请求时刻的 SQL 注入、XSS、路径穿越和命令注入——在抵达应用之前就被 WAF 拦掉。
- 公开表单上那层显然的、以量取胜的机器人滥用。
仍然漏掉的:
- 你自己代码里的易受攻击模式。 WAF 在请求时刻拦掉载荷,但帮不了你找到或修好底下那段易受攻击的代码。下面的 SAST 和 DAST 才是你开始动手的方式。
- 应用逻辑缺陷——任何需要知道应用应该做什么的东西。IDOR(用户本该只能看到
1233时却请求了/orders/1234)、坏掉的认证或会话管理、缺失或不一致的授权检查、诸如重放购物车结账请求以跳过支付步骤这类业务逻辑漏洞。这些在扫描器眼里没有一个看起来是恶意的。 - 新的零日漏洞,在应用或其依赖中,且在特征出现之前。
- 执意的对手。 滥用合法访问权限的内部人员。资源充足的机器人运营者,他们买 CAPTCHA 代解服务,换 IP 的速度比你拉黑的速度还快。
接下来加什么:SAST 与 DAST
三个核心层和依赖扫描到位之后,自然的下一步是分析代码本身,而不只是流经它的流量。两种互补的技术:
- SAST(静态应用安全测试) 静态地读源码,按模式匹配出有风险的构造——用字符串拼接搭出来的 SQL 查询、缺失的授权检查、用用户输入拼出来的 shell 命令、未校验的重定向、写死的凭据。Semgrep 是开源的默认选择;像
p/security-audit和p/owasp-top-ten这类社区维护的规则集,能在多数语言上给你覆盖,而不必经历 CodeQL 或 SonarQube 那样的配置折腾。放进 CI,并对高严重度发现卡构建。 - DAST(动态应用安全测试) 做的正相反——它把应用跑起来,从外面探测它,爬取路由并尝试真实的攻击载荷。它对黑盒应用之所以强,恰恰是因为它不需要源码;扫描器就是一个合成攻击者。OWASP ZAP 是开源的默认选择,它有一个对预发或生产都安全的被动基线扫描,以及一个只应对着装有一次性数据的预发环境运行的破坏性完全主动扫描。Burp Suite 是商业上的行业标准;Nuclei 则以快速的、基于模板的 CVE 模式覆盖来补足两者中的任一个。
一个合理的扩展体系:每个 PR 上跑 Semgrep,每次部署到预发时跑 ZAP 基线扫描,按每夜或每周的计划跑 ZAP 完整认证扫描。对遗留应用上的 DAST 来说,带认证的扫描重要得不成比例——不带认证的扫描只看得见登录页,而所有有意思的端点全在它后面。