对齐 Muse P4:浏览器完全对齐(CDP broker 单元外 + a11y-tree-only + 确定性分类器)
- chv-guest/chv-cdp-broker.py(新):单元外 CDP broker,用户 chv-browser, 独家 chromium.launch(headless=True, args=LAUNCH_ARGS);启动参数白名单 无 --remote-debugging-port、无 devtools;导航前 URL 校验 + 拦截名单; 快照恒带 untrusted_input 与 injection_flags;高风险表单在页时 open 即 ask 入队;观测到导航 POST 自动暂停;命中拦截规则的下载取消 + 暂停 + ask;全部回包与错误经 sanitize_error + response_leaks 检查。 - chv-guest/chv-browser.py(P2 版重构):改为 broker 客户端,不再直连 Playwright(本文件无 playwright import),保留 URL 校验 + 快照截断 作为第二层防线;暂停状态与 broker 同步。 - chv-guest/chv_browser_guard.py(新):纯 stdlib 共享规则——validate_url、 拦截名单加载/匹配、快照截断、错误消毒(去 HTML、截断 200 字符、凭据形 key=value 与 sk- 形 token 打码)、注入启发式(中英文各 6 条)、表单风险 扫描、下载扫描(可执行扩展名)、文件型 ask 队列(pending/allowed/denied, 与 P3 ApprovalQueue 同语义)、启动参数与回包泄露检查、脚本注入面 禁止清单(FORBIDDEN_PAGE_ATTRS/FORBIDDEN_OPS)。 - chv-guest/browser-blocklist.txt(新):静态拦截名单;首版只有 RFC 2606 .test 自测条目,生产条目由运维构建时填入,无在线更新。 - chv-guest/build.sh/buildinfo.py:P2 三脚本 + P4 三文件纳入镜像与 buildinfo;新增 chv-cdp-broker.service 与 /run/chv/ask-queue tmpfiles。 - docs/chv-muse-p4-gap.md(新):现状 vs Muse 官方要求差距表、改动、 诚实边界、P2 合入约定、真机待验证清单(round 12)。 - docs/chv-production-readiness.md:补 §15(P4 一节;编号合入时若与 P2 的 §15 撞,顺延)。 - cloud/tests/test_chv_muse_p4.py(新,36 项):guard 单元、AST 钉住 禁止属性、假 Playwright broker 测试、假 broker agent 测试、真 Unix socket 联调、buildinfo/build 干跑断言;含否定测试(evaluate 被拒、 DOM 外泄被拒、DevTools 不可达、私有地址被拒、高风险表单被拦、 注入文本被标注)。 测试:P4 新测试 36/36;全量 cloud 套件 1903 项,两次连续全过 (skipped=8)。附记:第一次全量跑出 1 个失败但输出未保留、后两次 均未复现,疑为时序抖动,未追认。 真机验证未做:排队等 round-11/第三棒释放 100.65.51.33,清单见 gap 表 §5。本地提交,未推未合未部署。
This commit is contained in:
1 parent
06c3e172fe
commit
1c8fc3f7c8
11 files changed
+2878
-9
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# CHV 浏览器静态拦截名单(对齐 Muse P4)
|
||||
#
|
||||
# 格式:一行一个条目,# 开头是注释,空行跳过。
|
||||
# example.com 拦截该域名及其全部子域
|
||||
# .example.com 同上(前导点可写可不写)
|
||||
# 匹配规则见 chv_browser_guard.py 的 check_blocklist。
|
||||
#
|
||||
# 来源与更新机制(诚实口径):
|
||||
# 本文件随镜像发布,是静态名单。首版只含 RFC 2606 保留 .test 域
|
||||
# 的格式示例与自测条目(.test 域名保证不可解析,永不误拦真实站),
|
||||
# 不编造真实恶意域名。生产名单由运维在构建时用公开情报源
|
||||
# (如 URLhaus / Google Safe Browsing 导出)的域名替换本文件后
|
||||
# 重建镜像;更新 = 换文件 + 重建镜像,无在线更新(在线更新列为
|
||||
# 后续,不冒充已做)。名单缺失时视为空名单,不静默失败。
|
||||
#
|
||||
# 自测条目(.test 保留域,仅供测试与格式示例):
|
||||
malware.test
|
||||
.phishing.test
|
||||
+50
-4
@@ -26,6 +26,12 @@
|
||||
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
|
||||
# 代码、日志。
|
||||
#
|
||||
# 对齐 Muse P2(2026-11-07):镜像里加 systemd-nspawn 运行时单元
|
||||
#(chv-cell:codex 跑在单元内)+ 单元外 broker(chv-broker:
|
||||
#Unix socket + SO_PEERCRED 白名单代办)+ 浏览器子代理(chv-browser:
|
||||
#单元外独立进程,只返回无障碍树快照)。boot clone 仍在单元外做;
|
||||
#单元隔离口径见 docs/chv-muse-p2-gap.md。
|
||||
#
|
||||
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
||||
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
|
||||
# REPO_COMMIT=$(git rev-parse --short HEAD) \
|
||||
@@ -166,6 +172,20 @@ if [ ! -f "$SCRIPT_DIR/chv-sync.sh" ]; then
|
||||
echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 对齐 Muse P2:单元启动器 / broker / 浏览器子代理三脚本缺一即失败。
|
||||
for p2_script in chv-cell.py chv-broker.py chv-browser.py; do
|
||||
if [ ! -f "$SCRIPT_DIR/$p2_script" ]; then
|
||||
echo "缺少 $p2_script(Muse P2 客机脚本):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# 对齐 Muse P4:CDP broker / guard 共享模块 / 拦截名单三件缺一即失败。
|
||||
for p4_file in chv-cdp-broker.py chv_browser_guard.py browser-blocklist.txt; do
|
||||
if [ ! -f "$SCRIPT_DIR/$p4_file" ]; then
|
||||
echo "缺少 $p4_file(Muse P4 客机文件):$SCRIPT_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if [ "$DRY_RUN" = "0" ]; then
|
||||
if [ ! -e /dev/kvm ]; then
|
||||
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
||||
@@ -249,7 +269,7 @@ run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
|
||||
# 写进 /app/guest-versions-chromium.txt)。
|
||||
GT_INSTALL_CMD="curl -fsSL -o /tmp/gt-install.sh https://gitea.com/gitea/cli/raw/branch/main/scripts/install.sh && echo \"${GT_INSTALL_SH_SHA256} /tmp/gt-install.sh\" | sha256sum -c - && GITEA_CLI_VERSION=${GT_VERSION} GITEA_CLI_INSTALL_DIR=/usr/local/bin sh /tmp/gt-install.sh && rm -f /tmp/gt-install.sh && /usr/local/bin/gt version"
|
||||
VC_ARGS=(-a "$OUT/disk.raw"
|
||||
--run-command 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git openssh-server openssh-client cloud-init python3 python3-pip curl ca-certificates xz-utils fonts-wqy-microhei fontconfig && apt-get clean'
|
||||
--run-command 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git openssh-server openssh-client cloud-init python3 python3-pip curl ca-certificates xz-utils fonts-wqy-microhei fontconfig systemd-container iproute2 iptables && apt-get clean'
|
||||
--copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/"
|
||||
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/"
|
||||
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/"
|
||||
@@ -258,13 +278,37 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
||||
--copy-in "$REPO_DIR/host.py:/app/"
|
||||
--copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync'
|
||||
--copy-in "$SCRIPT_DIR/chv-cell.py:/usr/local/bin/"
|
||||
--copy-in "$SCRIPT_DIR/chv-broker.py:/usr/local/bin/"
|
||||
--copy-in "$SCRIPT_DIR/chv-browser.py:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/chv-cell.py /usr/local/bin/chv-cell && mv /usr/local/bin/chv-broker.py /usr/local/bin/chv-broker && mv /usr/local/bin/chv-browser.py /usr/local/bin/chv-browser && chmod +x /usr/local/bin/chv-cell /usr/local/bin/chv-broker /usr/local/bin/chv-browser'
|
||||
--copy-in "$SCRIPT_DIR/chv-cdp-broker.py:/usr/local/bin/"
|
||||
--copy-in "$SCRIPT_DIR/chv_browser_guard.py:/usr/local/bin/"
|
||||
--copy-in "$SCRIPT_DIR/browser-blocklist.txt:/usr/local/share/chv/"
|
||||
--run-command 'mv /usr/local/bin/chv-cdp-broker.py /usr/local/bin/chv-cdp-broker && chmod +x /usr/local/bin/chv-cdp-broker && chmod 0644 /usr/local/bin/chv_browser_guard.py /usr/local/share/chv/browser-blocklist.txt'
|
||||
--run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg'
|
||||
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
||||
--run-command "$GT_INSTALL_CMD"
|
||||
--run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt'
|
||||
--run-command 'printf "ssh_deletekeys: false\nbootcmd:\n - \"[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -A\"\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true'
|
||||
--run-command 'printf "PasswordAuthentication no\nPermitRootLogin prohibit-password\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n" > /etc/ssh/sshd_config.d/99-chv.conf && mkdir -p /run/sshd && ssh-keygen -A && sshd -t && grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config.d/99-chv.conf && grep -q "^PermitRootLogin prohibit-password" /etc/ssh/sshd_config.d/99-chv.conf && rm -f /etc/ssh/ssh_host_* && test -z "$(ls /etc/ssh/ssh_host_* 2>/dev/null)"'
|
||||
--run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true')
|
||||
--run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
|
||||
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots && chmod 0755 /run/chv /run/chv/shots'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service'
|
||||
--run-command 'printf "[Unit]\nDescription=CHV CDP broker (Muse P4)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-cdp-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-cdp-broker.service'
|
||||
--run-command 'printf "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
|
||||
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
|
||||
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败——
|
||||
# 合并后自动生效,P2 不改该脚本内容。
|
||||
VSOCK_NOTIFY_PRESENT=0
|
||||
if [ -f "$REPO_DIR/cloud/chv/guest/vsock_notify.py" ]; then
|
||||
VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/guest/vsock_notify.py:/usr/local/bin/"
|
||||
--run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify')
|
||||
VSOCK_NOTIFY_PRESENT=1
|
||||
else
|
||||
echo "警告:cloud/chv/guest/vsock_notify.py 不在仓库里(P1 未合并),镜像暂不含 vsock notify" >&2
|
||||
fi
|
||||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||||
VC_ARGS+=(
|
||||
--run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright python3 -m playwright install --with-deps chromium-headless-shell'
|
||||
@@ -316,14 +360,16 @@ fi
|
||||
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
|
||||
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} ${CHROMIUM_HEADLESS_SHELL_REV:-} # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
|
||||
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} ${CHROMIUM_HEADLESS_SHELL_REV:-} [vsock_notify?] # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
|
||||
else
|
||||
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
|
||||
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
|
||||
if [ "${PLAYWRIGHT:-1}" = "1" ] && [ -z "$CHROMIUM_HEADLESS_SHELL_REV" ]; then
|
||||
echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2
|
||||
fi
|
||||
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}"
|
||||
EXTRA_CONTENTS=""
|
||||
[ "$VSOCK_NOTIFY_PRESENT" = "1" ] && EXTRA_CONTENTS="vsock_notify"
|
||||
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}" "$EXTRA_CONTENTS"
|
||||
fi
|
||||
echo "tag=$TAG"
|
||||
echo "out=$OUT"
|
||||
|
||||
+20
-5
@@ -37,6 +37,11 @@ BASE_CONTENTS = [
|
||||
"xz-utils", "fonts-wqy-microhei", "fontconfig", "codex",
|
||||
"codex-code-mode-host", "codex-resources/bwrap",
|
||||
"/app/host.py", "gt", "chv-sync",
|
||||
# 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理。
|
||||
"systemd-container", "chv-cell", "chv-broker", "chv-browser",
|
||||
# 对齐 Muse P4:单元外 CDP broker + 浏览器安全共享模块 +
|
||||
# 静态拦截名单。
|
||||
"chv-cdp-broker", "chv-browser-guard", "browser-blocklist",
|
||||
]
|
||||
|
||||
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
||||
@@ -67,12 +72,18 @@ def _sha256_size(path):
|
||||
|
||||
|
||||
def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
||||
ubuntu_image_sha256, playwright, chromium_pinned_rev=None):
|
||||
ubuntu_image_sha256, playwright, chromium_pinned_rev=None,
|
||||
extra_contents=None):
|
||||
disk_path = os.path.join(outdir, "disk.raw")
|
||||
vmlinux_path = os.path.join(outdir, "vmlinux")
|
||||
disk_sha, disk_size = _sha256_size(disk_path)
|
||||
vmlinux_sha, vmlinux_size = _sha256_size(vmlinux_path)
|
||||
contents = list(BASE_CONTENTS)
|
||||
# extra_contents:构建时才确定的附加内容(如 P1 的 vsock_notify,
|
||||
# 文件存在才拷入)。调用方传什么记什么,不编默认值。
|
||||
for item in extra_contents or []:
|
||||
if item and item not in contents:
|
||||
contents.append(item)
|
||||
versions = {
|
||||
"dpkg": _read_lines(
|
||||
os.path.join(outdir, "guest-versions-dpkg.txt")),
|
||||
@@ -123,14 +134,18 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) not in (8, 9):
|
||||
if len(argv) not in (8, 9, 10):
|
||||
print("用法:buildinfo.py OUT TAG UBUNTU CODEX_VERSION "
|
||||
"REPO_COMMIT UBUNTU_IMAGE_SHA256 PLAYWRIGHT "
|
||||
"[CHROMIUM_HEADLESS_SHELL_REV]", file=sys.stderr)
|
||||
"[CHROMIUM_HEADLESS_SHELL_REV] [EXTRA_CONTENTS]", file=sys.stderr)
|
||||
return 2
|
||||
_, outdir, tag, ubuntu, codex_version, repo_commit, \
|
||||
ubuntu_image_sha256, playwright = argv[:8]
|
||||
chromium_pinned_rev = argv[8] if len(argv) == 9 else None
|
||||
chromium_pinned_rev = argv[8] if len(argv) >= 9 else None
|
||||
# 第 10 参数:逗号分隔的附加内容(如 vsock_notify),可空。
|
||||
extra_contents = None
|
||||
if len(argv) == 10 and argv[9]:
|
||||
extra_contents = [s for s in argv[9].split(",") if s]
|
||||
if chromium_pinned_rev and not re.fullmatch(r"[0-9]+",
|
||||
chromium_pinned_rev):
|
||||
print("CHROMIUM_HEADLESS_SHELL_REV 须为纯数字:%s"
|
||||
@@ -138,7 +153,7 @@ def main(argv):
|
||||
return 2
|
||||
info = build(outdir, tag, ubuntu, codex_version, repo_commit,
|
||||
ubuntu_image_sha256, playwright == "1",
|
||||
chromium_pinned_rev)
|
||||
chromium_pinned_rev, extra_contents)
|
||||
if not info["versions"]["dpkg"]:
|
||||
print("BUILDINFO versions.dpkg 为空", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv-broker:单元外 IPC broker(客机侧、单元外服务)。
|
||||
|
||||
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):单元内外走 Unix socket +
|
||||
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
|
||||
都在单元外——单元看不见 token,只能请 broker 代办。
|
||||
|
||||
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志):
|
||||
1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内;
|
||||
2. 对端 pid 的 pid ns 须与 /run/chv/cell.pid 记的单元 init 同 ns
|
||||
(读不到 cell.pid 时跳过第 2 项并警告;cell.pid 存在但对端 ns
|
||||
读不到时拒绝,不降级)。
|
||||
|
||||
操作白名单:
|
||||
ping -> {"ok": true}
|
||||
sync {action: pull|push|status, repo?} -> 单元外以 chv 身份跑
|
||||
chv-sync(list argv,不进 shell;仓目录只允许
|
||||
~/workspace 下,realpath + commonpath 校验)。
|
||||
browser {…} -> 原样转发给 chv-browser.sock(浏览器子代理),
|
||||
回包原样返回;浏览器服务不在时报错,不替它跑 playwright。
|
||||
|
||||
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
|
||||
连接,绝不杀死 broker 循环。
|
||||
"""
|
||||
import grp
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
BROKER_SOCK = "/run/chv/broker.sock"
|
||||
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||
UID_BASE = 1000
|
||||
UID_COUNT = 65536
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
SYNC_USER = "chv"
|
||||
SYNC_BIN = "/usr/local/bin/chv-sync"
|
||||
MAX_REQUEST_BYTES = 65536
|
||||
MAX_OUTPUT_BYTES = 8192
|
||||
|
||||
SYNC_ACTIONS = ("pull", "push", "status")
|
||||
|
||||
|
||||
def _read_ns_link(pid, proc_root="/proc"):
|
||||
"""读 /proc/<pid>/ns/pid 的链接目标;读不到返回 None。"""
|
||||
try:
|
||||
return os.readlink(os.path.join(proc_root, str(pid), "ns", "pid"))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
|
||||
"""对端是否允许用 broker。返回 (allowed, reason)。
|
||||
cell_pid 为 None 时只做 uid 检查(broker 打日志警告,调用方负责)。"""
|
||||
if not (UID_BASE <= peer_uid < UID_BASE + UID_COUNT):
|
||||
return False, "uid %d 不在单元映射范围 [%d, %d)" % (
|
||||
peer_uid, UID_BASE, UID_BASE + UID_COUNT)
|
||||
if cell_pid is None:
|
||||
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
|
||||
cell_ns = _read_ns_link(cell_pid, proc_root)
|
||||
if cell_ns is None:
|
||||
return False, "cell.pid 的 pid ns 读不到,拒绝(不降级)"
|
||||
peer_ns = _read_ns_link(peer_pid, proc_root)
|
||||
if peer_ns is None:
|
||||
return False, "对端 pid ns 读不到,拒绝"
|
||||
if peer_ns != cell_ns:
|
||||
return False, "对端 pid ns 与单元不一致"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
def _read_cell_pid():
|
||||
try:
|
||||
with open(CELL_PID_FILE) as fh:
|
||||
return int(fh.read().strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_repo(repo):
|
||||
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
|
||||
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
|
||||
if not repo:
|
||||
return None
|
||||
base = os.path.realpath(WORKSPACE)
|
||||
target = os.path.realpath(repo)
|
||||
if os.path.commonpath([base, target]) != base or target == base:
|
||||
raise ValueError("仓目录只允许 %s 下:%s" % (WORKSPACE, repo))
|
||||
return target
|
||||
|
||||
|
||||
def _truncate(text, limit=MAX_OUTPUT_BYTES):
|
||||
if text is None:
|
||||
return ""
|
||||
if len(text) > limit:
|
||||
return text[:limit] + "\n…(已截断)"
|
||||
return text
|
||||
|
||||
|
||||
def _run_sync(action, repo):
|
||||
if action not in SYNC_ACTIONS:
|
||||
return {"ok": False,
|
||||
"error": "sync action 只允许 %s:%s"
|
||||
% ("/".join(SYNC_ACTIONS), action)}
|
||||
try:
|
||||
repo_path = _resolve_repo(repo)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
cmd = ["runuser", "-u", SYNC_USER, "--", "env", "HOME=/home/chv",
|
||||
SYNC_BIN, action]
|
||||
if repo_path:
|
||||
cmd.append(repo_path)
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "chv-sync 超时(300 秒)"}
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": "chv-sync 起不来:%s" % e}
|
||||
return {"ok": proc.returncode == 0,
|
||||
"stdout": _truncate(proc.stdout),
|
||||
"stderr": _truncate(proc.stderr),
|
||||
"returncode": proc.returncode}
|
||||
|
||||
|
||||
def _forward_browser(req, sock_path=BROWSER_SOCK, timeout=120):
|
||||
"""转发给浏览器子代理;回包须是带 ok 的 dict,否则报错。"""
|
||||
try:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.settimeout(timeout)
|
||||
conn.connect(sock_path)
|
||||
except OSError as e:
|
||||
return {"ok": False,
|
||||
"error": "浏览器子代理未就绪:%s" % e}
|
||||
try:
|
||||
conn.sendall((json.dumps(req, ensure_ascii=False) + "\n").encode())
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
except OSError as e:
|
||||
return {"ok": False, "error": "浏览器子代理通信失败:%s" % e}
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
resp = json.loads(data.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {"ok": False, "error": "浏览器子代理回包不是 JSON"}
|
||||
if not isinstance(resp, dict) or "ok" not in resp:
|
||||
return {"ok": False, "error": "浏览器子代理回包格式不对"}
|
||||
return resp
|
||||
|
||||
|
||||
def handle_request(req, browser_sock=BROWSER_SOCK):
|
||||
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
|
||||
if not isinstance(req, dict):
|
||||
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||
op = req.get("op")
|
||||
if op == "ping":
|
||||
return {"ok": True, "cell": True}
|
||||
if op == "sync":
|
||||
return _run_sync(req.get("action"), req.get("repo"))
|
||||
if op == "browser":
|
||||
return _forward_browser(req, sock_path=browser_sock)
|
||||
return {"ok": False, "error": "未知操作:%r(白名单:ping/sync/browser)"
|
||||
% (op,)}
|
||||
|
||||
|
||||
def _peer_creds(conn):
|
||||
"""SO_PEERCRED:返回 (pid, uid, gid)。Linux struct ucred = 3 个 int。"""
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||
struct.calcsize("3i"))
|
||||
pid, uid, gid = struct.unpack("3i", raw)
|
||||
return pid, uid, gid
|
||||
|
||||
|
||||
def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
|
||||
"""监听 Unix socket,逐连接处理。返回码 0 只能是被信号杀死;
|
||||
socket 建不起来即抛错(systemd 会重启服务,不要静默带病跑)。"""
|
||||
if os.path.exists(sock_path):
|
||||
os.remove(sock_path)
|
||||
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(sock_path)
|
||||
srv.listen(16)
|
||||
# 0660 + 组 chv:单元内 root 在客机侧是 uid 1000(chv),
|
||||
# 组 chv 可连;其它用户连不上(再叠加 SO_PEERCRED 校验)。
|
||||
os.chmod(sock_path, 0o660)
|
||||
try:
|
||||
gid = grp.getgrnam("chv").gr_gid
|
||||
except KeyError:
|
||||
print("chv-broker: 组 chv 不存在,socket 权限置不全,拒绝启动",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
os.chown(sock_path, -1, gid)
|
||||
print("chv-broker: 监听 %s" % sock_path, file=sys.stderr, flush=True)
|
||||
while True:
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
pid, uid, _gid = _peer_creds(conn)
|
||||
cell_pid = _read_cell_pid()
|
||||
allowed, reason = peer_allowed(uid, pid, cell_pid)
|
||||
if not allowed:
|
||||
print("chv-broker: 拒绝连接 pid=%d uid=%d:%s"
|
||||
% (pid, uid, reason), file=sys.stderr, flush=True)
|
||||
conn.close()
|
||||
continue
|
||||
if cell_pid is None:
|
||||
print("chv-broker: 警告:cell.pid 缺失,只做了 uid 检查 "
|
||||
"(pid=%d uid=%d)" % (pid, uid), file=sys.stderr,
|
||||
flush=True)
|
||||
conn.settimeout(310)
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
try:
|
||||
req = json.loads(data.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
resp = {"ok": False, "error": "请求不是合法 JSON"}
|
||||
else:
|
||||
resp = handle_request(req, browser_sock=browser_sock)
|
||||
conn.sendall((json.dumps(resp, ensure_ascii=False)
|
||||
+ "\n").encode())
|
||||
except OSError as e:
|
||||
print("chv-broker: 连接处理异常:%s" % e, file=sys.stderr,
|
||||
flush=True)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv):
|
||||
sock_path = BROKER_SOCK
|
||||
browser_sock = BROWSER_SOCK
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == "--socket" and i + 1 < len(argv):
|
||||
sock_path = argv[i + 1]
|
||||
i += 2
|
||||
elif argv[i] == "--browser-socket" and i + 1 < len(argv):
|
||||
browser_sock = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
print("chv-broker: 未知参数:%s" % argv[i], file=sys.stderr)
|
||||
return 2
|
||||
serve(sock_path, browser_sock)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv-browser:浏览器子代理(客机侧、单元外独立进程)。
|
||||
|
||||
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):本文件源自 P2 的
|
||||
chv-browser.py(c61484a),P4 在此基础上重构——子代理不再直连
|
||||
Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
|
||||
(chv-cdp-broker.py)手里;本进程只是 broker 的客户端。
|
||||
|
||||
子代理只向单元返回页面无障碍树快照(Playwright
|
||||
accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
|
||||
在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
|
||||
|
||||
操作(Unix socket /run/chv/browser.sock,JSON 行):
|
||||
open {url} -> 经 broker 导航并返回 {title, url, a11y,
|
||||
truncated, untrusted_input, injection_flags,
|
||||
form_risk}
|
||||
snapshot {} -> 当前页同上
|
||||
screenshot {} -> 截图落盘,返回 {path}
|
||||
close {} -> 关当前页
|
||||
pause {} -> 暂停(用户接管/填表/自动暂停的统一状态)
|
||||
resume {} -> 解除暂停
|
||||
status {} -> {paused, url, form_risk}
|
||||
list_asks {} -> 待审批 ask 列表
|
||||
resolve_ask {ask_id, decision} -> 写回 allow/denied
|
||||
ping {} -> {"ok": true}
|
||||
|
||||
暂停时除 ping/resume/status/list_asks/resolve_ask 外一切操作
|
||||
拒绝——用户接管或凭证注入填表时,代理完全不能行动。
|
||||
|
||||
URL 校验、快照截断、错误消毒、禁用操作表都在
|
||||
chv_browser_guard.py(纯函数模块,两脚本共用)。本层再做一次
|
||||
validate_url 与 truncate_a11y(纵深防御),回包先过泄露检查。
|
||||
|
||||
只用标准库。运行身份:系统用户 chv-browser,socket 0600
|
||||
(broker 以 root 连,不受模式限制)。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||
CDP_SOCK = "/run/chv/cdp-broker.sock"
|
||||
MAX_REQUEST_BYTES = 65536
|
||||
|
||||
_GUARD = None
|
||||
|
||||
|
||||
def _load_guard():
|
||||
"""加载同目录的 chv_browser_guard(纯函数模块)。"""
|
||||
global _GUARD
|
||||
if _GUARD is not None:
|
||||
return _GUARD
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
path = os.path.join(here, "chv_browser_guard.py")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"chv_browser_guard", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
_GUARD = module
|
||||
return module
|
||||
|
||||
|
||||
class BrowserAgent:
|
||||
"""浏览器子代理:CDP broker 的客户端。broker_call 供单测注入
|
||||
假 broker(收到 req dict 返回 resp dict);正常运行时走
|
||||
Unix socket 直连 broker。"""
|
||||
|
||||
def __init__(self, cdp_sock_path=CDP_SOCK, broker_call=None):
|
||||
self._guard = _load_guard()
|
||||
self._cdp_sock_path = cdp_sock_path
|
||||
self._broker_call = broker_call
|
||||
self._paused = False
|
||||
|
||||
def _call(self, req):
|
||||
if self._broker_call is not None:
|
||||
return self._broker_call(req)
|
||||
try:
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.settimeout(120)
|
||||
conn.connect(self._cdp_sock_path)
|
||||
except OSError as e:
|
||||
return {"ok": False,
|
||||
"error": "CDP broker 未就绪:%s" % e}
|
||||
try:
|
||||
conn.sendall((json.dumps(req, ensure_ascii=False)
|
||||
+ "\n").encode())
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
except OSError as e:
|
||||
return {"ok": False,
|
||||
"error": "CDP broker 通信失败:%s" % e}
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
resp = json.loads(data.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return {"ok": False, "error": "CDP broker 回包不是 JSON"}
|
||||
if not isinstance(resp, dict) or "ok" not in resp:
|
||||
return {"ok": False, "error": "CDP broker 回包格式不对"}
|
||||
return resp
|
||||
|
||||
def _sync_paused(self, resp):
|
||||
"""从回包同步暂停镜像状态(broker 暂停时本层同样拒绝)。"""
|
||||
if isinstance(resp, dict) and "已暂停" in str(
|
||||
resp.get("error", "")):
|
||||
self._paused = True
|
||||
return resp
|
||||
|
||||
def _blocked_while_paused(self):
|
||||
return {"ok": False,
|
||||
"error": "浏览器已暂停(用户接管/填表/自动暂停)"}
|
||||
|
||||
def open(self, url):
|
||||
if self._paused:
|
||||
return self._blocked_while_paused()
|
||||
ok, err = self._guard.validate_url(url)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err}
|
||||
resp = self._call({"op": "open", "url": url})
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
|
||||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||||
resp = dict(resp)
|
||||
resp["a11y"] = tree
|
||||
resp["truncated"] = bool(resp.get("truncated")
|
||||
or truncated)
|
||||
return self._sync_paused(resp)
|
||||
|
||||
def snapshot(self):
|
||||
if self._paused:
|
||||
return self._blocked_while_paused()
|
||||
resp = self._call({"op": "snapshot"})
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
|
||||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||||
resp = dict(resp)
|
||||
resp["a11y"] = tree
|
||||
resp["truncated"] = bool(resp.get("truncated")
|
||||
or truncated)
|
||||
return self._sync_paused(resp)
|
||||
|
||||
def screenshot(self):
|
||||
if self._paused:
|
||||
return self._blocked_while_paused()
|
||||
return self._sync_paused(self._call({"op": "screenshot"}))
|
||||
|
||||
def close(self):
|
||||
if self._paused:
|
||||
return self._blocked_while_paused()
|
||||
resp = self._call({"op": "close"})
|
||||
return self._sync_paused(resp)
|
||||
|
||||
def pause(self):
|
||||
resp = self._call({"op": "pause"})
|
||||
if resp.get("ok"):
|
||||
self._paused = True
|
||||
return resp
|
||||
|
||||
def resume(self):
|
||||
resp = self._call({"op": "resume"})
|
||||
if resp.get("ok"):
|
||||
self._paused = False
|
||||
return resp
|
||||
|
||||
def status(self):
|
||||
resp = self._call({"op": "status"})
|
||||
if isinstance(resp, dict) and "paused" in resp:
|
||||
self._paused = bool(resp["paused"])
|
||||
return resp
|
||||
|
||||
def list_asks(self):
|
||||
return self._call({"op": "list_asks"})
|
||||
|
||||
def resolve_ask(self, ask_id, decision):
|
||||
resp = self._call({"op": "resolve_ask", "ask_id": ask_id,
|
||||
"decision": decision})
|
||||
if resp.get("ok") and resp.get("decision") == "allowed":
|
||||
self._paused = False
|
||||
return resp
|
||||
|
||||
|
||||
def handle_request(agent, req):
|
||||
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
|
||||
guard = _load_guard()
|
||||
if not isinstance(req, dict):
|
||||
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||
op = req.get("op")
|
||||
if op in guard.FORBIDDEN_OPS:
|
||||
return {"ok": False,
|
||||
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
|
||||
% op}
|
||||
if op == "ping":
|
||||
return {"ok": True, "browser": True}
|
||||
if op == "open":
|
||||
return agent.open(req.get("url"))
|
||||
if op == "snapshot":
|
||||
return agent.snapshot()
|
||||
if op == "screenshot":
|
||||
return agent.screenshot()
|
||||
if op == "close":
|
||||
return agent.close()
|
||||
if op == "pause":
|
||||
return agent.pause()
|
||||
if op == "resume":
|
||||
return agent.resume()
|
||||
if op == "status":
|
||||
return agent.status()
|
||||
if op == "list_asks":
|
||||
return agent.list_asks()
|
||||
if op == "resolve_ask":
|
||||
return agent.resolve_ask(req.get("ask_id"),
|
||||
req.get("decision"))
|
||||
return {"ok": False,
|
||||
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
|
||||
"close/pause/resume/status/list_asks/resolve_ask/"
|
||||
"ping)" % (op,)}
|
||||
|
||||
|
||||
def _handle_conn(agent, conn):
|
||||
"""一个连接一请求一响应;回包先过泄露检查,不过即换通用错;
|
||||
错误信息过消毒,绝不把页面原文原样放进回包。"""
|
||||
guard = _load_guard()
|
||||
conn.settimeout(120)
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
try:
|
||||
req = json.loads(data.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
resp = {"ok": False, "error": "请求不是合法 JSON"}
|
||||
else:
|
||||
try:
|
||||
resp = handle_request(agent, req)
|
||||
except Exception as e:
|
||||
resp = {"ok": False,
|
||||
"error": guard.sanitize_error(e)}
|
||||
if guard.response_leaks(resp):
|
||||
resp = {"ok": False, "error": "响应泄露检查未通过(已拦截)"}
|
||||
conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode())
|
||||
|
||||
|
||||
def serve(sock_path=BROWSER_SOCK, cdp_sock_path=CDP_SOCK):
|
||||
"""监听 Unix socket,逐连接处理。socket 0600。建不起来即抛错,
|
||||
不要静默带病跑。"""
|
||||
if os.path.exists(sock_path):
|
||||
os.remove(sock_path)
|
||||
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(sock_path)
|
||||
srv.listen(16)
|
||||
os.chmod(sock_path, 0o600)
|
||||
print("chv-browser: 监听 %s" % sock_path, file=sys.stderr, flush=True)
|
||||
agent = BrowserAgent(cdp_sock_path=cdp_sock_path)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
_handle_conn(agent, conn)
|
||||
except OSError as e:
|
||||
print("chv-browser: 连接处理异常:%s"
|
||||
% _load_guard().sanitize_error(e),
|
||||
file=sys.stderr, flush=True)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv):
|
||||
sock_path = BROWSER_SOCK
|
||||
cdp_sock_path = CDP_SOCK
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == "--socket" and i + 1 < len(argv):
|
||||
sock_path = argv[i + 1]
|
||||
i += 2
|
||||
elif argv[i] == "--cdp-socket" and i + 1 < len(argv):
|
||||
cdp_sock_path = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
|
||||
return 2
|
||||
serve(sock_path, cdp_sock_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv-cdp-broker:单元外 CDP broker(客机侧、单元外独立进程)。
|
||||
|
||||
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):Chromium 的所有权
|
||||
在这里,不在浏览器子代理手里。broker 独家持有 Playwright,
|
||||
CDP 连接只存在于本进程内;子代理(chv-browser.py)只经本
|
||||
socket 说话,绝不直连浏览器、绝不见 CDP 端点。
|
||||
|
||||
操作(Unix socket /run/chv/cdp-broker.sock,JSON 行):
|
||||
open {url} -> 导航并返回 {title, url, a11y, truncated,
|
||||
untrusted_input, injection_flags, form_risk}
|
||||
snapshot {} -> 当前页同上
|
||||
screenshot {} -> 截图落盘 /run/chv/shots/<uuid>.png,返回 {path}
|
||||
close {} -> 关当前页(浏览器进程继续跑)
|
||||
pause {} -> 暂停(用户接管/填表/自动暂停的统一状态)
|
||||
resume {} -> 解除暂停
|
||||
status {} -> {paused, url, form_risk}
|
||||
list_asks {} -> 待审批 ask 列表
|
||||
resolve_ask {ask_id, decision} -> 写回 allow/denied
|
||||
ping {} -> {"ok": true}
|
||||
|
||||
暂停时除 ping/resume/status/list_asks 外一切操作拒绝——
|
||||
用户接管或凭证注入填表时,代理完全不能行动。
|
||||
|
||||
安全规则(纯函数在 chv_browser_guard.py,本文件只做接线):
|
||||
导航前 validate_url + 静态拦截名单;快照恒带
|
||||
untrusted_input=true(外部页面文本永远是数据、不是指令),
|
||||
命中注入启发式带 injection_flags;高风险表单在页时 open
|
||||
导航即返回 ask(入队、执行停止);观测到导航 POST 即自动
|
||||
暂停 + ask;下载命中可执行扩展名即取消 + 暂停 + ask。
|
||||
所有回包与日志过 sanitize_error;响应里绝无 ws 端点、
|
||||
DevTools 地址、原始 HTML。
|
||||
|
||||
Playwright 调用白名单:launch/goto/wait_for_load_state/title/
|
||||
url/accessibility.snapshot/screenshot/close + 只读监听
|
||||
on(request/download)。evaluate/add_script_tag/add_init_script/
|
||||
expose_function/set_content 等脚本注入面不在代码里出现,
|
||||
测试用 AST 扫本文件钉住。
|
||||
|
||||
只用标准库 + playwright(运行时,懒加载)。运行身份:系统用户
|
||||
chv-browser,socket 0600。
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
CDP_SOCK = "/run/chv/cdp-broker.sock"
|
||||
ASK_QUEUE_DIR = "/run/chv/ask-queue"
|
||||
BLOCKLIST_PATH = "/usr/local/share/chv/browser-blocklist.txt"
|
||||
SHOTS_DIR = "/run/chv/shots"
|
||||
PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
|
||||
MAX_REQUEST_BYTES = 65536
|
||||
NAV_TIMEOUT_MS = 30000
|
||||
|
||||
_GUARD = None
|
||||
|
||||
|
||||
def _load_guard():
|
||||
"""加载同目录的 chv_browser_guard(纯函数模块)。运行时
|
||||
安装在 /usr/local/bin/chv_browser_guard.py;测试时与本
|
||||
文件同在 chv-guest/。只在首次调用加载一次。"""
|
||||
global _GUARD
|
||||
if _GUARD is not None:
|
||||
return _GUARD
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
path = os.path.join(here, "chv_browser_guard.py")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"chv_browser_guard", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
_GUARD = module
|
||||
return module
|
||||
|
||||
|
||||
def _load_playwright():
|
||||
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。"""
|
||||
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
|
||||
PLAYWRIGHT_BROWSERS_PATH)
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as e:
|
||||
raise RuntimeError(
|
||||
"chv-cdp-broker: playwright 未装(%s)" % e)
|
||||
return sync_playwright
|
||||
|
||||
|
||||
class CdpBroker:
|
||||
"""CDP broker:独家持有 Chromium。playwright_factory 供单测
|
||||
注入假实现;正常运行时用 _load_playwright()。"""
|
||||
|
||||
def __init__(self, playwright_factory=None,
|
||||
blocklist_path=BLOCKLIST_PATH,
|
||||
ask_queue_dir=ASK_QUEUE_DIR, shots_dir=SHOTS_DIR):
|
||||
self._guard = _load_guard()
|
||||
self._factory = playwright_factory or _load_playwright
|
||||
self._blocklist_path = blocklist_path
|
||||
self._ask_queue_dir = ask_queue_dir
|
||||
self._shots_dir = shots_dir
|
||||
self._pw = None
|
||||
self._browser = None
|
||||
self._page = None
|
||||
self._paused = False
|
||||
self._form_risk = {"level": "none", "fields": []}
|
||||
self._current_url = ""
|
||||
|
||||
def _ensure_browser(self):
|
||||
if self._browser is not None:
|
||||
return
|
||||
ok, err = self._guard.check_launch_args(
|
||||
self._guard.LAUNCH_ARGS)
|
||||
if not ok:
|
||||
raise RuntimeError("启动参数不合规:%s" % err)
|
||||
self._pw = self._factory()
|
||||
entered = self._pw.__enter__()
|
||||
self._browser = entered.chromium.launch(
|
||||
headless=True, args=list(self._guard.LAUNCH_ARGS))
|
||||
|
||||
def _new_page(self):
|
||||
if self._page is not None:
|
||||
try:
|
||||
self._page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._page = self._browser.new_page()
|
||||
self._page.on("request", self._on_request)
|
||||
self._page.on("download", self._on_download)
|
||||
|
||||
def _on_request(self, request):
|
||||
"""只读监听:导航 POST(表单提交)即自动暂停 + 入队。
|
||||
非导航 POST(beacon/fetch)不在此处理——它不代表提交。"""
|
||||
try:
|
||||
method = request.method
|
||||
is_nav = request.is_navigation_request()
|
||||
except Exception:
|
||||
return
|
||||
if method != "POST" or not is_nav:
|
||||
return
|
||||
self._paused = True
|
||||
self._guard.enqueue_ask(
|
||||
self._ask_queue_dir, "form_submit_observed",
|
||||
self._current_url, "观测到导航 POST(疑似表单提交)")
|
||||
|
||||
def _on_download(self, download):
|
||||
"""只读监听:命中可执行扩展名即取消下载 + 暂停 + 入队。"""
|
||||
try:
|
||||
name = download.suggested_filename
|
||||
except Exception:
|
||||
return
|
||||
blocked, reason = self._guard.scan_download(name)
|
||||
if not blocked:
|
||||
return
|
||||
try:
|
||||
download.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._paused = True
|
||||
self._guard.enqueue_ask(
|
||||
self._ask_queue_dir, "download_blocked",
|
||||
self._current_url, "下载命中拦截规则:%s" % reason)
|
||||
|
||||
def _paused_resp(self):
|
||||
return {"ok": False,
|
||||
"error": "浏览器已暂停(用户接管/填表/自动暂停)"}
|
||||
|
||||
def _ask_resp(self, ask_id, detail):
|
||||
return {"ok": False, "ask": True, "ask_id": ask_id,
|
||||
"error": "需审批:%s" % detail}
|
||||
|
||||
def open(self, url):
|
||||
if self._paused:
|
||||
return self._paused_resp()
|
||||
ok, err = self._guard.validate_url(url)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err}
|
||||
host = self._guard.url_host(url)
|
||||
entries = self._guard.load_blocklist(self._blocklist_path)
|
||||
if self._guard.check_blocklist(host, entries):
|
||||
return {"ok": False, "error": "命中恶意站拦截名单:%s"
|
||||
% host}
|
||||
if self._form_risk.get("level") != "none":
|
||||
ask_id = self._guard.enqueue_ask(
|
||||
self._ask_queue_dir, "form_submit", url,
|
||||
"高风险表单在页(%s),导航须审批"
|
||||
% self._form_risk["level"])
|
||||
return self._ask_resp(ask_id, "高风险表单在页,导航须审批")
|
||||
try:
|
||||
self._ensure_browser()
|
||||
self._new_page()
|
||||
self._page.goto(url, timeout=NAV_TIMEOUT_MS)
|
||||
self._page.wait_for_load_state("domcontentloaded",
|
||||
timeout=NAV_TIMEOUT_MS)
|
||||
except Exception as e:
|
||||
return {"ok": False,
|
||||
"error": self._guard.sanitize_error(e)}
|
||||
return self.snapshot()
|
||||
|
||||
def snapshot(self):
|
||||
if self._paused:
|
||||
return self._paused_resp()
|
||||
if self._page is None:
|
||||
return {"ok": False, "error": "还没有打开的页面(先 open)"}
|
||||
try:
|
||||
title = self._page.title()
|
||||
url = self._page.url
|
||||
tree = self._page.accessibility.snapshot()
|
||||
except Exception as e:
|
||||
return {"ok": False,
|
||||
"error": self._guard.sanitize_error(e)}
|
||||
tree, truncated = self._guard.truncate_a11y(tree or {})
|
||||
flags = self._guard.detect_injection(tree)
|
||||
self._form_risk = self._guard.detect_form_risk(tree)
|
||||
self._current_url = url
|
||||
return {"ok": True, "title": title, "url": url,
|
||||
"a11y": tree, "truncated": truncated,
|
||||
"untrusted_input": True, "injection_flags": flags,
|
||||
"form_risk": self._form_risk}
|
||||
|
||||
def screenshot(self):
|
||||
if self._paused:
|
||||
return self._paused_resp()
|
||||
if self._page is None:
|
||||
return {"ok": False, "error": "还没有打开的页面(先 open)"}
|
||||
try:
|
||||
os.makedirs(self._shots_dir, exist_ok=True)
|
||||
path = os.path.join(self._shots_dir, "%s.png"
|
||||
% uuid.uuid4().hex)
|
||||
self._page.screenshot(path=path)
|
||||
except Exception as e:
|
||||
return {"ok": False,
|
||||
"error": self._guard.sanitize_error(e)}
|
||||
return {"ok": True, "path": path}
|
||||
|
||||
def close(self):
|
||||
if self._page is not None:
|
||||
try:
|
||||
self._page.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._page = None
|
||||
self._form_risk = {"level": "none", "fields": []}
|
||||
self._current_url = ""
|
||||
return {"ok": True, "closed": True}
|
||||
|
||||
def pause(self):
|
||||
self._paused = True
|
||||
return {"ok": True, "paused": True}
|
||||
|
||||
def ping(self):
|
||||
return {"ok": True, "broker": True}
|
||||
|
||||
def resume(self):
|
||||
self._paused = False
|
||||
return {"ok": True, "paused": False}
|
||||
|
||||
def status(self):
|
||||
return {"ok": True, "paused": self._paused,
|
||||
"url": self._current_url,
|
||||
"form_risk": self._form_risk}
|
||||
|
||||
def list_asks(self):
|
||||
return {"ok": True,
|
||||
"asks": self._guard.list_asks(self._ask_queue_dir)}
|
||||
|
||||
def resolve_ask(self, ask_id, decision):
|
||||
ok, err = self._guard.resolve_ask(
|
||||
self._ask_queue_dir, ask_id, decision)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err}
|
||||
if decision == "allowed":
|
||||
self._paused = False
|
||||
# 批准即放行这次操作:清掉快照缓存的表单风险(下一次
|
||||
# snapshot 会按新页面重算,不会丢检测)。
|
||||
self._form_risk = {"level": "none", "fields": []}
|
||||
else:
|
||||
self.close()
|
||||
self._paused = False
|
||||
return {"ok": True, "ask_id": ask_id,
|
||||
"decision": decision}
|
||||
|
||||
def shutdown(self):
|
||||
try:
|
||||
if self._browser is not None:
|
||||
self._browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self._pw is not None:
|
||||
self._pw.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def handle_request(broker, req):
|
||||
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
|
||||
未知操作与禁用操作一律拒绝,不转发、不执行。"""
|
||||
guard = _load_guard()
|
||||
if not isinstance(req, dict):
|
||||
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||
op = req.get("op")
|
||||
if op in guard.FORBIDDEN_OPS:
|
||||
return {"ok": False,
|
||||
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
|
||||
% op}
|
||||
if op == "ping":
|
||||
return {"ok": True, "broker": True}
|
||||
if op == "open":
|
||||
return broker.open(req.get("url"))
|
||||
if op == "snapshot":
|
||||
return broker.snapshot()
|
||||
if op == "screenshot":
|
||||
return broker.screenshot()
|
||||
if op == "close":
|
||||
return broker.close()
|
||||
if op == "pause":
|
||||
return broker.pause()
|
||||
if op == "resume":
|
||||
return broker.resume()
|
||||
if op == "status":
|
||||
return broker.status()
|
||||
if op == "list_asks":
|
||||
return broker.list_asks()
|
||||
if op == "resolve_ask":
|
||||
return broker.resolve_ask(req.get("ask_id"),
|
||||
req.get("decision"))
|
||||
return {"ok": False,
|
||||
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
|
||||
"close/pause/resume/status/list_asks/resolve_ask/"
|
||||
"ping)" % (op,)}
|
||||
|
||||
|
||||
def _handle_conn(broker, conn):
|
||||
"""一个连接一请求一响应;回包先过泄露检查,不过即换通用错。"""
|
||||
guard = _load_guard()
|
||||
conn.settimeout(120)
|
||||
data = b""
|
||||
while len(data) < MAX_REQUEST_BYTES:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if data.endswith(b"\n"):
|
||||
break
|
||||
try:
|
||||
req = json.loads(data.decode())
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
resp = {"ok": False, "error": "请求不是合法 JSON"}
|
||||
else:
|
||||
try:
|
||||
resp = handle_request(broker, req)
|
||||
except Exception as e:
|
||||
resp = {"ok": False,
|
||||
"error": guard.sanitize_error(e)}
|
||||
if guard.response_leaks(resp):
|
||||
resp = {"ok": False, "error": "响应泄露检查未通过(已拦截)"}
|
||||
conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode())
|
||||
|
||||
|
||||
def serve(sock_path=CDP_SOCK, shots_dir=SHOTS_DIR,
|
||||
blocklist_path=BLOCKLIST_PATH, ask_queue_dir=ASK_QUEUE_DIR,
|
||||
playwright_factory=None):
|
||||
"""监听 Unix socket,逐连接处理。socket 0600。建不起来即抛错,
|
||||
不要静默带病跑。playwright_factory 只供测试注入假实现。"""
|
||||
if os.path.exists(sock_path):
|
||||
os.remove(sock_path)
|
||||
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
srv.bind(sock_path)
|
||||
srv.listen(16)
|
||||
os.chmod(sock_path, 0o600)
|
||||
print("chv-cdp-broker: 监听 %s" % sock_path,
|
||||
file=sys.stderr, flush=True)
|
||||
broker = CdpBroker(playwright_factory=playwright_factory,
|
||||
shots_dir=shots_dir,
|
||||
blocklist_path=blocklist_path,
|
||||
ask_queue_dir=ask_queue_dir)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
conn, _ = srv.accept()
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
_handle_conn(broker, conn)
|
||||
except OSError as e:
|
||||
print("chv-cdp-broker: 连接处理异常:%s"
|
||||
% _load_guard().sanitize_error(e),
|
||||
file=sys.stderr, flush=True)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
broker.shutdown()
|
||||
|
||||
|
||||
def main(argv):
|
||||
sock_path = CDP_SOCK
|
||||
shots_dir = SHOTS_DIR
|
||||
blocklist_path = BLOCKLIST_PATH
|
||||
ask_queue_dir = ASK_QUEUE_DIR
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
if argv[i] == "--socket" and i + 1 < len(argv):
|
||||
sock_path = argv[i + 1]
|
||||
i += 2
|
||||
elif argv[i] == "--shots-dir" and i + 1 < len(argv):
|
||||
shots_dir = argv[i + 1]
|
||||
i += 2
|
||||
elif argv[i] == "--blocklist" and i + 1 < len(argv):
|
||||
blocklist_path = argv[i + 1]
|
||||
i += 2
|
||||
elif argv[i] == "--ask-queue" and i + 1 < len(argv):
|
||||
ask_queue_dir = argv[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
print("chv-cdp-broker: 未知参数:%s" % argv[i],
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
serve(sock_path, shots_dir, blocklist_path, ask_queue_dir)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,409 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv-cell:客机内 systemd-nspawn 运行时单元(Hatch 式)启动器。
|
||||
|
||||
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):codex 跑在单元内,不再
|
||||
直接跑在客机用户态。单元是单命令容器(--as-pid2,不 boot 第二个
|
||||
systemd),单元即沙箱——单元内 codex 用 danger-full-access,不再
|
||||
套 bwrap(bwrap 的 --proc 挂载在嵌套命名空间里走不通,见 AGENTS.md
|
||||
内层 proc 定位)。
|
||||
|
||||
关键映射(与 Muse“单元 root 映射为 host 侧非特权用户”同款):
|
||||
--private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。
|
||||
效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600
|
||||
文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读;
|
||||
再加 --inaccessible 遮蔽作纵深防御。
|
||||
|
||||
网络:--network-veth。宿主侧 ve-chv-cell(10.200.0.1/24),单元侧
|
||||
host0(10.200.0.2/24,默认路由经 10.200.0.1),客机侧 NAT 出站
|
||||
(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内配网经
|
||||
nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静默
|
||||
回落共享网络;CHV_CELL_NETWORK=shared 是显式调试逃生口,须显式
|
||||
设置并打日志,绝不默认。
|
||||
|
||||
环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型
|
||||
网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。
|
||||
|
||||
用法(客机侧 root 运行,执行器经 SSH 调):
|
||||
chv-cell run [--network veth|shared] -- <cmd...>
|
||||
# 例:chv-cell run -- python3 /app/host.py --state
|
||||
# /home/chv/.codex/host-state.json --codex-home /home/chv/.codex
|
||||
# --sandbox danger-full-access dispatch --name t --task ...
|
||||
chv-cell argv [--network veth|shared] -- <cmd...>
|
||||
# 只打印 nspawn 参数表(JSON),不启动
|
||||
chv-cell network-commands [--uplink IFACE] [--cell-pid PID]
|
||||
# 只打印配网命令表(JSON),不执行
|
||||
只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
MACHINE = "chv-cell"
|
||||
# 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。
|
||||
UID_BASE = 1000
|
||||
UID_COUNT = 65536
|
||||
CELL_USER = "chv"
|
||||
CELL_UID = 1000
|
||||
CELL_GID = 1000
|
||||
WORKSPACE = "/home/chv/workspace"
|
||||
CODEX_HOME = "/home/chv/.codex"
|
||||
BROKER_DIR = "/run/chv"
|
||||
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||
|
||||
# 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。
|
||||
CELL_NET = "10.200.0.0/24"
|
||||
HOST_IP = "10.200.0.1"
|
||||
CELL_IP = "10.200.0.2"
|
||||
HOST_IFACE = "ve-chv-cell"
|
||||
CELL_IFACE = "host0"
|
||||
|
||||
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
|
||||
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
|
||||
DROP_CAPABILITIES = [
|
||||
"CAP_SYS_ADMIN",
|
||||
"CAP_SYS_PTRACE",
|
||||
"CAP_NET_ADMIN",
|
||||
"CAP_NET_RAW",
|
||||
"CAP_SYS_MODULE",
|
||||
"CAP_SYS_RAWIO",
|
||||
"CAP_SYS_BOOT",
|
||||
"CAP_MKNOD",
|
||||
]
|
||||
|
||||
# syscall 过滤:@system-service 集合再扣掉 io_uring 三件套
|
||||
#(Muse 点名禁用 io_uring)。
|
||||
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
|
||||
"~io_uring_enter ~io_uring_register")
|
||||
|
||||
# 单元内遮掉的路径:凭证与浏览器(单元内跑不起自己的浏览器,
|
||||
# 只能走 broker 的 chv-browser)。
|
||||
INACCESSIBLE_BASE = [
|
||||
"/etc/chv",
|
||||
"/home/chv/.git-credentials",
|
||||
"/usr/local/share/ms-playwright",
|
||||
]
|
||||
|
||||
# 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等)
|
||||
# 不在白名单里,绝不进单元。
|
||||
FORWARD_ENV_ALLOWLIST = (
|
||||
"SUB2API_BASE_URL",
|
||||
"SUB2API_API_KEY",
|
||||
"XCM_API_KEY",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"no_proxy",
|
||||
)
|
||||
|
||||
|
||||
def _playwright_package_dir():
|
||||
"""playwright pip 包目录(遮蔽用)。算不出时回落到 noble 默认路径。"""
|
||||
try:
|
||||
import sysconfig
|
||||
purelib = sysconfig.get_path("purelib")
|
||||
if purelib:
|
||||
return os.path.join(purelib, "playwright")
|
||||
except Exception:
|
||||
pass
|
||||
return "/usr/local/lib/python3.12/dist-packages/playwright"
|
||||
|
||||
|
||||
def build_nspawn_argv(command, network="veth",
|
||||
workspace=WORKSPACE, codex_home=CODEX_HOME,
|
||||
broker_dir=BROKER_DIR,
|
||||
playwright_package_dir=None):
|
||||
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
|
||||
(list,如 ["python3", "/app/host.py", ...])。"""
|
||||
if not command:
|
||||
raise ValueError("chv-cell: 单元内命令不能为空")
|
||||
if network not in ("veth", "shared"):
|
||||
raise ValueError("chv-cell: network 只能是 veth|shared:%r"
|
||||
% network)
|
||||
if playwright_package_dir is None:
|
||||
playwright_package_dir = _playwright_package_dir()
|
||||
argv = [
|
||||
"systemd-nspawn", "--quiet",
|
||||
"--machine=%s" % MACHINE,
|
||||
"--directory=/",
|
||||
"--as-pid2",
|
||||
"--private-users=%d:%d" % (UID_BASE, UID_COUNT),
|
||||
"--no-new-privileges",
|
||||
"--system-call-filter=%s" % SYSCALL_FILTER,
|
||||
]
|
||||
for cap in DROP_CAPABILITIES:
|
||||
argv.append("--drop-capability=%s" % cap)
|
||||
for path in INACCESSIBLE_BASE + [playwright_package_dir]:
|
||||
argv.append("--inaccessible=%s" % path)
|
||||
argv += [
|
||||
"--bind=%s" % workspace,
|
||||
"--bind=%s" % codex_home,
|
||||
"--bind=%s" % broker_dir,
|
||||
"--tmpfs=/tmp",
|
||||
]
|
||||
if network == "veth":
|
||||
argv.append("--network-veth")
|
||||
argv += [
|
||||
"--setenv=CHV_CELL=1",
|
||||
"--setenv=HOME=/home/chv",
|
||||
]
|
||||
for name in FORWARD_ENV_ALLOWLIST:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
argv.append("--setenv=%s=%s" % (name, value))
|
||||
argv += ["--chdir=%s" % workspace]
|
||||
argv += list(command)
|
||||
return argv
|
||||
|
||||
|
||||
def parse_default_route_iface(ip_route_output):
|
||||
"""从 `ip route show default` 的输出里解析上行口名。
|
||||
无默认路由时返回 None(调用方大声失败)。"""
|
||||
for line in (ip_route_output or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("default "):
|
||||
continue
|
||||
m = re.search(r"\bdev\s+(\S+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def find_uplink_iface(run=None):
|
||||
"""发现客机上行口(默认路由的 dev)。run 供单测注入。"""
|
||||
run = run or subprocess.run
|
||||
proc = run(["ip", "route", "show", "default"],
|
||||
capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return parse_default_route_iface(proc.stdout)
|
||||
|
||||
|
||||
def host_network_commands(uplink_iface):
|
||||
"""客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。
|
||||
调用方按顺序执行,任一失败即停。"""
|
||||
if not uplink_iface:
|
||||
raise ValueError("chv-cell: 上行口名为空,配不出 NAT")
|
||||
return [
|
||||
["ip", "addr", "add", "%s/24" % HOST_IP, "dev", HOST_IFACE],
|
||||
["ip", "link", "set", HOST_IFACE, "up"],
|
||||
["sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||
["iptables", "-t", "nat", "-A", "POSTROUTING",
|
||||
"-s", CELL_NET, "-o", uplink_iface, "-j", "MASQUERADE"],
|
||||
["iptables", "-A", "FORWARD", "-i", HOST_IFACE,
|
||||
"-o", uplink_iface, "-j", "ACCEPT"],
|
||||
["iptables", "-A", "FORWARD", "-i", uplink_iface,
|
||||
"-o", HOST_IFACE, "-m", "state", "--state",
|
||||
"RELATED,ESTABLISHED", "-j", "ACCEPT"],
|
||||
]
|
||||
|
||||
|
||||
def host_network_cleanup_commands(uplink_iface):
|
||||
"""配网清理命令表(与 host_network_commands 配对,-A 改 -D)。
|
||||
清理是 best-effort:删不掉(规则已不在)不报错。"""
|
||||
if not uplink_iface:
|
||||
raise ValueError("chv-cell: 上行口名为空,清不了 NAT")
|
||||
cmds = []
|
||||
for cmd in host_network_commands(uplink_iface):
|
||||
if "iptables" not in cmd:
|
||||
continue
|
||||
cleaned = [c if c != "-A" else "-D" for c in cmd]
|
||||
cmds.append(cleaned)
|
||||
return cmds
|
||||
|
||||
|
||||
def find_cell_init_pid(nspawn_pid, proc_root="/proc"):
|
||||
"""找单元 init 的 pid:nspawn 进程的第一个子进程
|
||||
(--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。
|
||||
找不到返回 None。"""
|
||||
try:
|
||||
task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
|
||||
for tid in os.listdir(task_dir):
|
||||
children_path = os.path.join(task_dir, tid, "children")
|
||||
try:
|
||||
with open(children_path) as fh:
|
||||
children = fh.read().split()
|
||||
except OSError:
|
||||
continue
|
||||
if children:
|
||||
return int(children[0])
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def cell_network_commands(cell_pid):
|
||||
"""单元内配网命令表(纯函数):nsenter 进单元 init 的 netns,
|
||||
给 host0 配 IP、起接口、加默认路由。"""
|
||||
if not cell_pid:
|
||||
raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络")
|
||||
prefix = ["nsenter", "-t", str(cell_pid), "-n"]
|
||||
return [
|
||||
prefix + ["ip", "addr", "add", "%s/24" % CELL_IP,
|
||||
"dev", CELL_IFACE],
|
||||
prefix + ["ip", "link", "set", CELL_IFACE, "up"],
|
||||
prefix + ["ip", "route", "add", "default", "via", HOST_IP],
|
||||
]
|
||||
|
||||
|
||||
def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
|
||||
"""bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对
|
||||
就写不进 workspace)。root 建的目录要 chown 回 chv。"""
|
||||
os.makedirs(path, exist_ok=True)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
if st.st_uid != uid or st.st_gid != gid:
|
||||
os.chown(path, uid, gid)
|
||||
except OSError as e:
|
||||
print("chv-cell: chown %s 失败:%s" % (path, e), file=sys.stderr)
|
||||
raise
|
||||
|
||||
|
||||
def _wait_for_iface(iface, timeout=10.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
proc = subprocess.run(["ip", "link", "show", iface],
|
||||
capture_output=True)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def _run_cmds(cmds, what):
|
||||
for cmd in cmds:
|
||||
print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr)
|
||||
proc = subprocess.run(cmd)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"chv-cell: %s 失败(rc=%d):%s"
|
||||
% (what, proc.returncode, " ".join(cmd)))
|
||||
|
||||
|
||||
def run_cell(command, network="veth"):
|
||||
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。"""
|
||||
if os.geteuid() != 0:
|
||||
print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
_ensure_bind_source(WORKSPACE)
|
||||
_ensure_bind_source(CODEX_HOME)
|
||||
_ensure_bind_source(BROKER_DIR, uid=0, gid=0)
|
||||
argv = build_nspawn_argv(command, network=network)
|
||||
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
|
||||
proc = subprocess.Popen(argv)
|
||||
uplink = None
|
||||
cell_pid = None
|
||||
try:
|
||||
if network == "veth":
|
||||
if not _wait_for_iface(HOST_IFACE):
|
||||
raise RuntimeError(
|
||||
"chv-cell: veth 接口 %s 未出现,拒绝回落共享网络"
|
||||
% HOST_IFACE)
|
||||
uplink = find_uplink_iface()
|
||||
if not uplink:
|
||||
raise RuntimeError(
|
||||
"chv-cell: 客机无默认路由,配不出 NAT")
|
||||
_run_cmds(host_network_commands(uplink), "客机侧配网")
|
||||
cell_pid = find_cell_init_pid(proc.pid)
|
||||
if not cell_pid:
|
||||
raise RuntimeError("chv-cell: 找不到单元 init pid")
|
||||
with open(CELL_PID_FILE, "w") as fh:
|
||||
fh.write(str(cell_pid))
|
||||
_run_cmds(cell_network_commands(cell_pid), "单元内配网")
|
||||
return proc.wait()
|
||||
finally:
|
||||
if uplink:
|
||||
for cmd in host_network_cleanup_commands(uplink):
|
||||
subprocess.run(cmd, capture_output=True)
|
||||
try:
|
||||
if os.path.exists(CELL_PID_FILE):
|
||||
os.remove(CELL_PID_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 1:
|
||||
print("用法:chv-cell run|argv|network-commands …",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
verb = argv[0]
|
||||
rest = argv[1:]
|
||||
network = os.environ.get("CHV_CELL_NETWORK", "veth")
|
||||
# --network 只在 run/argv 前接受,其它位置一律不认。
|
||||
if rest and rest[0] == "--network":
|
||||
if len(rest) < 2:
|
||||
print("chv-cell: --network 缺值", file=sys.stderr)
|
||||
return 2
|
||||
network = rest[1]
|
||||
rest = rest[2:]
|
||||
if network not in ("veth", "shared"):
|
||||
print("chv-cell: network 只能是 veth|shared:%r" % network,
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if network == "shared":
|
||||
print("chv-cell: 警告:共享网络模式(调试逃生口),单元与"
|
||||
"客机共用网络命名空间", file=sys.stderr)
|
||||
if verb == "run":
|
||||
if not rest or rest[0] != "--":
|
||||
print("chv-cell: run 用法:chv-cell run -- <cmd...>",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
command = rest[1:]
|
||||
if not command:
|
||||
print("chv-cell: 单元内命令不能为空", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
return run_cell(command, network=network)
|
||||
except RuntimeError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return 1
|
||||
if verb == "argv":
|
||||
if not rest or rest[0] != "--":
|
||||
print("chv-cell: argv 用法:chv-cell argv -- <cmd...>",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(build_nspawn_argv(rest[1:], network=network),
|
||||
ensure_ascii=False))
|
||||
return 0
|
||||
if verb == "network-commands":
|
||||
uplink = None
|
||||
cell_pid = None
|
||||
i = 0
|
||||
while i < len(rest):
|
||||
if rest[i] == "--uplink" and i + 1 < len(rest):
|
||||
uplink = rest[i + 1]
|
||||
i += 2
|
||||
elif rest[i] == "--cell-pid" and i + 1 < len(rest):
|
||||
cell_pid = int(rest[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
print("chv-cell: network-commands 未知参数:%s"
|
||||
% rest[i], file=sys.stderr)
|
||||
return 2
|
||||
out = {}
|
||||
if uplink:
|
||||
out["host"] = host_network_commands(uplink)
|
||||
out["host_cleanup"] = host_network_cleanup_commands(uplink)
|
||||
if cell_pid:
|
||||
out["cell"] = cell_network_commands(cell_pid)
|
||||
print(json.dumps(out, ensure_ascii=False))
|
||||
return 0
|
||||
print("chv-cell: 未知子命令:%s" % verb, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""chv_browser_guard:浏览器安全共享纯函数(客机侧)。
|
||||
|
||||
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):本模块只放纯函数,
|
||||
不 import playwright、不起进程、不连网络,供 chv-cdp-broker.py
|
||||
与 chv-browser.py 两脚本共用,避免三份拷贝漂移。
|
||||
|
||||
内容:
|
||||
validate_url P2 的 URL 规则:只 http/https,拒 localhost/
|
||||
私有地址字面量(DNS 重绑定是 Sentinel 的事)。
|
||||
check_blocklist 静态恶意站名单(browser-blocklist.txt)后缀匹配。
|
||||
truncate_a11y P2 的无障碍树截断(200 节点/64KB)。
|
||||
sanitize_error 错误信息消毒:去 HTML 标签、截断 200 字符、
|
||||
仍含尖括号即只留异常类型名——快照路径上不许有
|
||||
原始 DOM/HTML 外泄口子。
|
||||
detect_injection DOM 内提示注入启发式(中英文模式表,命中返标签;
|
||||
是规则,不是模型分类器)。
|
||||
detect_form_risk 无障碍树扫密码/支付字段。
|
||||
scan_download 下载扫描钩子:一条可执行扩展名规则。
|
||||
enqueue/list/resolve_ask 文件型待审批队列(与 P3 ApprovalQueue
|
||||
同语义:pending/allowed/denied)。
|
||||
LAUNCH_ARGS / FORBIDDEN_PAGE_ATTRS Chromium 启动参数白名单与
|
||||
禁用的 Playwright 页面调用名表。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
MAX_A11Y_NODES = 200
|
||||
MAX_A11Y_CHARS = 65536
|
||||
MAX_ERROR_CHARS = 200
|
||||
|
||||
# 拒绝的 URL 主机:localhost 与私有地址字面量(P2 原样)。
|
||||
_PRIVATE_HOST_RE = re.compile(
|
||||
r"^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|"
|
||||
r"172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|"
|
||||
r"\[::1\]|\[fe8[0-9a-f]*:.*\])$",
|
||||
re.IGNORECASE)
|
||||
|
||||
# Chromium 启动参数白名单:均为真实开关;刻意没有
|
||||
# --remote-debugging-port(DevTools 不暴露)与 --no-sandbox
|
||||
#(浏览器以非 root 的 chv-browser 身份跑,不降级沙箱)。
|
||||
LAUNCH_ARGS = [
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--mute-audio",
|
||||
]
|
||||
|
||||
# 禁用的 Playwright 页面调用名:页面上下文 JS 与进程内 exec
|
||||
# 的全部入口。broker 与子代理的代码里不得出现这些属性名,
|
||||
# 测试用 AST 扫两个脚本钉住(字符串常量里的出现不算)。
|
||||
FORBIDDEN_PAGE_ATTRS = (
|
||||
"evaluate", "evaluate_handle", "add_script_tag",
|
||||
"add_init_script", "expose_function", "set_content",
|
||||
"keyboard", "mouse", "touchscreen",
|
||||
)
|
||||
|
||||
# 浏览器 socket 白名单之外的操作名(子代理层拒绝)。
|
||||
FORBIDDEN_OPS = (
|
||||
"evaluate", "content", "devtools", "js", "click", "fill",
|
||||
"type", "press", "hover", "select", "check", "route",
|
||||
"expose", "add_init_script", "add_script_tag", "set_content",
|
||||
"eval",
|
||||
)
|
||||
|
||||
# 下载拦截:可执行扩展名(一条规则;后续规则只加条目)。
|
||||
BLOCKED_DOWNLOAD_EXTS = frozenset({
|
||||
".exe", ".msi", ".bat", ".cmd", ".ps1", ".sh", ".dmg",
|
||||
".pkg", ".apk", ".scr", ".vbs", ".com", ".gadget",
|
||||
})
|
||||
|
||||
# 提示注入启发式模式表(中英文各 6 条):字面匹配,不是语义判断。
|
||||
# 命中只做标注(untrusted + injection_flags),不做拦截决定。
|
||||
INJECTION_PATTERNS = (
|
||||
("en_ignore_instructions", r"ignore (all )?(previous|prior|above) instructions"),
|
||||
("en_you_are_now", r"you are now (a|an|the)"),
|
||||
("en_system_prompt", r"(reveal|show|print|disregard).{0,40}system prompt"),
|
||||
("en_disregard", r"disregard (all )?(previous|prior|above)"),
|
||||
("en_new_instructions", r"new instructions:? (you|to|do)"),
|
||||
("en_jailbreak", r"(jailbreak|DAN mode|do anything now)"),
|
||||
("zh_ignore", r"忽略(以上|之前|所有|先前)的?(指令|指示|提示)"),
|
||||
("zh_pretend", r"假装你是"),
|
||||
("zh_now_you_are", r"你现在是"),
|
||||
("zh_leak_prompt", r"(泄露|泄漏|输出|打印).{0,20}系统提示"),
|
||||
("zh_admin", r"以管理员(身份|权限)"),
|
||||
("zh_no_limit", r"(无视|绕过).{0,20}(限制|规则|审查)"),
|
||||
)
|
||||
|
||||
# 表单风险:无障碍树节点名/autocomplete 里的敏感词(小写匹配)。
|
||||
PASSWORD_HINTS = ("password", "密码", "current-password",
|
||||
"new-password", "passwd", "pass word")
|
||||
PAYMENT_HINTS = ("payment", "支付", "信用卡", "银行卡", "卡号",
|
||||
"cc-number", "cc-exp", "cc-csc", "cc-name",
|
||||
"card number", "cvv", "expiry", "有效期")
|
||||
|
||||
|
||||
def validate_url(url):
|
||||
"""URL 校验:只允许 http/https,且主机不是 localhost/私有地址
|
||||
字面量。返回 (ok, error)。"""
|
||||
if not isinstance(url, str) or not url:
|
||||
return False, "url 不能为空"
|
||||
m = re.match(r"^(https?)://([^/]+)", url, re.IGNORECASE)
|
||||
if not m:
|
||||
return False, "只允许 http/https URL:%s" % url[:80]
|
||||
host = m.group(2).split("@")[-1].split(":")[0].lower()
|
||||
if _PRIVATE_HOST_RE.match(host):
|
||||
return False, "拒绝 localhost/私有地址:%s" % host
|
||||
return True, ""
|
||||
|
||||
|
||||
def url_host(url):
|
||||
"""取 URL 的主机小写形式;取不到返回空串。"""
|
||||
m = re.match(r"^(https?)://([^/]+)", url or "", re.IGNORECASE)
|
||||
if not m:
|
||||
return ""
|
||||
return m.group(2).split("@")[-1].split(":")[0].lower()
|
||||
|
||||
|
||||
def load_blocklist(path):
|
||||
"""读静态拦截名单:每行一个条目(域名或 .后缀),# 开头是注释,
|
||||
空行跳过。文件缺失即空名单(不静默编造条目)。"""
|
||||
entries = set()
|
||||
try:
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
entries.add(line.lower())
|
||||
except OSError:
|
||||
return set()
|
||||
return entries
|
||||
|
||||
|
||||
def check_blocklist(host, entries):
|
||||
"""主机是否命中拦截名单:精确匹配或后缀匹配(条目 .example.com
|
||||
命中 a.example.com;条目 example.com 同样命中子域)。"""
|
||||
host = (host or "").lower()
|
||||
if not host or not entries:
|
||||
return False
|
||||
for entry in entries:
|
||||
entry = entry.lstrip(".")
|
||||
if host == entry or host.endswith("." + entry):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
|
||||
max_chars=MAX_A11Y_CHARS):
|
||||
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
|
||||
P2 原样搬入(两脚本共用,不再各存一份)。"""
|
||||
state = {"count": 0, "truncated": False}
|
||||
|
||||
def walk(n):
|
||||
if not isinstance(n, dict):
|
||||
return n
|
||||
state["count"] += 1
|
||||
if state["count"] > max_nodes:
|
||||
state["truncated"] = True
|
||||
return None
|
||||
out = {}
|
||||
for key, value in n.items():
|
||||
if key == "children" and isinstance(value, list):
|
||||
children = []
|
||||
for child in value:
|
||||
walked = walk(child)
|
||||
if walked is None:
|
||||
state["truncated"] = True
|
||||
break
|
||||
children.append(walked)
|
||||
out[key] = children
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
tree = walk(node)
|
||||
text = json.dumps(tree, ensure_ascii=False)
|
||||
if len(text) > max_chars:
|
||||
state["truncated"] = True
|
||||
tree = {"role": (node or {}).get("role"),
|
||||
"name": (node or {}).get("name"),
|
||||
"truncated_chars": True}
|
||||
return tree, state["truncated"]
|
||||
|
||||
|
||||
def sanitize_error(exc):
|
||||
"""错误信息消毒:只留异常类型名 + 去 HTML 标签、截断后的首行;
|
||||
凭据形内容(token=/password= 等 key=value 与 sk- 形 token)打码;
|
||||
清洗后仍含尖括号或 javascript: 即丢弃原文。绝不把页面原文
|
||||
或凭据原样放进回包或日志。"""
|
||||
name = type(exc).__name__ if not isinstance(exc, str) else "Error"
|
||||
text = exc if isinstance(exc, str) else str(exc)
|
||||
text = re.sub(r"<[^>]{0,500}>", "", text)
|
||||
text = re.sub(r"(?i)(token|password|passwd|secret|api[-_]?key)"
|
||||
r"\s*=\s*[^\s&;\"']+", r"\1=<redacted>", text)
|
||||
text = re.sub(r"sk-[A-Za-z0-9_\-]{8,}", "<redacted>", text)
|
||||
text = " ".join(text.split())[:MAX_ERROR_CHARS]
|
||||
if "<" in text or ">" in text or "javascript:" in text.lower():
|
||||
return "浏览器操作失败(%s)" % name
|
||||
if not text:
|
||||
return "浏览器操作失败(%s)" % name
|
||||
return "浏览器操作失败(%s):%s" % (name, text)
|
||||
|
||||
|
||||
def _collect_text(node, out):
|
||||
"""收集无障碍树里全部字符串值(name/value/description 等),
|
||||
供注入启发式扫描。只收字符串,不收 role 名以外的结构。"""
|
||||
if isinstance(node, dict):
|
||||
for value in node.values():
|
||||
_collect_text(value, out)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_collect_text(item, out)
|
||||
elif isinstance(node, str):
|
||||
out.append(node)
|
||||
|
||||
|
||||
def detect_injection(tree):
|
||||
"""DOM 内提示注入启发式:扫描快照全部文本,返回命中的模式标签
|
||||
列表(去重、按模式表顺序)。无命中即空列表。规则而已,不是
|
||||
模型分类器——命中只标注,不拦截。"""
|
||||
texts = []
|
||||
_collect_text(tree or {}, texts)
|
||||
blob = "\n".join(texts)
|
||||
hits = []
|
||||
for label, pattern in INJECTION_PATTERNS:
|
||||
if re.search(pattern, blob, re.IGNORECASE):
|
||||
hits.append(label)
|
||||
return hits
|
||||
|
||||
|
||||
def _node_hints(node, hints):
|
||||
"""一个无障碍树节点的 name/autocomplete/type 里是否含敏感词。"""
|
||||
blob = " ".join(str(node.get(k, "")) for k in
|
||||
("name", "autocomplete", "type", "description"))
|
||||
blob = blob.lower()
|
||||
return any(h in blob for h in hints)
|
||||
|
||||
|
||||
def _walk_form_risk(node, found):
|
||||
if not isinstance(node, dict):
|
||||
return
|
||||
role = str(node.get("role", "")).lower()
|
||||
if role in ("textbox", "textfield", "combobox"):
|
||||
if _node_hints(node, PASSWORD_HINTS):
|
||||
found.setdefault("password", []).append(
|
||||
str(node.get("name", ""))[:40])
|
||||
elif _node_hints(node, PAYMENT_HINTS):
|
||||
found.setdefault("payment", []).append(
|
||||
str(node.get("name", ""))[:40])
|
||||
for child in node.get("children") or []:
|
||||
_walk_form_risk(child, found)
|
||||
|
||||
|
||||
def detect_form_risk(tree):
|
||||
"""扫无障碍树里的密码/支付字段。返回 {level, fields}:level 是
|
||||
none/password/payment(支付优先于密码),fields 是命中的字段名
|
||||
(最多 5 个,供审批记录看,不进模型上下文原样只作标注)。"""
|
||||
found = {}
|
||||
_walk_form_risk(tree or {}, found)
|
||||
if found.get("payment"):
|
||||
return {"level": "payment",
|
||||
"fields": found["payment"][:5]}
|
||||
if found.get("password"):
|
||||
return {"level": "password",
|
||||
"fields": found["password"][:5]}
|
||||
return {"level": "none", "fields": []}
|
||||
|
||||
|
||||
def scan_download(filename):
|
||||
"""下载扫描钩子(一条规则):可执行扩展名、或中间段含可执行
|
||||
扩展名(双扩展名把戏,如 report.pdf.exe / archive.exe.pdf),
|
||||
即拦。返回 (blocked, reason)。"""
|
||||
name = (filename or "").strip().lower()
|
||||
if not name:
|
||||
return False, ""
|
||||
parts = [p for p in name.split(".") if p]
|
||||
if len(parts) < 2:
|
||||
return False, ""
|
||||
final_ext = "." + parts[-1]
|
||||
if final_ext in BLOCKED_DOWNLOAD_EXTS:
|
||||
return True, "可执行扩展名:%s" % final_ext
|
||||
for middle in parts[1:-1]:
|
||||
if ("." + middle) in BLOCKED_DOWNLOAD_EXTS:
|
||||
return True, "双扩展名可疑:%s" % filename[:60]
|
||||
return False, ""
|
||||
|
||||
|
||||
def check_launch_args(args):
|
||||
"""Chromium 启动参数校验:不得含 --remote-debugging-*(DevTools
|
||||
不暴露),不得含 devtools 字串。返回 (ok, error)。"""
|
||||
for arg in args or []:
|
||||
if arg.startswith("--remote-debugging"):
|
||||
return False, "启动参数不得含 %s" % arg
|
||||
if "devtools" in arg.lower():
|
||||
return False, "启动参数不得含 DevTools:%s" % arg
|
||||
return True, ""
|
||||
|
||||
|
||||
def _ask_path(queue_dir, ask_id):
|
||||
return os.path.join(queue_dir, "ask-%s.json" % ask_id)
|
||||
|
||||
|
||||
def enqueue_ask(queue_dir, kind, url, detail):
|
||||
"""待审批入队:一条 ask 一个 JSON 文件(原子写:先写 tmp 再
|
||||
rename)。返回 ask_id。队列目录不存在即建。"""
|
||||
os.makedirs(queue_dir, exist_ok=True)
|
||||
ask_id = uuid.uuid4().hex[:12]
|
||||
record = {"ask_id": ask_id, "kind": kind, "url": url or "",
|
||||
"detail": detail or "", "status": "pending",
|
||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
|
||||
time.gmtime())}
|
||||
tmp_path = _ask_path(queue_dir, ask_id) + ".tmp"
|
||||
with open(tmp_path, "w") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp_path, _ask_path(queue_dir, ask_id))
|
||||
return ask_id
|
||||
|
||||
|
||||
def list_asks(queue_dir):
|
||||
"""列出待审批(status pending)的 ask,按创建时间排序。"""
|
||||
out = []
|
||||
try:
|
||||
names = sorted(os.listdir(queue_dir))
|
||||
except OSError:
|
||||
return []
|
||||
for name in names:
|
||||
if not (name.startswith("ask-") and name.endswith(".json")):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(queue_dir, name)) as fh:
|
||||
record = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if record.get("status") == "pending":
|
||||
out.append(record)
|
||||
out.sort(key=lambda r: r.get("created_at", ""))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_ask(queue_dir, ask_id, decision):
|
||||
"""写回审批决定:decision 只认 allowed/denied。返回 (ok, error)。
|
||||
不存在的 ask_id 即报错,不静默成功。"""
|
||||
if decision not in ("allowed", "denied"):
|
||||
return False, "decision 只认 allowed/denied:%s" % decision
|
||||
path = _ask_path(queue_dir, ask_id)
|
||||
try:
|
||||
with open(path) as fh:
|
||||
record = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return False, "ask 不存在:%s" % ask_id
|
||||
if record.get("status") != "pending":
|
||||
return False, "ask 已处理:%s" % ask_id
|
||||
record["status"] = decision
|
||||
tmp_path = path + ".tmp"
|
||||
with open(tmp_path, "w") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
os.replace(tmp_path, path)
|
||||
return True, ""
|
||||
|
||||
|
||||
def response_leaks(resp):
|
||||
"""回包泄露检查(测试与 broker 自检共用):递归扫响应里的字符串,
|
||||
命中 ws://、DevTools 字串或 HTML 标签即返回问题列表。空列表即干净。"""
|
||||
problems = []
|
||||
text = json.dumps(resp, ensure_ascii=False)
|
||||
if "ws://" in text or "wss://" in text:
|
||||
problems.append("响应含 ws 端点")
|
||||
# 只查 CDP 形态(devtools/ 路径、remote-debugging),不查裸词——
|
||||
# 正常 URL 里也可能含 devtools(如 devtools.example.com)。
|
||||
if re.search(r"devtools/", text, re.IGNORECASE):
|
||||
problems.append("响应含 DevTools 路径")
|
||||
if "remote-debugging" in text.lower():
|
||||
problems.append("响应含 remote-debugging")
|
||||
if re.search(r"<[a-zA-Z][^>]{0,200}>", text):
|
||||
problems.append("响应含 HTML 标签")
|
||||
return problems
|
||||
Reference in new issue
Block a user