Filter

Filters run Actions when they Match regexes on a Stream.

Filters are reaction's main component, enclosing most of its runtime logic.

A Filter is attached to a Stream and receive its text stream as an input.

It applies one or more regexes to each line. When there is one or more Match in a given period, the Filter is Triggered and executes one or more Actions.

regex

A list of regexes to try matching on the input Stream.

Whenever one of them matches, a Match is created.

streams:
  nginx:
    ...
    filters:
      unauthorized:
        regex:
          - '^<ip> .* HTTP/\d\.\d" 401'
streams: {
  nginx: {
    ...
    filters: {
      unauthorized: {
        regex: [
          '^<ip> .* HTTP/\d\.\d" 401',
        ],
      },
    },
  },
}

Patterns can be referenced in the regexes by providing their name enclosed in < and >. The Match will then contain the actual value matched by the Pattern and can be reused in Actions.

All regexes of a given Filter must include the exact same set of Patterns.

patterns:
  ip:
    type: 'ip'

streams:
  nginx:
    ...
    filters:
      unauthorized:
        regex:
          - '<ip> .* HTTP/\d\.\d" 401'
patterns: {
  ip: {
    type: 'ip',
  },
},
streams: {
  nginx: {
    ...
    filters: {
      unauthorized: {
        regex: [
          '^<ip> .* HTTP/\d\.\d" 401',
        ],
      },
    },
  },
}

Regex syntax is documented here

type

Available since v2.4.0.

Defaults to "regex", in which case a regex must be provided.

{
  my_fitler: {
    type: "regex",
    regex: [...],
  }
}
my_fitler:
  type: regex
  regex: [...]

Otherwise, it can be "struct", in which case reaction will parse each line as a structured format, such as JSON.

options

Available since v2.4.0.

Used in conjunction with type: "struct".

options.lang

Must be set. Only "json" is available for now.

options.fields

This represents the fields we want to match in our JSON line.

It replaces the common regex option.

Each field can be matched with one of those options:

options.fields.<name>.regex

This field's value must match the regex. The regex can contain Patterns.

A regex can be matched against strings and numbers. Numbers will be converted to string.

{
  my_filter: {
    type: "struct",
    options: {
      lang: "json",
      fields: {
        # short form
        ClientIP: "<ip>",
        # long form
        ClientIP: {
          regex: "<ip>",
        }
      }
    }
  }
}
my_filter:
  type: struct
  options:
    lang: json
    fields:
      # short form
      ClientIP: "<ip>"
      # long form
      ClientIP:
        regex: "<ip>"

Example of matching line:

{"ClientIP":"203.0.113.57","DownstreamStatus":401}

options.fields.<name>.equals

This field's value must match the given value.

The given value can be any JSON value: number, string, arrary...

{
  my_filter: {
    type: "struct",
    options: {
      lang: "json",
      fields: {
        RequestPath: {
          equals: "/login",
        },
        DownstreamStatus: {
          equals: 403,
        },
        ProxyChain: {
          equals: ["http://10.0.0.2:443", "http://127.0.0.1:8709"],
        },
      }
    }
  }
}
my_filter:
  type: struct
  options:
    lang: json
    fields:
      RequestPath:
        equals: "/login"

      DownstreamStatus:
        equals: 403

      ProxyChain:
        equals: ["http://10.0.0.2:443", "http://127.0.0.1:8709"]

Example of matching line:

{"ClientIP":"203.0.113.57","DownstreamStatus":403,"ProxyChain":["http://10.0.0.2:443","http://127.0.0.1:8709"],"RequestPath":"/login"}

options.fields.<name>.exists

If true, the field must exist and its value must be non-null.

If false, the field must not exist, or its value must be null.

{
  my_filter: {
    type: "struct",
    options: {
      lang: "json",
      fields: {
        RejectReason: {
          exist: true,
        },
      }
    }
  }
}
my_filter:
  type: struct
  options:
    lang: json
    fields:
      RejectReason:
        exist: true

Example of matching lines:

{"ClientIP":"203.0.113.57","RejectReason":"invalid password"}
{"ClientIP":"203.0.113.57","RejectReason":true}
{"ClientIP":"203.0.113.57","RejectReason":false}

Example of non-matching lines:

{"ClientIP":"203.0.113.57","error":"invalid password"}
{"ClientIP":"203.0.113.57","RejectReason":null}
{"ClientIP":"203.0.113.57"}

options.fields.<name>.<name>

It is possible to match on nested fields of arbitrary depth:

