ForcePilot/backend/package/yuxi/channels/adapters/irc/security.py
Kris 2dd1a075f2 feat(irc): 实现完整的IRC适配器基础功能模块
新增IRC协议相关的全套工具模块,包括:
- 核心协议解析与CTCP处理
- 消息发送缓存与文本 sanitize
- 账号配置管理与运行时状态
- 命令处理与权限控制
- 服务发现与诊断工具
- 多账号网关与配置加载
2026-05-12 00:45:10 +08:00

344 lines
11 KiB
Python

from __future__ import annotations
import re
import uuid
from dataclasses import dataclass, field
from yuxi.utils.logging_config import logger
from .normalize import build_allowlist_candidates, resolve_allowlist_match
from .protocol import parse_irc_prefix
class DMPolicy:
PAIRING = "pairing"
OPEN = "open"
DISABLED = "disabled"
class SecurityConstants:
DM_POLICY = DMPolicy
PAIRING = DMPolicy.PAIRING
OPEN = "open"
DISABLED = "disabled"
ALLOWLIST = "allowlist"
def _matches_mention(text: str, nick: str) -> bool:
pattern = rf"\b{escape_irc_regex_literal(nick)}\b[:,]?"
return bool(re.search(pattern, text))
def escape_irc_regex_literal(text: str) -> str:
if not text:
return ""
escaped = re.escape(text)
return escaped
def _resolve_channel_config(
channel: str,
groups: dict,
global_allow_from: list[str],
global_policy: str,
) -> tuple[str, list[str], dict]:
exact = groups.get(channel)
if exact is None:
for key, val in groups.items():
if isinstance(key, str) and key.lower() == channel.lower():
exact = val
break
wildcard = groups.get("*")
resolved_allow_from = list(global_allow_from) if global_allow_from else []
if isinstance(exact, dict):
channel_allow = exact.get("allowFrom")
if channel_allow is not None:
resolved_allow_from = list(channel_allow)
resolved_policy = global_policy
if isinstance(exact, dict) and exact.get("groupPolicy"):
resolved_policy = exact["groupPolicy"]
resolved_require_mention = True
if isinstance(exact, dict) and "requireMention" in exact:
resolved_require_mention = exact["requireMention"]
elif isinstance(wildcard, dict) and "requireMention" in wildcard:
resolved_require_mention = wildcard["requireMention"]
resolved_skills = None
if isinstance(exact, dict) and exact.get("skills"):
resolved_skills = exact["skills"]
resolved_system_prompt: str | None = None
if isinstance(exact, dict) and exact.get("systemPrompt"):
resolved_system_prompt = exact["systemPrompt"]
resolved_enabled = True
if isinstance(exact, dict) and "enabled" in exact:
resolved_enabled = exact["enabled"]
resolved_channel_config = {
"requireMention": resolved_require_mention,
"skills": resolved_skills,
"systemPrompt": resolved_system_prompt,
"enabled": resolved_enabled,
}
return resolved_policy, resolved_allow_from, resolved_channel_config
@dataclass
class SecurityCheckResult:
allowed: bool
reason: str = ""
challenge_id: str | None = None
matched_entry: str | None = None
@dataclass
class IRCSecurityConfig:
dm_policy: str = "pairing"
group_policy: str = "allowlist"
allow_from: list[str] = field(default_factory=list)
group_allow_from: list[str] = field(default_factory=list)
groups: dict = field(default_factory=dict)
dangerously_allow_name_matching: bool = False
group_allow_from_fallback_to_allow_from: bool = False
def check_dm(
self,
sender_prefix: str | None,
sender_nick: str,
) -> SecurityCheckResult:
if self.dm_policy == DMPolicy.OPEN:
return SecurityCheckResult(allowed=True, reason="dm policy: open")
if self.dm_policy == DMPolicy.DISABLED:
return SecurityCheckResult(allowed=False, reason="dm policy: disabled")
if not self.allow_from:
if self.dm_policy == DMPolicy.PAIRING:
challenge_id = str(uuid.uuid4())
logger.info(f"IRC DM pairing challenge issued for {sender_nick}: {challenge_id}")
return SecurityCheckResult(
allowed=False,
reason="dm policy: pairing challenge required",
challenge_id=challenge_id,
)
return SecurityCheckResult(allowed=False, reason="dm policy: allowlist empty")
prefix_info = parse_irc_prefix(sender_prefix)
candidates = build_allowlist_candidates(
sender_nick,
prefix_info.get("user") or None,
prefix_info.get("host") or None,
)
if self.dangerously_allow_name_matching and sender_nick in self.allow_from:
return SecurityCheckResult(
allowed=True,
reason=f"dm allowlist match (name): {sender_nick}",
matched_entry=sender_nick,
)
match = resolve_allowlist_match(candidates, self.allow_from)
if match:
return SecurityCheckResult(
allowed=True,
reason=f"dm allowlist match: {match}",
matched_entry=match,
)
logger.warning(f"IRC DM blocked from {sender_nick}, candidates: {candidates}, allowlist: {self.allow_from}")
return SecurityCheckResult(allowed=False, reason="dm not in allowlist")
def check_group(
self,
channel: str,
sender_prefix: str | None,
sender_nick: str,
) -> tuple[SecurityCheckResult, dict]:
channel_policy, allow_from, resolved_config = _resolve_channel_config(
channel, self.groups, self.group_allow_from, self.group_policy
)
if not resolved_config.get("enabled", True):
return (
SecurityCheckResult(allowed=False, reason=f"channel {channel} disabled"),
resolved_config,
)
if channel_policy == "open":
result = SecurityCheckResult(allowed=True, reason=f"group policy: open for {channel}")
if not resolved_config.get("requireMention", True):
result.reason += " (mention not required)"
return result, resolved_config
if channel_policy == "disabled":
return (
SecurityCheckResult(
allowed=False,
reason=f"group policy: disabled for {channel}",
),
resolved_config,
)
if not allow_from:
if self.group_allow_from_fallback_to_allow_from and self.allow_from:
allow_from = list(self.allow_from)
else:
return (
SecurityCheckResult(
allowed=False,
reason=f"group allowlist empty for {channel}",
),
resolved_config,
)
prefix_info = parse_irc_prefix(sender_prefix)
candidates = build_allowlist_candidates(
sender_nick,
prefix_info.get("user") or None,
prefix_info.get("host") or None,
)
if self.dangerously_allow_name_matching and sender_nick in allow_from:
return (
SecurityCheckResult(
allowed=True,
reason=f"group allowlist match (name) for {channel}: {sender_nick}",
matched_entry=sender_nick,
),
resolved_config,
)
match = resolve_allowlist_match(candidates, allow_from)
if match:
return (
SecurityCheckResult(
allowed=True,
reason=f"group allowlist match for {channel}: {match}",
matched_entry=match,
),
resolved_config,
)
logger.warning(
f"IRC group blocked from {sender_nick} in {channel}, candidates: {candidates}, allowlist: {allow_from}"
)
return (
SecurityCheckResult(
allowed=False,
reason=f"not in group allowlist for {channel}",
),
resolved_config,
)
def check_mention(
self,
text: str,
bot_nick: str,
_channel: str = "",
resolved_config: dict | None = None,
) -> bool:
require_mention = True
if resolved_config:
require_mention = resolved_config.get("requireMention", True)
if not require_mention:
return True
mentioned = _matches_mention(text, bot_nick)
if not mentioned:
logger.debug(f"IRC mention gate blocked: '{text[:80]}...'")
return mentioned
def collect_security_warnings(
config: IRCSecurityConfig | None,
use_tls: bool = True,
nickserv_enabled: bool = False,
) -> list[str]:
warnings: list[str] = []
if not use_tls:
warnings.append("TLS is disabled — connection is not encrypted")
if not nickserv_enabled:
warnings.append("NickServ is not enabled — nick may not be reserved")
if config is not None:
if config.group_policy == "open":
warnings.append("Group policy is 'open' — anyone can trigger bot in channels")
mutable_entries = _find_mutable_allowlist_entries(config)
for entry in mutable_entries:
warnings.append(f"Mutable allowlist entry detected: '{entry}' — consider using nick!user@host format")
return warnings
def _find_mutable_allowlist_entries(config: IRCSecurityConfig) -> list[str]:
mutable: list[str] = []
for entry in config.allow_from:
if entry == "*":
continue
if "!" not in entry and "@" not in entry:
mutable.append(entry)
for entry in config.group_allow_from:
if "!" not in entry and "@" not in entry:
mutable.append(entry)
for channel_name, channel_config in config.groups.items():
if isinstance(channel_config, dict):
for entry in channel_config.get("allowFrom", []):
if "!" not in entry and "@" not in entry:
mutable.append(entry)
return mutable
def resolve_irc_group_sender_allowed(
channel: str,
sender_prefix: str | None,
sender_nick: str,
groups: dict,
group_allow_from: list[str],
group_policy: str,
) -> tuple[bool, str, str | None]:
prefix_info = parse_irc_prefix(sender_prefix)
candidates = build_allowlist_candidates(
sender_nick,
prefix_info.get("user") or None,
prefix_info.get("host") or None,
)
exact = groups.get(channel)
if exact is None:
for key, val in groups.items():
if isinstance(key, str) and key.lower() == channel.lower():
exact = val
break
if isinstance(exact, dict) and exact.get("allowFrom"):
match = resolve_allowlist_match(candidates, list(exact["allowFrom"]))
if match:
return True, "group_inner_allowlist", match
if group_allow_from:
match = resolve_allowlist_match(candidates, list(group_allow_from))
if match:
return True, "group_outer_allowlist", match
if group_policy == "open":
return True, "group_policy_open", None
channel_policy = group_policy
if isinstance(exact, dict) and exact.get("groupPolicy"):
channel_policy = exact["groupPolicy"]
if channel_policy == "open":
return True, "channel_policy_open", None
return False, "blocked", None