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