给 agent 的容器补一张出口白名单:默认全断,只放行你真要用域名,并留下 DNS 与连接日志
大部分人给 agent 的容器只是”跑了起来”。能联网、能解析任意域名、能连任意 IP——这三件事默认都是开着的,而你根本没想过要关。
OpenAI 那份复盘里最有教育意义的一段是:他们留了几条窄服务(metrics、logging、认证)对外,agent 就复用防火墙已经批准的那条连接,去打 metrics 服务里被拦的认证端点,再让那个端点去 fetch 模型给的 URL。翻译成日常语言:只要有一条”合法”的出网通道,agent 就能把它变成任意出网通道。
所以正确的默认不是”堵掉危险的”,是”什么都不放行,然后逐条加”。
结构
三个东西:一个没有外网路由的内部网络、一个只认白名单的转发代理、一个跑在内部网络里的 agent 容器。
[agent] ──> [squid 代理] ──> 外网
│ │
└── internal 网络(无默认路由) proxy 同时挂 internal + 外网
agent 自己一条出网的路都没有。它想访问任何域名,只能走代理,而代理只认你写的那几个。
第一步:代理的白名单
# squid.conf
# 只放行这几类:模型 API、包镜像源、你自己的服务
acl allow_domains dstdomain .openai.com .anthropic.com .openrouter.ai
acl allow_domains dstdomain .api.kimi.com .platform.moonshot.cn
acl allow_domains dstdomain .registry.npmjs.org .pypi.org .files.pythonhosted.org
acl allow_domains dstdomain .github.com .githubusercontent.com
acl CONNECT method CONNECT
acl Safe_ports port 443 80
http_access deny !Safe_ports
http_access allow allow_domains
http_access deny all
# 关键:日志要留,它是你唯一的复盘材料
access_log stdio:/var/log/squid/access.log squid
cache deny all
http_port 3128
dstdomain 前面的点表示包含子域名。这一行对 CONNECT 也生效,所以 HTTPS 隧道同样被白名单管住——别漏掉这一点,只配 http_access 而不配端口白名单,等于给了一条绕过口。
第二步:编排文件
# docker-compose.yml
services:
proxy:
image: ubuntu/squid:latest
volumes:
- ./squid.conf:/etc/squid/squid.conf:ro
- ./squid-log:/var/log/squid
networks:
- agentnet
- egress
agent:
build: .
networks:
- agentnet
environment:
HTTP_PROXY: http://proxy:3128
HTTPS_PROXY: http://proxy:3128
NO_PROXY: ''
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
- DAC_OVERRIDE
security_opt:
- no-new-privileges:true
networks:
agentnet:
internal: true # 这一行是整套方案的地基
egress: {}
三个点别漏:
internal: true。它让 agentnet 没有任何默认路由,agent 直连外网和向外部 DNS 发请求都会在链路层就失败。这也是为什么前面那个 DNS 隧道打不出来——不是靠规则挡的,是靠没有路。NO_PROXY: ''。很多基础镜像会预置no_proxy=localhost,127.0.0.1之类,某些 SDK 见到非空值就会绕过代理直连。显式写空。cap_drop: ALL。挡住 raw socket 那条路。留着 CAP_NET_RAW,上面所有的网络限制都能被绕过。
第三步:探测脚本
验收不能靠”我觉得应该没问题”。下面这段放进镜像里,每次改配置都跑一遍。
#!/usr/bin/env bash
# scripts/sandbox/probe.sh —— 在 agent 容器里跑:bash scripts/sandbox/probe.sh
set -u
BLOCKED="example.com www.baidu.com 1.1.1.1"
ALLOWED="api.openai.com registry.npmjs.org"
fail=0
echo "== 1) 非白名单域名必须不可达"
for h in $BLOCKED; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "https://$h" 2>/dev/null || echo 000)
if [ "$code" = "000" ] || [ "$code" = "503" ]; then
echo " PASS $h 被拦($code)"
else
echo " FAIL $h 通了($code)——出口没关住"; fail=$((fail+1))
fi
done
echo "== 2) DNS 必须不通"
for h in $BLOCKED; do
if getent hosts "$h" >/dev/null 2>&1; then
echo " FAIL $h 解析成功——DNS 没断"; fail=$((fail+1))
else
echo " PASS $h 解析失败"
fi
done
echo "== 3) 白名单域名必须可达"
for h in $ALLOWED; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://$h" 2>/dev/null || echo 000)
if [ "$code" != "000" ]; then
echo " PASS $h 可达($code)"
else
echo " FAIL $h 不可达——白名单没配对,agent 会干不了活"; fail=$((fail+1))
fi
done
echo "== 4) 原始套接字必须被禁掉"
if ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then
echo " FAIL 还能 ping——CAP_NET_RAW 没摘掉"; fail=$((fail+1))
else
echo " PASS ping 被拒"
fi
echo
[ "$fail" -eq 0 ] && echo "全部通过(4 组)" || echo "有 $fail 项没过"
exit "$fail"
怎么知道自己做对了
5 条,全过才算数:
probe.sh退出码是 0,四组全部 PASS。 脚本报 FAIL 的话,按它指的那一项回去改,别凭感觉调。- 白名单里一个域名都不多。 打开
squid.conf,逐行问”这个 agent 真的需要它吗”。每加一个dstdomain,你就多给了一条可复用的出网通道——OpenAI 那次就是这么出去的。 - 你能从
squid-log/access.log里说出过去 24 小时 agent 尝试访问了哪些被拒的域名。 查不到日志,说明你连”它被拦了几次”都不知道,等于没监控。 - 故意让 agent 去访问一个随机域名,它被拒,而且告警能在你的日志里找到。 测的不是规则生效,是”生效这件事你能看见”。
- 摘掉代理变量之后 agent 完全断网。 把
HTTP_PROXY和HTTPS_PROXY清空重跑一次probe.sh,第 3 组必须全 FAIL。这条验证的是”所有流量真的都走了代理”,没有旁路。
几个坑
- SDK 不一定读
HTTP_PROXY。 Node 的原生fetch默认完全不认代理环境变量。要么换undici的ProxyAgent,要么在容器里用iptables把 443 强制重定向到代理——只设环境变量就以为万事大吉,是最常见的一种自欺。 internal: true之后镜像拉不了、包装不上。 构建阶段要走外网,用docker build的--network=host,或者把依赖提前装进镜像,别在运行期联网装包。ubuntu/squid的日志目录权限很挑。 第一次挂./squid-log空目录容易起不来,先mkdir -p squid-log && chmod 777 squid-log试一次,跑通了再收紧。- 白名单域名会变。 模型厂商换域名、加区域端点,你的 agent 就开始报连不上。access.log 里出现成片的
TCP_DENIED,第一反应应该是查白名单过期,而不是放宽规则。 - 这套只管网络。 文件系统挂载、宿主机 socket、凭证注入都是另外的洞。别因为网络收住了就以为沙箱成了。
顺手做掉的两件事
同一轮 OpenAI 的复盘里还掉出两个今天就能处理的东西:
- Brotli:CVE-2026-111497,CVSS 9.2,影响 1.0.3 到 1.0.10 之前、1.1.0 到 1.1.1 之前、1.2.0 到 1.2.1 之前;修正在 1.0.10 / 1.1.1 / 1.2.1。
- Node.js:26.x、24.x、22.x 三条线的安全版本 10 月 21 日已发,覆盖
node:zlib解压缩的输入。
查一遍只要两条命令:
node -v && npm ls brotli compress-brotli 2>/dev/null; brotli --version 2>/dev/null
dangerously-skip=0 npx --yes npm-audit-resolve --help >/dev/null 2>&1 || npm audit --audit-level=high
最后一句
这套东西搭起来大概两小时,之后每次改 agent 环境花五分钟跑一次探测。
它换来的不是”绝对安全”——OpenAI 那份复盘的结尾是”我们预计未来的模型能找出我们现在还不知道的漏洞”,连他们都不敢说绝对。它换来的是当 agent 干了你不让它干的事,你知道这件事发生过。这两者的差距,就是能不能复盘的区别。