The Ur Project

Documenting the craft of stacking things so they remain stacked. For a very long time.

flagger.rules(5)

NAME

flagger.rules - rule files for flagger(8)

SYNOPSIS

/usr/share/flagger/rules.d/*.toml

/etc/flagger/rules.d/*.toml

DESCRIPTION

A rule file is how flagger(8) learns what abuse looks like for one service. It says which journal messages to look at, which of them count against an address, and how much. One file per service, more or less: sshd has one, dovecot has one, and if you run nginx and want it watched, you write one.

The files are TOML. Unknown keys are rejected rather than ignored, and a pattern that doesn't compile names its file and its rule number. Only files ending in .toml are read.

Which file wins?

Both directories are read, /usr/share first. A file in /etc with the same name replaces the shipped file of that name entirely. It doesn't merge with it.

So to adjust a shipped rule set, copy the file to /etc/flagger/rules.d/ and edit the copy. To add rules, use a new file name. Your edits never get clobbered by a package update, and you always know which file is in charge.

FILE FORMAT

One [source] table, then any number of [[rule]] tables:

[source] identifier = ["sshd", "sshd-session"]

[[rule]] score = 0.2 ban = "10s" regex = 'Invalid user .* from %ip% port \d+'

Write patterns in TOML literal strings, with single quotes. Backslashes are then taken as written, so \d stays \d. In a double-quoted string you'd have to double every one of them.

[source]

This picks the journal entries the file's rules are tested against. Rules are scoped to their source: a pattern in this file is never tested against a message from a different service. That's not just tidiness, it's what keeps a pattern written for sshd from firing on whatever some other program happens to log.

At least one of these is required:

identifier = string | [ string, ... ]
Match SYSLOG_IDENTIFIER. The usual choice.
unit = string | [ string, ... ]
Match _SYSTEMD_UNIT, for example "dovecot.service".
match = { FIELD = value, ... }
Match any journal fields you like. See systemd.journal-fields(7). Each value can be a string or a list of strings.

How do several values combine?

Several values for one field are OR'd. Several fields are AND'd, whether they're in one match or spread over identifier, unit and match. So match = { SYSLOG_IDENTIFIER = "sshd", _UID = "0" } needs both to hold.

Different files are always OR'd against each other. A file selecting on unit and another selecting on identifier both work at the same time.

An empty list would match nothing, and is rejected.

[[rule]]

score = float
Added to the address's running total on every match. Must be more than zero. When the total reaches 1.0, the address is banned. Required.
regex = string
The pattern, tested against the message text. MESSAGE= is implied. Unlike tallow(1), don't write it. Required.
ban = duration
If present, every match also drops the address for this long, right away, whatever the score is. This is a throttle: it fires every time, doesn't touch the score, and never counts as an offense for escalation. Optional. Leave it out for a score-only rule. A zero duration is rejected. Durations are written as in flagger.toml(5): "10s", "15m".

Several rules in one file can match the same message, and they all count. That's how the shipped sshd rules make a root password failure worth 0.7: 0.2 from the general failure pattern, plus 0.5 from the root-specific one.

THE %ip% MACRO

%ip% expands to a capture group that matches an IPv4 or IPv6 address. Use it instead of writing an address pattern yourself:

regex = 'Failed .* for .* from %ip% port \d+ ssh2'

It can appear only once in a pattern.

Doesn't it need to be an exact address pattern?

No. Whatever %ip% captures is then parsed as an address, and that parse decides whether the match counts. So the pattern can be loose, and a rule can still never feed the firewall something that isn't an address.

A pattern can use its own capture group instead. Then the group named ip is used if there is one, and otherwise the first group. A pattern with no capture group at all is rejected: there'd be nothing to ban.

Why doesn't my sshd rule ever match?

Check for UseDNS yes in sshd_config(5). With it, sshd logs host names instead of addresses, and a host name isn't an address. Turn UseDNS off.

REGULAR EXPRESSION SYNTAX

Patterns use the Rust regex syntax: the usual character classes, repetition, alternation, groups, anchors, and Perl classes such as \d and \s. A pattern matches anywhere in the message unless you anchor it with ^ or $.

Where's lookahead?

There isn't any, and no backreferences (\1) either. The engine doesn't backtrack. That's the point: matching takes time linear in the length of the message, so nobody can write a rule, by accident or otherwise, that takes exponential time on hostile input. And an attacker gets to write a good part of what this program reads. None of the patterns ported from tallow(1) needed the missing features.

TESTING RULES

You shouldn't have to restart a daemon and wait for an attacker to find out whether a pattern works. flagger --test runs a line past every loaded rule, whatever its source, and tells you what happens. It needs no privileges, no journal and no firewall:

$ flagger --test 'Invalid user admin from 192.0.2.10 port 55232' sshd.toml:3 score 0.2 ban 10s ip 192.0.2.10 Invalid user .* from %ip% port \d+ 1 of 12 rules matched

It exits 0 if a rule matched and gave an address, 1 if nothing matched, and 2 if a rule matched but captured something that isn't an address. A 2 means that rule's pattern is too loose.

flagger --check parses every rule file and lists what it found:

rules: 3 files, 12 rules dovecot.toml SYSLOG_IDENTIFIER=auth 1 rule quasselcore.toml SYSLOG_IDENTIFIER=quasselcore 1 rule sshd.toml SYSLOG_IDENTIFIER=sshd,sshd-session 10 rules

To try rules that aren't installed yet, point --rules-dir at your working directory.

EXAMPLES

An nginx rule, as /etc/flagger/rules.d/nginx.toml. Four probes for scripts that don't exist add up to 1.2 and get a ban, and every probe is also throttled for 15 seconds on its own:

[source] identifier = "nginx"

[[rule]] score = 0.3 ban = "15s" regex = 'Primary script unknown.*client: %ip%,'

Selecting on a unit rather than an identifier:

[source] unit = "postfix.service"

[[rule]] score = 0.4 regex = 'warning: unknown[%ip%]: SASL LOGIN authentication failed'

Overriding a shipped file. Copy it, then edit the copy:

cp /usr/share/flagger/rules.d/sshd.toml /etc/flagger/rules.d/sshd.toml $EDITOR /etc/flagger/rules.d/sshd.toml flagger --check systemctl restart flagger

CONVERTING TALLOW PATTERNS

There's no automatic conversion, but it's mechanical. This from tallow(1):

{ "ban": 10, "score": 0.2, "pattern": "MESSAGE=Invalid user .* from ([0-9a-z:.]+) port \d+" }

becomes:

[[rule]] score = 0.2 ban = "10s" regex = 'Invalid user .* from %ip% port \d+'

Four things changed. ban is a duration instead of a bare number of seconds, and it's left out entirely when it was 0. The MESSAGE= prefix is gone. The hand-written address class became %ip%. And the literal string got rid of the doubled backslashes.

tallow's filter key becomes [source] at the top of the file. Unlike in tallow(1), it really does scope the rules below it.

BUGS

flagger(8) is NOT A SECURITY SOLUTION, and a rule that matches too much will lock out real users. Test new rules with --test, and think about running the daemon with --dry-run for a while after adding them.

SEE ALSO

flagger(8), flagger.toml(5), systemd.journal-fields(7)