haraka/haraka-plugin-karma

View on GitHub
index.js

Summary

Maintainability
C
1 day
Test Coverage
'use strict'
// karma - reward good and penalize bad mail senders

const constants = require('haraka-constants')
const redis = require('redis')
const utils = require('haraka-utils')

const phase_prefixes = utils.to_object([
  'connect',
  'helo',
  'mail_from',
  'rcpt_to',
  'data',
])

exports.register = function () {
  this.inherits('haraka-plugin-redis')

  // set up defaults
  this.deny_hooks = utils.to_object([
    'unrecognized_command',
    'helo',
    'data',
    'data_post',
    'queue',
    'queue_outbound',
  ])
  this.deny_exclude_hooks = utils.to_object('rcpt_to queue queue_outbound')
  this.deny_exclude_plugins = utils.to_object([
    'access',
    'helo.checks',
    'data.headers',
    'spamassassin',
    'mail_from.is_resolvable',
    'clamd',
    'tls',
  ])

  this.load_karma_ini()

  this.register_hook('init_master', 'init_redis_plugin')
  this.register_hook('init_child', 'init_redis_plugin')

  this.register_hook('connect_init', 'results_init')
  this.register_hook('connect_init', 'ip_history_from_redis')
}

exports.load_karma_ini = function () {
  const plugin = this

  plugin.cfg = plugin.config.get(
    'karma.ini',
    {
      booleans: ['+asn.enable'],
    },
    function () {
      plugin.load_karma_ini()
    },
  )

  plugin.merge_redis_ini()

  const cfg = plugin.cfg
  if (cfg.deny && cfg.deny.hooks) {
    plugin.deny_hooks = utils.to_object(cfg.deny.hooks)
  }

  const e = cfg.deny_excludes
  if (e && e.hooks) {
    plugin.deny_exclude_hooks = utils.to_object(e.hooks)
  }

  if (e && e.plugins) {
    plugin.deny_exclude_plugins = utils.to_object(e.plugins)
  }

  if (cfg.result_awards) {
    plugin.preparse_result_awards()
  }

  if (!cfg.redis) cfg.redis = {}
  if (!cfg.redis.host && cfg.redis.server_ip) {
    cfg.redis.host = cfg.redis.server_ip // backwards compat
  }
  if (!cfg.redis.port && cfg.redis.server_port) {
    cfg.redis.port = cfg.redis.server_port // backwards compat
  }
}

exports.results_init = async function (next, connection) {
  if (this.should_we_skip(connection)) {
    connection.logdebug(this, 'skipping')
    return next()
  }

  if (connection.results.get('karma')) {
    connection.logerror(this, 'this should never happen')
    return next() // init once per connection
  }

  if (this.cfg.awards) {
    // todo is a list of connection/transaction awards to 'watch' for.
    // When discovered, apply the awards value
    const todo = {}
    for (const key in this.cfg.awards) {
      const award = this.cfg.awards[key].toString()
      todo[key] = award
    }
    connection.results.add(this, { score: 0, todo })
  } else {
    connection.results.add(this, { score: 0 })
  }

  if (!connection.server.notes.redis) {
    connection.logerror(this, 'karma requires the redis plugin')
    return next()
  }

  if (!this.result_awards) return next() // not configured

  if (connection.notes.redis) {
    connection.logdebug(this, `redis already subscribed`)
    return // another plugin has already called this.
  }

  connection.notes.redis = redis.createClient(this.redisCfg.pubsub)
  connection.notes.redis.on('error', (err) => {
    connection.logerror(this, err.message)
  })
  await connection.notes.redis.connect()

  const pattern = this.get_redis_sub_channel(connection)
  connection.notes.redis.pSubscribe(pattern, (message) => {
    this.check_result(connection, message)
  })

  next()
}

exports.preparse_result_awards = function () {
  if (!this.result_awards) this.result_awards = {}

  const cra = this.cfg.result_awards
  // arrange results for rapid traversal by check_result() :
  // ex: karma.result_awards.clamd.fail = { .... }
  for (const anum of Object.keys(cra)) {
    const [pi_name, prop, operator, value, award, reason, resolv] =
      cra[anum].split(/(?:\s*\|\s*)/)

    const ra = this.result_awards

    if (!ra[pi_name]) ra[pi_name] = {}

    if (!ra[pi_name][prop]) ra[pi_name][prop] = []

    ra[pi_name][prop].push({ id: anum, operator, value, award, reason, resolv })
  }
}

