diff --git a/Changes.md b/Changes.md index 97d5fe450..a2380d7dd 100644 --- a/Changes.md +++ b/Changes.md @@ -28,6 +28,9 @@ - logger: extend add_log_methods to Classes (connection, plugins, hmail) #3322 - logger: when logging via `logger` methods, use short names #3322 - logger: check Object.hasOwn to avoid circular deps +- mail_from.resolvable: refactored, leaning on improved net_utils #3322 + - fixes haraka/haraka-net-utils#88 +- messagesniffer: repackaged as NPM module - outbound - check for local_mx only when default route is used #3307 - client_pool: use tls_socket directly (shed line_socket) @@ -49,8 +52,6 @@ - remove undocumented use of send_email with arity of 2. #3322 - encapsulate force_tls logic into get_force_tls #3322 - queue/lmtp: refactored for DRY and improved readability #3322 -- mail_from.resolvable: refactored, leaning on improved net_utils #3322 - - fixes haraka/haraka-net-utils#88 - smtp_client: pass connect_timeout, maybe fixes #3281 - spamassassin: repackaged as NPM module #3348 - style(es6): more for...of loops diff --git a/Plugins.md b/Plugins.md index c5010a1ca..aa7cc9e04 100644 --- a/Plugins.md +++ b/Plugins.md @@ -171,7 +171,7 @@ A comprehensive list of known plugins. Create a PR to add yours to these lists. [url-logreader]: https://github.com/haraka/haraka-plugin-log-reader [url-milter]: https://github.com/haraka/haraka-plugin-milter [url-mfres]: https://github.com/haraka/Haraka/blob/master/docs/plugins/mail_from.is_resolvable.md -[url-msgsniff]: https://github.com/haraka/Haraka/blob/master/docs/plugins/messagesniffer.md +[url-msgsniff]: https://github.com/haraka/haraka-plugin-messagesniffer [url-ms]: http://www.armresearch.com/Products/ [url-creds]: https://github.com/haraka/Haraka/blob/master/docs/plugins/prevent_credential_leaks.md [url-postgres]: https://github.com/haraka/haraka-plugin-rcpt-postgresql diff --git a/config/messagesniffer.ini b/config/messagesniffer.ini deleted file mode 100644 index d1750ddc3..000000000 --- a/config/messagesniffer.ini +++ /dev/null @@ -1,18 +0,0 @@ -;port=9001 -;tmpdir=/tmp -;gbudb_report_deny=true -;tag_string=[SPAM] - -;[gbudb] -;white=accept -;caution=allow -;black=allow -;truncate=reject - -;[message] -;white=allow -;local_white=accept -;caution=allow -;black=allow -;truncate=reject -;nonzero=reject diff --git a/docs/plugins/messagesniffer.md b/docs/plugins/messagesniffer.md deleted file mode 100644 index 871ab8651..000000000 --- a/docs/plugins/messagesniffer.md +++ /dev/null @@ -1,163 +0,0 @@ -messagesniffer -============== - -This plugin provides integration with the commerical Anti-Spam product [MessageSniffer](http://armresearch.com/products/sniffer.jsp) by Arm Research Labs using its XML Client interface [XCI](http://armresearch.com/support/articles/software/snfServer/xci/) over TCP. - -Installation ------------- - -Install the SNF Client/Server package for your platform as per the instructions on the MessageSniffer website. - -Modify your SNFServer.xml file and under the 'xheaders' section set: - -* output mode='api' - -This prevents MessageSniffer from adding additional headers to the temporary file used to send it the message data which is -unnecessary as Haraka reads the headers from the XCI response. - -* rulebase on-off='on' -* result on-off='on' -* black on-off='on' -* while on-off='on' -* clean on-off='on' -* all symbol on-off='on' - -These cause SNFServer to send Haraka additional headers that are inserted into all messages scanned by MessageSniffer and -will aid debugging and troubleshooting. - -Once this is done start/restart the SNF server. - -Configuration -------------- - -This plugin uses `messagesniffer.ini` for configuration. The `[main]` section is for global configuration, the `[gbudb]` -section is used to specify the action that should be taken based on the GBUdb result which is checked at the start of the -connection and the `[message]` section is used to specify the action to be taken based on the main scan result. - -`[main]` - -- port - - Default: 9001 - TCP port to use when communicating to the SNFServer daemon. - This needs to match the `` value in the SNFServer.xml file. - -- tmpdir - - Default: /tmp - Temporary directory used to write temporary message files to that are read by the SNFServer daemon. - This directory and the files within need to be readable by the user that SNFServer is running as. - -- gbudb\_report\_deny = [ true | false | 0 | 1 ] - - Default: false - This is an experimental option that will record a GBUdb 'bad' encounter for a connected IP address when a client - disconnects with no message having been sent or seen by MessageSniffer but Haraka has recorded a hard rejection at - some point during the session. The idea behind this option is that it allows other Haraka plugins rejections influence - GBUdb IP reputation where MessageSniffer isn't seeing the actual message because it is being rejected pre-DATA. - -- tag\_string - - Default: [SPAM] - String to prepend to the Subject line if the 'tag' action is applied. - -`[gbudb]` - -- white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: accept - Action to take when GBUdb reports a 'white' result. - -- caution = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: continue - Action to take when GBUdb reports a 'caution' result. - -- black = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: continue - Action to take when GBUdb reports a 'black' result. - -- truncate = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: reject - Action to take when GBUdb reports a 'truncate' result. - -`[message]` - -- white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: continue - Action to take when MessageSniffer reports a 'white' result (result code: 0). - -- local\_white = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: accept - Action to take when MessageSniffer reports a local whitelist result (result code: 1). - NOTE: You will not see this result unless you Arm support have customized your rulebase and added white rules for you. - -- truncate = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: reject - Action to take when MessageSniffer reports a GBUdb result of 'truncate' (result code: 20). - NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured - Source and DrillDown options in the Training section of SNFServer.xml. - -- caution = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: continue - Action to take when MessageSniffer reports a GBUdb result of 'caution' (result code: 40). - NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured - Source and DrillDown options in the Training section of SNFServer.xml. - -- black = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Default: continue - Action to take when MessageSniffer reports a GBUdb result of 'black' (result code: 63). - NOTE: GBUdb IP lookups during the data phase can be different than the connecting IP address if you have configured - Source and DrillDown options in the Training section of SNFServer.xml. - -- code\_NN = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - NOTE: replace NN with the numeric MessageSniffer [result code](http://armresearch.com/support/articles/software/snfServer/core.jsp) - Action to take when MessageSniffer reports a result code other than those explicitly defined above. - -- nonzero = [ accept | allow | continue | retry | tempfail | reject | quarantine | tag ] - - Defalt: reject - Action to take for any non-zero result code other than those explicity defined above. This is a catch-all result that - is checked last after all other settings have been checked so you can define a code\_NN value to prevent this action from - being taken. - -Actions -------- - -* accept - - Accept the message and skip further plugins (whitelist). - -* allow | continute - - Continue to the next plugin. - -* retry | tempfail - - Reject the message with a temporary failure message (DENYSOFT). - -* reject - - Reject the message with a permanent failure message (DENY). - -* quarantine - - Continue to the next plugin. If the message isn't rejected by another plugin - it will cause the message to be quarantined - and the message will not be delivered to the recipient(s). - - NOTE: this option requires the queue/quarantine plugin in your config/plugins files and it must be listed before any - other queue plugins. - -* tag - - Tag the subject with the default 'tag\_string' defined in the `main` section above, this will also set X-Spam-Flag: YES in - the message headers. Once tagged, processing will continue to the next plugin. - diff --git a/package.json b/package.json index e081e292b..f3d430123 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "haraka-plugin-karma": "^2.1.5", "haraka-plugin-known-senders": "^1.1.0", "haraka-plugin-limit": "^1.2.3", + "haraka-plugin-messagesniffer": "^1.0.0", "haraka-plugin-p0f": "^1.0.9", "haraka-plugin-qmail-deliverable": "^1.2.3", "haraka-plugin-rcpt-ldap": "^1.1.0", diff --git a/plugins/messagesniffer.js b/plugins/messagesniffer.js deleted file mode 100644 index 8d08ce13a..000000000 --- a/plugins/messagesniffer.js +++ /dev/null @@ -1,381 +0,0 @@ -// messagesniffer - -const fs = require('fs'); -const net = require('net'); -const plugin = exports; - -// Defaults -let port = 9001; - -exports.register = function () { - const cfg = this.config.get('messagesniffer.ini'); - if (cfg.main.port) port = parseInt(cfg.main.port); -} - -exports.hook_connect = function (next, connection) { - const cfg = this.config.get('messagesniffer.ini'); - // Skip any private IP ranges - // Skip connection.transaction undefined - if (connection?.remote?.is_private || !connection?.transaction) return next(); - - // Retrieve GBUdb information for the connecting IP - SNFClient(``, (err, result) => { - if (err) { - connection.logerror(this, err.message); - return next(); - } - let match; - if ((match = /)[^])+)\/>/.exec(result))) { - // Log result - connection.loginfo(this, match[1]); - // Populate result - const gbudb = {}; - const split = match[1].toString().split(/\s+/); - for (const element of split) { - const split2 = element.split(/=/); - gbudb[split2[0]] = split2[1].replace(/(?:^'|'$)/g,''); - } - // Set notes for other plugins - connection.notes.gbudb = gbudb; - // Handle result - switch (gbudb.range) { - case 'new': - case 'normal': - return next(); - case 'white': - // Default for white if no configuration - if (!cfg.gbudb || (cfg.gbudb && !cfg.gbudb[gbudb.range])) { - return next(OK); - } - // fall through - case 'caution': - case 'black': - case 'truncate': - if (cfg.gbudb?.[gbudb.range]) { - connection.loginfo(this, `range=${gbudb.range} action=${cfg.gbudb[gbudb.range]}`); - switch (cfg.gbudb[gbudb.range]) { - case 'accept': - // Whitelist - connection.notes.gbudb.action = 'accept'; - return next(OK); - case 'allow': - case 'continue': - // Continue to next plugin - connection.notes.gbudb.action = 'allow'; - return next(); - case 'retry': - case 'tempfail': - return next(DENYSOFT, `Poor GBUdb reputation for [${connection.remote.ip}]`); - case 'reject': - return next(DENY, `Poor GBUdb reputation for [${connection.remote.ip}]`); - case 'quarantine': - connection.notes.gbudb.action = 'quarantine'; - connection.notes.quarantine = true; - connection.notes.quarantine_action = [ OK, `Message quarantined (${connection.transaction.uuid})` ]; - break; - case 'tag': - connection.notes.gbudb.action = 'tag'; - break; - default: - // Unknown action - return next(); - } - } - else if (gbudb.range === 'truncate') { - // Default for truncate - return next(DENY, `Poor GBUdb reputation for [${connection.remote.ip}]`); - } - return next(); - default: - // Unknown - connection.logerror(this, `Unknown GBUdb range: ${gbudb.range}`); - next(); - } - } - else { - next(); - } - }); -} - -exports.hook_data_post = function (next, connection) { - const cfg = this.config.get('messagesniffer.ini'); - const txn = connection?.transaction; - if (!txn) return next(); - - function tag_subject (){ - const tag = cfg.main.tag_string || '[SPAM]'; - const subj = txn.header.get_decoded('Subject'); - // Try and prevent any double subject modifications - const subject_re = new RegExp(`^${tag}`); - if (!subject_re.test(subj)) { - txn.remove_header('Subject'); - txn.add_header('Subject', `${tag} ${subj}`); - } - // Add spam flag - txn.remove_header('X-Spam-Flag'); - txn.add_header('X-Spam-Flag', 'YES'); - } - - // Check GBUdb results - if (connection.notes.gbudb?.action) { - switch (connection.notes.gbudb.action) { - case 'accept': - case 'quarantine': - return next(OK); - case 'tag': - // Tag message - tag_subject(); - return next(); - } - } - - const tmpdir = cfg.main.tmpdir || '/tmp'; - const tmpfile = `${tmpdir}/${txn.uuid}.tmp`; - const ws = fs.createWriteStream(tmpfile); - - ws.once('error', err => { - connection.logerror(this, `Error writing temporary file: ${err.message}`); - next(); - }); - - ws.once('close', () => { - const start_time = Date.now(); - SNFClient(``, (err, result) => { - const end_time = Date.now(); - const elapsed = end_time - start_time; - // Delete the tempfile - fs.unlink(tmpfile, () => {}); - let match; - // Make sure we actually got a result - if ((match = /((?:(?!<\/xhdr>)[^])+)/.exec(result,'m'))) { - // Parse the returned headers and add them to the message - const xhdr = match[1].split('\r\n'); - const headers = []; - for (const line of xhdr) { - // Check for continuation - if (/^\s/.test(line)) { - // Continuation; add to previous header value - if (headers[headers.length-1]) { - headers[headers.length-1].value += `${line}\r\n`; - } - } - else { - // Must be a header - match = /^([^: ]+):(?:\s*(.+))?$/.exec(line); - if (match) { - headers.push({ header: match[1], value: (match[2] ? `${match[2]}\r\n` : '\r\n') }); - } - } - } - // Add headers to message - for (const header of headers) { - // If present save the group for logging purposes - if (header.header === 'X-MessageSniffer-SNF-Group') { - group = header.value.replace(/\r?\n/gm, ''); - } - // Log GBUdb analysis - if (header.header === 'X-GBUdb-Analysis') { - // Retrieve IP address determined by GBUdb - const gbudb_split = header.value.split(/,\s*/); - gbudb_ip = gbudb_split[1]; - connection.logdebug(this, `GBUdb: ${header.value.replace(/\r?\n/gm, '')}`); - } - if (header.header === 'X-MessageSniffer-Rules') { - rules = header.value.replace(/\r?\n/gm, '').replace(/\s+/g,' ').trim(); - connection.logdebug(this, `rules: ${rules}`); - } - // Remove any existing headers - txn.remove_header(header.header); - txn.add_header(header.header, header.value); - } - } - // Summary log - connection.loginfo(this, `result: time=${elapsed}ms code=${code - }${gbudb_ip ? ` ip="${gbudb_ip}"` : '' - }${group ? ` group="${group}"` : '' - }${rules ? ` rule_count=${rules.split(/\s+/).length}` : '' - }${rules ? ` rules="${rules}"` : ''}`); - // Result code MUST in the 0-63 range otherwise we got an error - // http://www.armresearch.com/support/articles/software/snfServer/errors.jsp - if (code === 0 || (code && code <= 63)) { - // Handle result - let action; - if (cfg.message) { - if (code === 0 && cfg.message.white) { - action = cfg.message.white; - } - else if (code === 1) { - if (cfg.message.local_white) { - action = cfg.message.local_white; - } - else { - return next(OK); - } - } - else if (code === 20) { - if (cfg.message.truncate) { - action = cfg.message.truncate; - } - else { - return next(DENY, `Poor GBUdb reputation for IP [${connection.remote.ip}]`); - } - } - else if (code === 40 && cfg.message.caution) { - action = cfg.message.caution; - } - else if (code === 63 && cfg.message.black) { - action = cfg.message.black; - } - else { - if (cfg.message[`code_${code}`]) { - action = cfg.message[`code_${code}`]; - } - else { - if (code > 1 && code !== 40) { - if (cfg.message.nonzero) { - action = cfg.message.nonzero; - } - else { - return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`); - } - } - } - } - } - else { - // Default with no configuration - if (code > 1 && code !== 40) { - return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`); - } - else { - return next(); - } - } - switch (action) { - case 'accept': - // Whitelist - return next(OK); - case 'allow': - case 'continue': - // Continue to next plugin - return next(); - case 'retry': - case 'tempfail': - return next(DENYSOFT, `Spam detected by MessageSniffer (code=${code} group=${group})`); - case 'reject': - return next(DENY, `Spam detected by MessageSniffer (code=${code} group=${group})`); - case 'quarantine': - // Set flag for queue/quarantine plugin - txn.notes.quarantine = true; - txn.notes.quarantine_action = [ OK, `Message quarantined (${txn.uuid})` ]; - break; - case 'tag': - tag_subject(); - // fall through - default: - return next(); - } - } - else { - // Out-of-band code returned - // Handle Bulk/Noisy special rule by re-writing the Precedence header - if (code === 100) { - let precedence = txn.header.get('precedence'); - if (precedence) { - // We already have a precedence header - precedence = precedence.trim().toLowerCase(); - switch (precedence) { - case 'bulk': - case 'list': - case 'junk': - // Leave these as they are - break; - default: - // Remove anything else and replace it with 'bulk' - txn.remove_header('precedence'); - txn.add_header('Precedence', 'bulk'); - } - } - else { - txn.add_header('Precedence', 'bulk'); - } - } - return next(); - } - } - else { - // Something must have gone wrong - connection.logwarn(this, `unexpected response: ${result}`); - } - return next(); - }); - }); - - // TODO: we only need the first 64Kb of the message - txn.message_stream.pipe(ws); -} - -exports.hook_disconnect = function (next, connection) { - const cfg = this.config.get('messagesniffer.ini'); - - // Train GBUdb on rejected messages and recipients - if (cfg.main.gbudb_report_deny && !connection.notes.snf_run && - (connection.rcpt_count.reject > 0 || connection.msg_count.reject > 0)) { - const snfreq = ``; - SNFClient(snfreq, (err, result) => { - if (err) { - connection.logerror(this, err.message); - } - else { - connection.logdebug(this, `GBUdb bad encounter added for ${connection.remote.ip}`); - } - next(); - }); - } - else { - next(); - } -} - -function SNFClient (req, cb) { - let result; - const sock = new net.Socket(); - sock.setTimeout(30 * 1000); // Connection timeout - sock.once('timeout', function () { - this.destroy(); - cb(new Error('connection timed out')); - }); - sock.once('error', err => cb(err)); - sock.once('connect', function () { - // Connected, send request - plugin.logprotocol(`> ${req}`); - this.write(`${req}\n`); - }); - sock.on('data', data => { - plugin.logprotocol(`< ${data}`); - // Buffer all the received lines - (result ? result += data : result = data); - }); - sock.once('end', () => { - // Check for result - if (/