Files
codex-app-server/cloud/chv/hardening.py
T

213 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CHV 安全加固 helper(feat-chv-secfix,叶子模块)。
这里只放不依赖执行器类的纯校验/文件操作:state_dir 安全
目录、VMM socket 活性、net 条目隔离校验、快照 URL 白名单、
egress 代理地址校验。宿主防火墙规则、tap 桥接本身是运维
外部状态,代码建不出来也不校验——那部分只写进部署清单
(docs/chv-production-readiness.md §11),绝不在代码里
假装已隔离。
"""
import json
import os
import socket
from cloud.chv.errors import ChvFailure
# NET_JSON 条目字段白名单(L4):tap/mac/ip/mask 之外的字段
# 一律拒绝——未知字段原样送进 VMM API 的行为不确定,静默
# 透传等于把 VMM 的字段语义赌在运维手写上。
_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"})
def ensure_secure_state_dir(state_dir: str) -> None:
"""建 state_dir 并保证 0700(H6):目录不存在即按 0700
建;已存在但组/其它位可访时 chmod 0700;失败即
ChvFailure(state_dir_insecure),绝不带着 /tmp 式
宽松目录启动 VM。umask 影响 mkdir 的默认权限,故建
后一律显式 chmod 校验一次。"""
try:
os.makedirs(state_dir, exist_ok=True)
os.chmod(state_dir, 0o700)
except OSError as exc:
raise ChvFailure(
"state_dir_insecure",
"state_dir 无法建成安全目录(0700):%s:%s"
% (state_dir, exc))
try:
mode = os.stat(state_dir).st_mode & 0o777
except OSError as exc:
raise ChvFailure(
"state_dir_insecure",
"state_dir 无法校验权限:%s:%s" % (state_dir, exc))
if mode & 0o077:
raise ChvFailure(
"state_dir_insecure",
"state_dir 权限仍宽松(%o),拒绝启动:%s"
% (mode, state_dir))
return state_dir
def socket_is_live(socket_path: str) -> bool:
"""VMM api-socket 是否真有监听者(M7):能连通才算活。
普通文件、空文件、已死 VMM 的残留 socket 一律算死——
容量计数不再被残留文件占满。连接失败的任何异常都算
死(不区分 refused/超时,计数是尽力而为的读操作)。
注意竞态:刚启动、尚未 listen 的 VMM 会被判死,容量
门在并发唤醒时可能短暂低估——文档明写该窗口,容量门
是“拒绝超限”的护栏,不是精确的分布式锁。"""
try:
if not socket_path or not os.path.exists(socket_path):
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.5)
return sock.connect_ex(socket_path) == 0
finally:
sock.close()
except OSError:
return False
def parse_net_entries(net_json: str) -> list:
"""解析 CHV_NET_JSON 为条目 list(非法 JSON / 非
list/dict 即 net_config_invalid)。"""
if not net_json:
return []
try:
net = json.loads(net_json)
except ValueError:
raise ChvFailure("net_config_invalid",
"CHV_NET_JSON 不是合法 JSON")
entries = net if isinstance(net, list) else [net]
if not all(isinstance(e, dict) for e in entries):
raise ChvFailure("net_config_invalid",
"CHV_NET_JSON 条目须为 dict")
return entries
def validate_net_entries(entries: list, existing_entries: list) -> list:
"""net 条目隔离校验(H7 + L4):
- 字段白名单:未知字段即 net_config_invalid;
- 条目内 tap/mac/ip 归一化(mac 小写、空 tap 视为未
指定,不参与 tap 冲突比较——空 tap 是“VMM 默认网卡”
的运维写法,代码不编造 tap 名,但空 tap 多 VM 共用
默认网卡的风险仍在部署清单里明写);
- 本次条目内 tap/mac/ip 重复即 net_config_invalid;
- 与已登记 VM(existing_entries,每项为 {tap, mac, ip,
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
拒绝启动并由调用方审计。
返回归一化后的条目 list。"""
normalized = [_normalize_net_entry(e) for e in entries]
_check_net_duplicates(normalized)
_check_net_conflicts(normalized, existing_entries)
return normalized
def _normalize_net_entry(entry: dict) -> dict:
"""单条 net 条目归一化:字段白名单 + tap/mac/ip 归一化。
空 tap 视为未指定,不参与 tap 冲突比较(见
validate_net_entries 的说明,纯移动)。"""
unknown = set(entry) - _NET_FIELDS
if unknown:
raise ChvFailure(
"net_config_invalid",
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
% (",".join(sorted(unknown)),
",".join(sorted(_NET_FIELDS))))
return {"tap": str(entry.get("tap") or "").strip(),
"mac": str(entry.get("mac") or "").strip().lower(),
"ip": str(entry.get("ip") or "").strip(),
"mask": str(entry.get("mask") or "")}
def _check_net_duplicates(normalized: list) -> None:
"""本次条目内 tap/mac/ip 重复即 net_config_invalid。"""
seen = {"tap": set(), "mac": set(), "ip": set()}
for entry in normalized:
for kind in ("tap", "mac", "ip"):
value = entry.get(kind)
if not value:
continue
if value in seen[kind]:
raise ChvFailure(
"net_config_invalid",
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
seen[kind].add(value)
def _check_net_conflicts(normalized: list, existing_entries: list) -> None:
"""与已登记 VM 的 tap/mac/ip 任一重复即 net_tap_conflict。"""
for entry in normalized:
for other in existing_entries:
for kind in ("tap", "mac", "ip"):
value = entry.get(kind)
if value and value == other.get(kind):
raise ChvFailure(
"net_tap_conflict",
"net %s %s 已被 VM %s 使用,拒绝共享"
" tap/mac/ip:每 VM 必须独立"
% (kind, value, other.get("vm")))
def validate_egress_proxy(egress_proxy: str) -> None:
"""egress 代理地址校验(H1 代码门的一部分):须为
http/https URL 且带主机名;否则 net_config_invalid…
实际 kind 用 config_invalid(preflight 的配置类失败)。
空值由调用方另行处理(未设 + 未显式允许直连时
preflight 已有 egress_not_configured)。"""
if not egress_proxy:
return None
try:
from urllib.parse import urlparse
parsed = urlparse(egress_proxy)
except ValueError:
parsed = None
if parsed is None or parsed.scheme not in ("http", "https") \
or not parsed.hostname:
raise ChvFailure(
"config_invalid",
"CHV_EGRESS_PROXY 必须是 http/https 代理地址:%r"
% (egress_proxy,))
return egress_proxy
def _is_within(directory, path):
"""目录内判定:经 compat215.resolve_inside(#215 目录门
同签名),越界回 None 即不在内。绝对路径先转相对路径
再过门,.. 逃逸与符号链接逃逸一律挡。"""
from cloud.chv.compat215 import resolve_inside
rel = os.path.relpath(path, directory)
return resolve_inside(directory, rel) is not None
def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None,
allow_remote=False):
"""快照 URL 白名单(L2):
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
否则 snapshot_path_not_allowed;
- 其它协议(含无 :// 的裸路径由调用方先补 file://):
须显式 CHV_ALLOW_REMOTE_SNAPSHOT=1,否则
snapshot_remote_not_allowed。
返回 file:// 目标的本地路径(非 file:// 时返回 None)。"""
if snapshot_url.startswith("file://"):
path = snapshot_url[len("file://"):]
allowed = [state_dir]
if snapshot_dir:
allowed.append(snapshot_dir)
if not any(_is_within(d, path) for d in allowed):
raise ChvFailure(
"snapshot_path_not_allowed",
"file:// 快照须在 state_dir 或 CHV_SNAPSHOT_DIR "
"内:%s" % snapshot_url)
return path
if not allow_remote:
raise ChvFailure(
"snapshot_remote_not_allowed",
"非 file:// 快照须显式设 CHV_ALLOW_REMOTE_SNAPSHOT=1:%s"
% snapshot_url)
return None