eilandert/mailstrix

By eilandert

Updated about 12 hours ago

Mail-attachment malware scanner: deep extraction + ~10k YARA rules. rspamd/SA/Sieve/ICAP/standalone.

Image
Networking
Security
0

1.8K

eilandert/mailstrix repository overview

⚠️ THIS README IS TOO LONG — Docker Hub caps descriptions at 25000 bytes. See the full README on GitHub: https://github.com/eilandert/mailstrix


Mailstrix — the owl that finds malware hiding in your mail

strixd — YARA malware scanning for rspamd

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.

CI Release Go Reference

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:

  • rspamd — an async mailstrix.lua plugin (contrib/rspamd/) POSTs each message/part to strixd at SMTP time and turns the hits into a spam-score symbol.
  • SpamAssassin — the Mailstrix.pm plugin scans each message through the same central service and turns a YARA match into a spam-score hit (contrib/spamassassin/).
  • Dovecot / Sieve — the lean strix-scan client scans at delivery and a Sieve rule quarantines a match (contrib/sieve/).
  • ICAP — set 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.

Exactly what it does

  • Scans mail with YARAPOST /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/).
  • Ships ~10k public rules baked in — YARA-Forge, signature-base, ANY.RUN, Didier Stevens, bartblaze, InQuest, CAPEv2, YARAify; precompiled .yac, daily refresh.
  • Decompresses Office macros before matching — MS-OVBA VBA out of .docm/.xlsm/.doc/.xls, scans the cleartext (sets the VBA rule var).
  • Cracks open containers — pulls the hidden payload out of: OLE2/OOXML, RTF \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.
  • Deobfuscates before matching — decodes long base64/hex runs, undoes 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.
  • Resolves Excel 4.0 (XLM) macros — detects hidden/very-hidden macrosheets (OOXML + legacy .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.
  • Triages PDFs — surfaces /OpenAction, /JS, /Launch, /EmbeddedFile, /JBIG2Decode and hex-name obfuscation as scoreable markers.
  • Catches macro-less & exploit attacks — Equation Editor (CVE-2017-11882), remote-template injection (CVE-2017-0199 / T1221), DDE/DDEAUTO fields, MHTML/x-usc (CVE-2021-40444), Shell.Explorer CLSID, and OLE structural indicators (ObjectPool, embedded Flash, digital-signature, doc-security).
  • Decrypts default-password documents — VelvetSweatshop XOR, BIFF8 RC4, and OOXML agile/standard AES, so an "encrypted" but default-keyed payload is unlocked and re-scanned (other encryption is flagged, not cracked).
  • Analyses carved executables — PE/ELF structural checks on embedded/decoded binaries (section entropy / packing, overlay, .NET, anomalies), plus base64-PE carving that re-aligns a padded MZ header so the pe rules fire.
  • Ships its own heuristic rules — an mraptor-style autoexec∧write∧execute rule, olevba suspicious-keyword / VBA-shellcode-API heuristics, LOLBin / WMI / PowerShell / anti-analysis intent rules, HTML-smuggling (data: URI, embedded SVG) detection, and a position-independent-shellcode GetEIP prologue rule.
  • Scales effort under load — a single 1–10 effort dial (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.
  • Speaks ICAP too (optional)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.
  • Uses the attachment namefilename/extension YARA vars from the plugin's X-MAILSTRIX-Filename, so name-keyed (THOR/Loki) rules fire.
  • Checks abuse.ch feeds (optional) — URLhaus malware-URL/host lookup (with URL defanging), MalwareBazaar attachment-SHA256 lookup, ThreatFox URL/domain IOCs, and the Feodo Tracker C&C IP blocklist; all cached, fail-open.
  • Drops/demotes noisy rulesMAILSTRIX_RULE_DENYLIST (suppress) and MAILSTRIX_RULE_ALLOWLIST (keep but score log-only) without patching upstream.
  • Caches verdictsSHA256(body) → matches (LRU+TTL), plus request coalescing and an optional shared Redis/Valkey L2, for a high-volume firehose.
  • Fails open, always — a scan error, timeout, or libyara panic is reported as "no match"; a broken scanner never blocks mail. Bounded concurrency, per-scan timeout, body cap, graceful drain on SIGTERM.
  • Updatable rules without a rebuildstrixd fetch-rules pulls a version-matched, sha256-verified compiled bundle into a cache; SIGHUP reloads.
  • CLI toolsstrixd 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/).
  • Observable/health, /ready, /version, Prometheus /metrics (scans, matches, cache, per-extractor counters, rule staleness).

Install

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 binariesstrixd-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).

