8.7 KiB
8.7 KiB
| 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 # 应输出 ok(userns 可用)
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"}
必查项(漏一个就是洞):
- remote_host/remote_user 格式:正则白名单,防
evil.com;rm -rf、user root;rm注入。坑:用户从文档/聊天记录复制 IP 会带入零宽空格\u200b、BOM\ufeff等不可见字符,str.strip()去不掉(非 isspace)→ 正则匹配失败报「格式非法」。校验前用re.sub(r"[\s\u200b\u200c\u200d\u2060\ufeff]+", "", s)统一清理(_clean_field),再走正则。 - remote_port:
int()要 try/except(传"abc"会 500),范围 1-65535。 - remote_dir 是相对路径(从远程登录用户 home 目录开始):相对路径天然限制在 home 内,无需系统目录黑名单。校验三点:① 非绝对路径(不以
/开头);② 不含..(防穿越到 home 外);③ 只含安全字符[a-zA-Z0-9._/-](防 shell 注入)。坑:shlex.quote("~/rel")会返回'~/rel'(单引号),~不展开,创建字面~目录——远程 ssh 命令里用"$HOME/rel"拼接让远程 shell 展开,rsync 里用~/rel(rsync 自己展开~,不经过 shlex.quote)。 - remote_key_path 限制
~/.ssh/下:允许任意绝对路径等于能读服务器上任意私钥(git deploy key、其他服务 key)去认证别的主机。 - org/机构级配置需权限校验:任何登录用户都能设 org 级工作环境 = 能把整个机构内容迁移到攻击者服务器。org 级只允许
owner.superuser(查userrole表),user 级强制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 key(known_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 或 2(PID namespace 生效)
ls / # 只显示只读挂载目录(bin/dev/etc/home/lib...)
touch /etc/test # 必须报 "Read-only file system"
ls /home # 显示部署目录内容(可写)
逻辑账号数据结构
- 表:
sd_deploy_accounts(user_id, account_nameag_<username>, deploy_dir, status, sandbox_config) - 目录:
<base>/ag_<username>/{home,workspace,data,logs},os.makedirs(exist_ok=True)幂等 - 账号名 sanitize:
re.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 源可达:离线环境降级为目录隔离,不阻塞主流程。