feat: anti-swear obfuscation bypass + filter statistics

- SwearNormalizer: expanded leet/lookalike map (q→р, digits), strips separators inside
  words, collapses repeated letters; single source of truth for AhoCorasick
- SwearFilter: detects obfuscated profanity on normalized text, masks tolerant matches
  in replace mode via tolerant word patterns
- FilterStatistics: per-filter block counters -> /lochat filterstats subcommand
- tests: SwearNormalizerTest, FilterStatisticsTest
This commit is contained in:
loki5512344 2026-08-09 21:17:18 +02:00
parent cda89c78e4
commit 689ab1a9ae
Signed by: boba
GPG key ID: 253067914055423B
9 changed files with 435 additions and 46 deletions

View file

@ -72,9 +72,9 @@
- Party chat: private groups `/party`.
- Network channels (Bungee/Velocity) sync.
### 2. Anti-swear hardening + filter statistics
- Bypass obfuscation: `q→р` leet/Unicode substitution, repeated chars between letters, spaces/dots inside words.
- Track filter stats (how many messages blocked per filter) → expose via command/placeholder.
### 2. Anti-swear hardening + filter statistics ✅
- Obfuscation bypass: `SwearNormalizer` (q→р / leet-подстановки, повторы букв «сууука», разделители внутри слова «с.у.к.а» / «с у к а») + tolerant-маскирование в replace-режиме. Single source of truth для Aho-Corasick.
- Filter stats: `FilterStatistics` считает блокировки по каждому фильтру → `/lochat filterstats`.
### 3. Stable plugin API + API events
- Publish `api/` modules to a repo so third-party plugins can depend on it.

View file