exports.check_result = function (connection, message) {
  // connection.loginfo(this, message);
  // {"plugin":"karma","result":{"fail":"spamassassin.hits"}}
  // {"plugin":"geoip","result":{"country":"CN"}}

  const m = JSON.parse(message)
  if (m && m.result && m.result.asn) {
    this.check_result_asn(m.result.asn, connection)
  }
  if (!this.result_awards[m.plugin]) return // no awards for plugin

  for (const r of Object.keys(m.result)) {
    // each result in mess
    if (r === 'emit') continue // r: pass, fail, skip, err, ...

    const pi_prop = this.result_awards[m.plugin][r]
    if (!pi_prop) continue // no award for this plugin property

    const thisResult = m.result[r]
    // ignore empty arrays, objects, and strings
    if (Array.isArray(thisResult) && thisResult.length === 0) continue
    if (typeof thisResult === 'object' && !Object.keys(thisResult).length) {
      continue
    }
    if (typeof thisResult === 'string' && !thisResult) continue // empty

    // do any award conditions match this result?
    for (const thisAward of pi_prop) {
      // each award...
      // { id: '011', operator: 'equals', value: 'all_bad', award: '-2'}
      const thisResArr = this.result_as_array(thisResult)
      switch (thisAward.operator) {
        case 'eq':
        case 'equal':
        case 'equals':
          this.check_result_equal(thisResArr, thisAward, connection)
          break
        case 'match':
          this.check_result_match(thisResArr, thisAward, connection)
          break
        case 'lt':
          this.check_result_lt(thisResArr, thisAward, connection)
          break
        case 'gt':
          this.check_result_gt(thisResArr, thisAward, connection)
          break
        case 'length':
          this.check_result_length(thisResArr, thisAward, connection)
          break
      }
    }
  }
}

exports.result_as_array = function (result) {
  if (typeof result === 'string') return [result]
  if (typeof result === 'number') return [result]
  if (typeof result === 'boolean') return [result]
  if (Array.isArray(result)) return result
  if (typeof result === 'object') {
    const array = []
    Object.keys(result).forEach((tr) => {
      array.push(result[tr])
    })
    return array
  }
  this.loginfo(`what format is result: ${result}`)
  return result
}

exports.check_result_asn = function (asn, conn) {
  if (!this.cfg.asn_awards) return
  if (!this.cfg.asn_awards[asn]) return

  conn.results.incr(this, { score: this.cfg.asn_awards[asn] })
  conn.results.push(this, { fail: 'asn_awards' })
}

