4f37c65f18
etcd 是 K8S 控制平面的关键组件,缺少 etcd 诊断会导致很多故障无法 继续排查。本次将 etcdctl 和 etcdutl 纳入命令白名单: 只读 (自动执行): - etcdctl endpoint status/health/hashkv - 端点健康 - etcdctl member list - 成员列表 - etcdctl alarm list - 告警 - etcdctl auth/user/role status|list - 认证与权限查看 - etcdctl check perf/datascale - 性能检查 - etcdctl get/watch - 读取 key - etcdctl version - etcdutl snapshot status/hash - 快照检查 - etcdutl version 写操作 (人工审批): - etcdctl put/del/compact/txn - 数据修改 - etcdctl snapshot save - 保存快照 - etcdctl member add/remove/update - 成员管理 - etcdctl alarm disarm - 解除告警 - etcdctl auth enable/disable - 认证开关 - etcdctl user/role add/delete/grant/revoke - 权限管理 - etcdutl snapshot restore - 恢复快照 实现细节: - etcdctl/etcdutl 命令支持全局 flag (--endpoints/--cacert 等在子命令前) - 两段式命令按 (verb, subverb) 组合精确分类,避免 member list (只读) 和 member remove (写) 混淆 - 新增 12 个 etcd 命令分类测试,全部 56 个测试通过
335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""AI Agent 可用的命令工具和安全策略。
|
|
|
|
支持两类命令:
|
|
1. kubectl 命令 - 通过 classify_kubectl_command 分类
|
|
2. 系统诊断 shell 命令 - 通过 classify_shell_command 分类 (白名单)
|
|
|
|
classify_agent_command 统一分发,execute_command 自动选择执行方式。
|
|
所有命令均通过 subprocess_exec 执行 (无 shell),禁止管道、重定向和命令组合。
|
|
"""
|
|
import asyncio
|
|
import shlex
|
|
from dataclasses import dataclass
|
|
|
|
from backend.diagnosis import KUBECTL
|
|
|
|
# ── kubectl 命令分类 ──────────────────────────────────────
|
|
|
|
READ_ONLY_VERBS = {
|
|
"api-resources", "api-versions", "auth", "cluster-info", "config", "describe",
|
|
"diff", "explain", "get", "logs", "top", "version", "wait",
|
|
}
|
|
WRITE_OR_INTERACTIVE_VERBS = {
|
|
"annotate", "apply", "attach", "autoscale", "cordon", "cp", "create",
|
|
"debug", "delete", "drain", "edit", "exec", "expose", "label", "patch",
|
|
"port-forward", "replace", "rollout", "run", "scale", "set", "taint",
|
|
"uncordon",
|
|
}
|
|
GLOBAL_FLAGS_WITH_VALUE = {
|
|
"--as", "--as-group", "--as-uid", "--cache-dir", "--certificate-authority",
|
|
"--client-certificate", "--client-key", "--cluster", "--context", "--kubeconfig",
|
|
"--namespace", "-n", "--profile", "--request-timeout", "--server", "-s",
|
|
"--tls-server-name", "--token", "--user",
|
|
}
|
|
FORBIDDEN_TOKENS = {";", "|", "||", "&&", ">", ">>", "<", "<<", "`", "$"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CommandDecision:
|
|
mode: str # "read" (只读,自动执行) 或 "write" (修改,需审批)
|
|
requires_approval: bool
|
|
verb: str # kubectl 子命令 或 shell 命令名
|
|
args: list[str]
|
|
|
|
|
|
def _parse_command(command: str) -> list[str]:
|
|
"""解析单条命令,禁止 shell 操作符、重定向和命令组合。"""
|
|
if not isinstance(command, str) or not command.strip():
|
|
raise ValueError("命令不能为空")
|
|
if any(token in command for token in FORBIDDEN_TOKENS) or "\n" in command or "\r" in command:
|
|
raise ValueError("不允许 shell 操作符、重定向或多条命令")
|
|
try:
|
|
return shlex.split(command)
|
|
except ValueError as exc:
|
|
raise ValueError(f"命令格式错误: {exc}") from exc
|
|
|
|
|
|
def parse_kubectl_command(command: str) -> list[str]:
|
|
"""仅解析单条 kubectl 命令,不允许 shell、重定向或命令组合。"""
|
|
parts = _parse_command(command)
|
|
if not parts or parts[0] != "kubectl":
|
|
raise ValueError("只允许执行 kubectl 命令")
|
|
if len(parts) < 2:
|
|
raise ValueError("kubectl 命令缺少操作类型")
|
|
return parts[1:]
|
|
|
|
|
|
def _find_verb(args: list[str]) -> str:
|
|
index = 0
|
|
while index < len(args):
|
|
arg = args[index]
|
|
if arg == "--":
|
|
break
|
|
if arg in GLOBAL_FLAGS_WITH_VALUE:
|
|
index += 2
|
|
continue
|
|
if any(arg.startswith(flag + "=") for flag in GLOBAL_FLAGS_WITH_VALUE if flag.startswith("--")):
|
|
index += 1
|
|
continue
|
|
if arg.startswith("-"):
|
|
index += 1
|
|
continue
|
|
return arg.lower()
|
|
raise ValueError("无法识别 kubectl 操作类型")
|
|
|
|
|
|
READ_ONLY_CONFIG_SUBCOMMANDS = {"current-context", "get-clusters", "get-contexts", "get-users", "view"}
|
|
|
|
|
|
def classify_kubectl_command(command: str) -> CommandDecision:
|
|
args = parse_kubectl_command(command)
|
|
verb = _find_verb(args)
|
|
if verb == "config":
|
|
try:
|
|
verb_index = args.index(next(arg for arg in args if arg.lower() == "config"))
|
|
subcommand = args[verb_index + 1].lower()
|
|
except (StopIteration, ValueError, IndexError):
|
|
return CommandDecision("write", True, verb, args)
|
|
if subcommand in READ_ONLY_CONFIG_SUBCOMMANDS:
|
|
return CommandDecision("read", False, verb, args)
|
|
return CommandDecision("write", True, verb, args)
|
|
if verb in READ_ONLY_VERBS:
|
|
return CommandDecision("read", False, verb, args)
|
|
if verb in WRITE_OR_INTERACTIVE_VERBS:
|
|
return CommandDecision("write", True, verb, args)
|
|
return CommandDecision("write", True, verb, args)
|
|
|
|
|
|
# ── 系统诊断 shell 命令分类 (白名单) ─────────────────────
|
|
|
|
# 只读诊断命令白名单:value 为 None 表示该命令本身只读,任意参数均可;
|
|
# 为集合时,第一个非 flag 参数必须属于该集合。
|
|
SHELL_READ_ONLY_COMMANDS: dict[str, set[str] | None] = {
|
|
"systemctl": {
|
|
"status", "is-active", "is-enabled", "is-failed", "list-units",
|
|
"list-sockets", "list-jobs", "show", "cat", "list-unit-files",
|
|
"list-dependencies",
|
|
},
|
|
"journalctl": None, # 只读,任意参数 (-u, -n, --no-pager, --since 等)
|
|
"crictl": {"ps", "inspect", "logs", "info", "version", "images", "stats"},
|
|
"docker": {"ps", "logs", "inspect", "stats", "version", "info", "images", "top"},
|
|
"df": None, "free": None, "uptime": None, "uname": None, "hostname": None,
|
|
"ip": {"addr", "address", "route", "link", "neighbor", "neigh", "rule"},
|
|
"ss": None, "netstat": None, "lsblk": None, "ls": None, "cat": None,
|
|
"head": None, "tail": None, "wc": None, "grep": None, "egrep": None, "fgrep": None,
|
|
"ps": None, "mount": None, "lsmod": None, "lscpu": None, "lsof": None,
|
|
"dmesg": None, "ping": None, "nslookup": None, "dig": None, "getent": None,
|
|
"timedatectl": None, "date": None, "stat": None, "file": None, "blkid": None,
|
|
}
|
|
|
|
# etcdctl / etcdutl 由 classify_shell_command 中的专用块处理 (两段式命令)
|
|
# 从通用白名单中移除,避免被单段式逻辑误判
|
|
|
|
# etcdctl 单段式只读 verb
|
|
_ETCDCTL_READ_VERBS = {"get", "watch", "version", "lock", "lease", "move-leader", "leader"}
|
|
# etcdctl 单段式写 verb (需人工审批)
|
|
_ETCDCTL_WRITE_VERBS = {"put", "del", "delete", "compact", "txn", "snapshot"}
|
|
# etcdctl 两段式 (verb, subverb) 只读组合
|
|
_ETCDCTL_READ_SUBVERBS = {
|
|
"endpoint": {"status", "health", "hashkv"},
|
|
"member": {"list", "list-urls"},
|
|
"alarm": {"list"},
|
|
"auth": {"status"},
|
|
"user": {"list", "get"},
|
|
"role": {"list", "get"},
|
|
"check": {"perf", "datascale"},
|
|
}
|
|
# etcdctl 两段式 (verb, subverb) 写组合 (需人工审批)
|
|
_ETCDCTL_WRITE_SUBVERBS = {
|
|
"member": {"add", "remove", "update"},
|
|
"alarm": {"disarm"},
|
|
"auth": {"enable", "disable"},
|
|
"user": {"add", "delete", "grant-role", "revoke-role", "passwd"},
|
|
"role": {"add", "delete", "grant-permission", "revoke-permission"},
|
|
}
|
|
# etcdutl snapshot 只读子命令
|
|
_ETCDUTL_SNAPSHOT_READ = {"status", "hash"}
|
|
|
|
# 系统修改命令白名单:需要人工审批
|
|
SHELL_WRITE_COMMANDS: dict[str, set[str] | None] = {
|
|
"systemctl": {"restart", "start", "stop", "reload", "enable", "disable", "daemon-reload"},
|
|
}
|
|
|
|
# ip 命令的写操作动词 (出现即拒绝自动执行)
|
|
_IP_WRITE_ACTIONS = {"set", "add", "del", "delete", "change", "replace", "append", "prepend"}
|
|
|
|
# cat 允许读取的安全路径
|
|
_SAFE_CAT_FILES = {"/etc/os-release", "/etc/hosts", "/etc/resolv.conf"}
|
|
_SAFE_CAT_DIRS = ("/proc/", "/sys/", "/etc/kubernetes/", "/etc/cni/", "/etc/systemd/system/")
|
|
|
|
|
|
def _is_safe_cat_path(path: str) -> bool:
|
|
if not path:
|
|
return False
|
|
if path in _SAFE_CAT_FILES:
|
|
return True
|
|
return any(path.startswith(d) for d in _SAFE_CAT_DIRS)
|
|
|
|
|
|
def _first_non_flag(args: list[str]) -> str | None:
|
|
for arg in args:
|
|
if not arg.startswith("-"):
|
|
return arg.lower()
|
|
return None
|
|
|
|
|
|
def parse_shell_command(command: str) -> list[str]:
|
|
"""解析单条 shell 诊断命令,禁止 shell 操作符和命令组合。"""
|
|
parts = _parse_command(command)
|
|
if not parts:
|
|
raise ValueError("命令不能为空")
|
|
return parts
|
|
|
|
|
|
def classify_shell_command(command: str) -> CommandDecision:
|
|
"""对白名单内的系统诊断命令进行分类。
|
|
|
|
systemctl 同时出现在只读和写白名单中:restart/stop 等走写表(需审批),
|
|
status/is-active 等走只读表(自动执行)。因此先检查写表,再检查只读表。
|
|
"""
|
|
parts = parse_shell_command(command)
|
|
binary = parts[0]
|
|
verb = _first_non_flag(parts[1:])
|
|
|
|
if binary in SHELL_WRITE_COMMANDS:
|
|
allowed = SHELL_WRITE_COMMANDS[binary]
|
|
if allowed is None or (verb is not None and verb in allowed):
|
|
return CommandDecision("write", True, binary, parts[1:])
|
|
|
|
# etcdctl: 单段式或两段式命令,verb/subverb 均跳过全局 flag (--endpoints 等)
|
|
# member/alarm/auth 等同时有只读和写子命令,需按 (verb, subverb) 组合精确判断
|
|
if binary == "etcdctl":
|
|
non_flags = [a for a in parts[1:] if not a.startswith("-")]
|
|
ev = non_flags[0].lower() if non_flags else None
|
|
esub = non_flags[1].lower() if len(non_flags) > 1 else None
|
|
# 单段式 verb
|
|
if ev in _ETCDCTL_READ_VERBS:
|
|
return CommandDecision("read", False, ev, parts[1:])
|
|
if ev in _ETCDCTL_WRITE_VERBS:
|
|
return CommandDecision("write", True, ev, parts[1:])
|
|
# 两段式: 先查写子命令 (精确匹配 subverb),再查只读子命令
|
|
if ev in _ETCDCTL_WRITE_SUBVERBS and esub in _ETCDCTL_WRITE_SUBVERBS[ev]:
|
|
return CommandDecision("write", True, ev, parts[1:])
|
|
if ev in _ETCDCTL_READ_SUBVERBS:
|
|
if esub is None or esub in _ETCDCTL_READ_SUBVERBS[ev]:
|
|
return CommandDecision("read", False, ev, parts[1:])
|
|
raise ValueError(f"etcdctl {ev} 不支持子命令: {esub}")
|
|
raise ValueError(f"etcdctl 不支持该子命令: {ev}")
|
|
|
|
if binary == "etcdutl":
|
|
non_flags = [a for a in parts[1:] if not a.startswith("-")]
|
|
ev = non_flags[0].lower() if non_flags else None
|
|
esub = non_flags[1].lower() if len(non_flags) > 1 else None
|
|
if ev == "snapshot":
|
|
if esub in _ETCDUTL_SNAPSHOT_READ:
|
|
return CommandDecision("read", False, ev, parts[1:])
|
|
return CommandDecision("write", True, ev, parts[1:])
|
|
if ev == "version":
|
|
return CommandDecision("read", False, ev, parts[1:])
|
|
raise ValueError(f"etcdutl 不支持该子命令: {ev}")
|
|
|
|
if binary in SHELL_READ_ONLY_COMMANDS:
|
|
allowed = SHELL_READ_ONLY_COMMANDS[binary]
|
|
if allowed is not None and (verb is None or verb not in allowed):
|
|
raise ValueError(f"命令 {binary} 不支持该子命令: {verb}")
|
|
if binary == "ip" and any(arg in _IP_WRITE_ACTIONS for arg in parts[1:]):
|
|
raise ValueError("ip 命令包含写操作,请使用只读形式如 'ip addr show'")
|
|
if binary == "cat":
|
|
for arg in parts[1:]:
|
|
if not arg.startswith("-") and not _is_safe_cat_path(arg):
|
|
raise ValueError(
|
|
"cat 仅允许读取 /proc, /sys, /etc/kubernetes, /etc/cni, "
|
|
"/etc/systemd/system, /etc/os-release, /etc/hosts, /etc/resolv.conf"
|
|
)
|
|
return CommandDecision("read", False, binary, parts[1:])
|
|
|
|
raise ValueError(f"不允许执行该命令: {binary}")
|
|
|
|
|
|
def classify_agent_command(command: str) -> CommandDecision:
|
|
"""统一分类 Agent 命令:kubectl 或白名单系统诊断命令。"""
|
|
stripped = (command or "").strip()
|
|
if stripped.startswith("kubectl ") or stripped == "kubectl":
|
|
return classify_kubectl_command(stripped)
|
|
return classify_shell_command(stripped)
|
|
|
|
|
|
# ── 命令执行 ──────────────────────────────────────────────
|
|
|
|
async def execute_command(command: str, timeout: int = 30) -> dict:
|
|
"""执行单条 Agent 命令 (kubectl 或白名单系统诊断命令),无 shell。"""
|
|
decision = classify_agent_command(command)
|
|
parts = _parse_command(command)
|
|
if parts[0] == "kubectl":
|
|
executable = KUBECTL
|
|
args = decision.args
|
|
else:
|
|
executable = parts[0]
|
|
args = parts[1:]
|
|
|
|
proc = await asyncio.create_subprocess_exec(
|
|
executable,
|
|
*args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
return {
|
|
"command": command,
|
|
"mode": decision.mode,
|
|
"returncode": proc.returncode,
|
|
"stdout": stdout.decode(errors="replace")[:20000].strip(),
|
|
"stderr": stderr.decode(errors="replace")[:10000].strip(),
|
|
}
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.communicate()
|
|
return {
|
|
"command": command,
|
|
"mode": decision.mode,
|
|
"returncode": -1,
|
|
"stdout": "",
|
|
"stderr": f"命令执行超时 ({timeout}s)",
|
|
}
|
|
|
|
|
|
async def execute_kubectl(command: str, timeout: int = 30) -> dict:
|
|
"""执行单条 kubectl 命令 (兼容旧接口)。"""
|
|
decision = classify_kubectl_command(command)
|
|
proc = await asyncio.create_subprocess_exec(
|
|
KUBECTL,
|
|
*decision.args,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
return {
|
|
"command": command,
|
|
"mode": decision.mode,
|
|
"returncode": proc.returncode,
|
|
"stdout": stdout.decode(errors="replace")[:20000].strip(),
|
|
"stderr": stderr.decode(errors="replace")[:10000].strip(),
|
|
}
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.communicate()
|
|
return {
|
|
"command": command,
|
|
"mode": decision.mode,
|
|
"returncode": -1,
|
|
"stdout": "",
|
|
"stderr": f"命令执行超时 ({timeout}s)",
|
|
}
|