# fail2ban

The fail2ban utility provided the inspiration for this plugin. However, this approach isn't limited by simple string matching and supports various actions. It monitors events for failures using failed(). When a key exceeds its failure limit within minutes time window, ban() is called. Events pass through regardless of failure status. The following definitions are available in addition to the standard this context:

  • ipset(...args): Wraps the ipset command-line utility for IP-based banning.

# Config

Required:

  • failed: Return failures in the form {key: string, ts: number, limit: number} where ts is minutes since epoch. Return {key: null, ts: null, limit: null} if there is no failure.
  • ban: Called when failure limit reached. Return true to confirm the ban.
  • minutes: Time window for failure counting

# Example

TODO: remove failures since handled by the plugin?

pipeline:
  fail2ban:
    config:
      failures: {} # to avoid banning the same ip multiple times
      minutes: 5
      failed: !!js/function >-
        function(event) {
          let key
          let ts
          let limit
          if (event.ts && event.message) {
            if (!event.client) {
              event.client = {}
            }
            if (!event.labels) {
              event.labels = {}
            }
            ts = event.ts.unix() / 60 // convert to minutes since epoch
            let match
            if (event.source?.ip && event.event?.action === 'dropped' && event.event?.dataset === 'iptables') {
              // blacklist anything probing the firewall too much
              key = `iptables~${event.source.ip}`
              limit = 10
            } else if (event.process === 'sshd') {
              match = event.message.match(/invalid user (?<user>\S+) (?<ip>\S+) port (?<port>\d+)/ui)
              if (match) {
                const {user, ip, port} = match.groups
                event.client.ip = ip
                event.client.port = port
                event.labels.user = user
                key = `ssh~${ip}`
                limit = 1
              }
            } else if (event.client.ip && event.event?.category?.includes('web') && event.event?.type?.includes('access')) {
              // assuming web access log parsed by us
              const path = event.url?.path ?? ''
              const status = event.http?.response?.status_code
              if (path.endsWith('.env') || path.includes('\\x')) {
                key = `web~${event.client.ip}`
                limit = 1
              } else if (status >= 400 && status < 500) {
                key = `web~${event.client.ip}`
                limit = status !== 403 ? 5 : 10 // 5 failures is too easy to ban legit users
              }
            }
            if (key) {
              this.config.failures[key] = event
            }
          }
          return {key, ts, limit}
        }
      ban: !!js/function >-
        async function ban(event, key) {
          if (!this.config.failures[key]) {
            return false
          }
          const [service, ip] = key.split('~')
          this.logbus.stats({banned: 1})
          const {stderr, stdout} = await this.ipset('add', '-!', `blacklist-${service}`, ip)
          this.logbus.log.warn({ip, service, stderr, stdout}, 'banned')
          event.tags.push('banned')
          delete this.config.failures[key]
          return true
        }