exports.check_result_lt = function (thisResult, thisAward, conn) {
  for (const element of thisResult) {
    const tr = parseFloat(element)
    if (tr >= parseFloat(thisAward.value)) continue
    if (conn.results.has('karma', 'awards', thisAward.id)) continue

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.check_result_gt = function (thisResult, thisAward, conn) {
  for (const element of thisResult) {
    const tr = parseFloat(element)
    if (tr <= parseFloat(thisAward.value)) continue
    if (conn.results.has('karma', 'awards', thisAward.id)) continue

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.check_result_equal = function (thisResult, thisAward, conn) {
  for (const element of thisResult) {
    if (thisAward.value === 'true') {
      if (!element) continue
    } else {
      if (element != thisAward.value) continue
    }
    if (!/auth/.test(thisAward.plugin)) {
      // only auth attempts are scored > 1x
      if (conn.results.has('karma', 'awards', thisAward.id)) continue
    }

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.check_result_match = function (thisResult, thisAward, conn) {
  const re = new RegExp(thisAward.value, 'i')

  for (const element of thisResult) {
    if (!re.test(element)) continue
    if (conn.results.has('karma', 'awards', thisAward.id)) continue

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.check_result_length = function (thisResult, thisAward, conn) {
  for (const element of thisResult) {
    const [operator, qty] = thisAward.value.split(/\s+/) // requires node 6+

    switch (operator) {
      case 'eq':
      case 'equal':
      case 'equals':
        if (parseInt(element, 10) != parseInt(qty, 10)) continue
        break
      case 'gt':
        if (parseInt(element, 10) <= parseInt(qty, 10)) continue
        break
      case 'lt':
        if (parseInt(element, 10) >= parseInt(qty, 10)) continue
        break
      default:
        conn.results.add(this, { err: `invalid operator: ${operator}` })
        continue
    }

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.check_result_exists = function (thisResult, thisAward, conn) {
  /* eslint-disable no-unused-vars */
  for (const r of thisResult) {
    const [operator, qty] = thisAward.value.split(/\s+/)

    switch (operator) {
      case 'any':
      case '':
        break
      default:
        conn.results.add(this, { err: `invalid operator: ${operator}` })
        continue
    }

    conn.results.incr(this, { score: thisAward.award })
    conn.results.push(this, { awards: thisAward.id })
  }
}

exports.apply_tarpit = function (connection, hook, score, next) {
  if (!this.cfg.tarpit) return next() // tarpit disabled in config

  // If tarpit is enabled on the reset_transaction hook, Haraka doesn't
  // wait. Then bad things happen, like a Haraka crash.
  if (utils.in_array(hook, ['reset_transaction', 'queue'])) return next()

  // no delay for senders with good karma
  const k = connection.results.get('karma')
  if (score === undefined) score = parseFloat(k.score)
  if (score >= 0) return next()

  // how long to delay?
  const delay = this.tarpit_delay(score, connection, hook, k)
  if (!delay) return next()

  connection.logdebug(this, `tarpitting ${hook} for ${delay}s`)
  setTimeout(() => {
    connection.logdebug(this, `tarpit ${hook} end`)
    next()
  }, delay * 1000)
}

exports.tarpit_delay = function (score, connection, hook, k) {
  if (this.cfg.tarpit.delay && parseFloat(this.cfg.tarpit.delay)) {
    connection.logdebug(this, 'static tarpit')
    return parseFloat(this.cfg.tarpit.delay)
  }

  const delay = score * -1 // progressive tarpit

  // detect roaming users based on MSA ports that require auth
  if (
    utils.in_array(connection.local.port, [587, 465]) &&
    utils.in_array(hook, ['ehlo', 'connect'])
  ) {
    return this.tarpit_delay_msa(connection, delay, k)
  }

  const max = this.cfg.tarpit.max || 5
  if (delay > max) {
    connection.logdebug(this, `tarpit capped to: ${max}`)
    return max
  }

  return delay
}

exports.tarpit_delay_msa = function (connection, delay, k) {
  const trg = 'tarpit reduced for good'

  delay = parseFloat(delay)

  // Reduce delay for good history
  const history = (k.good || 0) - (k.bad || 0)
  if (history > 0) {
    delay = delay - 2
    connection.logdebug(this, `${trg} history: ${delay}`)
  }

  // Reduce delay for good ASN history
  let asn = connection.results.get('asn')
  if (!asn) asn = connection.results.get('geoip')
  if (asn && asn.asn && k.neighbors > 0) {
    connection.logdebug(this, `${trg} neighbors: ${delay}`)
    delay = delay - 2
  }

  const max = this.cfg.tarpit.max_msa || 2
  if (delay > max) {
    connection.logdebug(this, `tarpit capped at: ${delay}`)
    delay = max
  }

  return delay
}

exports.should_we_skip = function (connection) {
  if (connection.remote.is_private) return true
  if (connection.notes.disable_karma) return true
  return false
}

exports.should_we_deny = function (next, connection, hook) {
  const r = connection.results.get('karma')
  if (!r) return next()

  this.check_awards(connection) // update awards first

  const score = parseFloat(r.score)
  if (isNaN(score)) {
    connection.logerror(this, 'score is NaN')
    connection.results.add(this, { score: 0 })
    return next()
  }

  let negative_limit = -5
  if (this.cfg.thresholds && this.cfg.thresholds.negative) {
    negative_limit = parseFloat(this.cfg.thresholds.negative)
  }

  if (score > negative_limit) {
    return this.apply_tarpit(connection, hook, score, next)
  }
  if (!this.deny_hooks[hook]) {
    return this.apply_tarpit(connection, hook, score, next)
  }

  let rejectMsg = 'very bad karma score: {score}'
  if (this.cfg.deny && this.cfg.deny.message) {
    rejectMsg = this.cfg.deny.message
  }

  if (/\{/.test(rejectMsg)) {
    rejectMsg = rejectMsg.replace(/\{score\}/, score)
    rejectMsg = rejectMsg.replace(/\{uuid\}/, connection.uuid)
  }

  return this.apply_tarpit(connection, hook, score, () => {
    next(constants.DENY, rejectMsg)
  })
}

exports.hook_deny = function (next, connection, params) {
  if (this.should_we_skip(connection)) return next()

  // let pi_deny     = params[0];  // (constants.deny, denysoft, ok)
  // let pi_message  = params[1];
  const pi_name = params[2]
  // let pi_function = params[3];
  // let pi_params   = params[4];
  const pi_hook = params[5]

  // exceptions, whose 'DENY' should not be captured
  if (pi_name) {
    if (pi_name === 'karma') return next()
    if (this.deny_exclude_plugins[pi_name]) return next()
  }
  if (pi_hook && this.deny_exclude_hooks[pi_hook]) return next()

  if (!connection.results) return next(constants.OK) // resume the connection

  // intercept any other denials
  connection.results.add(this, { msg: `deny: ${pi_name}` })
  connection.results.incr(this, { score: -2 })

  next(constants.OK) // resume the connection
}

exports.hook_connect = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  const asnkey = this.get_asn_key(connection)
  if (asnkey) {
    this.check_asn(connection, asnkey)
  }
  this.should_we_deny(next, connection, 'connect')
}

exports.hook_helo = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'helo')
}

exports.hook_ehlo = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'ehlo')
}

exports.hook_vrfy = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'vrfy')
}

exports.hook_noop = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'noop')
}

exports.hook_data = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'data')
}

