The static trails now live in their own repository, stamparm/trails, and reach a deployment as one assembled file instead of shipping in the source tree.
Everything else in this release is secondary to that, so read the upgrade note first.
Upgrading from 3.1.x — do this
A maltrail.conf written before 3.2 has no STATIC_TRAILS_URL, and without it the static trail set is not loaded at all. Add:
STATIC_TRAILS_URL https://github.com/stamparm/trails/releases/latest/download/trails.csv.gz
…or re-run install.sh, which writes it for you.
Maltrail will not let this pass quietly. The updater prints a banner across the terminal, server --doctor fails with exit 1, and maltrail-sensor -T fails its preflight — so the systemd unit's ExecStartPre catches it before the sensor ever runs. Losing the trail set is indistinguishable from a quiet network, and that is not a thing to discover in a month.
There is deliberately no silent fallback to a default URL. A deployment that fetches its detection content from an address the operator never configured is a worse answer than one that says what is missing.
Why the split
trails/static was 98.7% of every blob byte in this repository's history and 9,169 of 9,549 commits. Two consequences: git log, git blame and git bisect were useless on the code, and updating detection content meant pulling a new version of the software.
The repository went from 9,578 commits and a 110 MiB pack to 353 commits and 3.3 MiB. Content history was not deleted — it moved, with authorship intact.
What changes for a deployment
| Static trails | fetched from STATIC_TRAILS_URL, cached next to TRAILS_FILE
|
| Update cost | ~11 MB gzipped, or 65 bytes when the published digest says nothing changed |
| Content freshness | published up to 4×/day; new indicators reach you within hours instead of on your next git pull
|
| Pinning | point STATIC_TRAILS_URL at a dated content-YYYYMMDD-HHMM release so a bad publish is not immediately global
|
| Offline / air-gapped | trails-bootstrap.csv.gz ships with this release and install.sh seeds the cache from it, so a first start detects immediately even with no connectivity
|
| Integrity | a payload that does not match its published sha256 is refused in favour of the cache |
| Feeds | unchanged — still fetched by your deployment directly from each publisher |
| Custom trails | unchanged, but CUSTOM_TRAILS_DIR no longer defaults inside the installation directory
|
trails/feeds/ moved to feeds/. trails/static and trails/custom stopped pretending to be feed plugins — they only ever were so update_trails() could run one loop, which is why DISABLED_FEEDS static used to silently disable 1.6M trails.
Also in this release
Everything that accumulated since 3.1.1:
- Alerting:
ALERT_WEBHOOK_URLposts events at or above a severity threshold, with throttling. Slack, Mattermost, Discord, a SIEM collector, or your own endpoint. - Event index: a per-day SQLite sidecar.
/countsis exact rather than estimated, and/huntskips non-matching lines instead of reading every one. - JA3/JA4 client fingerprints matched as trails, byte-identical to the Python implementation and pinned by generated vectors.
- Beaconing heuristic — timer-regular reconnects to one destination, rated suspicious, never malware, because uptime monitors beacon too.
- Trail confidence: how strongly sources agree, surfaced in
/checkand the trail drawer. server --doctorpreflight, and both config parsers now flag an unknown option instead of ignoring a typo.- Longest-match whitelist precedence — an exact static trail on a more specific name than its whitelisted ancestor now fires. 3,082 trails on shared platforms were loaded, counted, and could never match.
- Source citations survive the split: each release of the trail set carries a provenance sidecar, so the trail drawer still shows the report an indicator was extracted from.
Fixes worth naming: UDP event intake was losing 24% at 10k events/s (a thread and an open() per datagram — now lossless to 20k/s); /hunt's IP search was 9× slower than a plain substring hunt; --detect-test told a healthy install its detection was broken; --smoke-test printed nothing and exited 1; every IPv6 event was unmapped on the attack map. The server also refuses to start with the private key Maltrail used to ship, and RIPEstat is proxied through /ripe so script-src is back to 'self'.
See the CHANGELOG for the full list.
Verifying what you downloaded
sha256sum -c maltrail-sensor-3.2-x86_64-unknown-linux-gnu.tar.gz.sha256
gh attestation verify oci://ghcr.io/stamparm/maltrail:3.2 --repo stamparm/maltrailBinaries are built inside AlmaLinux 8 with libpcap linked statically. Checked on the published x86_64 binary: highest glibc symbol required is 2.28, and libpcap is not among its dynamic dependencies — so it starts on RHEL 8/9, Debian 12, Ubuntu and openSUSE Leap 15 whether or not libpcap is installed. Alpine/musl has no prebuilt sensor; build from source there.
Container: ghcr.io/stamparm/maltrail:3.2