Quick start

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 (or MAILSTRIX_TOKEN_FILE) and the caller must present the same secret as a Bearer header or X-MAILSTRIX-Token. Leave it unset (or none/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.

Scanning without a server

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
Updating rules without rebuilding (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.

Thin client for Dovecot / Sieve (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 0clean — no rule matched (also on a fail-open scanner outage)
Exit 1at least one rule matched
Exit 2usage / read / (fail-closed) transport error
  • Fails open by default — any transport error, timeout, or non-200 is treated as clean (exit 0), so a scanner outage never blocks or bounces delivery. Pass -fail-open=false for interactive triage where a silent miss is worse.
  • Token via -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.
  • Same wire format as the rspamd plugin: 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.

Configuration

Every setting is an env var and a serve CLI flag (flag > env > default).

EnvDefaultMeaning
MAILSTRIX_HOST / MAILSTRIX_PORT0.0.0.0 / 8079HTTP 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/rulesdir of *.yar/*.yara compiled at boot and on SIGHUP
MAILSTRIX_RULESa precompiled .yac bundle; loaded instead of RULES_DIR (faster start)
MAILSTRIX_RULES_MAX_AGE0 (off)seconds; flag rules stale (metric + /ready body) once older than this. Fail-open: never fails readiness
MAILSTRIX_SCAN_TIMEOUT8 (s)per-request libyara budget (raw + all extracted streams share it)
MAILSTRIX_BACKEND_TIMEOUT1 (s)how long to wait for an admission / scan slot
MAILSTRIX_MAX_CONCURRENTauto (CPU count)max concurrent libyara scans (CPU gate)
MAILSTRIX_MAX_INFLIGHTauto (2× concurrent)max in-flight requests (admission gate); kept above the scan gate so a slow body/Redis can't starve scans
MAILSTRIX_MAX_BODY8388608 (8 MiB)max request body, in bytes (checked before reading)
MAILSTRIX_EFFORT_MAX10effort-tier ceiling (1–10); the hard cap a per-request X-MAILSTRIX-Effort header can never exceed (DoS guard)
MAILSTRIX_EFFORT= MAILSTRIX_EFFORT_MAXdefault 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_TTL3600 (s)verdict cache TTL; 0 disables caching
MAILSTRIX_CACHE_SIZE65536in-memory LRU entries
MAILSTRIX_REDIS_URLoptional shared L2 cache, e.g. redis://host:6379/6
MAILSTRIX_REDIS_PREFIXyara:scan:Redis key prefix
MAILSTRIX_METRICS_AUTHoffrequire 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_REFRESH21600 (6 h)URLhaus feed refresh (floor 5 min)
MAILSTRIX_URLHAUS_MAX_URLS64max URLs examined per message
MAILSTRIX_MBAZAAR_KEY[_FILE]abuse.ch Auth-Key (same key); enables the MalwareBazaar hash lookup
MAILSTRIX_MBAZAAR_REFRESH86400 (24 h)MalwareBazaar feed refresh (floor 5 min)
MAILSTRIX_MBAZAAR_FEEDfull dumpoverride 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_REFRESH21600 (6 h)ThreatFox feed refresh (floor 5 min)
MAILSTRIX_THREATFOX_MAX_URLS64max URLs/domains examined per message
MAILSTRIX_FEODOoffset 1 to enable the Feodo Tracker C&C IP-blocklist lookup (no key needed)
MAILSTRIX_FEODO_REFRESH21600 (6 h)Feodo feed refresh (floor 5 min)
MAILSTRIX_BIGFILE_THRESHOLD6291456 (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_RULESbaked seedoptional .yac bundle scanned for oversized buffers; unset ⇒ the baked local.yac seed set
MAILSTRIX_RULE_DENYLISThttpcomma-sep rule names to suppress (case-insensitive); set empty to disable
MAILSTRIX_RULE_ALLOWLISTcomma-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_VERBOSEofflog one line per request
MAILSTRIX_LOG_STDOUToffinfo/access logs to stdout (errors always stderr)
MAILSTRIX_PPROFoffenable /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 (/ready503, in-flight scans finish) before exiting — safe for rolling updates.

Sizing profiles

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.

ProfileMAILSTRIX_MAX_CONCURRENTMAILSTRIX_MAX_BODYmem_limitRedisExpected p95RPS capacity
Small — single mailhost, <100 msgs/min210485760 (10 MiB)128moptional (LRU only)<500 ms~10
Medium — mailhost, 100–1000 msgs/minauto (CPU count)26214400 (25 MiB)256mrecommended<300 ms~50
Large — cluster, >1000 msgs/minauto26214400 (25 MiB)512m+required<200 ms~200+

Notes:

  • MalwareBazaar full-dump mode (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.
  • For the Large profile, run multiple replicas behind a load balancer rather than one container with a very high 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.

Rules

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

Tag summary

Content type

Image

Digest

sha256:97cd091a8

Size

20.7 MB

Last updated

about 12 hours ago

docker pull eilandert/mailstrix:1.2.0