这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
348 lines
11 KiB
Python
348 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")
|
|
else:
|
|
warnings.append(
|
|
"NickServ authentication sends password via PRIVMSG in plaintext — prefer SASL for stronger authentication"
|
|
)
|
|
|
|
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
|