ForcePilot/backend/package/yuxi/channel/security/audit.py
Kris 849d21120f feat(channel/security): 新增完整安全工具模块
新增一系列安全相关功能:
1.  新增二维码生成工具,支持自定义参数导出图片/Base64/字节流
2.  新增HTML内容安全处理工具,包括标签剥离、转义、URL校验
3.  新增日志敏感信息脱敏工具,支持配置、字典、日志记录脱敏
4.  新增密钥管理运行时,支持从环境变量/文件加载密钥
5.  新增身份链接管理,支持多渠道身份绑定与解析
6.  新增SSRF防护工具,支持域名/IP校验与固定主机
7.  新增安全权限修复工具,修复文件目录权限与配置项
8.  新增外部内容安全处理,支持LLM特殊令牌剥离与注入检测
9.  新增认证限流工具,支持多维度限流与本地回环豁免
10. 新增配对管理工具,支持安全配对码生成与校验
11. 新增白名单管理工具,支持DM/群组/来源白名单校验
2026-05-21 10:32:57 +08:00

1492 lines
57 KiB
Python

from __future__ import annotations
import logging
import stat
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from yuxi.channel.secrets.models import SecretSource
from yuxi.channel.secrets.providers import SecretsProvider
logger = logging.getLogger(__name__)
SecurityAuditSeverity = str
CRITICAL = "critical"
WARN = "warn"
INFO = "info"
@dataclass
class SecurityAuditFinding:
check_id: str
severity: SecurityAuditSeverity
title: str
detail: str
remediation: str = ""
provider: str | None = None
profile_id: str | None = None
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"checkId": self.check_id,
"severity": self.severity,
"title": self.title,
"detail": self.detail,
}
if self.remediation:
d["remediation"] = self.remediation
if self.provider:
d["provider"] = self.provider
if self.profile_id:
d["profileId"] = self.profile_id
return d
@dataclass
class SecurityAuditSummary:
critical: int = 0
warn: int = 0
info: int = 0
def to_dict(self) -> dict[str, int]:
return {"critical": self.critical, "warn": self.warn, "info": self.info}
@property
def score(self) -> int:
base = 100
base -= self.critical * 15
base -= self.warn * 5
base -= self.info * 1
return max(0, min(100, base))
@property
def grade(self) -> str:
s = self.score
if s >= 90:
return "A"
if s >= 75:
return "B"
if s >= 60:
return "C"
if s >= 40:
return "D"
return "F"
@dataclass
class SecurityAuditReport:
ts: float = field(default_factory=time.time)
summary: SecurityAuditSummary = field(default_factory=SecurityAuditSummary)
findings: list[SecurityAuditFinding] = field(default_factory=list)
secrets_report: dict[str, Any] | None = None
score: int = 100
grade: str = "A"
def to_dict(self) -> dict[str, Any]:
d: dict[str, Any] = {
"ts": self.ts,
"summary": self.summary.to_dict(),
"score": self.score,
"grade": self.grade,
"findings": [f.to_dict() for f in self.findings],
}
if self.secrets_report:
d["secretsReport"] = self.secrets_report
return d
def _count_summary(findings: list[SecurityAuditFinding]) -> SecurityAuditSummary:
summary = SecurityAuditSummary()
for f in findings:
if f.severity == CRITICAL:
summary.critical += 1
elif f.severity == WARN:
summary.warn += 1
else:
summary.info += 1
return summary
# ── 公共配置遍历工具 ──────────────────────────────────────────────────
def _walk_channels(
config: dict[str, Any],
callback: Callable[[str, dict[str, Any]], None],
recurse_keys: tuple[str, ...] = ("groups", "accounts", "dms"),
) -> None:
"""遍历 config.channels 下所有嵌套字典,对每个字典节点调用 callback(scope, node)。
使用 id() 去重防止循环引用导致的无限递归。
"""
channels = config.get("channels")
if not isinstance(channels, dict):
return
seen: set[int] = set()
def _visit(scope: str, value: Any) -> None:
if not isinstance(value, dict) or id(value) in seen:
return
seen.add(id(value))
callback(scope, value)
for key, nested in value.items():
if key in recurse_keys and isinstance(nested, dict):
_visit(f"{scope}.{key}", nested)
elif isinstance(nested, dict):
_visit(f"{scope}.{key}", nested)
for channel_id, channel_value in channels.items():
if isinstance(channel_value, dict):
_visit(f"channels.{channel_id}", channel_value)
def _walk_config_values(
config: dict[str, Any],
path: str,
callback: Callable[[str, Any], None],
max_depth: int = 8,
) -> None:
"""递归遍历 config 中任意路径下的所有值,调用 callback(path, value)。"""
if max_depth <= 0:
return
node = config
for part in path.split("."):
if not isinstance(node, dict):
return
node = node.get(part)
if not isinstance(node, dict):
return
seen: set[int] = set()
def _visit(scope: str, value: Any, depth: int) -> None:
if depth <= 0 or not isinstance(value, dict) or id(value) in seen:
return
seen.add(id(value))
callback(scope, value)
for key, nested in value.items():
if isinstance(nested, dict):
_visit(f"{scope}.{key}", nested, depth - 1)
_visit(path, node, max_depth)
# ── Collector: dmPolicy / groupPolicy 暴露面扫描 ─────────────────────
def collect_exposure_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
def _callback(scope: str, node: dict[str, Any]) -> None:
if node.get("groupPolicy") == "open":
findings.append(
SecurityAuditFinding(
check_id="exposure.group_policy.open",
severity=WARN,
title=f"groupPolicy=open detected at {scope}",
detail=f"{scope}.groupPolicy is set to 'open', allowing anyone to reach this channel.",
remediation=f"Set {scope}.groupPolicy to 'allowlist' or 'disabled' to restrict access.",
provider=scope.split(".")[-1] if "." in scope else scope,
)
)
if node.get("dmPolicy") == "open":
findings.append(
SecurityAuditFinding(
check_id="exposure.dm_policy.open",
severity=WARN,
title=f"dmPolicy=open detected at {scope}",
detail=f"{scope}.dmPolicy is set to 'open', allowing anyone to DM this agent.",
remediation=f"Set {scope}.dmPolicy to 'allowlist', 'pairing', or 'disabled' to restrict DM access.",
provider=scope.split(".")[-1] if "." in scope else scope,
)
)
_walk_channels(config, _callback)
return findings
# ── Collector: Exec 安全矩阵 ──────────────────────────────────────────
def _collect_exec_trusted_dir_findings(trusted_dirs: list[str], scope: str) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
risky_dirs: list[tuple[str, str]] = []
for entry in trusted_dirs:
raw = entry.strip()
if not raw:
continue
p = Path(raw)
if not p.is_absolute():
risky_dirs.append((raw, "relative path (trust boundary depends on process cwd)"))
continue
try:
normalized = str(p.resolve()).replace("\\", "/").lower()
except OSError:
risky_dirs.append((raw, "unresolvable path"))
continue
if any(normalized == r or normalized.startswith(r + "/") for r in ["/tmp", "/var/tmp", "/private/tmp"]):
risky_dirs.append((raw, "temporary directory is mutable and easy to poison"))
continue
if any(normalized.startswith(prefix) for prefix in ["/users/", "/home/", "/root/"]):
risky_dirs.append((raw, "home-scoped directory (typically user-writable)"))
continue
if normalized.startswith("c:/users/"):
risky_dirs.append((raw, "home-scoped directory (typically user-writable)"))
continue
if any(
normalized == bin_dir
for bin_dir in [
"/usr/local/bin",
"/opt/homebrew/bin",
"/opt/local/bin",
"/home/linuxbrew/.linuxbrew/bin",
]
):
risky_dirs.append((raw, "package-manager bin directory (often user-writable)"))
continue
if risky_dirs:
detail_lines = [f"{d} ({r})" for d, r in risky_dirs[:10]]
if len(risky_dirs) > 10:
detail_lines.append(f"+{len(risky_dirs) - 10} more entries")
findings.append(
SecurityAuditFinding(
check_id="exec.trusted_dirs.risky",
severity=WARN,
title=f"Risky safeBinTrustedDirs at {scope}",
detail=f"Detected risky trusted directories at {scope}:\n" + "\n".join(detail_lines),
remediation="Prefer root-owned immutable bins, keep default trust dirs (/bin, /usr/bin).",
)
)
return findings
def collect_exec_security_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
exec_config = config.get("tools", {}).get("exec", {})
if not isinstance(exec_config, dict):
return findings
exec_security = exec_config.get("security", "deny")
if exec_security == "full":
open_channels = _list_open_channel_paths(config)
severity = CRITICAL if open_channels else WARN
findings.append(
SecurityAuditFinding(
check_id="exec.security_full",
severity=severity,
title="Exec security=full is configured",
detail=(
"Full exec trust is enabled"
+ (
". Open channel access was detected at:\n" + "\n".join(f"- {p}" for p in open_channels)
if open_channels
else "."
)
),
remediation='Prefer tools.exec.security="allowlist" with ask prompts.',
)
)
if exec_security != "deny" and (open_channels := _list_open_channel_paths(config)):
findings.append(
SecurityAuditFinding(
check_id="exec.open_channels_with_exec",
severity=CRITICAL if exec_security == "full" else WARN,
title="Open channels can reach exec-enabled agents",
detail="Open DM/group access detected:\n" + "\n".join(f"- {p}" for p in open_channels),
remediation="Tighten dmPolicy/groupPolicy to pairing or allowlist, or disable exec.",
)
)
safe_bins = exec_config.get("safeBins", [])
if isinstance(safe_bins, list) and safe_bins:
interpreter_bins = {"python", "python3", "node", "ruby", "perl", "php", "lua", "bash", "sh", "zsh"}
found_interpreters = [b for b in safe_bins if isinstance(b, str) and b.lower().strip() in interpreter_bins]
if found_interpreters:
findings.append(
SecurityAuditFinding(
check_id="exec.safe_bins_interpreter",
severity=WARN,
title="safeBins includes interpreter/runtime binaries",
detail=f"Detected interpreter-like safeBins: {', '.join(found_interpreters)}",
remediation="Remove interpreter bins from safeBins and prefer explicit allowlist entries.",
)
)
trusted_dirs = exec_config.get("safeBinTrustedDirs", [])
if isinstance(trusted_dirs, list) and trusted_dirs:
findings.extend(_collect_exec_trusted_dir_findings(trusted_dirs, "tools.exec"))
agents = config.get("agents", {}).get("list", [])
if isinstance(agents, list):
for agent_entry in agents:
if not isinstance(agent_entry, dict):
continue
agent_id = agent_entry.get("id", "unknown")
agent_exec = agent_entry.get("tools", {}).get("exec", {})
if not isinstance(agent_exec, dict):
continue
agent_security = agent_exec.get("security", exec_security)
if agent_security == "full":
findings.append(
SecurityAuditFinding(
check_id="exec.agent_security_full",
severity=WARN,
title=f"Agent '{agent_id}' has exec security=full",
detail=f"Agent {agent_id} is configured with full exec trust.",
remediation="Prefer allowlist mode with explicit safe bins for this agent.",
)
)
agent_trusted_dirs = agent_exec.get("safeBinTrustedDirs", [])
if isinstance(agent_trusted_dirs, list) and agent_trusted_dirs:
findings.extend(
_collect_exec_trusted_dir_findings(
agent_trusted_dirs,
f"agents.list.{agent_id}.tools.exec",
)
)
elevate_config = config.get("tools", {}).get("elevated", {})
if isinstance(elevate_config, dict):
allow_from = elevate_config.get("allowFrom", {})
if isinstance(allow_from, dict):
for provider, entries in allow_from.items():
if isinstance(entries, list) and "*" in entries:
findings.append(
SecurityAuditFinding(
check_id=f"exec.elevated.wildcard.{provider}",
severity=CRITICAL,
title=f"Elevated exec allowlist contains wildcard for {provider}",
detail=f"tools.elevated.allowFrom.{provider} includes '*', effectively approving everyone.",
remediation="Remove wildcard and specify explicit allowlist entries.",
)
)
return findings
def _list_open_channel_paths(config: dict[str, Any]) -> list[str]:
paths: list[str] = []
def _callback(scope: str, node: dict[str, Any]) -> None:
if node.get("groupPolicy") == "open":
paths.append(f"{scope}.groupPolicy")
if node.get("dmPolicy") == "open":
paths.append(f"{scope}.dmPolicy")
_walk_channels(config, _callback)
return list(dict.fromkeys(paths))
# ── Collector: 网关 HTTP 未认证风险检测 ────────────────────────────────
def _is_loopback(host: str) -> bool:
host = host.split(":")[0]
if host in {"127.0.0.1", "::1", "localhost", "localhost.localdomain"}:
return True
if host.startswith("127."):
return True
try:
import ipaddress
ip = ipaddress.ip_address(host)
return ip.is_loopback
except ValueError:
return False
def collect_gateway_http_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
gateway = config.get("gateway", {})
if not isinstance(gateway, dict):
return findings
bind_host = str(gateway.get("bind", "127.0.0.1"))
has_auth = bool(
gateway.get("auth", {}).get("token") or gateway.get("auth", {}).get("password") or config.get("gateway_token")
)
if not _is_loopback(bind_host) and not has_auth:
findings.append(
SecurityAuditFinding(
check_id="gateway.bind_no_auth",
severity=CRITICAL,
title="Gateway bound to non-loopback address without authentication",
detail=f"Gateway bind={bind_host} exposes the gateway to the network without auth configured.",
remediation="Configure gateway.auth.token or gateway.auth.password, or bind to 127.0.0.1.",
)
)
if _is_loopback(bind_host) and not has_auth:
findings.append(
SecurityAuditFinding(
check_id="gateway.loopback_no_auth",
severity=WARN,
title="Gateway bound to loopback without authentication key",
detail="No gateway auth token or password configured, even for loopback.",
remediation="Set a gateway.auth.token or set GATEWAY_TOKEN env var.",
)
)
trusted_proxies = gateway.get("trustedProxies", [])
if isinstance(trusted_proxies, list) and trusted_proxies:
has_non_loopback_proxy = any(not _is_loopback(str(p)) for p in trusted_proxies)
if has_non_loopback_proxy:
findings.append(
SecurityAuditFinding(
check_id="gateway.trusted_proxy_non_loopback",
severity=WARN,
title="Trusted proxy includes non-loopback addresses",
detail="Gateway trustedProxies include non-loopback entries.",
remediation="Only trust loopback proxies (127.0.0.1, ::1) unless reverse-proxy auth is in place.",
)
)
allowed_origins = gateway.get("controlUI", {}).get("allowedOrigins", [])
if isinstance(allowed_origins, list) and "*" in allowed_origins:
findings.append(
SecurityAuditFinding(
check_id="gateway.cors_wildcard",
severity=WARN,
title="CORS allowedOrigins contains wildcard",
detail="Gateway control UI allows any origin via '*' wildcard.",
remediation="Restrict to specific trusted origins.",
)
)
token_value = gateway.get("auth", {}).get("token", "")
if isinstance(token_value, str) and len(token_value) > 0 and len(token_value) < 32:
findings.append(
SecurityAuditFinding(
check_id="gateway.token_too_short",
severity=WARN,
title="Gateway auth token is too short",
detail=f"Token length is {len(token_value)}, recommend at least 32 characters.",
remediation="Use a longer random token: openssl rand -hex 32",
)
)
return findings
# ── Collector: 文件系统权限审计 ───────────────────────────────────────
def _check_path_permissions(path: str | Path, label: str, is_dir: bool = False) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
path = Path(path)
try:
path_stat = path.stat()
except OSError:
return findings
mode = stat.S_IMODE(path_stat.st_mode)
if path.is_symlink():
findings.append(
SecurityAuditFinding(
check_id=f"fs.{label}.symlink",
severity=WARN if label == "state_dir" else CRITICAL,
title=f"{label} is a symlink",
detail=f"{path} is a symlink; treat this as an extra trust boundary.",
)
)
is_other_writable = bool(mode & stat.S_IWOTH)
is_group_writable = bool(mode & stat.S_IWGRP)
if is_other_writable:
findings.append(
SecurityAuditFinding(
check_id=f"fs.{label}.world_writable",
severity=CRITICAL,
title=f"{label} is world-writable",
detail=f"{path} has mode {oct(mode)}; other users can write into it.",
remediation=f"chmod {0o700 if is_dir else 0o600} {path}",
)
)
elif is_group_writable:
findings.append(
SecurityAuditFinding(
check_id=f"fs.{label}.group_writable",
severity=WARN,
title=f"{label} is group-writable",
detail=f"{path} has mode {oct(mode)}; group users can write into it.",
remediation=f"chmod {0o700 if is_dir else 0o600} {path}",
)
)
if not is_dir:
is_other_readable = bool(mode & stat.S_IROTH)
is_group_readable = bool(mode & stat.S_IRGRP)
if is_other_readable:
findings.append(
SecurityAuditFinding(
check_id=f"fs.{label}.world_readable",
severity=CRITICAL,
title=f"{label} is world-readable",
detail=f"{path} has mode {oct(mode)}; config may contain tokens and private settings.",
remediation=f"chmod 600 {path}",
)
)
elif is_group_readable:
findings.append(
SecurityAuditFinding(
check_id=f"fs.{label}.group_readable",
severity=WARN,
title=f"{label} is group-readable",
detail=f"{path} has mode {oct(mode)}; config may contain tokens and private settings.",
remediation=f"chmod 600 {path}",
)
)
return findings
def collect_filesystem_findings(
state_dir: str | Path | None = None,
config_path: str | Path | None = None,
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
if state_dir:
findings.extend(_check_path_permissions(state_dir, "state_dir", is_dir=True))
if config_path:
findings.extend(_check_path_permissions(config_path, "config", is_dir=False))
return findings
# ── Collector: 限流配置审计 ────────────────────────────────────────────
def collect_rate_limit_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
rate_limit = config.get("security", {}).get("rateLimit", {})
auth_rate = rate_limit.get("auth", rate_limit)
if isinstance(auth_rate, dict) and auth_rate.get("enabled") is False:
findings.append(
SecurityAuditFinding(
check_id="rate_limit.auth_disabled",
severity=WARN,
title="Auth rate limiting is disabled",
detail=(
"Authentication rate limiting is explicitly disabled, "
"exposing login/API endpoints to brute-force."
),
remediation="Enable security.rateLimit.auth with appropriate maxAttempts and cooldown windows.",
)
)
login_rate = rate_limit.get("loginMaxAttempts")
if login_rate is not None and login_rate < 3:
findings.append(
SecurityAuditFinding(
check_id="rate_limit.login_window_too_tight",
severity=INFO,
title="Login rate limit window may be too restrictive",
detail=f"Login maxAttempts={login_rate} is very low and may cause legitimate user lockouts.",
)
)
loopback_exempt = rate_limit.get("loopbackExempt", True)
if not loopback_exempt:
findings.append(
SecurityAuditFinding(
check_id="rate_limit.loopback_not_exempt",
severity=INFO,
title="Loopback is not exempt from rate limiting",
detail="Local API calls via loopback are subject to rate limits, which may affect admin tools.",
)
)
return findings
# ── Collector: 配对安全审计 ───────────────────────────────────────────
def collect_pairing_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
def _callback(scope: str, node: dict[str, Any]) -> None:
if node.get("dmPolicy") != "pairing":
return
pairing_config = node.get("pairing", {})
if not isinstance(pairing_config, dict):
return
code_length = pairing_config.get("codeLength", 8)
if code_length < 6:
findings.append(
SecurityAuditFinding(
check_id=f"pairing.code_too_short.{scope.replace('.', '_')}",
severity=WARN,
title=f"Pairing code length is too short at {scope}",
detail=f"Pairing code length is {code_length} (recommend >= 8).",
remediation="Increase pairing.codeLength to at least 8.",
)
)
ttl = pairing_config.get("pendingTtlSeconds", 600)
if ttl > 3600:
findings.append(
SecurityAuditFinding(
check_id=f"pairing.ttl_too_long.{scope.replace('.', '_')}",
severity=WARN,
title=f"Pairing pending TTL is too long at {scope}",
detail=f"Pending pairing code expires in {ttl}s (> 24 hours).",
remediation="Reduce pairing.pendingTtlSeconds to 3600 (1 hour) or less.",
)
)
_walk_channels(config, _callback)
return findings
# ── Collector: 跨 channel 安全一致性对比 ───────────────────────────────
def collect_cross_channel_consistency_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
channels = config.get("channels")
if not isinstance(channels, dict):
return findings
channel_policies: dict[str, dict[str, str]] = {}
for channel_id, channel_value in channels.items():
if not isinstance(channel_value, dict):
continue
policies: dict[str, str] = {}
for key in ("dmPolicy", "groupPolicy", "pairing", "encryption"):
val = channel_value.get(key)
if val is not None:
policies[key] = str(val) if not isinstance(val, str) else val
if policies:
channel_policies[channel_id] = policies
if len(channel_policies) < 2:
return findings
dm_policies = {k: v.get("dmPolicy", "unknown") for k, v in channel_policies.items()}
unique_dm = set(dm_policies.values())
if len(unique_dm) > 1:
lines = [f" - {k}: dmPolicy={v}" for k, v in dm_policies.items()]
findings.append(
SecurityAuditFinding(
check_id="consistency.dm_policy_divergent",
severity=WARN,
title="Inconsistent dmPolicy across channels",
detail="Different channels have different dmPolicy settings:\n" + "\n".join(lines),
remediation="Review and align dmPolicy settings across channels for consistent security posture.",
)
)
group_policies = {k: v.get("groupPolicy", "unknown") for k, v in channel_policies.items()}
unique_gp = set(group_policies.values())
if len(unique_gp) > 1:
lines = [f" - {k}: groupPolicy={v}" for k, v in group_policies.items()]
findings.append(
SecurityAuditFinding(
check_id="consistency.group_policy_divergent",
severity=WARN,
title="Inconsistent groupPolicy across channels",
detail="Different channels have different groupPolicy settings:\n" + "\n".join(lines),
remediation="Review and align groupPolicy settings across channels.",
)
)
return findings
# ── Collector: 日志安全审计 ────────────────────────────────────────────
def collect_logging_findings(
config: dict[str, Any],
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
logging_config = config.get("logging", {})
if not isinstance(logging_config, dict):
return findings
redact = str(logging_config.get("redactSensitive", "tools"))
if redact == "off":
findings.append(
SecurityAuditFinding(
check_id="logging.redact_off",
severity=WARN,
title="Log redaction is disabled",
detail='logging.redactSensitive="off" can leak secrets into logs and status output.',
remediation='Set logging.redactSensitive="tools".',
)
)
return findings
# ── Collector: 沙箱环境探针 (Deep Mode) ───────────────────────────────
def _detect_container_environment() -> dict[str, bool]:
import os as _os
indicators = {
"docker_cgroup": False,
"docker_env": False,
"kubernetes": False,
"container_file": False,
}
try:
with open("/proc/1/cgroup") as f:
cgroup_content = f.read()
if "docker" in cgroup_content or "containerd" in cgroup_content:
indicators["docker_cgroup"] = True
if "kubepods" in cgroup_content:
indicators["kubernetes"] = True
except OSError:
pass
if _os.path.exists("/.dockerenv"):
indicators["docker_env"] = True
if _os.path.exists("/run/secrets/kubernetes.io"):
indicators["kubernetes"] = True
if _os.path.exists("/.containerenv"):
indicators["container_file"] = True
return indicators
def collect_sandbox_probes() -> list[SecurityAuditFinding]:
import os as _os
import sys as _sys
findings: list[SecurityAuditFinding] = []
container = _detect_container_environment()
is_container = any(container.values())
if is_container:
findings.append(
SecurityAuditFinding(
check_id="sandbox.in_container",
severity=INFO,
title="Running inside a container environment",
detail=f"Container indicators: {', '.join(k for k, v in container.items() if v)}",
remediation="Ensure container is properly sandboxed with minimal privileges.",
)
)
try:
uid = _os.getuid()
except AttributeError:
uid = None
if uid is not None and uid == 0:
findings.append(
SecurityAuditFinding(
check_id="sandbox.root_user",
severity=CRITICAL,
title="Process is running as root",
detail="Running as UID 0 inside container — violates least-privilege principle.",
remediation="Use USER directive in Dockerfile to switch to non-root user.",
)
)
try:
import ctypes
import ctypes.util
import platform
libc_name = ctypes.util.find_library("c")
if libc_name is not None:
libc = ctypes.CDLL(libc_name, use_errno=True)
caps = ctypes.c_uint64(0)
machine = platform.machine().lower()
if machine in ("x86_64", "amd64"):
SYS_capget = 125
elif machine in ("aarch64", "arm64"):
SYS_capget = 90
else:
SYS_capget = None
if SYS_capget is not None:
hdr = (0x20080522, 0)
try:
ret = libc.syscall(SYS_capget, ctypes.byref(ctypes.c_int(hdr[0])), ctypes.byref(caps))
if ret == 0 and caps.value != 0:
findings.append(
SecurityAuditFinding(
check_id="sandbox.capabilities",
severity=WARN,
title="Process has Linux capabilities",
detail=f"Capability set: 0x{caps.value:016x} — container may have elevated privileges.",
remediation="Drop all capabilities in container: --cap-drop=ALL",
)
)
except (OSError, AttributeError):
pass
except Exception:
pass
if is_container:
sensitive_mounts = ["/var/run/docker.sock", "/proc", "/sys"]
existing = [m for m in sensitive_mounts if _os.path.exists(m)]
if existing:
findings.append(
SecurityAuditFinding(
check_id="sandbox.sensitive_mounts",
severity=WARN if "/var/run/docker.sock" not in existing else CRITICAL,
title="Sensitive host paths are accessible",
detail=f"Accessible: {', '.join(existing)}",
remediation="Avoid mounting Docker socket or host /proc into containers.",
)
)
exec_prefix = getattr(_sys, "exec_prefix", "")
if exec_prefix and not is_container:
home_dir = _os.path.expanduser("~")
if str(exec_prefix).startswith(home_dir):
findings.append(
SecurityAuditFinding(
check_id="sandbox.user_python",
severity=INFO,
title="Python runtime in user home directory",
detail=f"Python exec_prefix={exec_prefix} is inside user home.",
remediation="Use system Python or dedicated venv outside home for production.",
)
)
return findings
# ── Collector: 环境变量密钥暴露检测 (Deep Mode) ──────────────────────
_SENSITIVE_ENV_PATTERNS = [
(r"(?i)api[_-]?key", "api_key", CRITICAL),
(r"(?i)secret[_-]?key", "secret_key", CRITICAL),
(r"(?i)access[_-]?token", "access_token", CRITICAL),
(r"(?i)private[_-]?key", "private_key", CRITICAL),
(r"(?i)database[_-]?url", "database_url", WARN),
(r"(?i)connection[_-]?string", "connection_string", WARN),
(r"(?i)jwt[_-]?secret", "jwt_secret", CRITICAL),
(r"(?i)encryption[_-]?key", "encryption_key", CRITICAL),
(r"(?i)admin[_-]?password", "admin_password", CRITICAL),
(r"(?i)smtp[_-]?password", "smtp_password", WARN),
]
def collect_env_secrets_exposure(
env: dict[str, str],
) -> list[SecurityAuditFinding]:
import re as _re
findings: list[SecurityAuditFinding] = []
for var_name, var_value in env.items():
if not isinstance(var_value, str) or not var_value.strip():
continue
for pattern, label, severity in _SENSITIVE_ENV_PATTERNS:
if _re.search(pattern, var_name):
masked = var_value[:4] + "***" if len(var_value) > 4 else "***"
findings.append(
SecurityAuditFinding(
check_id=f"env.secrets.{label}",
severity=severity,
title=f"Environment variable '{var_name}' may contain secrets",
detail=f"Variable '{var_name}' matches sensitive pattern '{label}' (value: {masked}).",
remediation="Move secrets to a dedicated secrets manager or .env file outside version control.",
)
)
break
return findings
# ── Collector: SSRF 防护配置审计 ──────────────────────────────────────
def collect_ssrf_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
ssrf_config = config.get("security", {}).get("ssrf", {})
if not isinstance(ssrf_config, dict):
ssrf_config = {}
allowlist = ssrf_config.get("allowlist", [])
if not isinstance(allowlist, list) or not allowlist:
findings.append(
SecurityAuditFinding(
check_id="ssrf.allowlist_empty",
severity=WARN,
title="SSRF allowlist is empty or not configured",
detail="SSRF protection is active but no allowlist is configured; external requests may be blocked or allowed by default depending on implementation.",
remediation="Configure security.ssrf.allowlist with trusted external hostnames.",
)
)
default_deny = ssrf_config.get("defaultDeny", True)
if not default_deny:
findings.append(
SecurityAuditFinding(
check_id="ssrf.default_deny_disabled",
severity=CRITICAL,
title="SSRF default-deny policy is disabled",
detail="security.ssrf.defaultDeny is set to false; all hostnames are allowed unless explicitly blocked.",
remediation="Enable security.ssrf.defaultDeny=true and use allowlist for permitted destinations.",
)
)
wildcard_entries = [entry for entry in allowlist if isinstance(entry, str) and entry == "*"]
if wildcard_entries:
findings.append(
SecurityAuditFinding(
check_id="ssrf.wildcard_allowlist",
severity=CRITICAL,
title="SSRF allowlist contains wildcard",
detail="security.ssrf.allowlist includes '*', effectively disabling SSRF protection.",
remediation="Remove wildcard from SSRF allowlist and specify explicit trusted hostnames.",
)
)
return findings
# ── Collector: 插件安全审计 ───────────────────────────────────────────
def collect_plugin_security_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
plugin_config = config.get("plugins", {})
if not isinstance(plugin_config, dict):
return findings
sandbox_enabled = plugin_config.get("sandbox", True)
if sandbox_enabled is False:
findings.append(
SecurityAuditFinding(
check_id="plugin.sandbox_disabled",
severity=CRITICAL,
title="Plugin sandbox is disabled",
detail="plugins.sandbox is set to false; dynamic plugin code runs without restriction.",
remediation="Enable plugins.sandbox=true and use PluginSandbox for dynamic code execution.",
)
)
path_validation = plugin_config.get("pathValidation", True)
if path_validation is False:
findings.append(
SecurityAuditFinding(
check_id="plugin.path_validation_disabled",
severity=CRITICAL,
title="Plugin path validation is disabled",
detail="plugins.pathValidation is set to false; arbitrary filesystem paths can be loaded as plugins.",
remediation="Enable plugins.pathValidation=true to prevent path traversal attacks.",
)
)
dependency_whitelist = plugin_config.get("dependencyWhitelist")
if dependency_whitelist is None:
findings.append(
SecurityAuditFinding(
check_id="plugin.dependency_whitelist_missing",
severity=WARN,
title="Plugin dependency whitelist is not configured",
detail="No dependency whitelist configured; plugins can declare arbitrary PyPI packages for installation.",
remediation="Configure plugins.dependencyWhitelist with approved packages and version constraints.",
)
)
elif isinstance(dependency_whitelist, list) and not dependency_whitelist:
findings.append(
SecurityAuditFinding(
check_id="plugin.dependency_whitelist_empty",
severity=WARN,
title="Plugin dependency whitelist is empty",
detail="Dependency whitelist is explicitly empty; all plugin dependencies will be rejected.",
remediation="Populate plugins.dependencyWhitelist with trusted packages, or remove the key to use defaults.",
)
)
auto_install = plugin_config.get("autoInstallDependencies", False)
if auto_install is True:
findings.append(
SecurityAuditFinding(
check_id="plugin.auto_install_enabled",
severity=WARN,
title="Plugin auto-install dependencies is enabled",
detail="plugins.autoInstallDependencies is true; plugin manifests can trigger automatic pip installs.",
remediation="Disable autoInstallDependencies and require manual review before installing plugin dependencies.",
)
)
return findings
# ── Collector: 审批流程安全审计 ───────────────────────────────────────
def collect_approval_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
approval_config = config.get("security", {}).get("approval", {})
if not isinstance(approval_config, dict):
return findings
identity_verification = approval_config.get("identityVerification", True)
if identity_verification is False:
findings.append(
SecurityAuditFinding(
check_id="approval.identity_verification_disabled",
severity=CRITICAL,
title="Approval identity verification is disabled",
detail="security.approval.identityVerification is false; anyone with a request_id can approve or reject requests.",
remediation="Enable security.approval.identityVerification=true and validate decided_by against approver allowlist.",
)
)
require_reason = approval_config.get("requireReason", False)
if not require_reason:
findings.append(
SecurityAuditFinding(
check_id="approval.reason_not_required",
severity=INFO,
title="Approval reason is not required",
detail="Approvers can register decisions without providing a reason, reducing audit trail quality.",
remediation="Set security.approval.requireReason=true to enforce reason capture.",
)
)
ttl_seconds = approval_config.get("pendingTtlSeconds", 300)
if isinstance(ttl_seconds, (int, float)) and ttl_seconds > 3600:
findings.append(
SecurityAuditFinding(
check_id="approval.pending_ttl_too_long",
severity=WARN,
title="Approval pending TTL is too long",
detail=f"Pending approvals expire after {ttl_seconds}s (> 1 hour), increasing window for replay attacks.",
remediation="Reduce security.approval.pendingTtlSeconds to 300 (5 minutes) or less.",
)
)
return findings
# ── Collector: 消息内容安全审计 ───────────────────────────────────────
def collect_message_security_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
render_config = config.get("render", {})
if not isinstance(render_config, dict):
render_config = {}
html_filter = render_config.get("htmlFilter", True)
if html_filter is False:
findings.append(
SecurityAuditFinding(
check_id="message.html_filter_disabled",
severity=CRITICAL,
title="HTML filter is disabled in message rendering",
detail="render.htmlFilter is false; incoming messages may contain <script>, <iframe>, and other dangerous HTML tags.",
remediation="Enable render.htmlFilter=true and use sanitize_text_content for user-generated content.",
)
)
url_validation = render_config.get("urlValidation", True)
if url_validation is False:
findings.append(
SecurityAuditFinding(
check_id="message.url_validation_disabled",
severity=CRITICAL,
title="URL validation is disabled in message rendering",
detail="render.urlValidation is false; javascript:, data:, and other dangerous URL schemes may pass through.",
remediation="Enable render.urlValidation=true and use sanitize_url to block dangerous schemes.",
)
)
markdown_mode = render_config.get("markdownMode", "safe")
if markdown_mode == "unsafe":
findings.append(
SecurityAuditFinding(
check_id="message.markdown_unsafe",
severity=WARN,
title="Markdown rendering is in unsafe mode",
detail="render.markdownMode is 'unsafe'; raw HTML inside markdown is passed through without filtering.",
remediation="Set render.markdownMode to 'safe' or 'strip' to prevent HTML injection via markdown.",
)
)
outbound_config = config.get("security", {}).get("outbound", {})
if isinstance(outbound_config, dict):
if outbound_config.get("allowRawHtml", False):
findings.append(
SecurityAuditFinding(
check_id="message.outbound_raw_html_allowed",
severity=WARN,
title="Outbound raw HTML is allowed",
detail="security.outbound.allowRawHtml is true; outgoing messages may contain unfiltered HTML.",
remediation="Disable security.outbound.allowRawHtml unless explicitly required by a trusted channel.",
)
)
return findings
# ── Collector: 密钥管理安全审计 ───────────────────────────────────────
def collect_secrets_management_findings(config: dict[str, Any]) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
secrets_config = config.get("secrets", {})
if not isinstance(secrets_config, dict):
return findings
exec_provider = secrets_config.get("execProvider", {})
if isinstance(exec_provider, dict):
allow_insecure_path = exec_provider.get("allowInsecurePath", False)
if allow_insecure_path is True:
findings.append(
SecurityAuditFinding(
check_id="secrets.exec.allow_insecure_path",
severity=CRITICAL,
title="Secrets exec provider allows insecure paths",
detail="secrets.execProvider.allowInsecurePath is true; relative or arbitrary command paths are permitted.",
remediation="Disable allowInsecurePath and use absolute paths in trusted directories for secret resolution commands.",
)
)
allow_symlink = exec_provider.get("allowSymlinkCommand", False)
if allow_symlink is True:
findings.append(
SecurityAuditFinding(
check_id="secrets.exec.allow_symlink",
severity=WARN,
title="Secrets exec provider allows symlink commands",
detail="secrets.execProvider.allowSymlinkCommand is true; command path may be hijacked via symlink.",
remediation="Disable allowSymlinkCommand to prevent symlink-based command substitution attacks.",
)
)
timeout_ms = exec_provider.get("timeoutMs", 5000)
if isinstance(timeout_ms, (int, float)) and timeout_ms > 30000:
findings.append(
SecurityAuditFinding(
check_id="secrets.exec.timeout_too_long",
severity=WARN,
title="Secrets exec provider timeout is too long",
detail=f"Exec provider timeout is {timeout_ms}ms (> 30s), increasing DoS risk from hung commands.",
remediation="Reduce secrets.execProvider.timeoutMs to 5000 (5s) or less.",
)
)
max_output = exec_provider.get("maxOutputBytes", 1024 * 1024)
if isinstance(max_output, (int, float)) and max_output > 10 * 1024 * 1024:
findings.append(
SecurityAuditFinding(
check_id="secrets.exec.max_output_too_large",
severity=INFO,
title="Secrets exec provider max output is very large",
detail=f"Max output bytes is {max_output} (> 10MB), which may cause memory pressure.",
remediation="Limit maxOutputBytes to 1MB or less for secret resolution commands.",
)
)
file_provider = secrets_config.get("fileProvider", {})
if isinstance(file_provider, dict):
if file_provider.get("allowInsecurePath", False):
findings.append(
SecurityAuditFinding(
check_id="secrets.file.allow_insecure_path",
severity=CRITICAL,
title="Secrets file provider allows insecure paths",
detail="secrets.fileProvider.allowInsecurePath is true; secret files can be read from arbitrary locations.",
remediation="Disable allowInsecurePath and restrict secret files to a dedicated base directory.",
)
)
env_provider = secrets_config.get("envProvider", {})
if isinstance(env_provider, dict):
allowlist = env_provider.get("allowlist")
if allowlist is None:
findings.append(
SecurityAuditFinding(
check_id="secrets.env.allowlist_missing",
severity=WARN,
title="Secrets env provider has no allowlist",
detail="No envProvider.allowlist configured; any environment variable can be resolved as a secret.",
remediation="Configure envProvider.allowlist with explicit environment variable names.",
)
)
return findings
# ─ Collector: Identity Links 安全审计 ────────────────────────────────
async def collect_identity_link_findings(
resolver=None,
) -> list[SecurityAuditFinding]:
findings: list[SecurityAuditFinding] = []
if resolver is None:
from yuxi.channel.security.identity_link import IdentityLinkResolver
resolver = IdentityLinkResolver()
await resolver.ensure_loaded()
links = resolver.list_links()
if not links:
findings.append(SecurityAuditFinding(
check_id="identity_links.empty",
severity=INFO,
title="未配置任何跨渠道身份关联",
detail="Identity Links 为空,不同渠道的同一用户将拥有独立的会话历史。",
remediation="通过 identity_links.add RPC 或 WebUI 为跨渠道用户配置身份关联。",
))
return findings
all_linked_ids: set[str] = set()
for identity, peer_ids in links.items():
if not peer_ids:
findings.append(SecurityAuditFinding(
check_id=f"identity_links.orphan_identity.{identity}",
severity=WARN,
title=f"身份 '{identity}' 没有关联任何渠道账号",
detail=f"Identity '{identity}' 的 linked_ids 为空,该身份不会产生任何效果。",
remediation="为此身份添加至少一个渠道账号关联,或删除该身份。",
))
for pid in peer_ids:
if pid in all_linked_ids:
findings.append(SecurityAuditFinding(
check_id="identity_links.duplicate_peer",
severity=WARN,
title=f"渠道账号 '{pid}' 被多个身份关联",
detail="同一渠道账号不应同时关联到多个统一身份,这会导致会话路由不确定。",
remediation="检查并修正重复关联,确保每个渠道账号只关联一个统一身份。",
))
all_linked_ids.add(pid)
if len(links) > 50:
findings.append(SecurityAuditFinding(
check_id="identity_links.too_many_identities",
severity=INFO,
title=f"身份关联数量 ({len(links)}) 较多",
detail="建议定期清理不再使用的跨渠道身份关联。",
))
return findings
# ── 统一编排引擎 ──────────────────────────────────────────────────────
@dataclass
class SecurityAuditOptions:
config: dict[str, Any] = field(default_factory=dict)
env: dict[str, str] | None = None
deep: bool = False
include_filesystem: bool = True
include_channel_security: bool = True
include_secrets_audit: bool = True
include_plugin_security: bool = True
include_approval_security: bool = True
include_message_security: bool = True
include_ssrf_security: bool = True
include_secrets_management: bool = True
state_dir: str | Path | None = None
config_path: str | Path | None = None
secrets_providers: dict[SecretSource, SecretsProvider] | None = None
env_path: str | Path | None = None
async def _run_secrets_audit_wrapper(
config: dict[str, Any],
providers: dict | None = None,
env_path: str | Path | None = None,
) -> dict[str, Any] | None:
try:
from yuxi.channel.secrets.audit import run_secrets_audit
except ModuleNotFoundError:
logger.warning("Secrets audit module not available, skipping")
return None
try:
result = await run_secrets_audit(
config=config,
providers=providers,
env_path=env_path,
)
return {
"status": result.report.status,
"summary": result.report.summary,
"filesScanned": result.report.files_scanned,
"findings": [
{
"code": f.code,
"severity": f.severity,
"file": f.file,
"jsonPath": f.json_path,
"message": f.message,
}
for f in result.report.findings
],
"exitCode": result.exit_code,
}
except Exception as e:
logger.exception("Secrets audit failed")
return {"status": "error", "error": str(e)}
async def run_security_audit(
opts: SecurityAuditOptions,
) -> SecurityAuditReport:
all_findings: list[SecurityAuditFinding] = []
all_findings.extend(collect_exposure_findings(opts.config))
all_findings.extend(collect_exec_security_findings(opts.config))
all_findings.extend(collect_logging_findings(opts.config))
all_findings.extend(collect_gateway_http_findings(opts.config))
all_findings.extend(collect_rate_limit_findings(opts.config))
all_findings.extend(collect_pairing_findings(opts.config))
if opts.include_channel_security:
all_findings.extend(collect_cross_channel_consistency_findings(opts.config))
all_findings.extend(await collect_identity_link_findings())
if opts.include_filesystem:
state_dir = opts.state_dir
config_path = opts.config_path
all_findings.extend(
collect_filesystem_findings(
state_dir=state_dir,
config_path=config_path,
)
)
secrets_report: dict[str, Any] | None = None
if opts.include_secrets_audit:
secrets_report = await _run_secrets_audit_wrapper(
opts.config,
providers=opts.secrets_providers,
env_path=opts.env_path,
)
if opts.include_ssrf_security:
all_findings.extend(collect_ssrf_findings(opts.config))
if opts.include_plugin_security:
all_findings.extend(collect_plugin_security_findings(opts.config))
if opts.include_approval_security:
all_findings.extend(collect_approval_findings(opts.config))
if opts.include_message_security:
all_findings.extend(collect_message_security_findings(opts.config))
if opts.include_secrets_management:
all_findings.extend(collect_secrets_management_findings(opts.config))
if opts.deep:
all_findings.extend(collect_sandbox_probes())
all_findings.extend(collect_env_secrets_exposure(opts.env or {}))
summary = _count_summary(all_findings)
score = summary.score
grade = summary.grade
report = SecurityAuditReport(
summary=summary,
findings=all_findings,
secrets_report=secrets_report,
score=score,
grade=grade,
)
return report
async def run_security_audit_simple(
config: dict[str, Any],
providers: dict[SecretSource, SecretsProvider] | None = None,
state_dir: str | Path | None = None,
config_path: str | Path | None = None,
) -> SecurityAuditReport:
return await run_security_audit(
SecurityAuditOptions(
config=config,
secrets_providers=providers,
state_dir=state_dir,
config_path=config_path,
)
)
# ── Exports ───────────────────────────────────────────────────────────
__all__ = [
"CRITICAL",
"WARN",
"INFO",
"SecurityAuditFinding",
"SecurityAuditReport",
"SecurityAuditSummary",
"SecurityAuditOptions",
"SecurityAuditSeverity",
"run_security_audit",
"run_security_audit_simple",
"collect_exposure_findings",
"collect_exec_security_findings",
"collect_gateway_http_findings",
"collect_filesystem_findings",
"collect_rate_limit_findings",
"collect_pairing_findings",
"collect_cross_channel_consistency_findings",
"collect_logging_findings",
"collect_sandbox_probes",
"collect_env_secrets_exposure",
"collect_ssrf_findings",
"collect_plugin_security_findings",
"collect_approval_findings",
"collect_message_security_findings",
"collect_secrets_management_findings",
"collect_identity_link_findings",
]