Running three Cloudron servers (9.2.x, mail app 3.18.2, SpamAssassin 4.0.0) with
about 35 mailboxes between them. While hardening against a run of phishing that
was getting through, I went looking at how SpamAssassin is set up inside the mail
container and found a few things that I think are worth sharing — plus some
questions I couldn't answer from the docs.
Everything below is reproducible with docker exec mail … on the host.
1. The optional plugins are off — but that's upstream, not Cloudron
My first assumption was that Cloudron had disabled them. It hasn't:
docker exec mail dpkg -V spamassassin
??5?????? c /etc/spamassassin/local.cf
??5?????? c /etc/spamassassin/v310.pre
Only local.cf and v310.pre differ from the Debian package. v342.pre is
byte-identical, and that is the file holding Phishing, FromNameSpoof,
DecodeShortURLs, URILocalBL and PhishTag — all commented out exactly as
upstream ships them. So this is stock SpamAssassin behaviour, and I want to be
clear I'm not reporting a Cloudron bug here. It only becomes a Cloudron question
because of point 2.
For completeness, Cloudron's entire spamd setup is this, from /app/code/start.sh:
setup_spamd() {
mkdir -p /run/spamd /app/data/spamd /run/spamd/tmp
touch /app/data/spamd/custom.cf /app/data/spamd/acl.cf
chown -R cloudron:cloudron /app/data/spamd /run/spamd
}
2. loadplugin works from custom.cf, but the stock rules don't come with it
The only persistent config surface is the Custom SpamAssassin rules field in
the Email view, which is /app/data/spamd/custom.cf, included at the end of
local.cf. Anything in /etc/spamassassin/ is reset on app update.
loadplugin is accepted there and lints clean:
docker exec mail spamassassin --lint --cf="loadplugin Mail::SpamAssassin::Plugin::FromNameSpoof"
# exit 0
But it has no effect on the plugin's own rules. Those live in
/usr/share/spamassassin/72_active.cf behind an ifplugin guard, and that file
is parsed before local.cf pulls in custom.cf — so by the time the plugin
loads, the guard has already been evaluated and the rules skipped for good.
Verified with a message whose From: display name is the recipient's address
(should trip T_FROMNAME_EQUALS_TO
# without the plugin
tests=FROM_2_EMAILS_SHORT,… score=3.0
# with --cf="loadplugin …FromNameSpoof"
tests=FROM_2_EMAILS_SHORT,… score=3.0 ← identical
Workaround that does work: load the plugin and redefine the rules yourself
in custom.cf under your own names. That gives 3.0 → 4.9 on the same message:
loadplugin Mail::SpamAssassin::Plugin::FromNameSpoof
header __MY_FROMNAME_SPOOF eval:check_fromname_spoof()
header __MY_FROMNAME_EQUALS_TO eval:check_fromname_equals_to()
meta MY_FROMNAME_SPOOFED_EMAIL (__MY_FROMNAME_SPOOF && !__VIA_ML && !__VIA_RESIGNER && !__RP_MATCHES_RCVD)
meta MY_FROMNAME_EQUALS_TO __MY_FROMNAME_EQUALS_TO
score MY_FROMNAME_SPOOFED_EMAIL 0.01
score MY_FROMNAME_EQUALS_TO 0.01
It works, but you're maintaining a copy of upstream's rule definitions, which
will silently drift when SpamAssassin changes them.
3. sa-update never runs
This one surprised me more than the plugins:
docker exec mail ls /var/lib/spamassassin/
sa-update-keys ← only the keys, no rules dir
docker exec mail ls /etc/cron.d/ ← e2scrub_all, php
docker exec mail grep -rn sa-update /app/code/start.sh /etc/supervisor/ ← nothing
No versioned rules directory means sa-update has never completed. The static
rule set is therefore whatever the Debian package ships —
/usr/share/spamassassin/50_scores.cf is dated 2022-07-01 on my servers.
In practice this matters less than it sounds, because the adaptive parts are all
still live: DNSBL/URIBL lookups are DNS queries and always current, Bayes trains
itself, and custom.cf is mine. But rule updates also carry rescored weights
from current corpora, and those never arrive.
I can see why it isn't run: /var/lib/spamassassin isn't persistent, so any
sa-update result is discarded on the next app update.
4. Two smaller things
- The
Phishing plugin reads its feed file only in finish_parsing_end, i.e.
once at spamd startup. Refreshing the feed on disk does nothing until
supervisorctl restart spamd. I'm doing that from a host cron job; it works,
but it feels like reaching around the platform.
- Unrelated to Cloudron, but a time-saver for anyone else wiring up that plugin:
https://openphish.com/feed.txt is now a 302 to a GitHub raw URL, so curl
without -L silently writes an HTML error page over your feed. And the
phishstats.info/phish_score.csv URL in the plugin's own documentation
currently returns 404.
Questions
- Is there a supported way to load a SpamAssassin plugin early enough that its
stock ifplugin-guarded rules activate? A persistent .pre — or a second
textarea next to the custom-rules field that gets written to a .pre — would
solve it without anyone having to copy upstream rule definitions.
- Is not running
sa-update a deliberate choice? If so, what's the reasoning,
and is there a recommended way to keep the static rule set reasonably current
given that /var/lib/spamassassin doesn't survive an app update?
- Would you consider enabling FromNameSpoof by default? It needs no external
service, no feed, and no configuration, and display-name spoofing ("Rabobank"
from a random domain) is one of the most common phishing shapes we see.
- For external data files that a plugin reads at startup (like the phishing
feed): is a host cron doing docker exec mail supervisorctl restart spamd the
intended approach, or is there a cleaner hook?
Happy to test anything on my side — I have three servers and a corpus of real
phishing that got through, so I can measure rather than guess.