exports.hook_queue = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'queue')
}

exports.hook_queue_outbound = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.should_we_deny(next, connection, 'queue_outbound')
}

exports.hook_reset_transaction = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  connection.results.add(this, { emit: true })
  this.should_we_deny(next, connection, 'reset_transaction')
}

exports.hook_unrecognized_command = function (next, connection, params) {
  if (this.should_we_skip(connection)) return next()

  // in case karma is in config/plugins before tls
  if (params[0].toUpperCase() === 'STARTTLS') return next()

  // in case karma is in config/plugins before AUTH plugin(s)
  if (connection.notes.authenticating) return next()

  connection.results.incr(this, { score: -1 })
  connection.results.add(this, { fail: `cmd:(${params})` })

  return this.should_we_deny(next, connection, 'unrecognized_command')
}

exports.ip_history_from_redis = function (next, connection) {
  const plugin = this

  if (this.should_we_skip(connection)) return next()

  const expire = (this.cfg.redis.expire_days || 60) * 86400 // to days
  const dbkey = `karma|${connection.remote.ip}`

  // redis plugin is emitting errors, no need to here
  if (!this.db) return next()

  this.db
    .hGetAll(dbkey)
    .then((dbr) => {
      if (dbr === null) {
        plugin.init_ip(dbkey, connection.remote.ip, expire)
        return next()
      }

      plugin.db
        .multi()
        .hIncrBy(dbkey, 'connections', 1) // increment total conn
        .expire(dbkey, expire) // extend expiration
        .exec()
        .catch((err) => {
          connection.results.add(plugin, { err })
        })

      const results = {
        good: dbr.good,
        bad: dbr.bad,
        connections: dbr.connections,
        history: parseInt((dbr.good || 0) - (dbr.bad || 0)),
        emit: true,
      }

      // Careful: don't become self-fulfilling prophecy.
      if (parseInt(dbr.good) > 5 && parseInt(dbr.bad) === 0) {
        results.pass = 'all_good'
      }
      if (parseInt(dbr.bad) > 5 && parseInt(dbr.good) === 0) {
        results.fail = 'all_bad'
      }

      connection.results.add(plugin, results)

      plugin.check_awards(connection)
      next()
    })
    .catch((err) => {
      connection.results.add(plugin, { err })
      next()
    })
}

