mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-08-21 23:22:28 +02:00
398 lines
19 KiB
Python
398 lines
19 KiB
Python
from . import register_tool
|
|
from .base import _xml_response
|
|
from .terminal import (
|
|
RunCommand, _is_dangerous_command, _split_merged_command,
|
|
_tokenize_command, _protect_substitutions, _restore_substitutions,
|
|
)
|
|
import re
|
|
|
|
# shell 元字符黑名单: $(...) 和反引号不再直接拒 — 由 _check_substitutions 递归校验内部命令.
|
|
# 堵 进程替换 heredoc 输入重定向 后台执行符 换行注入 — 父类 shell=True 下会被 bash 解释执行.
|
|
_SHELL_METACHAR_RE = re.compile(
|
|
r'[<>]\(' # <( / >( 进程替换
|
|
r'|<<<' # here-string
|
|
r'|<<' # heredoc
|
|
r'|<' # < 输入重定向
|
|
r'|(?<!&)&(?!&)' # 单 & 后台执行(不误杀 &&)
|
|
r'|\n|\r' # 换行符(解析器差异: shlex 当空白, shell 当命令分隔)
|
|
)
|
|
|
|
# 只读命令白名单(首词): 子代理 OS 层诊断回退通道 — panel 专用只读工具未覆盖的状态/进程/端口/日志/
|
|
# 配置/网络/资源/语法校验. 默认拒绝.
|
|
# 不纳入: 交互式 pager(less/more hang)、非交互不存在的 alias(ll)、带参即写(mount/ip/ifconfig)、
|
|
# 多执行通道(mysql→MysqlQuery, env→printenv).
|
|
_READONLY_COMMANDS = {
|
|
# 文本与文件查看
|
|
'cat', 'head', 'tail', 'wc', 'grep', 'egrep', 'fgrep', 'rg', 'ack',
|
|
'find', 'ls', 'stat', 'file', 'tree', 'du', 'df', 'diff',
|
|
'md5sum', 'sha256sum', 'od', 'hexdump',
|
|
# 路径工具(管道常用)
|
|
'which', 'whereis', 'readlink', 'realpath', 'basename', 'dirname',
|
|
# 进程与系统资源(top 需 -b 批处理; 裸交互 top 由 timeout 兜底)
|
|
'ps', 'top', 'free', 'uptime', 'uname', 'w', 'who', 'id', 'whoami',
|
|
'vmstat', 'iostat', 'sar',
|
|
# 网络(无 ip/ifconfig — 写在 tokens[2], 用 ss/netstat/lsof 查连接端口)
|
|
'ss', 'netstat', 'lsof', 'dig', 'nslookup', 'host', 'ping', 'traceroute', 'curl',
|
|
# 系统与服务只读
|
|
'systemctl', 'journalctl', 'dmesg', 'lsblk', 'lsmod', 'lspci', 'lsusb',
|
|
'findmnt', 'getenforce', 'sestatus', 'printenv', 'echo', 'date',
|
|
'hostname', 'hostnamectl', 'timedatectl', 'lsb_release', 'getent', 'last',
|
|
# 应用层(只读形态由 _READONLY_FORMS 严格限定; 默认拒绝未列出的子命令/选项)
|
|
'nginx', 'php', 'redis-cli', 'crontab', 'openssl', 'docker',
|
|
}
|
|
|
|
# 反向只读形态: 首词 -> 允许的只读子命令/选项(默认拒绝). 三种形态:
|
|
# subs : tokens[1] ∈ subs(docker/redis-cli/crontab/openssl); docker 还支持 obj 二级
|
|
# subs_opts: 跳过所有 - 开头选项 token, 取第一个非选项 token ∈ subs; 选项必须 ∈ opts(systemctl/timedatectl/hostnamectl)
|
|
# opts : 所有 - 开头 token ∈ opts(curl/nginx/php/date/dmesg/hostname/ss); 组合短选项(-sL)拆解逐一检查
|
|
# no_flags : 见到即拒(redis-cli 远程, openssl 写文件)
|
|
# no_args : 禁止裸参(hostname — 裸参 = 设置主机名)
|
|
_READONLY_FORMS = {
|
|
# -- docker: subs 型(含 obj 二级) --
|
|
'docker': {
|
|
'subs': {'ps', 'inspect', 'logs', 'stats', 'version', 'images', 'top',
|
|
'port', 'history', 'diff', 'info', 'search'},
|
|
'obj': {'network': {'ls', 'inspect'},
|
|
'volume': {'ls', 'inspect'},
|
|
'image': {'ls', 'inspect', 'history'},
|
|
'container': {'ls', 'inspect', 'top', 'logs', 'stats', 'diff', 'port'},
|
|
'system': {'df', 'info', 'events'}},
|
|
},
|
|
# -- redis-cli: subs 型(大写归一, CONFIG 仅 GET) --
|
|
'redis-cli': {
|
|
'subs': {'GET', 'MGET', 'EXISTS', 'TYPE', 'KEYS', 'SCAN', 'TTL', 'PTTL',
|
|
'DBSIZE', 'INFO', 'TIME', 'ECHO', 'COMMAND', 'OBJECT', 'CONFIG'},
|
|
'no_flags': {'-h', '-a', '-p', '-u', '--rdb', '--pipe', '--cluster'},
|
|
},
|
|
# -- crontab: subs 型(仅 -l) --
|
|
'crontab': {
|
|
'subs': {'-l', '--list'},
|
|
},
|
|
# -- openssl: subs 型 + no_flags --
|
|
'openssl': {
|
|
'subs': {'x509', 's_client', 'verify', 'version', 's_time', 'pkcs7', 'ec',
|
|
'pkey', 'speed', 'ciphers', 'list', 'errstr'},
|
|
'no_flags': {'-out', '-keyout', '-CAserial', '-passout', '-signkey'},
|
|
},
|
|
# -- systemctl: subs_opts 型(跳过前置选项, 取实际子命令) --
|
|
'systemctl': {
|
|
'subs_opts': True,
|
|
'opts': {'--no-ask-password', '--no-pager', '--no-legend', '--quiet', '-q',
|
|
'--system', '--user', '--global', '--failed', '--all', '-a',
|
|
'--full', '-l', '--plain', '--host', '-H', '--machine', '-M',
|
|
'--property', '-p', '--state', '--type', '-t', '--output', '-o'},
|
|
'arg_opts': {'--host', '-H', '--machine', '-M', '--property', '-p',
|
|
'--state', '--type', '-t', '--output', '-o'},
|
|
'subs': {'status', 'is-active', 'is-enabled', 'is-failed', 'list-units',
|
|
'list-unit-files', 'list-timers', 'list-jobs', 'list-dependencies',
|
|
'list-sockets', 'list-machines', 'show', 'cat', 'help', 'get-default',
|
|
'is-system-running', 'list-dependencies'},
|
|
},
|
|
# -- timedatectl: subs_opts 型 --
|
|
'timedatectl': {
|
|
'subs_opts': True,
|
|
'opts': {'--no-ask-password', '--adjust-system-clock', '--host', '-H',
|
|
'--machine', '-M', '--help', '--version'},
|
|
'arg_opts': {'--host', '-H', '--machine', '-M'},
|
|
'subs': {'status', 'show', 'list-timezones', 'timesync-status', 'ntp-servers'},
|
|
},
|
|
# -- hostnamectl: subs_opts 型(仅 status 只读) --
|
|
'hostnamectl': {
|
|
'subs_opts': True,
|
|
'opts': {'--no-ask-password', '--static', '--transient', '--pretty',
|
|
'--host', '-H', '--machine', '-M', '--help', '--version'},
|
|
'arg_opts': {'--host', '-H', '--machine', '-M'},
|
|
'subs': {'status'},
|
|
},
|
|
# -- curl: opts 型(裸 URL 放行) --
|
|
'curl': {
|
|
'opts': {'-I', '-i', '-s', '-S', '-L', '-v', '-k', '-A', '-m', '-H',
|
|
'--head', '--silent', '--location', '--max-time', '--insecure',
|
|
'--compressed', '--connect-timeout'},
|
|
},
|
|
# -- nginx: opts 型(必须至少一个只读 flag; 裸跑 = 启动服务, 拒) --
|
|
'nginx': {
|
|
'opts': {'-t', '-T', '-V', '-v', '-q'},
|
|
'require_opt': True,
|
|
},
|
|
# -- php: opts 型(必须至少一个只读 flag; 裸跑 = REPL, 拒) --
|
|
'php': {
|
|
'opts': {'-l', '-v', '-m', '-i', '--ini', '--rf', '--rc', '--re', '--ri'},
|
|
'require_opt': True,
|
|
},
|
|
# -- date: opts 型, 排除 -s/--set; 裸 date = 显示当前时间(放行) --
|
|
'date': {
|
|
'opts': {'-u', '-R', '-I', '-r', '-d', '--utc', '--rfc-2822', '--rfc-3339',
|
|
'--iso-8601', '--date', '--reference', '--help', '--version'},
|
|
},
|
|
# -- dmesg: opts 型, 排除 -c/-C/-n/-D/-w --
|
|
'dmesg': {
|
|
'opts': {'-H', '-k', '-l', '-u', '-T', '-t', '-P', '-p', '-e', '-x',
|
|
'--human', '--kernel', '--level', '--userspace', '--ctime',
|
|
'--notime', '--force-prefix', '--reltime', '--since', '--until',
|
|
'--decode', '--help', '--version'},
|
|
},
|
|
# -- hostname: opts 型, 禁止裸参(hostname NEWS = 设置主机名); 无参 = 显示(放行) --
|
|
'hostname': {
|
|
'opts': {'-A', '-d', '-f', '-i', '-I', '-s', '--all-fqdns', '--domain',
|
|
'--fqdn', '--ip-address', '--all-ip-addresses', '--short',
|
|
'--help', '--version'},
|
|
'no_args': True, # 裸参 = 设置主机名(写)
|
|
},
|
|
# -- ss: opts 型, 排除 -K/--kill/-D/--dump --
|
|
'ss': {
|
|
'opts': {'-a', '-l', '-p', '-t', '-u', '-w', '-x', '-4', '-6', '-0', '-n',
|
|
'-r', '-o', '-e', '-m', '-i', '-s', '-b', '-Z', '-z', '-d', '-f',
|
|
'-A', '-N', '-O', '--all', '--listening', '--tcp', '--udp', '--raw',
|
|
'--unix', '--packet', '--dccp', '--sctp', '--vsock', '--processes',
|
|
'--numeric', '--resolve', '--options', '--extended', '--memory',
|
|
'--info', '--summary', '--bpf', '--context', '--help', '--version'},
|
|
},
|
|
}
|
|
|
|
# 正向写子命令(全已移入 _READONLY_FORMS; 保留空 dict 供未来扩展, 不加新条目)
|
|
_WRITE_SUBCOMMANDS = {}
|
|
|
|
# 写操作符: 输出重定向/追加(>/dev/null 除外), 管道到解释器/tee/&, find 写参数(位置不固定, 走正则)
|
|
_WRITE_OPERATOR_RE = re.compile(
|
|
r'>>?(?!\s*/dev/null\b)' # 重定向 > 或 >>(目标不是 /dev/null; 放行 2>/dev/null)
|
|
r'|\|\s*(?:bash|sh|python|perl|tee)\b' # 管道到解释器/tee
|
|
r'|\|&' # 管道带 stderr
|
|
r'|\s-(?:exec|execdir|ok|okdir|delete|fls|fprintf?)\b' # find 写参数(任意位置; -fprint/-fprintf)
|
|
)
|
|
|
|
|
|
def _pipeline_segments(part: str) -> list:
|
|
"""按管道 | 拆分单段 part, 返回每段的 token 列表。
|
|
跳过引号与 $(...) `...` 内的 |; || 已被外层 _split_merged_command 拆走, 不会进入此处。
|
|
"""
|
|
protected, subs = _protect_substitutions(part)
|
|
tokens = _tokenize_command(protected)
|
|
segments, current = [], []
|
|
for tok in tokens:
|
|
s = tok.strip()
|
|
if s == '|':
|
|
if current:
|
|
segments.append(current)
|
|
current = []
|
|
elif s:
|
|
current.append(_restore_substitutions(tok, subs))
|
|
if current:
|
|
segments.append(current)
|
|
return segments
|
|
|
|
|
|
def _check_readonly_form(seg: list) -> tuple:
|
|
"""反向校验: seg 是否符合首词允许的只读形态. 返回 (ok, reason). 默认拒绝.
|
|
仅对 _READONLY_FORMS 中的命令生效; 其余命令返回 (True, None).
|
|
三种形态: subs_opts(跳过选项找子命令) / subs(查 tokens[1]) / opts(白名单选项).
|
|
"""
|
|
first = seg[0]
|
|
form = _READONLY_FORMS.get(first)
|
|
if not form:
|
|
return True, None
|
|
args = seg[1:]
|
|
|
|
# ── subs_opts 型(systemctl/timedatectl/hostnamectl): 跳过前置选项 ──
|
|
if form.get('subs_opts'):
|
|
opts = form['opts']
|
|
arg_opts = form.get('arg_opts', set())
|
|
sub = ''
|
|
i = 0
|
|
while i < len(args):
|
|
t = args[i]
|
|
if t.startswith('--'):
|
|
if t not in opts:
|
|
return False, f"{first} option not read-only: {t}"
|
|
if t in arg_opts:
|
|
i += 2 # 跳过选项值
|
|
else:
|
|
i += 1
|
|
elif t.startswith('-') and len(t) > 1:
|
|
if t in opts:
|
|
if t in arg_opts:
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
else:
|
|
# 尝试组合短选项拆解
|
|
chars = [f'-{c}' for c in t[1:]]
|
|
if any(c not in opts for c in chars):
|
|
return False, f"{first} option not read-only: {t}"
|
|
if any(c in arg_opts for c in chars):
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
else:
|
|
sub = t
|
|
break
|
|
if sub:
|
|
if sub not in form['subs']:
|
|
return False, f"{first} subcommand not read-only: {sub}"
|
|
# 无子命令 = 默认只读行为(如裸 systemctl = list-units)
|
|
return True, None
|
|
|
|
# ── subs 型(docker/redis-cli/crontab/openssl) ──
|
|
if 'subs' in form:
|
|
if first == 'docker':
|
|
sub = args[0] if args else ''
|
|
if sub in form['subs']:
|
|
return True, None
|
|
if sub in form.get('obj', {}) and len(args) > 1 and args[1] in form['obj'][sub]:
|
|
return True, None
|
|
return False, f"docker subcommand not read-only: {sub}"
|
|
|
|
if first == 'redis-cli':
|
|
for t in args:
|
|
if t in form['no_flags']:
|
|
return False, f"redis-cli remote/forbidden flag: {t}"
|
|
for i, t in enumerate(args):
|
|
if not t.startswith('-'):
|
|
cmd = t.upper()
|
|
if cmd not in form['subs']:
|
|
return False, f"redis command not read-only: {t}"
|
|
if cmd == 'CONFIG':
|
|
nxt = args[i + 1].upper() if len(args) > i + 1 else ''
|
|
if nxt != 'GET':
|
|
return False, f"redis CONFIG only allows GET: {t}"
|
|
return True, None
|
|
return False, "redis-cli missing command"
|
|
|
|
if first == 'crontab':
|
|
if args and args[0] in form['subs']:
|
|
return True, None
|
|
return False, "crontab only allows -l/--list"
|
|
|
|
if first == 'openssl':
|
|
sub = args[0] if args else ''
|
|
if sub not in form['subs']:
|
|
return False, f"openssl subcommand not read-only: {sub}"
|
|
for t in args:
|
|
if t in form['no_flags']:
|
|
return False, f"openssl write flag: {t}"
|
|
return True, None
|
|
|
|
# ── opts 型(curl/nginx/php/date/dmesg/hostname/ss) ──
|
|
if 'opts' in form:
|
|
has_opt = False
|
|
for t in args:
|
|
if t.startswith('--'):
|
|
if t not in form['opts']:
|
|
return False, f"{first} option not read-only: {t}"
|
|
has_opt = True
|
|
elif t.startswith('-'):
|
|
if t in form['opts']:
|
|
has_opt = True
|
|
else:
|
|
chars = [f'-{c}' for c in t[1:]]
|
|
if not chars or any(c not in form['opts'] for c in chars):
|
|
return False, f"{first} option not read-only: {t}"
|
|
has_opt = True
|
|
else:
|
|
# 裸参(非 - 前缀)
|
|
if form.get('no_args'):
|
|
return False, f"{first} does not accept arguments: {t}"
|
|
if form.get('require_opt') and not has_opt:
|
|
return False, f"{first} requires a read-only flag (e.g. nginx -t / php -l)"
|
|
return True, None
|
|
|
|
return True, None
|
|
|
|
|
|
def _check_substitutions(command: str) -> tuple:
|
|
"""提取并递归校验 $(...) 和反引号中的命令. 返回 (ok, reason).
|
|
复用 _protect_substitutions 提取, 对每个替换项内部命令独立过 _is_readonly_command.
|
|
安全: 内部命令同样过白名单/写操作符/形态检查, 安全模型与外部一致.
|
|
"""
|
|
protected, subs = _protect_substitutions(command)
|
|
for original in subs:
|
|
if original.startswith('$('):
|
|
inner = original[2:-1] # 去掉 $( 和 )
|
|
else:
|
|
inner = original[1:-1] # 去掉反引号
|
|
inner = inner.strip()
|
|
if inner:
|
|
ok, reason = _is_readonly_command(inner)
|
|
if not ok:
|
|
return False, f"substitution blocked: {reason}"
|
|
return True, None
|
|
|
|
|
|
def _is_readonly_command(command: str) -> tuple:
|
|
"""整条命令链是否纯只读, 返回 (ok, reason)。三层防线(均不动 RunCommand):
|
|
① 命令替换递归校验($()/反引号内命令过白名单) + shell 元字符拦截(进程替换 heredoc & 后台 换行);
|
|
② 危险命令黑名单 + 写操作符正则(重定向/管道解释器/tee/find 写参数);
|
|
③ 链式拆分(; && || → 管道 |)后逐段: 白名单首词 → 反向只读形态(_READONLY_FORMS).
|
|
"""
|
|
# ①a 命令替换递归校验: $(basename x) → 提取 inner 过白名单; $(rm -rf /) → rm 不在白名单拒
|
|
ok, reason = _check_substitutions(command)
|
|
if not ok:
|
|
return False, reason
|
|
|
|
# ①b shell 元字符: 引号内同样拒(宁可杀错), 堵进程替换/heredoc/后台/换行 等注入
|
|
m = _SHELL_METACHAR_RE.search(command)
|
|
if m:
|
|
return False, f"shell metacharacter blocked: {m.group(0)}"
|
|
|
|
# ② 危险命令黑名单(rm -rf /, dd, mkfs, reboot, > /etc/passwd ...)
|
|
dangerous, reason = _is_dangerous_command(command)
|
|
if dangerous:
|
|
return False, f"dangerous pattern: {reason}"
|
|
|
|
for part in _split_merged_command(command):
|
|
# 写操作符(重定向 / 管道到解释器 / tee / find 写参数)
|
|
if _WRITE_OPERATOR_RE.search(part):
|
|
return False, f"write operator in: {part}"
|
|
# 每段管道(含右侧): 白名单 → 只读形态
|
|
for seg in _pipeline_segments(part):
|
|
if not seg:
|
|
continue
|
|
first = seg[0]
|
|
if first not in _READONLY_COMMANDS:
|
|
return False, f"command not in readonly whitelist: {first}"
|
|
ok, reason = _check_readonly_form(seg)
|
|
if not ok:
|
|
return False, reason
|
|
# 正向写子命令(全已移入 _READONLY_FORMS, 此 dict 为空; 保留以备未来扩展)
|
|
subs = _WRITE_SUBCOMMANDS.get(first)
|
|
if subs and len(seg) > 1 and seg[1] in subs:
|
|
return False, f"write subcommand: {first} {seg[1]}"
|
|
return True, None
|
|
|
|
|
|
@register_tool(category="Agent", name_cn="Read Only Command", risk_level="low", subagent_only=True)
|
|
class ReadOnlyCommand(RunCommand):
|
|
"""
|
|
(sub-agent only) Execute a whitelisted read-only shell command to collect diagnostic evidence (logs, status, ports, configs, resource usage). Non-whitelisted or write-capable commands are rejected.
|
|
|
|
When to use:
|
|
- You are a sub-agent (Task worker) and panel tools cannot provide the needed diagnostic information.
|
|
- Prefer this over any other command execution — it is risk_level=low and needs no confirmation.
|
|
|
|
Usage tips:
|
|
- Supports && ; || chains and pipes. Every segment's first token must be a common read-only utility (cat/grep/find/ls/ps/ss/top/vmstat/journalctl/dig/df/openssl/nginx -t/php -l, etc.).
|
|
- Hard reject (even with a whitelisted head): process substitution (`<()`), `&` background, `\n` injection, redirects (> >> except >/dev/null), find -exec/-delete, non-read-only pipe targets (xargs/sed/awk/tee).
|
|
- `$()` and backtick substitutions are validated recursively — the inner command must itself pass the whitelist (e.g. `$(hostname)` ok, `$(rm /tmp)` blocked).
|
|
- Most commands are default-deny on subcommands/options — only their read-only forms pass (e.g. `systemctl status`, `docker ps`, `redis-cli info`, `curl -I`, `date -u`, `ss -tln`, `nginx -t`, `crontab -l`). SQL → MysqlQuery.
|
|
|
|
Args:
|
|
command: Read-only shell command (supports && ; || and pipes). Every segment must pass the whitelist.
|
|
cwd: Working directory for the command.
|
|
timeout: Timeout in milliseconds (default 30000 = 30s).
|
|
description: Brief description of what this command does (for logging).
|
|
"""
|
|
|
|
_tool_name = "ReadOnlyCommand"
|
|
|
|
def execute(self, command: str, cwd: str = None,
|
|
timeout: int = 30000, description: str = None) -> str:
|
|
ok, reason = _is_readonly_command(command)
|
|
if not ok:
|
|
return _xml_response(self._tool_name, "error",
|
|
f"Read-only check failed: {reason}")
|
|
# 复用父类无状态执行: 强制 blocking, 不开放 session 以收紧状态面
|
|
return super().execute(
|
|
command=command, blocking=True, cwd=cwd,
|
|
timeout=timeout, description=description,
|
|
)
|