8.7 KiB
Raw Blame History

name description version
zero-root-bwrap-sandbox Use when 需要零 root 的多用户隔离部署/运行环境(逻辑账号+bwrap沙箱 1.0.0

零 root 逻辑隔离账号 + bwrap 沙箱

为多用户提供隔离的部署/运行环境,但全程零 root(不 sudo、不 useradd、不装系统包。 核心:逻辑账号(目录 + DB 记录)+ bubblewrap 沙箱unprivileged user namespaces

核心决策:真实 Linux 账号 vs 逻辑隔离

先问清账号用途,再选方案:

真实 Linux 账号 逻辑隔离(本 skill
创建方式 useradd(需 root mkdir + DB 记录(零 root
登录方式 SSH 密码/密钥 部署进程直接文件系统访问
隔离 uid/磁盘配额/进程 bwrap namespace + 目录
root 依赖 创建需 root 全程零 root

判断标准:账号是给人 SSH 登录部署,还是给部署进程一个隔离环境。 前者要真实账号(且应走独立 root 服务而非给 web 服务 sudo 免密);后者用逻辑隔离。

⚠️ 不要给运行 LLM 命令的 web 服务 sudo 免密create_subprocess_shell 执行 LLM 生成的命令已是高风险入口sudo 免密(哪怕白名单 useradd/usermod一旦被 RCE 即可 useradd -o -u 0 造 root。

bwrap 零 root 获取(关键技巧)

bwrap 通常未预装,但可零 root 获取:

cd /tmp && apt download bubblewrap        # 普通用户可下载 .deb无需 root
dpkg -x bubblewrap*.deb /tmp/bwrap_ext    # 解包,无需 root
cp /tmp/bwrap_ext/usr/bin/bwrap /path/bin/bwrap
/path/bin/bwrap --version                 # 验证

前置条件(缺一不可):

cat /proc/sys/kernel/unprivileged_userns_clone   # 应为 1
unshare -Ur echo ok                              # 应输出 okuserns 可用)
ldd /path/bin/bwrap | grep "not found"           # 应无输出libcap/libseccomp 已自带)

一键部署脚本build.sh里集成

mkdir -p "$cdir/bin"
if [ ! -x "$cdir/bin/bwrap" ]; then
    if command -v bwrap >/dev/null 2>&1; then
        cp "$(command -v bwrap)" "$cdir/bin/bwrap"
    else
        tmpd=$(mktemp -d)
        ( cd "$tmpd" && apt download bubblewrap >/dev/null 2>&1 && dpkg -x bubblewrap*.deb . && cp usr/bin/bwrap "$cdir/bin/bwrap" ) 2>/dev/null || true
        rm -rf "$tmpd"
    fi
fi

start.sh 里 export PATH="$WORKDIR/bin:$PATH",运行时 shutil.which("bwrap") 即可找到。

bwrap 沙箱参数模板

[
    BWRAP,
    "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
    "--die-with-parent",
    "--ro-bind", "/usr", "/usr",
    "--ro-bind", "/bin", "/bin",
    "--ro-bind", "/sbin", "/sbin",
    "--ro-bind", "/lib", "/lib",
    "--ro-bind", "/lib64", "/lib64",
    "--ro-bind", "/etc", "/etc",
    "--proc", "/proc",
    "--dev", "/dev",
    "--tmpfs", "/tmp",
    "--bind", deploy_dir, "/home",
    "--setenv", "HOME", "/home",     # 必须!否则沙箱内 HOME 指向宿主机不存在的路径
    "--chdir", "/home",
    "--", "bash", "-c", command,
]
  • 网络:默认保留(不加 --unshare-net),部署应用通常要网络/监听端口。要隔离加 --unshare-net
  • asyncio.create_subprocess_exec(*cmd, ...)(列表传参,防 shell 注入),非 create_subprocess_shell
  • bwrap 未装 → 降级为目录隔离(chdir 弱隔离),返回里必须标注 sandbox: false

检测 bwrap 的稳健写法

BWRAP = shutil.which("bwrap")
for p in (os.environ.get("BWRAP_PATH", ""), "/known/path/bwrap", ...):
    if not BWRAP and p and os.path.exists(p):
        BWRAP = p

远程主机沙箱(免密 SSH + 远程 bwrap

工作环境可切到远程主机:ssh -i key user@host 在远程同样跑 bwrap 沙箱。安全校验必须过一遍audit 踩过的坑):

_HOST_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$")  # 主机名/IPv4
_USER_RE = re.compile(r"^[a-zA-Z0-9._-]+$")                            # 用户名
_FORBIDDEN_DIRS = {"/", "/bin", "/sbin", "/usr", "/etc", "/lib", "/lib64", "/var",
                   "/root", "/boot", "/dev", "/proc", "/sys", "/tmp", "/opt",
                   "/run", "/srv", "/mnt", "/media", "/home"}

必查项(漏一个就是洞):

  1. remote_host/remote_user 格式:正则白名单,防 evil.com;rm -rfuser root;rm 注入。坑:用户从文档/聊天记录复制 IP 会带入零宽空格 \u200b、BOM \ufeff 等不可见字符,str.strip() 去不掉(非 isspace→ 正则匹配失败报「格式非法」。校验前用 re.sub(r"[\s\u200b\u200c\u200d\u2060\ufeff]+", "", s) 统一清理(_clean_field),再走正则。
  2. remote_portint() 要 try/except"abc" 会 500范围 1-65535。
  3. remote_dir 是相对路径(从远程登录用户 home 目录开始):相对路径天然限制在 home 内,无需系统目录黑名单。校验三点:① 非绝对路径(不以 / 开头);② 不含 ..(防穿越到 home 外);③ 只含安全字符 [a-zA-Z0-9._/-](防 shell 注入)。坑:shlex.quote("~/rel") 会返回 '~/rel'(单引号),~ 不展开,创建字面 ~ 目录——远程 ssh 命令里用 "$HOME/rel" 拼接让远程 shell 展开rsync 里用 ~/relrsync 自己展开 ~,不经过 shlex.quote
  4. remote_key_path 限制 ~/.ssh/允许任意绝对路径等于能读服务器上任意私钥git deploy key、其他服务 key去认证别的主机。
  5. org/机构级配置需权限校验:任何登录用户都能设 org 级工作环境 = 能把整个机构内容迁移到攻击者服务器。org 级只允许 owner.superuser(查 userroleuser 级强制 owner_id=自己

迁移失败状态一致性:先迁移成功再写 DB失败不回写 mode。否则 mode 已切但内容没迁,状态分裂。

SSH 命令构造要点:

  • create_subprocess_exec(*["ssh", ...]) 列表传参,非 shell 拼接。
  • 远程 bwrap 路径探测:BW=$(command -v bwrap || echo "$HOME/bin/bwrap"); exec "$BW" ...(远程 SSH 会话 PATH 不含自定义 bin
  • host key 校验用 -o StrictHostKeyChecking=accept-new,不要 =no=no 完全不验证主机身份 = MITM 可诱导服务器把机构数据 rsync 给攻击者。accept-new 首次记录 host keyknown_hosts 哈希格式,grep host 匹配不到是正常的),之后 host key 变化即拒绝。

每机构独立 SSH key防单点泄露

  • 不要所有机构共用一个 pipeline 私钥——一把 key 管所有机构,泄露一个全崩,且 pipeline 用户是最高风险用户(跑 LLM 命令)。
  • 每机构独立 ed25519 key pair~/.ssh/org_keys/<sanitized_org_id>/id_ed25519
def ensure_org_key(org_id):
    d = os.path.join(os.path.expanduser("~/.ssh/org_keys"), re.sub(r"[^a-zA-Z0-9_-]", "_", org_id))
    os.makedirs(d, mode=0o700, exist_ok=True)
    priv = os.path.join(d, "id_ed25519")
    if not (os.path.exists(priv) and os.path.exists(priv + ".pub")):
        subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", priv], check=True)
        os.chmod(priv, 0o600)
    return priv
  • org 级远程模式自动 ensure_org_key(owner_id)remote_key_path 强制指向机构 key不允许 admin 指定任意 key
  • 提供 get_org_pubkey 接口让 admin 拿到公钥加到远程主机 authorized_keys。

防逃逸验证(部署后必做)

# 沙箱内执行:
echo pid=$$          # 应为 1 或 2PID namespace 生效)
ls /                 # 只显示只读挂载目录bin/dev/etc/home/lib...
touch /etc/test      # 必须报 "Read-only file system"
ls /home             # 显示部署目录内容(可写)

逻辑账号数据结构

  • 表:sd_deploy_accountsuser_id, account_name ag_<username>, deploy_dir, status, sandbox_config
  • 目录:<base>/ag_<username>/{home,workspace,data,logs}os.makedirs(exist_ok=True) 幂等
  • 账号名 sanitizere.sub(r"[^a-zA-Z0-9_-]", "_", username),防路径穿越
  • 免密访问 = 部署进程直接文件读写,路径必须 abspath 后校验 startswith(deploy_dir + "/")(防越界)

Pitfalls

  • bwrap 未装不报错会静默降级run_in_sandbox 返回必须带 sandbox 字段,前端/日志可见降级状态。
  • HOME 不 setenv:沙箱内 HOME 继承宿主机(如 /d/pipeline),指向沙箱内不存在的路径,应用 cd $HOME 报错。
  • --unshare-all 会隔离网络:默认部署场景别用,改用显式 --unshare-user/pid/ipc/uts 保留网络。
  • 相对路径校验_is_safe_relpath 必须拒绝 / 开头和含 .. 的路径。
  • apt download 依赖 apt 源可达:离线环境降级为目录隔离,不阻塞主流程。