diff --git a/src/config.rs b/src/config.rs index 4b7d5ee..d2df833 100644 --- a/src/config.rs +++ b/src/config.rs @@ -106,6 +106,10 @@ pub struct Security { // broken-client quit flood, mass-join, nick-change flood). #[serde(default)] pub behavior: BehaviorRules, + // Content heuristics on channel messages echo sees (a bot is present) — the + // additive ones the kickers don't do: highlight-spam (mass-ping). + #[serde(default)] + pub content: ContentRules, } #[derive(Debug, Deserialize, Clone)] @@ -290,6 +294,53 @@ fn sec_quit_reasons() -> Vec { vec!["Excess Flood".to_string(), "Max SendQ exceeded".to_string()] } +#[derive(Debug, Deserialize, Clone)] +pub struct ContentRules { + #[serde(default = "sec_true")] + pub enabled: bool, + // A single message that mentions >= highlight_nicks distinct channel members + // (each nick at least highlight_min_len chars, to skip trivial nicks) is a + // highlight-spam (mass-ping) message. + #[serde(default = "sec_hl_nicks")] + pub highlight_nicks: u32, + #[serde(default = "sec_hl_min_len")] + pub highlight_min_len: u32, + // > highlight_permit such messages from one user within highlight_life s trips. + #[serde(default = "sec_hl_permit")] + pub highlight_permit: u32, + #[serde(default = "sec_hl_life")] + pub highlight_life: u64, + // Seconds the auto G-line lasts when armed (0 = kill the connection only). + #[serde(default = "sec_conn_ban")] + pub ban_duration: u64, +} + +impl Default for ContentRules { + fn default() -> Self { + Self { + enabled: sec_true(), + highlight_nicks: sec_hl_nicks(), + highlight_min_len: sec_hl_min_len(), + highlight_permit: sec_hl_permit(), + highlight_life: sec_hl_life(), + ban_duration: sec_conn_ban(), + } + } +} + +fn sec_hl_nicks() -> u32 { + 6 +} +fn sec_hl_min_len() -> u32 { + 3 +} +fn sec_hl_permit() -> u32 { + 1 +} +fn sec_hl_life() -> u64 { + 15 +} + #[derive(Debug, Deserialize, Clone)] pub struct Language { // Reply language for users who haven't picked one (a code like "en" or "fr"). diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index a42f3a5..d1900e6 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -64,6 +64,9 @@ impl Engine { } } } + // Native anti-abuse content screening (highlight-spam) on channel lines + // echo can see — the additive heuristics the kickers lack. + ctx.actions.extend(self.security_screen_message(from, to, text)); // Record activity in bot channels (surfaced by StatServ). if self.db.channel(to).is_some_and(|c| c.assigned_bot.is_some()) { self.network.record_line(to, &nick, text); diff --git a/src/engine/security/mod.rs b/src/engine/security/mod.rs index 60ad253..1d888aa 100644 --- a/src/engine/security/mod.rs +++ b/src/engine/security/mod.rs @@ -176,6 +176,26 @@ fn quit_matches(reason: &str, markers: &[String]) -> bool { markers.iter().any(|m| !m.is_empty() && r.contains(&m.to_ascii_lowercase())) } +// How many distinct member nicks (already lowercased + length-filtered) `text` +// mentions as whole words — the mass-ping / highlight-spam signal. Tokenises on +// non-nick characters so a nick that is merely a substring of a word doesn't count. +fn count_highlights(text: &str, member_nicks_lc: &std::collections::HashSet) -> usize { + if member_nicks_lc.is_empty() { + return 0; + } + let mut hit = std::collections::HashSet::new(); + for tok in text.split(|c: char| !(c.is_alphanumeric() || "[]{}\\`|^_-".contains(c))) { + if tok.is_empty() { + continue; + } + let low = tok.to_ascii_lowercase(); + if member_nicks_lc.contains(&low) { + hit.insert(low); + } + } + hit.len() +} + // The coarse aggregation key for an address — the /24 for IPv4, the /64 for IPv6 // — used to catch clone floods spread across a subnet. fn cidr_of(ip: &str) -> String { @@ -370,6 +390,44 @@ impl super::Engine { } Vec::new() } + + // Content rules, if the subsystem and the content detectors are both on. + fn content_rules(&self) -> Option { + let c = self.security.cfg.as_ref()?; + (c.enabled && c.content.enabled).then(|| c.content.clone()) + } + + // Distinct channel members (nick at least `min_len` chars) this line pings. + fn channel_highlights(&self, channel: &str, text: &str, min_len: usize) -> usize { + let uids: Vec = self.network.channel_members(channel).map(|u| u.to_string()).collect(); + let nicks: std::collections::HashSet = uids + .iter() + .filter_map(|u| self.network.nick_of(u)) + .filter(|n| n.chars().count() >= min_len) + .map(|n| n.to_ascii_lowercase()) + .collect(); + count_highlights(text, &nicks) + } + + // Screen a channel message echo can see (a bot is present). The additive + // heuristic the kickers lack: highlight-spam (mass-ping). + pub(crate) fn security_screen_message(&mut self, from: &str, channel: &str, text: &str) -> Vec { + let Some(rules) = self.content_rules() else { + return Vec::new(); + }; + let Some((ip, who)) = self.security_identity(from) else { + return Vec::new(); + }; + let n = self.channel_highlights(channel, text, rules.highlight_min_len as usize); + if n >= rules.highlight_nicks as usize { + let now = self.now_secs(); + if self.security.counters.hit(&format!("hl|{from}"), now, rules.highlight_life) > rules.highlight_permit { + self.bump("security.highlight.trips"); + return self.security_act(&who, from, &format!("*@{ip}"), "highlight spam", &format!("pinged {n} users in {channel}"), rules.ban_duration); + } + } + Vec::new() + } } #[cfg(test)] @@ -424,4 +482,17 @@ mod tests { assert!(!quit_matches("Quit: brb", &m)); assert!(!quit_matches("Excess Flood", &[])); // no markers => never } + + #[test] + fn highlight_counting() { + use std::collections::HashSet; + let members: HashSet = ["alice", "bob", "carol", "dave"].iter().map(|s| s.to_string()).collect(); + // Distinct member nicks as whole words are counted once each. + assert_eq!(count_highlights("hey ALICE bob carol!! bob", &members), 3); + // A nick that's only a substring of a longer word does not count. + assert_eq!(count_highlights("aliceish bobcat", &members), 0); + // Non-members are ignored. + assert_eq!(count_highlights("alice eve mallory", &members), 1); + assert_eq!(count_highlights("anything at all", &HashSet::new()), 0); + } }