Тонкая настройка скоринга SpamAssassin: per-user правила, sa-learn и калибровка порогов под реальный поток
Дефолтный порог 5.0 откалиброван на чужом англоязычном корпусе десятилетней давности. Разбираем, как измерить собственный поток через mass-check, перекалибровать веса по гистограмме score, обучить Bayes без самоотравления и закрыть оставшиеся дыры meta-правилами — с командами, конфигами и цифрами.
EvilMail Team23 июля 2026 г.12 мин чтения
Почему дефолтный порог 5.0 врёт именно на вашем потоке
Свежий пример из спам-лога. Легитимное письмо от контрагента с квартальным отчётом набирает 6.2 и улетает в Junk:
Ни одного признака спама по существу — сумма мелких технических штрафов. URIBL_BLOCKED прилетел потому, что сервер ходит к публичному 8.8.8.8 и упёрся в лимит запросов. MISSING_DATE — кривой почтовый шлюз отправителя. А в этот же час явный фишинг с поддельным счётом набирает
Скоринг SpamAssassin: per-user правила, sa-learn и калибровка порогов — EvilMail Blog
3.8
и проходит в инбокс: тело у него аккуратное, домен свежий, а Bayes ещё ничего про него не знает.
Порог 5.0 — не физическая константа. Это точка на ROC-кривой, которую команда SpamAssassin подобрала генетическим алгоритмом на своём корпусе преимущественно англоязычной почты. К вашему ящику с русской деловой перепиской, транзакционными письмами от четырёх десятков SaaS и одним бухгалтером, который пересылает сканы счетов, эта точка отношения не имеет. SA перестаёт ошибаться не когда вы включаете больше плагинов, а когда измеряете собственное распределение score и двигаете три рычага — per-user веса, обученный Bayes и свои meta-правила — под конкретные ложняки из логов.
Анатомия скоринга: где живут веса и что кого перекрывает
Прежде чем крутить цифры, разберём порядок применения. Правила и их дефолтные веса лежат в /usr/share/spamassassin/*.cf. Эти файлы не трогают руками — они перезаписываются через sa-update. Ваши оверрайды идут слоями поверх:
/etc/mail/spamassassin/local.cf (RH) или /etc/spamassassin/local.cf (Debian) — site-wide настройки.
~/.spamassassin/user_prefs — per-user, либо тот же профиль из SQL/LDAP при spamd с --sql-config.
Каждый следующий слой перекрывает предыдущий по конкретной директиве. Задали score BAYES_99 5.3 в дистрибутиве, переопределили на 4.0 в local.cf, а для одного ящика вернули 5.5 в user_prefs — применится 5.5 именно для него.
Момент, на котором спотыкаются все, кто впервые лезет в калибровку: у правила четыре значения score.
Если у вас включены сетевые тесты (RBL/URIBL) и работает Bayes — применяется четвёртое число. Правите первое, удивляетесь, что ничего не изменилось. Всегда правьте столбец, который соответствует вашей конфигурации, либо задавайте все четыре одним значением.
Per-user правила: разные пороги для бухгалтерии и для маркетинга
Один порог на всех — компромисс, который проигрывает на краях распределения пользователей. Ящик отдела маркетинга живёт в рассылках: для него required_score 5.0 означает поток ложняков. Ящик, куда приходят платёжные документы, наоборот, требует агрессии — лучше проверить лишнее письмо в Junk, чем пропустить фишинг с реквизитами.
Включите SQL-бэкенд для профилей (spamd с --sql-config, DSN в local.cf):
Профиль бухгалтерии — жёстче, плюс доверенные DKIM-домены банков через welcomelist_from_dkim, которое переживает пересылку лучше, чем проверка по конверту:
Терминология: начиная с SA 3.4.5 канонические директивы — welcomelist_from / blocklist_from (старые whitelist/blacklist работают как алиасы, но в новых конфигах пишите новые). Отдельная тонкость мультитенантности — per-user Bayes против site-wide. Персональная база Bayes точнее отражает привычки конкретного человека, но требует, чтобы каждый пользователь реально обучал её. На инфраструктуре, где обучение централизовано, держите один site-wide Bayes (bayes_sql_override_username) — иначе получите десятки полупустых деградировавших баз.
sa-learn: обучить Bayes так, чтобы он не деградировал
Bayes отбирает около 15 самых показательных токенов письма и выдаёт вероятность спама — от BAYES_00 (score порядка −1.9) до BAYES_99 и BAYES_999. Это самый мощный отдельный сигнал в SA, но он ровно настолько хорош, насколько чиста обучающая выборка.
Кормите его из реальных Maildir. Спам — из папок Junk, ham — из инбоксов, которые пользователи разобрали руками:
--dump magic показывает баланс: число ham- и spam-сообщений, число токенов, атомарный счётчик. Держите баланс не хуже 1:5 в любую сторону — перекошенная база начинает штамповать один класс.
Главная ошибка — авто-обучение по собственному вердикту. Если SA учит Bayes на письмах, которые сам же погранично классифицировал, он усиливает собственные ошибки: self-poisoning. Лечится отступом порогов авто-обучения от required_score:
То есть автоматически учим как ham только уверенно чистое (score ниже −0.1), а как spam — только набравшее 12+, далеко за порогом. Пограничная зона 0…12 не идёт в обучение вообще — её размечает человек, разбирая Junk.
Для нескольких воркеров spamd/amavis не держите Bayes в DB_File — упрётесь в блокировки. Redis-бэкенд:
И не забывайте про экспирацию: bayes_expiry_max_db_size 150000 держит базу в разумном размере, ручной прогон — sa-learn --force-expire.
Собираем корпус и измеряем: mass-check и перекалибровка
Это ядро всей работы. Пока вы не измерили собственное распределение score, любая правка порога — гадание. Соберите два каталога — ham и spam из реального потока, минимум по 1000 писем в каждом — и прогоните mass-check:
bash
cd /usr/share/doc/spamassassin/masses # или masses/ из исходников
./mass-check -j 8 --net \
ham:dir:/corpus/ham \
spam:dir:/corpus/spam > mc.log
На выходе — .log, где для каждого письма записан итоговый score и сработавшие правила. Полный путь — скормить его инструментам из masses/ (logs-to-c, затем перцептрон garescorer), которые перебалансируют веса всех правил под ваш корпус. Это тяжело и нужно редко. Прагматичный вариант, который закрывает 90% пользы: постройте гистограмму score по ham и по spam и найдите точку минимального перекрытия.
Дальше считаете на своём корпусе две метрики: FP-rate (доля ham выше порога) и FN-rate (доля spam ниже порога). Двигаете required_score в точку, где FP-rate падает ниже целевого 0.1% при приемлемом FN. На практике для русскоязычного делового потока это часто 5.5–6.0, а не 5.0. Как это меняет вклад ключевых правил:
text
Правило Дефолт(net+bayes) Калибровка Причина
BAYES_99 5.3 4.2 база обучена — не даём ей решать в одиночку
URIBL_BLOCKED 1.5 0.0 артефакт публичного DNS, не сигнал
RDNS_NONE 1.2 0.5 много легитимных отправителей без PTR
required_score 5.0 6.0 точка минимального перекрытия
Meta-правила: закрываем то, что не ловят ни Bayes, ни RBL
Целевой фишинг проходит именно потому, что каждый отдельный признак сам по себе слаб. meta-правило комбинирует их булевой логикой и стреляет только на совпадении. Три боевых примера в отдельном .cf:
text
# 1. Фишинг со счётом: вложение + лексика оплаты + IBAN в теле + провал DKIM
body __BODY_IBAN /\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b/
body __PAY_LEX /\b(оплат|реквизит|срочно оплатите)\b/i
meta INV_PHISH (__HAS_ATTACHMENT && __BODY_IBAN && __PAY_LEX && DKIM_INVALID)
describe INV_PHISH Счёт с IBAN, платёжной лексикой и провалом DKIM
score INV_PHISH 4.5
# 2. Свежий домен отправителя + urgency-лексика
meta NEW_DOM_URGENCY (__FROM_NEW_DOMAIN && __URGENCY_LEX)
describe NEW_DOM_URGENCY Молодой домен + давление срочностью
score NEW_DOM_URGENCY 3.2
priority NEW_DOM_URGENCY -100
# 3. Кириллический homograph в отображаемом имени From
header __FROM_CYR_HOMO From:name =~ /[\x{0430}\x{043e}\x{0435}\x{0440}\x{0441}].*(sberbank|paypal|gosuslugi)/i
meta FROM_HOMOGRAPH __FROM_CYR_HOMO
describe FROM_HOMOGRAPH Латинский бренд с кириллическими буквами в имени
score FROM_HOMOGRAPH 4.0
У meta-правила нет собственного тела — оно только смотрит на результаты других правил. Значит, его зависимости должны отработать раньше. Если внутри meta есть сетевое правило (tflags net), DNS-lookup асинхронный, и meta должно уметь дождаться результата — управляйте очередью через priority (меньшее значение = раньше). Подчёркивание в имени (__FROM_NEW_DOMAIN) помечает вспомогательное правило, которое само по себе не даёт очков, а только служит зависимостью.
Отладка: как читать разбивку score
Когда письмо ушло не туда, не гадайте — прогоните трейс:
bash
spamassassin -t -D rules,bayes < /path/to/message.eml 2>&1 | less
Он покажет, какое правило когда сработало и с каким весом. Для постоянной видимости включите полный отчёт в заголовках:
text
add_header all Report _REPORT_
Тогда X-Spam-Report в каждом письме содержит построчную разбивку. Частые виновники ложняков:
`URIBL_BLOCKED` — сервер упёрся в rate-limit публичного DNS. Поднимите локальный рекурсор (unbound на 127.0.0.1) и пропишите его в /etc/resolv.conf. Это чинит и URIBL_BLOCKED, и половину RBL-таймаутов разом.
`RDNS_NONE` — у отправителя нет PTR. Легитимно чаще, чем кажется; понизьте вес.
T_*** — тестовые правила SA с ненулевым весом, которые в вашем потоке тянут не туда. Обнуляйте точечно: score T_SOME_RULE 0.
Чек-лист калибровки под свой поток
Локальный рекурсивный DNS (unbound/bind на loopback) — до всего остального. Публичный резолвер убивает RBL/URIBL.
Свежий Bayes с балансом ham:spam не хуже 1:5, проверка через sa-learn --dump magic.
Отступ авто-обучения: nonspam -0.1, spam 12.0 — против self-poisoning.
Собранный корпус ≥ 1000 ham и ≥ 1000 spam из реального Maildir.
mass-check, гистограмма score, required_score в точке минимального перекрытия (цель FP < 0.1%).
Per-user профили для крайних ящиков: маркетинг 6.5, бухгалтерия 4.0.
2–3 meta-правила под ваши реальные FN, с priority для сетевых зависимостей.
welcomelist_from_dkim для доверенных доменов вместо хрупкого welcomelist_from.
Bayes в Redis при нескольких воркерах + экспирация (bayes_expiry_max_db_size).
sa-update && systemctl reload spamassassin в cron еженедельно, с проверкой подписи (sa-update --gpgkey).
Мониторинг FP-rate раз в неделю — калибровка не разовая, поток дрейфует.
На инфраструктуре evilmail.pro мы фильтруем чужой мусор в промышленных объёмах, и вывод везде один: SpamAssassin становится точным не от количества плагинов, а от того, что вы кормите его собственными данными и правите три рычага под те ошибки, которые реально видите в логах. Дефолт — стартовая точка, а не финал.