{
  my_filter: {
    type: "struct",
    options: {
      lang: "json",
      fields: {
        request: {
          client_ip: "<ip>",
          uri: {
            equals: "/.env",
          },
          headers: {
            "User-Agent": {
              regex: ".*(ClaudeBot|GPTBot).*",
            }
          }
        }
      }
    }
  }
}
my_filter:
  type: struct
  options:
    lang: json
    fields:
      request:
        client_ip: "<ip>"
        uri:
          equals: "/.env"
        headers:
          "User-Agent":
            regex: ".*(ClaudeBot|GPTBot).*"

retry

How many Matches must happen before the Filter Triggers its Actions.

If not specified, defaults to 1. If specified, must be > 1.

Must be specified along with retryperiod.

retryperiod

The retain period for Matches.

Must be specified along with retry.

Format is defined as follows: <number> <unit>

  • whitespace between the integer and unit is optional
  • number must be a positive integer (>= 0, no floating point)
  • unit can be one of:
    • ms / millis / millisecond / milliseconds
    • s / sec / secs / second / seconds
    • m / min / mins / minute / minutes
    • h / hour / hours
    • d / day / days

Examples:

{
  streams:
    stream1: {
      filters: {
        filter0: {
          regex: [ ... ],
          // no retry/retryperiod, trigger as soon as there is a match
        },
        filter1: {
          regex: [ ... ],
          // 2 matches in 10 seconds to trigger
          retry: 2,
          retryperiod: '10 secs',
        },
        filter2: {
          regex: [ ... ],
          // 4 matches in 30 minutes to trigger
          retry: 4,
          retryperiod: '30m',
        },
        filter3: {
          regex: [ ... ],
          // 3 matches in 1 day to trigger
          retry: 3,
          retryperiod: '1day',
        },
      },
    },
  },
}
streams:
  stream1:
    filters:
      filter0:
        regex:
          - ...
          # no retry/retryperiod, trigger as soon as there is a match
      filter1:
        regex:
          - ...
        # 2 matches in 10 seconds to trigger
        retry: 2,
        retryperiod: '10 secs',
      filter2:
        regex:
          - ...
        # 4 matches in 30 minutes to trigger
        retry: 4,
        retryperiod: '30m',
      filter3:
        regex:
          - ...
        # 3 matches in 1 day to trigger
        retry: 3,
        retryperiod: '1day',

duplicate

Available since v2.2.0.

Specify what reaction must do when the Filter matches an already Triggered Match.

3 behaviors are possible: extend, rerun and ignore.

Before v2.2.0, reaction's filters used to execute the same actions multiple times.

For example, when an IP address was spamming the server, due to the latency between the moment the request was received and the moment the ban action was executed by reaction, and made effective by the firewall, other logs concerning this IP address could appear, resulting in a new trigger of the reaction filter. Therefore, an IP could be banned multiple times, resulting in bugs on some firewalls that deduplicate IPs, like ipset and nftables.

The new behavior defaults to extending the trigger period.

Let's take this filter as an example:

{
  regex: [ @'Failed password for .* from <ip>' ],
  actions: {
    ban: {
      cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
    },
    unban: {
      cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
      after: '2h',
    },
  },
}
regex: [ 'Failed password for .* from <ip>' ]
actions:
  ban:
    cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP']
  unban:
    cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP']
    after: '2h'

If a filter is triggered at 0:00, the ban action runs, and the unban action is scheduled at 2:00. If for some reason, some new matches appear at 0:10:

  • reaction defaults to extending the unban. So it won't schedule a new ban command. It will instead reschedule the existing unban action to 2:10. This default behavior can be explicitly specified by:
    duplicate: 'extend' // the default
  • reaction can launch a new ban command, and schedule a new unban command at 2:10. This (old default) behavior is possible by adding this parameter:
    duplicate: 'rerun'
  • reaction can also simply ignore new matches for this IP, until the unban command runs:
    duplicate: 'ignore'

Last example with duplicate:

{
  duplicate: 'rerun',
  regex: [ @'Failed password for .* from <ip>' ],
  actions: {
    ban: {
      cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP'],
    },
    unban: {
      cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP'],
      after: '2h',
    },
  },
}
duplicate: 'rerun'
regex:
  - 'Failed password for .* from <ip>'
actions:
  ban:
    cmd: ['iptables', '-w', '-A', 'reaction', '-s', '<ip>', '-j', 'DROP']
  unban:
    cmd: ['iptables', '-w', '-D', 'reaction', '-s', '<ip>', '-j', 'DROP']
    after: '2h'

actions

We can attach one or more Actions to a Filter.

{
  streams: {
    ...
    filters: {
      failedlogin: {
        regex: [ ... ],
        actions: {
          ...
        },
      },
      connectionreset: {
        regex: [ ... ],
        actions: {
          ...
        },
      },
    },
  },
}
streams:
  ...
  filters:
    failedlogin:
      regex:
        ...
      actions:
        ...
    connectionreset:
      regex:
        ...
      actions:
        ...