Власні правила оцінювання в SpamAssassin: регулярки, ваги і кирилиця
Дефолтний набір SpamAssassin натренований на англомовному спамі й пропускає фейкові «Нова Пошта», «ПриватБанк» та СМС-коди. Показуємо, як власними body- і meta-правилами з коректними регулярками під кирилицю точково добити те, що ruleset не бачить — без того, щоб зламати собі FP-поріг 5.0.
EvilMail Team16 липня 2026 р.10 хв читання
Лист «Ваша посилка Нова Пошта очікує оплати за адресою nova-poshta[.]pay-ua[.]top» падає у скриньку з X-Spam-Status: No, score=1.8 required=5.0. DKIM невалідний, домен зареєстрований три дні тому, у тілі — платіжний шортлінк, а SpamAssassin спокійно пропускає його в Inbox. Не тому що він поганий, а тому що його ruleset ніколи не бачив цього спаму.
Стандартний набір правил оновлюється через sa-update з каналів updates.spamassassin.org і заточений під англомовний корпус. Мережеві та структурні правила — URIBL_*, SPF_FAIL, DKIM_INVALID, FREEMAIL_* — мовно-нейтральні й працюють однаково для всіх. А от контентні, лексичні правила натреновані на англійських патернах: «viagra», «you won a prize», «wire transfer». Українська й російськомовна розсилка для них — біла пляма. І гнатися за Bayes-перенавчанням тут не найкоротший шлях: Bayes потребує сотень чистих зразків ham/spam, повільно розганяється й дає розмиті ваги. Швидше й точніше — дописати кілька власних правил як інженер, що вимірює влучність, а не як ентузіаст, що додає +5 за кожне слово.
Власні правила SpamAssassin: регулярки, ваги, кирилиця | EvilMail — EvilMail Blog
Правило коштує рівно стільки, скільки його
score
× (влучність − хиби). Далі — як будувати правила, які реально добивають те, що дефолт пропускає, і не коштують вам при цьому легітимної пошти.
Де живуть власні правила і чому окремий файл
Правила можна класти в /etc/spamassassin/local.cf, але краще завести окремий файл — наприклад /etc/spamassassin/99_local_ua.cf. Причина суто операційна: sa-update перезаписує файли зі свого каналу, але не чіпає файли, яких немає в його маніфесті. Окремий файл із префіксом 99_ завантажується останнім (файли читаються за алфавітом) і не конфліктує з апдейтами.
Після будь-якої зміни — обов'язкова валідація:
bash
spamassassin --lint
Якщо команда мовчить — синтаксис коректний. Будь-який вивід означає помилку: незакрита регулярка, дубльований score, посилання на неіснуючий subrule у meta. --lint не запускає жодного мережевого тесту, тож виконується за секунду й має бути у вас у м'язовій пам'яті перед кожним рестартом демона.
І одразу пастка, на якій втрачають години: після зміни правил демон треба перезапустити, інакше він працює зі старим набором у пам'яті.
bash
systemctl restart spamassassin # spamd
systemctl restart amavis # якщо SA викликається через amavisd-new
Анатомія правила: типи, регулярки, score
Реально потрібні п'ять типів. Синтаксис однаковий: тип ІМ'Я /регулярка/модифікатори, далі опис і оцінка.
`body` — матчить тіло листа після нормалізації: HTML знято, теги видалено, послідовності пробілів схлопнуто в один. Це головний тип для контентних правил.
`rawbody` — сире тіло з HTML як є. Потрібне, коли ви ловите саму розмітку (прихований текст, style="display:none").
`header` — матчить конкретний заголовок: header ІМ'Я From =~ /.../.
`uri` — матчить URL, витягнуті з листа, окремо від тексту навколо.
`meta` — булева комбінація інших правил. Тут народжується сигнал.
Мінімальний приклад:
body LOCAL_UA_SMS_CODE /код(?:у|ом)?\s*підтвердження/i
describe LOCAL_UA_SMS_CODE Просить код підтвердження з SMS
score LOCAL_UA_SMS_CODE 1.2
Кілька речей визначають, спрацює правило чи ні. SpamAssassin використовує Perl-регулярки (у деяких збірках — движок re2). Модифікатор /i дає регістронезалежність і потрібен майже завжди. Модифікатор /m на body майже не потрібен, бо тіло вже схлопнуте в суцільний потік — \s там ловить одиночні пробіли, а не переноси рядків, тому не покладайтеся на межі рядків так, як у rawbody. Якщо вам справді потрібна структура рядків — беріть rawbody.
Кирилиця в регулярках: кодування, яке ламає матчинг
Це найтонша частина. SpamAssassin декодує MIME (base64, quoted-printable) у Perl-рядки, але за замовчуванням не приводить їх до єдиного кодування: директива normalize_charset вимкнена (0), бо вимагає Perl-модуля Encode::Detect і тому лишається опційною. Без неї лист у KOI8-U чи Windows-1251 порівнюється з вашою UTF-8-регуляркою побайтово й не збігається — правила будуть тихо сліпі. Увімкніть її явно:
normalize_charset 1
Перевірити поточний стан своєї збірки:
bash
grep -rn normalize_charset /etc/spamassassin/*.cf
За normalize_charset 1 SpamAssassin конвертує тіло в UTF-8 перед матчингом — ви пишете патерни в UTF-8 і матчите будь-яке вхідне кодування однаково.
Кириличний діапазон у Perl-регексі — [\x{0400}-\x{04FF}]. Це дозволяє рахувати «кириличність» тіла й ловити транслітерацію. Реальний спам грає на трьох варіантах бренду: «Нова Пошта», «Nova Poshta» і — найпідступніше — змішані гліфи, коли латинські a, o, e, c, p підставлені всередину кириличного слова, щоб обійти лексичний матчинг. Це homoglyph-атака: людина читає «Нова Пошта», а рядок містить латинську o.
Регулярка, що ловить латинську літеру, вкраплену між кириличними:
body __UA_HOMOGLYPH /\b[\x{0400}-\x{04FF}]+[a-z][\x{0400}-\x{04FF}]+\b/
describe __UA_HOMOGLYPH Латиниця всередині кириличного слова (homoglyph)
Саме змішування — сильний сигнал: у легітимному українському тексті слово не буває наполовину латинським. Але тримайте це як subrule (про префікс __ — нижче), бо поодинокі збіги трапляються в артикулах товарів і кодах, і давати цьому власний score небезпечно.
Зважування: математика за поріг 5.0
Модель проста до банальності: SpamAssassin підсумовує score усіх правил, що спрацювали. Якщо сума ≥ required_score (дефолт 5.0) — лист позначається спамом. Поріг задається в local.cf:
required_score 5.0
Уся інженерія — у виборі чисел. Практична шкала, яку я тримаю в голові:
0.5–1.5 — сигнал-кандидат. Сам по собі нічого не вирішує, лише додає ваги (лексичне слово, urgency-фраза).
2.0–3.5 — впевнений локальний патерн, що майже не б'є по легіту.
Ніколи ≥ 5.0 для одного лексичного правила. Один false positive від такого правила = заблокований легітимний лист. Один розлючений клієнт коштує дорожче за сотню пропущеного спаму.
Від'ємні score теж інструмент: правило з score -2.0 на розсилку вашого партнерського домену тягне лист від спаму (це ham-сигнал, зазвичай із tflags nice).
Тонкість, про яку забувають: SpamAssassin має чотири score-set (0–3) залежно від того, чи доступні Bayes і мережеві тести. Якщо задати score одним числом — воно застосується до всіх чотирьох сетів, що зазвичай і потрібно. Якщо потрібна різна вага, коли Bayes навчений, задавайте чотирма числами:
score LOCAL_UA_NP_SCAM 0 0 1.5 2.0
Порядок сетів: [no-net,no-bayes] [no-net,bayes] [net,no-bayes] [net,bayes].
Meta-правила: комбінуй сигнали, а не слова
Окреме лексичне правило — це шум. Слово «оплата» є в тисячах легітимних листів. Сила з'являється, коли ви поєднуєте кілька слабких сигналів у булеву умову, яка спрацьовує лише разом. Це й робить meta.
Конвенція: subrules з префіксом __не мають власного score і не впливають на підсумок напряму — вони існують лише як інгредієнти для meta. Тому ви можете безпечно писати агресивні лексичні патерни в __-правилах: доки вони не в meta, вони нічого не блокують.
Повний робочий блок під фейкову «Нову Пошту»:
# --- інгредієнти (без score) ---
body __UA_NP_BRAND /\bнов[аоуи]?\W{0,3}пошт/i
body __UA_NP_BRAND_LAT /\bnova[\s\-]?poshta\b/i
body __UA_PAY_URGENCY /(очіку[єю].{0,20}оплат|терміново.{0,20}сплат|посилк.{0,20}затрим)/i
uri __UA_SHORTENER /\b(?:bit\.ly|cutt\.ly|is\.gd|t\.me|[a-z0-9-]+\.(?:top|pay|click))\//i
# --- сигнал ---
meta LOCAL_UA_NP_SCAM ( (__UA_NP_BRAND || __UA_NP_BRAND_LAT) && (__UA_PAY_URGENCY || __UA_SHORTENER) && !DKIM_VALID )
describe LOCAL_UA_NP_SCAM Фейкова Нова Пошта: бренд + urgency/шортлінк + невалідний DKIM
score LOCAL_UA_NP_SCAM 3.5
tflags LOCAL_UA_NP_SCAM noautolearn
Читається як інженерне твердження: «бренд Нова Пошта (кирилицею або транслітом) І (платіжна терміновість АБО шортлінк) І DKIM невалідний». Легітимна нотифікація від справжньої Нової Пошти має валідний DKIM і не веде на .top, тож у неї це правило не влучає. tflags noautolearn каже: не годуй цим збігом Bayes — meta-логіка вже впевнена, а автонавчання на ній лише зіпсує статистику. Ставте noautolearn на будь-яке жорстке meta, net — на правила з мережевою залежністю, nice — на ham-сигнали з від'ємним score.
Тестування, тюнінг і уникнення FP
Правило без прогону на корпусі — це здогадка. Цикл, який я проходжу перед деплоєм кожного правила.
Спершу синтаксис і суха прогонка одного зразка з дебагом:
-t — тестовий режим (звіт, нічого не змінює), -L вимикає мережеві тести (швидко, детерміновано), -D дає дебаг, а grep LOCAL_ фільтрує саме ваші правила. Ви побачите, чи спрацював meta й скільки додав.
Далі — прогін корпусу. Складіть два каталоги, ham/ і spam/, по 20+ реальних листів у кожному, і подивіться hit-rate:
bash
for f in spam/*.eml; do spamassassin -t -L < "$f"; done | grep -c LOCAL_UA_NP_SCAM
for f in ham/*.eml; do spamassassin -t -L < "$f"; done | grep -c LOCAL_UA_NP_SCAM
Мета проста: багато влучань по spam/, нуль по ham/. Джерело правди в проді — заголовок готового листа:
Тут видно кожне правило, що спрацювало, і сумарний score. Це те, за чим ви моніторите поведінку тиждень після деплою.
Правило великого пальця щодо false positive: якщо правило б'є по вашому ham-корпусу частіше ніж у 0.1% випадків — знижуйте score або викидайте. Лексичне правило, що влучає в один легітимний лист із тисячі, з score 3.5 — це блокування реальної пошти, і жоден пійманий спам цього не виправдовує.
Чек-лист впровадження
1.Бекап.cp /etc/spamassassin/99_local_ua.cf{,.bak} перед правками.
2.Пишіть інгредієнти як `__`-subrules — вони не мають score і нічого не ламають, доки не в meta.
3.Meta зверху сигналу. Комбінуйте бренд + тиск + структурний сигнал (DKIM/шортлінк), а не окремі слова.
6.`-t -L` на 20 ham + 20 spam. Нуль влучань по ham — умова деплою.
7.Деплой в окремий 99_local_ua.cf, щоб sa-update не затер.
8.Рестарт демона:systemctl restart spamassassin (або amavis) — без цього правила не підхопляться.
9.Моніторте `X-Spam-Status` тиждень. Правило з hit по ham > 0.1% — знизити score або видалити.
Так виглядає антиспам, зроблений як інженерія: кожне правило має вимірювану влучність, кожен score обґрунтований математикою поза порогом, а кожен деплой перевірений на корпусі. Дефолтний ruleset ловить глобальний спам; ваші дев'ять рядків у 99_local_ua.cf ловлять те, що прилетіло саме вашим користувачам українською — і роблять це, не чіпаючи легітимну пошту.