新增一系列安全相关功能: 1. 新增二维码生成工具,支持自定义参数导出图片/Base64/字节流 2. 新增HTML内容安全处理工具,包括标签剥离、转义、URL校验 3. 新增日志敏感信息脱敏工具,支持配置、字典、日志记录脱敏 4. 新增密钥管理运行时,支持从环境变量/文件加载密钥 5. 新增身份链接管理,支持多渠道身份绑定与解析 6. 新增SSRF防护工具,支持域名/IP校验与固定主机 7. 新增安全权限修复工具,修复文件目录权限与配置项 8. 新增外部内容安全处理,支持LLM特殊令牌剥离与注入检测 9. 新增认证限流工具,支持多维度限流与本地回环豁免 10. 新增配对管理工具,支持安全配对码生成与校验 11. 新增白名单管理工具,支持DM/群组/来源白名单校验
1492 lines
57 KiB
Python
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",
|
|
]
|