exports.hook_mail = function (next, connection, params) {
  if (this.should_we_skip(connection)) return next()

  this.check_spammy_tld(params[0], connection)

  // look for invalid (RFC 5321,(2)821) space in envelope from
  const full_from = connection.current_line
  if (full_from.toUpperCase().substring(0, 11) !== 'MAIL FROM:<') {
    connection.loginfo(this, `RFC ignorant env addr format: ${full_from}`)
    connection.results.add(this, { fail: 'rfc5321.MailFrom' })
  }

  // apply TLS awards (if defined)
  if (this.cfg.tls !== undefined) {
    if (this.cfg.tls.set && connection.tls.enabled) {
      connection.results.incr(this, { score: this.cfg.tls.set })
    }
    if (this.cfg.tls.unset && !connection.tls.enabled) {
      connection.results.incr(this, { score: this.cfg.tls.unset })
    }
  }

  return this.should_we_deny(next, connection, 'mail')
}

exports.hook_rcpt = function (next, connection, params) {
  if (this.should_we_skip(connection)) return next()

  const rcpt = params[0]

  // hook_rcpt    catches recipients that no rcpt_to plugin permitted
  // hook_rcpt_ok catches accepted recipients

  // odds of from_user=rcpt_user in ham: < 1%, in spam > 40%
  // 2015-05 30-day sample: 84% spam correlation
  if (connection?.transaction?.mail_from?.user === rcpt.user) {
    connection.results.add(this, { fail: 'env_user_match' })
  }

  this.check_syntax_RcptTo(connection)

  connection.results.add(this, { fail: 'rcpt_to' })

  return this.should_we_deny(next, connection, 'rcpt')
}

exports.hook_rcpt_ok = function (next, connection, rcpt) {
  if (this.should_we_skip(connection)) return next()

  const txn = connection.transaction
  if (txn && txn.mail_from && txn.mail_from.user === rcpt.user) {
    connection.results.add(this, { fail: 'env_user_match' })
  }

  this.check_syntax_RcptTo(connection)

  return this.should_we_deny(next, connection, 'rcpt')
}

exports.hook_data_post = function (next, connection) {
  // goal: prevent delivery of spam before queue

  if (this.should_we_skip(connection)) return next()

  this.check_awards(connection) // update awards

  const results = connection.results.collate(this)
  connection.logdebug(this, `adding header: ${results}`)
  connection.transaction.remove_header('X-Haraka-Karma')
  connection.transaction.add_header('X-Haraka-Karma', results)

  return this.should_we_deny(next, connection, 'data_post')
}

exports.increment = function (connection, key, val) {
  if (!this.db) return

  this.db.hIncrBy(`karma|${connection.remote.ip}`, key, 1)

  const asnkey = this.get_asn_key(connection)
  if (asnkey) this.db.hIncrBy(asnkey, key, 1)
}

exports.hook_disconnect = function (next, connection) {
  if (this.should_we_skip(connection)) return next()

  this.redis_unsubscribe(connection)

  const k = connection.results.get('karma')
  if (!k || k.score === undefined) {
    connection.results.add(this, { err: 'karma results missing' })
    return next()
  }

  if (!this.cfg.thresholds) {
    this.check_awards(connection)
    connection.results.add(this, { msg: 'no action', emit: true })
    return next()
  }

  if (k.score > (this.cfg.thresholds.positive || 3)) {
    this.increment(connection, 'good', 1)
  }
  if (k.score < 0) {
    this.increment(connection, 'bad', 1)
  }

  connection.results.add(this, { emit: true })
  next()
}

exports.get_award_loc_from_note = function (connection, award) {
  if (connection.transaction) {
    const obj = this.assemble_note_obj(connection.transaction, award)
    if (obj) return obj
  }

  // connection.logdebug(this, `no txn note: ${award}`);
  const obj = this.assemble_note_obj(connection, award)
  if (obj) return obj

  // connection.logdebug(this, `no conn note: ${award}`);
  return
}

exports.get_award_loc_from_results = function (connection, loc_bits) {
  let pi_name = loc_bits[1]
  let notekey = loc_bits[2]

  if (phase_prefixes[pi_name]) {
    pi_name = `${loc_bits[1]}.${loc_bits[2]}`
    notekey = loc_bits[3]
  }

  let obj
  if (connection.transaction) obj = connection.transaction.results.get(pi_name)

  // connection.logdebug(this, `no txn results: ${pi_name}`);
  if (!obj) obj = connection.results.get(pi_name)
  if (!obj) return

  // connection.logdebug(this, `found results for ${pi_name}, ${notekey}`);
  if (notekey) return obj[notekey]
  return obj
}

