diff --git a/TODO.md b/TODO.md index 1da4feb..72c19f4 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/src/main/java/com/loki/lochat/commands/admin/system/LoChatCommand.java b/src/main/java/com/loki/lochat/commands/admin/system/LoChatCommand.java index 201c792..41de207 100644 --- a/src/main/java/com/loki/lochat/commands/admin/system/LoChatCommand.java +++ b/src/main/java/com/loki/lochat/commands/admin/system/LoChatCommand.java @@ -31,6 +31,7 @@ public class LoChatCommand implements CommandExecutor, TabCompleter { sender.sendMessage(ChatFormatter.parse("ẲE9LoChat &#B798A8v" + plugin.getPluginMeta().getVersion())); sender.sendMessage(ChatFormatter.parse("⚖C9/lochat reload &#B798A8— перезагрузить конфиги")); sender.sendMessage(ChatFormatter.parse("⚖C9/lochat commands [reload] &#B798A8— кастомные команды")); + sender.sendMessage(ChatFormatter.parse("⚖C9/lochat filterstats &#B798A8— статистика фильтров чата")); return true; } switch (args[0].toLowerCase()) { @@ -49,6 +50,23 @@ public class LoChatCommand implements CommandExecutor, TabCompleter { ChatFormatter.parse("ẲE9/" + 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Фильтры чата: ⚖C9заблокировано ẲE9" + total + "⚖C9 сообщений")); + if (total == 0) { + sender.sendMessage(ChatFormatter.parse("&#B798A8Пока нечего показывать")); + break; + } + stats.snapshot().forEach((name, count) -> sender.sendMessage( + ChatFormatter.parse("ẲE9" + name + ":⚖C9 " + 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"); diff --git a/src/main/java/com/loki/lochat/core/filter/AdvancedMessageFilter.java b/src/main/java/com/loki/lochat/core/filter/AdvancedMessageFilter.java index 6560a02..6b55519 100644 --- a/src/main/java/com/loki/lochat/core/filter/AdvancedMessageFilter.java +++ b/src/main/java/com/loki/lochat/core/filter/AdvancedMessageFilter.java @@ -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; + } + /** * Очистка данных игрока при выходе */ diff --git a/src/main/java/com/loki/lochat/core/filter/FilterStatistics.java b/src/main/java/com/loki/lochat/core/filter/FilterStatistics.java new file mode 100644 index 0000000..ee5d251 --- /dev/null +++ b/src/main/java/com/loki/lochat/core/filter/FilterStatistics.java @@ -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 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 snapshot() { + return blocked.entrySet().stream() + .sorted(Map.Entry.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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/core/filter/filters/SwearFilter.java b/src/main/java/com/loki/lochat/core/filter/filters/SwearFilter.java index 9035c28..429f334 100644 --- a/src/main/java/com/loki/lochat/core/filter/filters/SwearFilter.java +++ b/src/main/java/com/loki/lochat/core/filter/filters/SwearFilter.java @@ -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)); + } } diff --git a/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java b/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java index eaa4086..7cb1cfd 100644 --- a/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java +++ b/src/main/java/com/loki/lochat/utils/text/AhoCorasick.java @@ -9,43 +9,6 @@ import java.util.Map; public final class AhoCorasick { - private static final Map 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 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 { diff --git a/src/main/java/com/loki/lochat/utils/text/SwearNormalizer.java b/src/main/java/com/loki/lochat/utils/text/SwearNormalizer.java new file mode 100644 index 0000000..7b93931 --- /dev/null +++ b/src/main/java/com/loki/lochat/utils/text/SwearNormalizer.java @@ -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 SUBSTITUTIONS = new HashMap<>(); + + /** + * Символы-разделители, которые вырезаются внутри слова (точки, дефисы, пробелы и т.п.). + * Не должно пересекаться с ключами SUBSTITUTIONS. + */ + private static final Set NOISE_CHARS = new HashSet<>(); + + /** + * Обратная карта: каноническая буква → все сырые варианты её написания (включая саму букву). + */ + private static final Map> VARIANTS = new HashMap<>(); + + private static final Map 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 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()); + } +} \ No newline at end of file diff --git a/src/test/java/com/loki/lochat/core/filter/FilterStatisticsTest.java b/src/test/java/com/loki/lochat/core/filter/FilterStatisticsTest.java new file mode 100644 index 0000000..e2093bb --- /dev/null +++ b/src/test/java/com/loki/lochat/core/filter/FilterStatisticsTest.java @@ -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 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()); + } +} \ No newline at end of file diff --git a/src/test/java/com/loki/lochat/utils/text/SwearNormalizerTest.java b/src/test/java/com/loki/lochat/utils/text/SwearNormalizerTest.java new file mode 100644 index 0000000..01cf679 --- /dev/null +++ b/src/test/java/com/loki/lochat/utils/text/SwearNormalizerTest.java @@ -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()); + } +} \ No newline at end of file