UNPKG

haraka-plugin-clamd

Version:

Haraka plugin that scans emails with clamd

399 lines (347 loc) 11.4 kB
// clamd const net = require('node:net') const utils = require('haraka-utils') const net_utils = require('haraka-net-utils') exports.load_excludes = function () { this.loginfo('Loading excludes file') const list = this.config.get('clamd.excludes', 'list', () => { this.load_excludes() }) const new_skip_list_exclude = [] const new_skip_list = [] for (const element of list) { let re switch (element[0]) { case '!': if (element[1] === '/') { // Regexp exclude try { re = new RegExp(element.substr(2, element.length - 2), 'i') new_skip_list_exclude.push(re) } catch (e) { this.logerror(`${e.message} (entry: ${element})`) } } else { // Wildcard exclude try { re = new RegExp(utils.wildcard_to_regexp(element.substr(1)), 'i') new_skip_list_exclude.push(re) } catch (e) { this.logerror(`${e.message} (entry: ${element})`) } } break case '/': // Regexp skip try { re = new RegExp(element.substr(1, element.length - 2), 'i') new_skip_list.push(re) } catch (e) { this.logerror(`${e.message} (entry: ${element})`) } break default: // Wildcard skip try { re = new RegExp(utils.wildcard_to_regexp(element), 'i') new_skip_list.push(re) } catch (e) { this.logerror(`${e.message} (entry: ${element})`) } } } // Make the new lists visible this.skip_list_exclude = new_skip_list_exclude this.skip_list = new_skip_list } exports.load_clamd_ini = function () { this.cfg = this.config.get( 'clamd.ini', { booleans: [ '-main.randomize_host_order', '-main.only_with_attachments', '+reject.virus', '+reject.error', // clamd options that are disabled by default. If admin enables // them for clamd, Haraka should reject by default. '+reject.Broken.Executable', '+reject.Structured', // DLP options '+reject.Encrypted', '+reject.PUA', '+reject.OLE2', '+reject.Safebrowsing', '+reject.UNOFFICIAL', // clamd.conf options enabled by default, but prone to false // positives. '-reject.Phishing', '+check.authenticated', '+check.private_ip', '+check.local_ip', ], }, () => { this.load_clamd_ini() }, ) const defaults = { clamd_socket: 'localhost:3310', timeout: 30, connect_timeout: 10, max_size: 26214400, } for (const key in defaults) { if (this.cfg.main[key] === undefined) { this.cfg.main[key] = defaults[key] } } const rejectPatterns = { 'Broken.Executable': '^Broken\\.Executable\\.?', Encrypted: '^Encrypted\\.', PUA: '^PUA\\.', Structured: '^Heuristics\\.Structured\\.', OLE2: '^Heuristics\\.OLE2\\.ContainsMacros', Safebrowsing: '^Heuristics\\.Safebrowsing\\.', Phishing: '^Heuristics\\.Phishing\\.', UNOFFICIAL: '\\.UNOFFICIAL$', } const all_reject_opts = [] const enabled_reject_opts = [] Object.keys(rejectPatterns).forEach((opt) => { all_reject_opts.push(rejectPatterns[opt]) if (!this.cfg.reject[opt]) return enabled_reject_opts.push(rejectPatterns[opt]) }) if (enabled_reject_opts.length) { this.allRE = new RegExp(all_reject_opts.join('|')) this.rejectRE = new RegExp(enabled_reject_opts.join('|')) } // resolve mismatch between docs (...attachment) and code (...attachments) if (this.cfg.main.only_with_attachment !== undefined) { this.cfg.main.only_with_attachments = !!this.cfg.main.only_with_attachment } } exports.register = function () { this.load_excludes() this.load_clamd_ini() } exports.hook_data = function (next, connection) { if (!this.cfg.main.only_with_attachments) return next() if (!this.should_check(connection)) return next() const txn = connection.transaction txn.parse_body = true txn.attachment_hooks((ctype, filename, body) => { connection.logdebug(this, `found ctype=${ctype}, filename=${filename}`) txn.notes.clamd_found_attachment = true }) next() } exports.hook_data_post = function (next, connection) { const plugin = this if (!plugin.should_check(connection)) return next() const txn = connection.transaction const { cfg } = plugin // Do we need to run? if (cfg.main.only_with_attachments && !txn.notes.clamd_found_attachment) { connection.logdebug(plugin, 'skipping: no attachments found') txn.results.add(plugin, { skip: 'no attachments' }) return next() } // Limit message size if (txn.data_bytes > cfg.main.max_size) { txn.results.add(plugin, { skip: 'exceeds max size', emit: true }) return next() } const hosts = cfg.main.clamd_socket.split(/[,; ]+/) if (cfg.main.randomize_host_order) { hosts.sort(() => 0.5 - Math.random()) } function try_next_host() { let connected = false if (!hosts.length) { if (txn) txn.results.add(plugin, { err: 'connecting' }) if (!plugin.cfg.reject.error) return next() return next(DENYSOFT, 'Error connecting to virus scanner') } const host = hosts.shift() connection.logdebug(plugin, `trying host: ${host}`) const socket = new net.Socket() net_utils.add_line_processor(socket) socket.on('timeout', () => { socket.destroy() if (!connected) { connection.logerror(plugin, `Timeout connecting to ${host}`) return try_next_host() } if (txn) txn.results.add(plugin, { err: 'clamd timed out' }) if (!plugin.cfg.reject.error) return next() return next(DENYSOFT, 'Virus scanner timed out') }) socket.on('error', (err) => { socket.destroy() if (!connected) { connection.logerror( plugin, `Connection to ${host} failed: ${err.message}`, ) return try_next_host() } // If an error occurred after connection and there are other hosts left to try, // then try those before returning DENYSOFT. if (hosts.length) { connection.logwarn(plugin, `error on host ${host}: ${err.message}`) return try_next_host() } if (txn) txn.results.add(plugin, { err: `error on host ${host}: ${err.message}`, }) if (!plugin.cfg.reject.error) return next() return next(DENYSOFT, 'Virus scanner error') }) socket.on('connect', () => { connected = true socket.setTimeout((cfg.main.timeout || 30) * 1000) const hp = socket.address() const addressInfo = hp === null ? '' : ` ${hp.address}:${hp.port}` connection.logdebug(plugin, `connected to host${addressInfo}`) plugin.send_clamd_predata(socket, () => { txn.message_stream.pipe(socket, { clamd_style: true }) }) }) let result = '' socket.on('line', (line) => { connection.logprotocol( plugin, `C:${line .split('') .filter((x) => { return 31 < x.charCodeAt(0) && 127 > x.charCodeAt(0) }) .join('')}`, ) result = line.replace(/\r?\n/, '') }) socket.setTimeout((cfg.main.connect_timeout || 10) * 1000) socket.on('end', () => { if (!txn) return next() if (/^stream: OK/.test(result)) { // OK txn.results.add(plugin, { pass: 'clean', emit: true }) return next() } const m = /^stream: (\S+) FOUND/.exec(result) if (m) { let virus // Virus found if (m[1]) { virus = m[1] } txn.results.add(plugin, { fail: virus ? virus : 'virus', emit: true, }) if ( virus && plugin.rejectRE && // enabled plugin.allRE.test(virus) && // has a reject option !plugin.rejectRE.test(virus) ) { // reject=false set txn.add_header('X-Haraka-Virus', virus) return next() } if (!plugin.cfg.reject.virus) { return next() } // Check skip list exclusions for (const element of plugin.skip_list_exclude) { if (!element.test(virus)) continue return next(DENY, `Message is infected with ${virus || 'UNKNOWN'}`) } // Check skip list for (const element of plugin.skip_list) { if (!element.test(virus)) continue connection.logwarn(plugin, `${virus} matches exclusion`) txn.add_header('X-Haraka-Virus', virus) return next() } return next(DENY, `Message is infected with ${virus || 'UNKNOWN'}`) } if (/size limit exceeded/.test(result)) { txn.results.add(plugin, { err: 'INSTREAM size limit exceeded. Check ' + 'StreamMaxLength in clamd.conf', }) // Continue as StreamMaxLength default is 25Mb return next() } // The current host returned an unknown result. If other hosts are available, // then try those before returning a DENYSOFT. if (hosts.length) { connection.logwarn( plugin, `unknown result: '${result}' from host ${host}`, ) socket.destroy() return try_next_host() } txn.results.add(plugin, { err: `unknown result: '${result}' from host ${host}`, }) if (!plugin.cfg.reject.error) return next() return next(DENYSOFT, 'Error running virus scanner') }) clamd_connect(socket, host) } // Start the process try_next_host() } exports.should_check = function (connection) { let result = true // default if (!connection?.transaction) return false if (this.cfg.check.authenticated == false && connection.notes.auth_user) { connection.transaction.results.add(this, { skip: 'authed' }) result = false } if (this.cfg.check.relay == false && connection.relaying) { connection.transaction.results.add(this, { skip: 'relay' }) result = false } if (this.cfg.check.local_ip == false && connection.remote.is_local) { connection.transaction.results.add(this, { skip: 'local_ip' }) result = false } if (this.cfg.check.private_ip == false && connection.remote.is_private) { if (this.cfg.check.local_ip == true && connection.remote.is_local) { // local IPs are included in private IPs } else { connection.transaction.results.add(this, { skip: 'private_ip' }) result = false } } return result } exports.send_clamd_predata = (socket, cb) => { socket.write('zINSTREAM\0', () => { const received = 'Received: from Haraka clamd plugin\r\n' const buf = Buffer.alloc(received.length + 4) buf.writeUInt32BE(received.length, 0) buf.write(received, 4) socket.write(buf, cb) }) } function clamd_connect(socket, host) { if (host.match(/^\//)) { socket.connect(host) // starts with /, unix socket return } const match = /^\[([^\] ]+)\](?::(\d+))?/.exec(host) if (match) { socket.connect(match[2] || 3310, match[1]) // IPv6 literal return } // IP:port, hostname:port or hostname const hostport = host.split(/:/) socket.connect(hostport[1] || 3310, hostport[0]) }