exports.get_award_location = function (connection, award_key) {
  // based on award key, find the requested note or result
  const bits = award_key.split('@')
  const loc_bits = bits[0].split('.')
  if (loc_bits.length === 1) return connection[bits[0]] // ex: relaying

  if (loc_bits[0] === 'notes') {
    // ex: notes.spf_mail_helo
    return this.get_award_loc_from_note(connection, bits[0])
  }

  if (loc_bits[0] === 'results') {
    // ex: results.geoip.distance
    return this.get_award_loc_from_results(connection, loc_bits)
  }

  // ex: transaction.results.spf
  if (
    connection.transaction &&
    loc_bits[0] === 'transaction' &&
    loc_bits[1] === 'results'
  ) {
    loc_bits.shift()
    return this.get_award_loc_from_results(connection.transaction, loc_bits)
  }

  connection.logdebug(this, `unknown location for ${award_key}`)
}

exports.get_award_condition = function (note_key, note_val) {
  let wants
  const keybits = note_key.split('@')
  if (keybits[1]) {
    wants = keybits[1]
  }

  const valbits = note_val.split(/\s+/)
  if (!valbits[1]) return wants
  if (valbits[1] !== 'if') return wants // no if condition

  if (valbits[2].match(/^(equals|gt|lt|match)$/)) {
    if (valbits[3]) wants = valbits[3]
  }
  return wants
}

exports.check_awards = function (connection) {
  const karma = connection.results.get('karma')
  if (!karma?.todo) return

  for (const key in karma.todo) {
    //     loc                     =     terms
    // note_location [@wants]      = award [conditions]
    // results.geoip.too_far       = -1
    // results.geoip.distance@4000 = -1 if gt 4000
    const award_terms = karma.todo[key]

    const note = this.get_award_location(connection, key)
    if (note === undefined) continue
    let wants = this.get_award_condition(key, award_terms)

    // test the desired condition
    const bits = award_terms.split(/\s+/)
    const award = parseFloat(bits[0])
    if (!bits[1] || bits[1] !== 'if') {
      // no if conditions
      if (!note) continue // failed truth test
      if (!wants) {
        // no wants, truth matches
        this.apply_award(connection, key, award)
        delete karma.todo[key]
        continue
      }
      if (note !== wants) continue // didn't match
    }

    // connection.loginfo(this, `check_awards, case matching for: ${wants}`

    // the matching logic here is inverted, weeding out misses (continue)
    // Matches fall through (break) to the apply_award below.
    const condition = bits[2]
    switch (condition) {
      case 'equals':
        if (wants != note) continue
        break
      case 'gt':
        if (parseFloat(note) <= parseFloat(wants)) continue
        break
      case 'lt':
        if (parseFloat(note) >= parseFloat(wants)) continue
        break
      case 'match':
        if (Array.isArray(note)) {
          // connection.logerror(this, 'matching an array');
          if (new RegExp(wants, 'i').test(note)) break
        }
        if (note.toString().match(new RegExp(wants, 'i'))) break
        continue
      case 'length': {
        const operator = bits[3]
        if (bits[4]) {
          wants = bits[4]
        }
        switch (operator) {
          case 'gt':
            if (note.length <= parseFloat(wants)) continue
            break
          case 'lt':
            if (note.length >= parseFloat(wants)) continue
            break
          case 'equals':
            if (note.length !== parseFloat(wants)) continue
            break
          default:
            connection.logerror(
              this,
              `length operator "${operator}" not supported.`,
            )
            continue
        }
        break
      }
      case 'in': // if in pass whitelisted
        // let list = bits[3];
        if (bits[4]) {
          wants = bits[4]
        }
        if (!Array.isArray(note)) continue
        if (!wants) continue
        if (note.indexOf(wants) !== -1) break // found!
        continue
      default:
        continue
    }
    this.apply_award(connection, key, award)
    delete karma.todo[key]
  }
}

