Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .tests/mailcow-f2b-feed/config.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .tests/mailcow-f2b-feed/mailcow-f2b-feed.log
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .tests/mailcow-f2b-feed/parser.assert
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions .tests/mailcow-f2b-feed/scenario.assert
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions parsers/s01-parse/Guezli/mailcow-f2b-bans.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions parsers/s01-parse/Guezli/mailcow-f2b-bans.yaml
Original file line number Diff line number Diff line change
@@ -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:
# "<ts> CRIT: Banning <IP>/<N> for <M> minutes" -> auto-trigger via F2B regex (CIDR-suffix present)
# "<ts> CRIT: Added host/network <IP> 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
52 changes: 52 additions & 0 deletions scenarios/Guezli/mailcow-f2b-feed.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions scenarios/Guezli/mailcow-f2b-feed.yaml
Original file line number Diff line number Diff line change
@@ -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