security: highlight-spam (mass-ping) content detector on channel messages echo sees
All checks were successful
CI / check (push) Successful in 5m38s
All checks were successful
CI / check (push) Successful in 5m38s
This commit is contained in:
parent
aabb1432e2
commit
e5b793c38e
3 changed files with 125 additions and 0 deletions
|
|
@ -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<String> {
|
|||
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").
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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<config::ContentRules> {
|
||||
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<String> = self.network.channel_members(channel).map(|u| u.to_string()).collect();
|
||||
let nicks: std::collections::HashSet<String> = 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<NetAction> {
|
||||
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<String> = ["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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue