Mail-attachment malware scanner: deep extraction + ~10k YARA rules. rspamd/SA/Sieve/ICAP/standalone.
1.8K
⚠️ THIS README IS TOO LONG — Docker Hub caps descriptions at 25000 bytes. See the full README on GitHub: https://github.com/eilandert/mailstrix
Mailstrix is the owl that finds malware hiding in your mail. It takes hostile attachments apart — unwrapping OLE2/OOXML, VBA, RTF objects, PDFs, archives and nested carriers — until YARA detection rules can finally see the dangerous bits. It runs out-of-process behind Rspamd (async HTTP), SpamAssassin, an ICAP server, Dovecot Sieve, or standalone.
strixd is a small HTTP service that scans email for malware with
YARA. You hand it a message (or one
attachment) on POST /scan; it runs ~10,000 curated public YARA rules over it
and tells you which ones matched. It ships as a ready-to-run Docker image with
the rules already baked in — see Quick start below or pull it
straight from Docker Hub.
Why YARA, in one paragraph. YARA is the rule engine malware analysts use to recognise families of malicious files — booby-trapped Office docs, packed executables, phishing kits, script droppers. A plain string signature dies the moment the author edits one byte; a YARA rule matches the shape of a file (PE imports, section entropy, embedded magic) and survives the next variant. strixd compiles those rules — libyara modules and all — and runs them over your mail.
Four ways to plug it into a mail server, all shipped in this repo:
mailstrix.lua plugin (contrib/rspamd/) POSTs each
message/part to strixd at SMTP time and turns the hits into a spam-score symbol.Mailstrix.pm plugin scans each message
through the same central service and turns a YARA match into a spam-score hit
(contrib/spamassassin/).strix-scan
client scans at delivery and a Sieve rule quarantines a match
(contrib/sieve/).MAILSTRIX_ICAP_ADDR and strixd also speaks ICAP (RFC 3507) so an
ICAP-aware proxy or content-filter (Squid, c-icap) scans REQMOD/RESPMOD bodies
through the same engine (ICAP mode). ┌──────────────────────┐ POST /scan ┌──────────────┐ ┌──────────────┐
│ rspamd (mailstrix.lua)│ ───────────▶ │ strixd │ ─▶ │ libyara │
│ SpamAssassin / Sieve │ ◀─────────── │ (Go service) │ │compiled rules│
│ (strix-scan) / ICAP │ {matches} └──────────────┘ └──────────────┘
└──────────────────────┘
Where should YARA scanning live — opinion. YARA scanning is genuinely CPU-intensive, and the MTA hot path is the most latency-sensitive place to spend that CPU: every connection waits on it, and at SMTP time you scan a lot of mail you will reject anyway. A defensible view is that it doesn't belong in the MTA at all — scanning at delivery (Dovecot LDA / Sieve), after rspamd has already dropped the obvious spam, scans far less and off the connection's critical path. Which is right depends on your mailflow and goals: scan early at SMTP to reject with rspamd's score, or scan late at delivery to quarantine a smaller, cleaner stream. strixd supports both; see the thin client and
contrib/sieve/ for the delivery-time path.
It runs out of process, never inside the MTA worker, because libyara is a C library (CGO): in an rspamd worker it would block the event loop and drag a heavy C dependency into the mail image. Separate, the caller stays async and strixd can be scaled, restarted, or reload its rules on its own. Same shape as the gozer DCC/Razor/Pyzor backend.
📋 Jump to Status & roadmap for what's done vs planned.
POST /scan raw message bytes (or one MIME part),
get back the matched rules as JSON; the rspamd mailstrix.lua plugin
(contrib/rspamd/) wires the hits into the spam score, or the strix-scan
client scans at delivery from Dovecot/Sieve (contrib/sieve/)..yac, daily refresh..docm/.xlsm/.doc/.xls, scans the cleartext (sets the VBA rule var).\objdata, OLE Package (Ole10Native), MSI, Outlook .msg, TNEF
(winmail.dat), OneNote .one, PDF (FlateDecode streams), .lnk shortcuts,
VBE/JSE encoded scripts, and nested archives (zip/7z/rar/gz/tar.gz/cab, recursive)
— then scans each.StrReverse, and folds the olevba string set (Chr/ChrW concat,
Replace(), Array() Xor k, Environ, Dridex DridexUrlDecode) to
cleartext; a bounded recursive pass unwinds multi-stage (2+-layer)
payloads, not just the first layer..xls BIFF), reassembles ptg-token formula strings
(BIFF8/.xlsb/SLK), and runs a bounded XLM emulator (cell eval, GOTO,
SET.VALUE) to resolve obfuscated cell references./OpenAction, /JS, /Launch, /EmbeddedFile,
/JBIG2Decode and hex-name obfuscation as scoreable markers.x-usc (CVE-2021-40444), Shell.Explorer CLSID, and OLE structural
indicators (ObjectPool, embedded Flash, digital-signature, doc-security).MZ header so the pe rules fire.data: URI, embedded
SVG) detection, and a position-independent-shellcode GetEIP prologue rule.MAILSTRIX_EFFORT)
scales decode depth, XLM/PDF clamps, feeds and scan timeout; auto sheds a
level at a time as the admission gate fills and climbs back as it drains.MAILSTRIX_ICAP_ADDR adds an RFC 3507
REQMOD/RESPMOD listener for ICAP-aware proxies, sharing the same scan engine,
cache and concurrency gate as /scan.filename/extension YARA vars from the
plugin's X-MAILSTRIX-Filename, so name-keyed (THOR/Loki) rules fire.MAILSTRIX_RULE_DENYLIST (suppress) and
MAILSTRIX_RULE_ALLOWLIST (keep but score log-only) without patching upstream.SHA256(body) → matches (LRU+TTL), plus request
coalescing and an optional shared Redis/Valkey L2, for a high-volume firehose.strixd fetch-rules pulls a
version-matched, sha256-verified compiled bundle into a cache; SIGHUP reloads.strixd scan (local triage), strixd extract (dump what a
container carves), strixd check-rules, strixd info; and strix-scan, a tiny
CGO-free client for a Dovecot/Sieve box (contrib/sieve/)./health, /ready, /version, Prometheus /metrics
(scans, matches, cache, per-extractor counters, rule staleness).Three ways, pick one:
Debian/Ubuntu package (.deb, amd64 + arm64) — attached to every
release:
# Resolve the latest version + your arch (release assets are version-pinned,
# e.g. strixd_1.1.1_amd64.deb — the bare latest/download/ path is not).
VER=$(curl -fsSL https://api.github.com/repos/eilandert/mailstrix/releases/latest \
| grep -oP '"tag_name":\s*"v\K[^"]+')
ARCH=$(dpkg --print-architecture) # amd64 or arm64
BASE=https://github.com/eilandert/mailstrix/releases/download/v${VER}
# strixd — the daemon (systemd unit + /etc/mailstrix/strixd.env config)
curl -fsSLO "${BASE}/strixd_${VER}_${ARCH}.deb"
sudo apt install "./strixd_${VER}_${ARCH}.deb"
# fetch the rolling compiled rule bundle into the cache dir, then start it
sudo -u strixd strixd fetch-rules -cache-dir /var/cache/mailstrix # or drop your own .yar in /var/lib/mailstrix/rules
sudoedit /etc/mailstrix/strixd.env # set MAILSTRIX_TOKEN, feeds, …
sudo systemctl enable --now strixd
# strix-scan — the lean CGO-free Sieve/LDA client (no daemon)
curl -fsSLO "${BASE}/strix-scan_${VER}_${ARCH}.deb"
sudo apt install "./strix-scan_${VER}_${ARCH}.deb"
The daemon package installs a hardened systemd unit (unprivileged strixd user,
ProtectSystem=strict, NoNewPrivileges) and a documented
/etc/mailstrix/strixd.env. State (rules) lives in /var/lib/mailstrix.
Static binaries — strixd-linux-{amd64,arm64} and
strix-scan-linux-{amd64,arm64} plus SHA256SUMS are on the same release page.
Docker — see Quick start below (rules baked in).
The image already bakes ~10k rules, so a token is all you need:
docker run -d --name strixd \
-e MAILSTRIX_TOKEN=changeme \
-p 8079:8079 \
eilandert/mailstrix
# ask it something:
printf 'hello' | curl -s -H 'X-MAILSTRIX-Token: changeme' \
--data-binary @- http://127.0.0.1:8079/scan
# -> {"matches":[]}
To use your own rules instead of the baked bundle:
docker run -d --name strixd \
-e MAILSTRIX_TOKEN=changeme \
-e MAILSTRIX_RULES= \
-e MAILSTRIX_RULES_DIR=/rules \
-v "$PWD/myrules:/rules:ro" \
-p 8079:8079 \
eilandert/mailstrix
Send an attachment name so name-keyed rules fire (base64 — the name is attacker-controlled, encoding it stops header injection):
printf 'MZ...' | curl -s -H 'X-MAILSTRIX-Token: changeme' \
-H "X-MAILSTRIX-Filename: $(printf 'invoice.exe' | base64)" \
--data-binary @- http://127.0.0.1:8079/scan
Token is optional but recommended. Set
MAILSTRIX_TOKEN(orMAILSTRIX_TOKEN_FILE) and the caller must present the same secret as aBearerheader orX-MAILSTRIX-Token. Leave it unset (ornone/0/off) to run an open scanner for a trusted private network — strixd logs a loud warning, since anyone who can reach the port can submit CPU-costly scans.
The /scan reply names the rule and its source ruleset file:
{"matches":[{"rule":"Suspicious_Macro","namespace":"sigbase-gen_maldoc.yar","tags":["office"],"meta":{"author":"…"}}]}
The list is [] (never null) when nothing matched. namespace is the file the
rule was compiled from, so a generic rule like http is traceable to the set
that shipped it. For the hardened container setup (read-only rootfs, dropped
caps, Docker secret, static IPv4) see
docker/docker-compose.yml.
The same binary scans locally — no HTTP, no token — by compiling the rules in-process. For one-off triage and pipelines:
strixd scan suspicious.doc # one file
strixd scan /var/mail/cur # a maildir, recursed
cat msg.eml | strixd scan # stdin
strixd scan -json /tmp/quarantine # machine-readable
Exit codes: 0 clean, 1 ≥1 match, 2 usage/load/read error. Other
helpers share the binary (strixd help):
strixd check-rules # compile rules, print the count, non-zero on failure (CI gate)
strixd extract suspicious.doc # show what the extractor carves (no scan)
strixd fetch-rules # update the cached rule bundle from the release
strixd info # build / libyara / loaded-bundle identity
fetch-rules)Rules move faster than image rebuilds — and outside Docker you'd otherwise need
yarac + a matching libyara to compile them. strixd fetch-rules downloads a
prebuilt, version-matched bundle into the cache instead:
strixd fetch-rules -cache-dir /var/cache/mailstrix
It reads a small manifest first and updates only when the published version
is newer; it refuses a bundle built against a different libyara,
verifies the sha256, and swaps atomically (keeping one .bak). On any error
the current bundle is untouched. Then SIGHUP (or restart) strixd to load it. The
bundle is published by docker/generate-rules.sh (run from cron); point -url /
MAILSTRIX_RULES_URL at a mirror if not fetching from GitHub.
strix-scan)strixd scan (above) compiles the rules in-process, so it needs libyara and
the rule set on the host that runs it — fine on the scanner box, too heavy for a
mail-delivery box that should stay thin. strix-scan is the answer: a
separate, tiny client that links no CGO / libyara and embeds no rules — pure
Go, a ~6 MB static binary you can drop on any mail host. It just reads the
message (stdin or a file), POSTs it to a central strixd serve, and exits on the
verdict — so all the CPU-heavy scanning stays on the central service.
# stdin or a file; exit code carries the verdict:
strix-scan -url http://strixd.internal:8079 -token-file /etc/strixd.token - < message
cat message | strix-scan -url http://strixd.internal:8079
| Exit 0 | clean — no rule matched (also on a fail-open scanner outage) |
| Exit 1 | at least one rule matched |
| Exit 2 | usage / read / (fail-closed) transport error |
-fail-open=false for interactive triage where a silent miss is worse.-token-file or MAILSTRIX_TOKEN — never -token on a shared host
(it shows in ps). Redirects are never followed, so the token can't leak to a
3xx target.X-MAILSTRIX-Token for auth, base64
X-MAILSTRIX-Filename for the attachment name.This is the delivery-time path from the opinion in the intro: let rspamd drop the obvious spam at SMTP, then scan the smaller, cleaner stream with YARA at delivery and quarantine a hit — off the connection's critical path.
A ready-to-use Dovecot Sieve example (the execute rule, an install wrapper, the
dovecot config, and a setup/test walkthrough) lives in contrib/sieve/.
Because the client fails open, a delivery is never lost if the backend is down.
Every setting is an env var and a serve CLI flag (flag > env > default).
| Env | Default | Meaning |
|---|---|---|
MAILSTRIX_HOST / MAILSTRIX_PORT | 0.0.0.0 / 8079 | HTTP bind address |
MAILSTRIX_TOKEN[_FILE] | — | shared secret for /scan (optional); comma-separated for zero-downtime rotation (e.g. old,new); unset / none / 0 / off ⇒ auth disabled, /scan runs open (warned at startup) |
MAILSTRIX_TOKEN_NEXT[_FILE] | — | incoming rotation token accepted alongside the primary; append here then migrate clients, then promote to MAILSTRIX_TOKEN and clear this |
MAILSTRIX_RULES_DIR | /rules | dir of *.yar/*.yara compiled at boot and on SIGHUP |
MAILSTRIX_RULES | — | a precompiled .yac bundle; loaded instead of RULES_DIR (faster start) |
MAILSTRIX_RULES_MAX_AGE | 0 (off) | seconds; flag rules stale (metric + /ready body) once older than this. Fail-open: never fails readiness |
MAILSTRIX_SCAN_TIMEOUT | 8 (s) | per-request libyara budget (raw + all extracted streams share it) |
MAILSTRIX_BACKEND_TIMEOUT | 1 (s) | how long to wait for an admission / scan slot |
MAILSTRIX_MAX_CONCURRENT | auto (CPU count) | max concurrent libyara scans (CPU gate) |
MAILSTRIX_MAX_INFLIGHT | auto (2× concurrent) | max in-flight requests (admission gate); kept above the scan gate so a slow body/Redis can't starve scans |
MAILSTRIX_MAX_BODY | 8388608 (8 MiB) | max request body, in bytes (checked before reading) |
MAILSTRIX_EFFORT_MAX | 10 | effort-tier ceiling (1–10); the hard cap a per-request X-MAILSTRIX-Effort header can never exceed (DoS guard) |
MAILSTRIX_EFFORT | = MAILSTRIX_EFFORT_MAX | default effort level when no X-MAILSTRIX-Effort header is sent (1 = raw + shallowest extraction, max = full depth). Set to auto (EFFORT-2) to derive the level from admission-gate pressure — full depth when idle, shedding a level at a time as in-flight scans fill the gate, climbing back as it drains (one level/scan; mailstrix_effort_auto_level gauge tracks it). The level scales real work: decode depth, XLM/PDF clamps, reputation feeds and scan timeout are all wired to the resolved profile (EFFORT-4), so a lower level genuinely does less. |
MAILSTRIX_CACHE_TTL | 3600 (s) | verdict cache TTL; 0 disables caching |
MAILSTRIX_CACHE_SIZE | 65536 | in-memory LRU entries |
MAILSTRIX_REDIS_URL | — | optional shared L2 cache, e.g. redis://host:6379/6 |
MAILSTRIX_REDIS_PREFIX | yara:scan: | Redis key prefix |
MAILSTRIX_METRICS_AUTH | off | require the token for /metrics and /version (/health & /ready stay open) |
MAILSTRIX_URLHAUS_KEY[_FILE] | — | abuse.ch Auth-Key; enables the URLhaus malware-URL lookup |
MAILSTRIX_URLHAUS_REFRESH | 21600 (6 h) | URLhaus feed refresh (floor 5 min) |
MAILSTRIX_URLHAUS_MAX_URLS | 64 | max URLs examined per message |
MAILSTRIX_MBAZAAR_KEY[_FILE] | — | abuse.ch Auth-Key (same key); enables the MalwareBazaar hash lookup |
MAILSTRIX_MBAZAAR_REFRESH | 86400 (24 h) | MalwareBazaar feed refresh (floor 5 min) |
MAILSTRIX_MBAZAAR_FEED | full dump | override the feed URL (e.g. the lighter "recent" export) |
MAILSTRIX_THREATFOX_KEY[_FILE] | — | abuse.ch Auth-Key (same key); enables the ThreatFox URL/domain IOC lookup |
MAILSTRIX_THREATFOX_REFRESH | 21600 (6 h) | ThreatFox feed refresh (floor 5 min) |
MAILSTRIX_THREATFOX_MAX_URLS | 64 | max URLs/domains examined per message |
MAILSTRIX_FEODO | off | set 1 to enable the Feodo Tracker C&C IP-blocklist lookup (no key needed) |
MAILSTRIX_FEODO_REFRESH | 21600 (6 h) | Feodo feed refresh (floor 5 min) |
MAILSTRIX_BIGFILE_THRESHOLD | 6291456 (6 MiB) | buffers larger than this scan against the smaller BIGFILE_RULES set, not the full bundle (cost gate); markers always use the full set; 0 disables the gate |
MAILSTRIX_BIGFILE_RULES | baked seed | optional .yac bundle scanned for oversized buffers; unset ⇒ the baked local.yac seed set |
MAILSTRIX_RULE_DENYLIST | http | comma-sep rule names to suppress (case-insensitive); set empty to disable |
MAILSTRIX_RULE_ALLOWLIST | — | comma-sep rule names to force log-only (kept + tagged mailstrix_allow); deny wins if in both |
MAILSTRIX_ICAP_ADDR | — (disabled) | TCP address for the optional ICAP listener (RFC 3507), e.g. :1344. When set, strixd also accepts REQMOD/RESPMOD from ICAP-aware proxies (Squid, c-icap). Unset = ICAP disabled. No ICAP-level auth; gate by network/firewall. |
MAILSTRIX_VERBOSE | off | log one line per request |
MAILSTRIX_LOG_STDOUT | off | info/access logs to stdout (errors always stderr) |
MAILSTRIX_PPROF | off | enable /debug/pprof profiling endpoints (off by default; auth-gated when MAILSTRIX_METRICS_AUTH is set) |
Reload rules: docker kill -s HUP strixd recompiles in place and flushes the
cache. A reload that fails to compile keeps the previous (working) rules — a bad
edit can't disarm a running scanner. On SIGTERM/SIGINT strixd drains (/ready →
503, in-flight scans finish) before exiting — safe for rolling updates.
MAILSTRIX_MAX_CONCURRENT defaults to the CPU count (auto). On a many-core host
(32+ CPUs) that reserves significant memory. The request-buffer ceiling is set by
the admission gate, not the scan gate: up to MAX_INFLIGHT requests can each hold
a full body plus its extracted streams, so the startup log estimates peak resident
as roughly MAX_INFLIGHT × MAX_BODY + RSS (the loaded-rules resident set). Size
mem_limit accordingly and pin MAX_CONCURRENT/MAX_INFLIGHT explicitly when the
defaults are too aggressive.
MAILSTRIX_MAX_INFLIGHT (default 2×MAX_CONCURRENT) is the admission gate — excess
requests receive a 503 immediately rather than queuing. Keep it above
MAX_CONCURRENT so a slow body read or Redis round-trip can't starve scan slots.
Redis/Valkey L2 (MAILSTRIX_REDIS_URL) dramatically improves throughput for repeated
attachments, which is common in mail (bulk campaigns, MTA retries, one body to N
recipients). Without it each scanner instance maintains its own in-process LRU
only.
| Profile | MAILSTRIX_MAX_CONCURRENT | MAILSTRIX_MAX_BODY | mem_limit | Redis | Expected p95 | RPS capacity |
|---|---|---|---|---|---|---|
| Small — single mailhost, <100 msgs/min | 2 | 10485760 (10 MiB) | 128m | optional (LRU only) | <500 ms | ~10 |
| Medium — mailhost, 100–1000 msgs/min | auto (CPU count) | 26214400 (25 MiB) | 256m | recommended | <300 ms | ~50 |
| Large — cluster, >1000 msgs/min | auto | 26214400 (25 MiB) | 512m+ | required | <200 ms | ~200+ |
Notes:
MAILSTRIX_MBAZAAR_KEY set) adds ~40 MiB resident
plus a ~100–150 MiB transient spike on refresh — raise mem_limit to ~768m in
that case.MAX_CONCURRENT: smaller per-container concurrency
improves tail latency under burst load and avoids one libyara panic taking all
capacity.MAILSTRIX_BACKEND_TIMEOUT (default 1s) caps how long a request waits for an
admission slot. Under sustained overload this is the 503 fuse — keep it short
so callers (rspamd) fail fast rather than stacking connections.The image bakes eight public rulesets at build time; a daily rebuild
(--build-arg CACHEBUST=$(date +%s)) re-pulls the latest. Full credit to the
authors — strixd only packages their work. Each set keeps its own license:
| Ruleset | Author / source | License | Notes |
⚠️ TRUNCATED — see the full README on GitHub: https://github.com/eilandert/mailstrix
Content type
Image
Digest
sha256:97cd091a8…
Size
20.7 MB
Last updated
about 12 hours ago
docker pull eilandert/mailstrix:1.2.0