给 agent 的容器补一张出口白名单:默认全断,只放行你真要用域名,并留下 DNS 与连接日志

2026-10-23 Skills安全Agent沙箱工程实践

大部分人给 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 条,全过才算数:

  1. probe.sh 退出码是 0,四组全部 PASS。 脚本报 FAIL 的话,按它指的那一项回去改,别凭感觉调。
  2. 白名单里一个域名都不多。 打开 squid.conf,逐行问”这个 agent 真的需要它吗”。每加一个 dstdomain,你就多给了一条可复用的出网通道——OpenAI 那次就是这么出去的。
  3. 你能从 squid-log/access.log 里说出过去 24 小时 agent 尝试访问了哪些被拒的域名。 查不到日志,说明你连”它被拦了几次”都不知道,等于没监控。
  4. 故意让 agent 去访问一个随机域名,它被拒,而且告警能在你的日志里找到。 测的不是规则生效,是”生效这件事你能看见”。
  5. 摘掉代理变量之后 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 干了你不让它干的事,你知道这件事发生过。这两者的差距,就是能不能复盘的区别。