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