@ -31,6 +31,7 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(ChatFormatter.parse("&#7858E9LoChat &#B798A8v" + plugin.getPluginMeta().getVersion()));
sender.sendMessage(ChatFormatter.parse("&#9878C9/lochat reload &#B798A8— перезагрузить конфиги"));
sender.sendMessage(ChatFormatter.parse("&#9878C9/lochat commands [reload] &#B798A8— кастомные команды"));
sender.sendMessage(ChatFormatter.parse("&#9878C9/lochat filterstats &#B798A8— статистика фильтров чата"));
return true;
}
switch (args[0].toLowerCase()) {
@ -49,6 +50,23 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
ChatFormatter.parse("&#7858E9/" + c.name() + " &#B798A8(" + c.type() + ")")));
}
}
case "filterstats" -> {
var filter = plugin.getChatFilter();
if (filter == null) {
sender.sendMessage(ChatFormatter.parse("&#CF6679Фильтр чата недоступен"));
break;
}
var stats = filter.getStatistics();
long total = stats.getTotalBlocked();
sender.sendMessage(ChatFormatter.parse(
"&#B798A8Фильтры чата: &#9878C9заблокировано &#7858E9" + total + "&#9878C9 сообщений"));
if (total == 0) {
sender.sendMessage(ChatFormatter.parse("&#B798A8Пока нечего показывать"));
break;
}
stats.snapshot().forEach((name, count) -> sender.sendMessage(
ChatFormatter.parse("&#7858E9" + name + ":&#9878C9 " + count)));
}
default -> sender.sendMessage(ChatFormatter.parse("&#CF6679Неизвестная команда"));
}
return true;
@ -63,7 +81,8 @@ public class LoChatCommand implements CommandExecutor, TabCompleter {
return new ArrayList<>();
}
if (args.length == 1) {
return List.of("reload", "commands").stream().filter(s -> s.startsWith(args[0].toLowerCase())).toList();
return List.of("reload", "commands", "filterstats").stream()
.filter(s -> s.startsWith(args[0].toLowerCase())).toList();
}
if (args.length == 2 && args[0].equalsIgnoreCase("commands")) {
return List.of("reload");

View file

@ -26,6 +26,7 @@ public class AdvancedMessageFilter {
private final SpamFilter spamFilter;
private final UrlFilter urlFilter;
private final CharacterFilter characterFilter;
private final FilterStatistics statistics = new FilterStatistics();
public AdvancedMessageFilter(FileConfiguration config, JavaPlugin plugin) {
this.filtersConfig = resolveFiltersConfig(plugin);
@ -72,6 +73,7 @@ public class AdvancedMessageFilter {
if (filtersConfig.isCapsEnabled()) {
String filtered = capsFilter.filter(player, message);
if (filtered == null) {
statistics.increment("caps");
return FilterResult.blocked("&#CF6679Не кричите в чате");
}
message = filtered;
@ -81,6 +83,7 @@ public class AdvancedMessageFilter {
if (filtersConfig.isSwearEnabled() && !canBypassFilter(player, "swear")) {
FilterResult swear = swearFilter.filter(player, message);
if (!swear.allowed()) {
statistics.increment("swear");
return swear;
}
message = swear.filteredMessage();
@ -90,12 +93,14 @@ public class AdvancedMessageFilter {
if (filtersConfig.isAdvertisingEnabled()) {
FilterResult r = urlFilter.filterUrls(player, message);
if (!r.allowed()) {
statistics.increment("url");
return r;
}
message = r.filteredMessage();
r = urlFilter.filterHiddenUrls(player, message);
if (!r.allowed()) {
statistics.increment("hidden-url");
return r;
}
}
@ -104,6 +109,7 @@ public class AdvancedMessageFilter {
if (filtersConfig.isIpEnabled()) {
FilterResult r = urlFilter.filterIPs(player, message);
if (!r.allowed()) {
statistics.increment("ip");
return r;
}
message = r.filteredMessage();
@ -118,6 +124,7 @@ public class AdvancedMessageFilter {
if (filtersConfig.isFloodEnabled() && !canBypassFilter(player, "flood")) {
FilterResult r = floodFilter.filter(player);
if (!r.allowed()) {
statistics.increment("flood");
return r;
}
}
@ -126,6 +133,7 @@ public class AdvancedMessageFilter {
if (filtersConfig.isSpamEnabled() && !canBypassFilter(player, "spam")) {
FilterResult r = spamFilter.filter(player, message);
if (!r.allowed()) {
statistics.increment("spam");
return r;
}
}
@ -133,6 +141,13 @@ public class AdvancedMessageFilter {
return FilterResult.ok(message);
}
/**
* Статистика блокировок по фильтрам (с момента запуска).
*/
public FilterStatistics getStatistics() {
return statistics;
}
/**
* Очистка данных игрока при выходе
*/

View file

@ -0,0 +1,43 @@
package com.loki.lochat.core.filter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
/**
* Счётчики заблокированных сообщений по каждому фильтру (с момента запуска / сброса).
*/
public final class FilterStatistics {
private final Map<String, LongAdder> blocked = new ConcurrentHashMap<>();
public void increment(String filterType) {
blocked.computeIfAbsent(filterType, k -> new LongAdder()).increment();
}
public long getBlocked(String filterType) {
LongAdder counter = blocked.get(filterType);
return counter == null ? 0L : counter.sum();
}
public long getTotalBlocked() {
return blocked.values().stream().mapToLong(LongAdder::sum).sum();
}
/**
* Снимок счётчиков, отсортированный по убыванию (заблокировавших больше всего — первыми).
*/
public Map<String, Long> snapshot() {
return blocked.entrySet().stream()
.sorted(Map.Entry.<String, LongAdder>comparingByValue(
(a, b) -> Long.compare(b.sum(), a.sum())))
.collect(LinkedHashMap::new,
(m, e) -> m.put(e.getKey(), e.getValue().sum()),
LinkedHashMap::putAll);
}
public void reset() {
blocked.clear();
}
}

View file

@ -3,6 +3,7 @@ package com.loki.lochat.core.filter.filters;
import com.loki.lochat.config.FiltersConfig;
import com.loki.lochat.core.filter.FilterResult;
import com.loki.lochat.utils.text.AhoCorasick;
import com.loki.lochat.utils.text.SwearNormalizer;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
@ -13,17 +14,16 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
public class SwearFilter {
private final boolean enabled;
private final boolean replaceMode;
private final String replacement;
private AhoCorasick ahoCorasick;
public SwearFilter(FiltersConfig filters, JavaPlugin plugin) {
this.enabled = filters.isSwearEnabled();
this.replaceMode = "replace".equals(filters.getSwearAction());
this.replacement = filters.getSwearReplacementChar();
if (enabled) {
loadWordList(plugin);
@ -49,13 +49,30 @@ public class SwearFilter {
return FilterResult.ok(message);
}
if (ahoCorasick.matches(message)) {
String normalized = SwearNormalizer.normalizeForSearch(message);
if (ahoCorasick.matches(normalized)) {
if (replaceMode) {
return FilterResult.ok(ahoCorasick.replaceAll(message, replacement));
return FilterResult.ok(maskMatches(message, normalized));
}
return FilterResult.blocked("Сообщение содержит запрещенные слова");
}
return FilterResult.ok(message);
}
/**
* Маскирует в оригинальном сообщении каждое найденное (возможно, обфусцированное) слово.
*/
private String maskMatches(String original, String normalized) {
String result = original;
for (String word : ahoCorasick.findMatches(normalized)) {
Matcher matcher = SwearNormalizer.wordPattern(word).matcher(result);
result = matcher.replaceAll(maskFor(word.length()));
}
return result;
}
private static String maskFor(int wordLength) {
return "*".repeat(Math.max(1, wordLength));
}
}

View file

@ -9,43 +9,6 @@ import java.util.Map;
public final class AhoCorasick {
private static final Map<Character, Character> NORMALIZE = new HashMap<>();
static {
NORMALIZE.put('a', 'а');
NORMALIZE.put('A', 'А');
NORMALIZE.put('e', 'е');
NORMALIZE.put('E', 'Е');
NORMALIZE.put('o', 'о');
NORMALIZE.put('O', 'О');
NORMALIZE.put('p', 'р');
NORMALIZE.put('P', 'Р');
NORMALIZE.put('c', 'с');
NORMALIZE.put('C', 'С');
NORMALIZE.put('y', 'у');
NORMALIZE.put('Y', 'У');
NORMALIZE.put('x', 'х');
NORMALIZE.put('X', 'Х');
NORMALIZE.put('k', 'к');
NORMALIZE.put('K', 'К');
NORMALIZE.put('m', 'м');
NORMALIZE.put('M', 'М');
NORMALIZE.put('h', 'н');
NORMALIZE.put('H', 'Н');
NORMALIZE.put('n', 'н');
NORMALIZE.put('N', 'Н');
NORMALIZE.put('t', 'т');
NORMALIZE.put('T', 'Т');
NORMALIZE.put('b', 'в');
NORMALIZE.put('B', 'В');
NORMALIZE.put('@', 'а');
NORMALIZE.put('0', 'о');
NORMALIZE.put('3', 'е');
NORMALIZE.put('1', 'и');
NORMALIZE.put('6', 'б');
}
private final Node root;
public AhoCorasick(List<String> words) {
@ -150,7 +113,7 @@ public final class AhoCorasick {
}
private static char normalize(char c) {
return NORMALIZE.getOrDefault(c, Character.toLowerCase(c));
return SwearNormalizer.normalize(c);
}
private static final class Node {

View file

@ -0,0 +1,196 @@
package com.loki.lochat.utils.text;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
/**
* Нормализация поискового текста для anti-swear.
* Борется с обфускацией: leet/substitution (q→р, 0→о), повторами букв (сууука),
* разделителями внутри слова (с.у.к.а, с у к а).
*/
public final class SwearNormalizer {
/**
* Таблица подстановок: «сырой» символ → каноническая (нижний регистр) русская буква.
* Latin-кириллические похожие (a→а, e→е, p→р...) и leet-цифры/символы.
*/
private static final Map<Character, Character> SUBSTITUTIONS = new HashMap<>();
/**
* Символы-разделители, которые вырезаются внутри слова (точки, дефисы, пробелы и т.п.).
* Не должно пересекаться с ключами SUBSTITUTIONS.
*/
private static final Set<Character> NOISE_CHARS = new HashSet<>();
/**
* Обратная карта: каноническая буква → все сырые варианты её написания (включая саму букву).
*/
private static final Map<Character, Set<Character>> VARIANTS = new HashMap<>();
private static final Map<String, Pattern> PATTERN_CACHE = new ConcurrentHashMap<>();
static {
// Latin-кириллические омоглифы
put('a', 'а');
put('e', 'е');
put('o', 'о');
put('p', 'р');
put('c', 'с');
put('y', 'у');
put('x', 'х');
put('k', 'к');
put('m', 'м');
put('h', 'н');
put('n', 'н');
put('t', 'т');
put('b', 'в');
put('r', 'г');
put('d', 'д');
put('u', 'у');
put('g', 'г');
put('i', 'и');
put('l', 'л');
put('s', 'с');
put('z', 'з');
put('q', 'р');
// Leet-цифры и символы
put('@', 'а');
put('0', 'о');
put('3', 'е');
put('1', 'и');
put('6', 'б');
put('4', 'а');
put('5', 'с');
put('7', 'т');
put('8', 'б');
put('9', 'г');
put('2', 'з');
put('$', 'с');
put('!', 'и');
put('|', 'л');
NOISE_CHARS.add(' ');
NOISE_CHARS.add('\t');
NOISE_CHARS.add('\n');
NOISE_CHARS.add('\r');
NOISE_CHARS.add('.');
NOISE_CHARS.add(',');
NOISE_CHARS.add(';');
NOISE_CHARS.add(':');
NOISE_CHARS.add('?');
NOISE_CHARS.add('-');
NOISE_CHARS.add('_');
NOISE_CHARS.add('*');
NOISE_CHARS.add('+');
NOISE_CHARS.add('/');
NOISE_CHARS.add('\\');
NOISE_CHARS.add('~');
NOISE_CHARS.add('^');
NOISE_CHARS.add('#');
NOISE_CHARS.add('&');
NOISE_CHARS.add('%');
NOISE_CHARS.add('=');
NOISE_CHARS.add('\'');
NOISE_CHARS.add('"');
NOISE_CHARS.add('`');
NOISE_CHARS.add('(');
NOISE_CHARS.add(')');
NOISE_CHARS.add('[');
NOISE_CHARS.add(']');
NOISE_CHARS.add('{');
NOISE_CHARS.add('}');
NOISE_CHARS.add('<');
NOISE_CHARS.add('>');
NOISE_CHARS.add('·');
NOISE_CHARS.add('«');
NOISE_CHARS.add('»');
NOISE_CHARS.add('„');
NOISE_CHARS.add('“');
NOISE_CHARS.add('”');
NOISE_CHARS.add('…');
}
private SwearNormalizer() {
}
private static void put(char raw, char letter) {
SUBSTITUTIONS.put(raw, letter);
VARIANTS.computeIfAbsent(letter, k -> new HashSet<>()).add(raw);
VARIANTS.computeIfAbsent(letter, k -> new HashSet<>()).add(letter);
}
/**
* Нормализация одного символа: нижний регистр + замена омоглифов/leet.
*/
public static char normalize(char c) {
char lower = Character.toLowerCase(c);
return SUBSTITUTIONS.getOrDefault(lower, lower);
}
/**
* Приводит текст к «поисковому» виду: подстановки + удаление разделителей + схлопывание
* повторяющихся букв. Результат — поток канонических букв, по которому можно искать слова.
*/
public static String normalizeForSearch(String text) {
StringBuilder sb = new StringBuilder(text.length());
char prev = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (NOISE_CHARS.contains(c)) {
continue;
}
char n = normalize(c);
if (n == prev) {
continue;
}
sb.append(n);
prev = n;
}
return sb.toString();
}
/**
* Строит tolerant-регулярку для поиска конкретного слова в оригинальном тексте:
* каждая буква — «один или несколько» вариантов написания, между буквами допускаются любые
* разделители (точки, пробелы и т.п.). Используется для маскирования в replace-режиме.
*/
public static Pattern wordPattern(String normalizedWord) {
return PATTERN_CACHE.computeIfAbsent(normalizedWord, SwearNormalizer::buildPattern);
}
private static Pattern buildPattern(String word) {
StringBuilder sb = new StringBuilder("(?iu)");
for (int i = 0; i < word.length(); i++) {
char letter = word.charAt(i);
Set<Character> variants = VARIANTS.getOrDefault(letter, Set.of(letter));
sb.append("(?:");
boolean first = true;
for (char v : variants) {
if (!first) {
sb.append('|');
}
sb.append(Pattern.quote(String.valueOf(v)));
first = false;
}
sb.append(")+");
if (i < word.length() - 1) {
sb.append("(?:");
boolean firstNoise = true;
for (char n : NOISE_CHARS) {
if (!firstNoise) {
sb.append('|');
}
sb.append(Pattern.quote(String.valueOf(n)));
firstNoise = false;
}
sb.append(")*");
}
}
return Pattern.compile(sb.toString());
}
}

View file

@ -0,0 +1,53 @@
package com.loki.lochat.core.filter;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FilterStatisticsTest {
@Test
void increment_countsPerFilter() {
FilterStatistics stats = new FilterStatistics();
stats.increment("swear");
stats.increment("swear");
stats.increment("flood");
assertEquals(2, stats.getBlocked("swear"));
assertEquals(1, stats.getBlocked("flood"));
assertEquals(0, stats.getBlocked("caps"));
assertEquals(3, stats.getTotalBlocked());
}
@Test
void snapshot_sortedDescending() {
FilterStatistics stats = new FilterStatistics();
stats.increment("flood");
stats.increment("swear");
stats.increment("swear");
stats.increment("spam");
stats.increment("spam");
stats.increment("spam");
Map<String, Long> snapshot = stats.snapshot();
assertEquals(3, snapshot.get("spam"));
assertEquals(2, snapshot.get("swear"));
assertEquals(1, snapshot.get("flood"));
// Первый элемент — фильтр с наибольшим количеством блокировок
String first = snapshot.keySet().iterator().next();
assertEquals("spam", first);
}
@Test
void reset_clearsCounters() {
FilterStatistics stats = new FilterStatistics();
stats.increment("swear");
stats.reset();
assertEquals(0, stats.getTotalBlocked());
assertTrue(stats.snapshot().isEmpty());
}
}

View file

@ -0,0 +1,83 @@
package com.loki.lochat.utils.text;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.regex.Matcher;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SwearNormalizerTest {
@Test
void normalizeForSearch_stripsSeparatorsInsideWord() {
assertEquals("сука", SwearNormalizer.normalizeForSearch("с.у.к.а"));
assertEquals("сука", SwearNormalizer.normalizeForSearch("с-у-к-а"));
assertEquals("сука", SwearNormalizer.normalizeForSearch("с у к а"));
}
@Test
void normalizeForSearch_collapsesRepeatedLetters() {
assertEquals("сука", SwearNormalizer.normalizeForSearch("сууука"));
assertEquals("сука", SwearNormalizer.normalizeForSearch("сссуккаа"));
}
@Test
void normalizeForSearch_appliesLeetSubstitution() {
assertEquals("сока", SwearNormalizer.normalizeForSearch("с0ка"));
assertEquals("мет", SwearNormalizer.normalizeForSearch("м3т"));
assertEquals("игра", SwearNormalizer.normalizeForSearch("1гра"));
assertEquals("пизда", SwearNormalizer.normalizeForSearch("п!зда"));
assertEquals("бан", SwearNormalizer.normalizeForSearch("6ан"));
assertEquals("хуй", SwearNormalizer.normalizeForSearch("хyй"));
assertEquals("хуй", SwearNormalizer.normalizeForSearch("ХУЙ"));
}
@Test
void normalizeForSearch_mixedObfuscation() {
assertEquals("сука", SwearNormalizer.normalizeForSearch("с-уу.к.а"));
assertEquals("сука", SwearNormalizer.normalizeForSearch("СУ К А"));
}
@Test
void normalizeForSearch_preservesCleanText() {
assertEquals("приветмир", SwearNormalizer.normalizeForSearch("привет мир"));
}
@Test
void ahoCorasick_detectsObfuscatedWord() {
AhoCorasick ac = new AhoCorasick(List.of("сука"));
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("с.у.к.а")));
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("сууука")));
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("СУ К А")));
assertTrue(ac.matches(SwearNormalizer.normalizeForSearch("эта с.у.к.а сволочь")));
assertFalse(ac.matches(SwearNormalizer.normalizeForSearch("чистое сообщение")));
}
@Test
void wordPattern_matchesObfuscatedForms() {
var pattern = SwearNormalizer.wordPattern("сука");
assertTrue(pattern.matcher("с.у.к.а").find());
assertTrue(pattern.matcher("сука").find());
assertTrue(pattern.matcher("СУУУКА").find());
assertTrue(pattern.matcher("с у к а").find());
assertTrue(pattern.matcher("это с-у-к-а сообщение").find());
}
@Test
void wordPattern_masksOriginalText() {
var pattern = SwearNormalizer.wordPattern("сука");
Matcher matcher = pattern.matcher("это с.у.к.а сообщение");
String masked = matcher.replaceAll("***");
assertEquals("это *** сообщение", masked);
}
@Test
void wordPattern_doesNotMatchSimilarWord() {
var pattern = SwearNormalizer.wordPattern("сука");
assertFalse(pattern.matcher("сучка").find());
assertFalse(pattern.matcher("сук").find());
}
}