diff --git a/.tests/mailcow-f2b-feed/config.yaml b/.tests/mailcow-f2b-feed/config.yaml new file mode 100644 index 00000000000..8423e207bf3 --- /dev/null +++ b/.tests/mailcow-f2b-feed/config.yaml @@ -0,0 +1,10 @@ +parsers: +- parsers/s01-parse/Guezli/mailcow-f2b-bans.yaml +- crowdsecurity/syslog-logs +- crowdsecurity/dateparse-enrich +scenarios: +- scenarios/Guezli/mailcow-f2b-feed.yaml +postoverflows: +- "" +log_file: mailcow-f2b-feed.log +log_type: mailcow-f2b diff --git a/.tests/mailcow-f2b-feed/mailcow-f2b-feed.log b/.tests/mailcow-f2b-feed/mailcow-f2b-feed.log new file mode 100644 index 00000000000..3798e142918 --- /dev/null +++ b/.tests/mailcow-f2b-feed/mailcow-f2b-feed.log @@ -0,0 +1,3 @@ +2026-05-09 13:50:00 CRIT: Banning 198.51.100.42/32 for 60 minutes +2026-05-09 13:51:30 CRIT: Banning 198.51.100.99/32 for 120 minutes +2026-05-09 13:55:12 CRIT: Added host/network 203.0.113.7 to denylist diff --git a/.tests/mailcow-f2b-feed/parser.assert b/.tests/mailcow-f2b-feed/parser.assert new file mode 100644 index 00000000000..9ddca68fc16 --- /dev/null +++ b/.tests/mailcow-f2b-feed/parser.assert @@ -0,0 +1,9 @@ +len(results["s01-parse"]["Guezli/mailcow-f2b-bans"]) == 3 +results["s01-parse"]["Guezli/mailcow-f2b-bans"][0].Success == true +results["s01-parse"]["Guezli/mailcow-f2b-bans"][0].Evt.Meta["log_type"] == "mailcow_f2b_ban" +results["s01-parse"]["Guezli/mailcow-f2b-bans"][0].Evt.Meta["source_ip"] == "198.51.100.42" +results["s01-parse"]["Guezli/mailcow-f2b-bans"][0].Evt.Meta["service"] == "mailcow-f2b" +results["s01-parse"]["Guezli/mailcow-f2b-bans"][1].Success == true +results["s01-parse"]["Guezli/mailcow-f2b-bans"][1].Evt.Meta["source_ip"] == "198.51.100.99" +results["s01-parse"]["Guezli/mailcow-f2b-bans"][2].Success == true +results["s01-parse"]["Guezli/mailcow-f2b-bans"][2].Evt.Meta["source_ip"] == "203.0.113.7" diff --git a/.tests/mailcow-f2b-feed/scenario.assert b/.tests/mailcow-f2b-feed/scenario.assert new file mode 100644 index 00000000000..59c1a92702f --- /dev/null +++ b/.tests/mailcow-f2b-feed/scenario.assert @@ -0,0 +1,5 @@ +len(results) == 3 +results[0].Overflow.Alert.GetScenario() == "Guezli/mailcow-f2b-feed" +results[1].Overflow.Alert.GetScenario() == "Guezli/mailcow-f2b-feed" +results[2].Overflow.Alert.GetScenario() == "Guezli/mailcow-f2b-feed" +results[0].Overflow.Alert.Remediation == true diff --git a/parsers/s01-parse/Guezli/mailcow-f2b-bans.md b/parsers/s01-parse/Guezli/mailcow-f2b-bans.md new file mode 100644 index 00000000000..ea3074de59a --- /dev/null +++ b/parsers/s01-parse/Guezli/mailcow-f2b-bans.md @@ -0,0 +1,41 @@ +## Mailcow netfilter-mailcow ban-line parser + +Parses ban lines emitted by Mailcow's internal `netfilter-mailcow` component +(Mailcow's own F2B-equivalent that lives inside the Mailcow Docker network). + +Two log patterns are recognized: + +| Source | Sample line | Trigger | +|---|---|---| +| Auto-ban from F2B regex | `CRIT: Banning 198.51.100.42/32 for 60 minutes` | regex match against Mailcow internal failure counters | +| Manual perm-ban from `F2B_BLACKLIST` | `CRIT: Added host/network 198.51.100.42 to denylist` | static blacklist via Mailcow admin UI | + +Extracted fields: + +- `source_ip` -- the banned IP +- `ban_duration_min` -- duration in minutes (only set on auto-bans) +- `log_type` -- set to `mailcow_f2b_ban` for the downstream scenario +- `service` -- set to `mailcow-f2b` + +### Acquisition example + +The `netfilter-mailcow` container logs to stdout in a plain-text format +(no syslog, no JSON), so the docker acquisition tags it via `labels.type`: + +```yaml +source: docker +container_name: + - mailcowdockerized-netfilter-mailcow-1 +labels: + type: mailcow-f2b +``` + +The parser filter then keys off `evt.Parsed.program == 'mailcow-f2b'`, +which `crowdsecurity/non-syslog` populates automatically from the +acquisition `labels.type`. + +### Companion scenario + +This parser is meant to be paired with `Guezli/mailcow-f2b-feed`, which +turns each parsed ban event into a Crowdsec LAPI decision so the +host-side nftables-bouncer can act on Mailcow-internal bans. diff --git a/parsers/s01-parse/Guezli/mailcow-f2b-bans.yaml b/parsers/s01-parse/Guezli/mailcow-f2b-bans.yaml new file mode 100644 index 00000000000..36280949ddc --- /dev/null +++ b/parsers/s01-parse/Guezli/mailcow-f2b-bans.yaml @@ -0,0 +1,27 @@ +filter: "evt.Parsed.program == 'mailcow-f2b' && (evt.Parsed.message contains 'Banning' || evt.Parsed.message contains 'Added host/network')" +onsuccess: next_stage +name: Guezli/mailcow-f2b-bans +description: "Parse Mailcow netfilter-mailcow ban lines (auto-rule-trigger + manual perm-ban)" +pattern_syntax: + # netfilter-mailcow prefixes every line with an ISO-8601-like timestamp + # (e.g. "2026-05-09 13:50:00 CRIT: Banning ...") + MAILCOW_F2B_TS: '%{YEAR}-%{MONTHNUM}-%{MONTHDAY} %{HOUR}:%{MINUTE}:%{SECOND}' + +nodes: + - grok: + # Two patterns netfilter-mailcow emits as CRIT-bans: + # " CRIT: Banning / for minutes" -> auto-trigger via F2B regex (CIDR-suffix present) + # " CRIT: Added host/network to denylist" -> manual perm-ban via F2B_BLACKLIST (no CIDR-suffix) + pattern: '%{MAILCOW_F2B_TS:ts}.*?(?:Banning|Added host/network) %{IP:banned_ip}(?:/\d+)?(?: for %{NUMBER:ban_min} minutes| to denylist)' + apply_on: message + statics: + - target: evt.StrTime + expression: evt.Parsed.ts + - meta: source_ip + expression: evt.Parsed.banned_ip + - meta: log_type + value: mailcow_f2b_ban + - meta: ban_duration_min + expression: evt.Parsed.ban_min + - meta: service + value: mailcow-f2b diff --git a/scenarios/Guezli/mailcow-f2b-feed.md b/scenarios/Guezli/mailcow-f2b-feed.md new file mode 100644 index 00000000000..85aed918228 --- /dev/null +++ b/scenarios/Guezli/mailcow-f2b-feed.md @@ -0,0 +1,52 @@ +## Mailcow F2B cross-layer ban feed + +Propagates **Mailcow's internal F2B bans** into the local Crowdsec LAPI, so +the host-side nftables-bouncer (and any other Crowdsec consumer) sees +and acts on bans that Mailcow detects but Crowdsec wouldn't catch by itself. + +### Why this exists + +Mailcow ships its own Fail2Ban-equivalent (`netfilter-mailcow`) which subscribes +to a Redis channel populated by SOGo, dovecot, postfix, the Mailcow admin UI +and rspamd. It implements iptables-level bans inside Mailcow's Docker network. + +The catch: those bans live **only** in Mailcow's container network. They never +reach Crowdsec, so: + +- The host-side **nftables-bouncer** doesn't know about them. +- They aren't logged as **Crowdsec alerts**, so cross-instance sharing, + CAPI propagation and unified alerting (Loki, Gotify, etc.) miss them. +- A SOGo-webmail bruteforce -- which Crowdsec out-of-the-box doesn't parse -- + gets banned by Mailcow only at the Docker network level, while the + bruteforcer can keep hitting other host-level services. + +This scenario closes the gap. It triggers on each parsed Mailcow-F2B +ban line and produces a Crowdsec LAPI decision for the banned IP. + +### Requirements + +- Companion parser `Guezli/mailcow-f2b-bans` (provided in the same PR) +- Acquisition of the `netfilter-mailcow` Docker container's stdout + +### Acquisition example + +```yaml +source: docker +container_name: + - mailcowdockerized-netfilter-mailcow-1 +labels: + type: mailcow-f2b +``` + +### Notes + +- The `behavior` label is `generic:bruteforce` because the underlying ban can + come from any of the layers Mailcow's F2B watches (SMTP, IMAP, webmail UI, + admin UI, rspamd UI), not just one specific protocol. +- `blackhole: 1h` prevents bucket spam from repeated ban-renew lines on the + same IP within an hour -- the actual Crowdsec decision lifetime is set by + your profile, not by `blackhole`. +- Trigger-style on purpose: Mailcow already did the threshold detection, + this scenario just propagates the verdict. +- Project repository with installer and detailed background: + https://github.com/Guezli/crowdsec-mailcow-f2b-feed diff --git a/scenarios/Guezli/mailcow-f2b-feed.yaml b/scenarios/Guezli/mailcow-f2b-feed.yaml new file mode 100644 index 00000000000..91359a6d63c --- /dev/null +++ b/scenarios/Guezli/mailcow-f2b-feed.yaml @@ -0,0 +1,27 @@ +# Propagate Mailcow's internal F2B bans into the local Crowdsec LAPI, +# so the host-side nftables-bouncer (and any other Crowdsec consumers) +# act on bans that Mailcow detects but Crowdsec wouldn't on its own +# (SOGo webmail UI, rspamd admin UI, Mailcow-specific dovecot/postfix +# patterns). +# +# Requires the companion parser `Guezli/mailcow-f2b-bans`, which sets +# evt.Meta.log_type to `mailcow_f2b_ban` on parsed ban lines. + +type: trigger +name: Guezli/mailcow-f2b-feed +description: "Propagate Mailcow internal F2B bans into the local Crowdsec LAPI" +filter: "evt.Meta.log_type == 'mailcow_f2b_ban'" +groupby: evt.Meta.source_ip +blackhole: 1h +labels: + service: mailcow-f2b + remediation: true + confidence: 3 + spoofable: 0 + classification: + - attack.T1110 + behavior: "generic:bruteforce" + label: "Mailcow F2B Cross-Layer Feed" +references: + - https://docs.mailcow.email/ + - https://github.com/Guezli/crowdsec-mailcow-f2b-feed