ForcePilot/backend/package/yuxi/channel/security/fix.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

239 lines
7.1 KiB
Python

from __future__ import annotations
import logging
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_O_NOFOLLOW = os.O_NOFOLLOW if hasattr(os, "O_NOFOLLOW") else 0
_HAS_FCHMOD = hasattr(os, "fchmod")
@dataclass
class SecurityFixResult:
status: str = "ok"
path: str = ""
changes: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@property
def ok(self) -> bool:
return self.status == "ok" and len(self.errors) == 0
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"path": self.path,
"changes": self.changes,
"errors": self.errors,
}
@dataclass
class SecurityPermissionTarget:
path: Path
mode: int
expected_type: str
def _safe_chmod(path: Path, mode: int, expected_type: str) -> SecurityFixResult:
result = SecurityFixResult(path=str(path))
try:
fd = os.open(path, os.O_RDONLY | _O_NOFOLLOW)
except FileNotFoundError:
result.status = "skip"
result.errors.append(f"{path}: file not found")
return result
except PermissionError:
result.status = "skip"
result.errors.append(f"{path}: permission denied reading stat")
return result
except OSError as e:
result.status = "error"
result.errors.append(f"{path}: open failed ({e})")
return result
try:
st = os.fstat(fd)
except OSError as e:
os.close(fd)
result.status = "error"
result.errors.append(f"{path}: fstat failed ({e})")
return result
is_dir = stat.S_ISDIR(st.st_mode)
is_file = stat.S_ISREG(st.st_mode)
if expected_type == "dir" and not is_dir:
os.close(fd)
result.status = "skip"
result.errors.append(f"{path}: expected directory but is not")
return result
if expected_type == "file" and not is_file:
os.close(fd)
result.status = "skip"
result.errors.append(f"{path}: expected file but is not")
return result
current_mode = stat.S_IMODE(st.st_mode)
if (current_mode & 0o777) == mode:
os.close(fd)
result.status = "ok"
result.changes.append(f"{path}: mode already {oct(mode)}")
return result
try:
if _HAS_FCHMOD:
os.fchmod(fd, mode)
else:
os.chmod(path, mode)
result.status = "ok"
result.changes.append(f"{path}: mode {oct(current_mode)} -> {oct(mode)}")
except PermissionError:
result.status = "error"
result.errors.append(f"{path}: permission denied setting mode {oct(mode)}")
except OSError as e:
result.status = "error"
result.errors.append(f"{path}: fchmod failed ({e})")
finally:
os.close(fd)
return result
def collect_security_permission_targets(
state_dir: str | Path | None = None,
config_path: str | Path | None = None,
) -> list[SecurityPermissionTarget]:
targets: list[SecurityPermissionTarget] = []
if state_dir:
p = Path(state_dir)
targets.append(SecurityPermissionTarget(path=p, mode=0o700, expected_type="dir"))
creds_dir = p / "credentials"
if creds_dir.exists() and creds_dir.is_dir():
targets.append(SecurityPermissionTarget(path=creds_dir, mode=0o700, expected_type="dir"))
oauth_dir = p / "oauth"
if oauth_dir.exists() and oauth_dir.is_dir():
targets.append(SecurityPermissionTarget(path=oauth_dir, mode=0o700, expected_type="dir"))
agents_dir = p / "agents"
if agents_dir.exists() and agents_dir.is_dir():
for agent_dir in agents_dir.iterdir():
if agent_dir.is_dir():
targets.append(SecurityPermissionTarget(path=agent_dir, mode=0o700, expected_type="dir"))
auth_file = agent_dir / "auth-profiles.json"
if auth_file.exists():
targets.append(SecurityPermissionTarget(path=auth_file, mode=0o600, expected_type="file"))
if config_path:
p = Path(config_path)
targets.append(SecurityPermissionTarget(path=p, mode=0o600, expected_type="file"))
return targets
def fix_security_permissions(
targets: list[SecurityPermissionTarget],
) -> list[SecurityFixResult]:
results: list[SecurityFixResult] = []
for target in targets:
result = _safe_chmod(target.path, target.mode, target.expected_type)
results.append(result)
if result.status == "error":
logger.warning("Security fix failed: %s", result.errors)
elif result.status == "ok" and any("->" in c for c in result.changes):
logger.info("Security fix applied: %s", result.changes)
return results
def fix_group_policy_in_config(config: dict[str, Any]) -> bool:
if not isinstance(config.get("channels"), dict):
return False
fixed = 0
channels = config["channels"]
def _fix(scope: dict):
nonlocal fixed
if not isinstance(scope, dict):
return
for key, value in scope.items():
if key == "groupPolicy" and value == "open":
scope[key] = "allowlist"
fixed += 1
elif isinstance(value, dict):
_fix(value)
for channel_id, channel_value in list(channels.items()):
if isinstance(channel_value, dict):
_fix(channel_value)
if fixed > 0:
logger.info("Fixed %d groupPolicy=open -> allowlist", fixed)
return fixed > 0
def fix_logging_redact_in_config(config: dict[str, Any]) -> bool:
logging_config = config.get("logging", {})
if isinstance(logging_config, dict) and logging_config.get("redactSensitive") == "off":
logging_config["redactSensitive"] = "tools"
logger.info("Fixed logging.redactSensitive: off -> tools")
return True
return False
def apply_security_fix_config_mutations(config: dict[str, Any]) -> bool:
mutated = fix_logging_redact_in_config(config)
mutated |= fix_group_policy_in_config(config)
return mutated
async def fix_security(
config: dict[str, Any],
state_dir: str | Path | None = None,
config_path: str | Path | None = None,
) -> list[SecurityFixResult]:
results: list[SecurityFixResult] = []
if apply_security_fix_config_mutations(config):
results.append(
SecurityFixResult(
status="ok",
path="<config>",
changes=["Applied config mutations for security hardening"],
)
)
targets = collect_security_permission_targets(
state_dir=state_dir,
config_path=config_path,
)
perm_results = fix_security_permissions(targets)
results.extend(perm_results)
return results
__all__ = [
"SecurityFixResult",
"SecurityPermissionTarget",
"collect_security_permission_targets",
"fix_security_permissions",
"fix_group_policy_in_config",
"fix_logging_redact_in_config",
"apply_security_fix_config_mutations",
"fix_security",
]