exports.apply_award = function (connection, nl, award) {
  if (!award) return
  if (isNaN(award)) {
    // garbage in config
    connection.logerror(this, `non-numeric award from: ${nl}:${award}`)
    return
  }

  const bits = nl.split('@')
  nl = bits[0] // strip off @... if present

  connection.results.incr(this, { score: award })
  connection.logdebug(this, `applied ${nl}:${award}`)

  let trimmed =
    nl.substring(0, 5) === 'notes'
      ? nl.substring(6)
      : nl.substring(0, 7) === 'results'
        ? nl.substring(8)
        : nl.substring(0, 19) === 'transaction.results'
          ? nl.substring(20)
          : nl

  if (trimmed.substring(0, 7) === 'rcpt_to') trimmed = trimmed.substring(8)
  if (trimmed.substring(0, 7) === 'mail_from') trimmed = trimmed.substring(10)
  if (trimmed.substring(0, 7) === 'connect') trimmed = trimmed.substring(8)
  if (trimmed.substring(0, 4) === 'data') trimmed = trimmed.substring(5)

  if (award > 0) connection.results.add(this, { pass: trimmed })
  if (award < 0) connection.results.add(this, { fail: trimmed })
}

exports.check_spammy_tld = function (mail_from, connection) {
  if (!this.cfg.spammy_tlds) return
  if (mail_from.isNull()) return // null sender (bounce)

  const from_tld = mail_from.host.split('.').pop()
  // connection.logdebug(this, `from_tld: ${from_tld}`);

  const tld_penalty = parseFloat(this.cfg.spammy_tlds[from_tld] || 0)
  if (tld_penalty === 0) return

  connection.results.incr(this, { score: tld_penalty })
  connection.results.add(this, { fail: 'spammy.TLD' })
}

exports.check_syntax_RcptTo = function (connection) {
  // look for an illegal (RFC 5321,(2)821) space in envelope recipient
  const full_rcpt = connection.current_line
  if (full_rcpt.toUpperCase().substring(0, 9) === 'RCPT TO:<') return

  connection.loginfo(this, `illegal envelope address format: ${full_rcpt}`)
  connection.results.add(this, { fail: 'rfc5321.RcptTo' })
}

exports.assemble_note_obj = function (prefix, key) {
  let note = prefix
  const parts = key.split('.')
  while (parts.length > 0) {
    let next = parts.shift()
    if (phase_prefixes[next]) {
      next = `${next}.${parts.shift()}`
    }
    note = note[next]
    if (note === null || note === undefined) break
  }
  return note
}

exports.check_asn = function (connection, asnkey) {
  if (!this.db) return

  const report_as = { name: this.name }

  if (this.cfg.asn.report_as) report_as.name = this.cfg.asn.report_as

  this.db
    .hGetAll(asnkey)
    .then((res) => {
      if (res === null) {
        const expire = (this.cfg.redis.expire_days || 60) * 86400 // days
        this.init_asn(asnkey, expire)
        return
      }

      this.db.hIncrBy(asnkey, 'connections', 1)
      const asn_score = parseInt(res.good || 0) - (res.bad || 0)
      const asn_results = {
        asn_score,
        asn_connections: res.connections,
        asn_good: res.good,
        asn_bad: res.bad,
        emit: true,
      }

      if (asn_score) {
        if (asn_score < -5) {
          asn_results.fail = 'asn:history'
        } else if (asn_score > 5) {
          asn_results.pass = 'asn:history'
        }
      }

      if (parseInt(res.bad) > 5 && parseInt(res.good) === 0) {
        asn_results.fail = 'asn:all_bad'
      }
      if (parseInt(res.good) > 5 && parseInt(res.bad) === 0) {
        asn_results.pass = 'asn:all_good'
      }

      connection.results.add(report_as, asn_results)
    })
    .catch((err) => {
      connection.results.add(this, { err })
    })
}

exports.init_ip = async function (dbkey, rip, expire) {
  if (!this.db) return
  await this.db
    .multi()
    .hmSet(dbkey, { bad: 0, good: 0, connections: 1 })
    .expire(dbkey, expire)
    .exec()
}

exports.get_asn_key = function (connection) {
  if (!this.cfg.asn.enable) return
  let asn = connection.results.get('asn')
  if (!asn || !asn.asn) asn = connection.results.get('geoip')
  if (!asn || !asn.asn || isNaN(asn.asn)) return
  return `as${asn.asn}`
}

exports.init_asn = function (asnkey, expire) {
  if (!this.db) return
  this.db
    .multi()
    .hmSet(asnkey, { bad: 0, good: 0, connections: 1 })
    .expire(asnkey, expire * 2) // keep ASN longer
    .exec()
}