diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..96490fa --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,10 @@ +engines: + eslint: + enabled: true + channel: "eslint-8" + config: + config: ".eslintrc.yaml" + +ratings: + paths: + - "**.js" diff --git a/.eslintrc.yaml b/.eslintrc.yaml new file mode 100644 index 0000000..cb20375 --- /dev/null +++ b/.eslintrc.yaml @@ -0,0 +1,10 @@ +env: + node: true + es6: true + mocha: true + es2022: true + +extends: ["@haraka"] + +rules: + no-unused-vars: 1 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..0dccc85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,11 @@ +### system info + +Please report your OS, Node version, and Haraka version by running this shell script on your Haraka server and replacing this section with the output. + +echo "Haraka | $(haraka -v)"; echo " --- | :--- "; echo "Node | $(node -v)"; echo "OS | $(uname -a)"; echo "openssl | $(openssl version)" + +### Expected behavior + +### Observed behavior + +### Steps to reproduce diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5ccd7ed --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +Changes proposed in this pull request: + +- +- + +Fixes # + +Checklist: + +- [ ] docs updated +- [ ] tests updated +- [ ] Changes.md updated +- [ ] package.json.version bumped diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8c3ac4a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "monthly" + allow: + - dependency-type: production diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d01042 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: [push, pull_request] + +env: + CI: true + +jobs: + lint: + uses: haraka/.github/.github/workflows/lint.yml@master + + # coverage: + # uses: haraka/.github/.github/workflows/coverage.yml@master + # secrets: inherit + + ubuntu: + needs: [lint] + uses: haraka/.github/.github/workflows/ubuntu.yml@master + + windows: + needs: [lint] + uses: haraka/.github/.github/workflows/windows.yml@master diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..2b614e3 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,13 @@ +name: "CodeQL" + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + - cron: "18 7 * * 4" + +jobs: + codeql: + uses: haraka/.github/.github/workflows/codeql.yml@master diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e81c15f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,16 @@ +name: publish + +on: + push: + branches: + - master + paths: + - package.json + +env: + CI: true + +jobs: + publish: + uses: haraka/.github/.github/workflows/publish.yml@master + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..625981f --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +package-lock.json +bower_components +# Optional npm cache directory +.npmrc +.idea +.DS_Store +haraka-update.sh \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a8e94cb --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".release"] + path = .release + url = git@github.com:msimerson/.release.git diff --git a/.release b/.release new file mode 160000 index 0000000..7cd5707 --- /dev/null +++ b/.release @@ -0,0 +1 @@ +Subproject commit 7cd5707f7d69f8d4dca1ec407ada911890e59d0a diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..542dea1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +### Unreleased + +### [1.0.0] - 2024-05-08 + +- initial release (repackaged from haraka/Haraka) + +[1.0.0]: https://github.com/haraka/haraka-plugin-template/releases/tag/v1.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29f9810 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Haraka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..97f443e --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +[![CI Test Status][ci-img]][ci-url] +[![Code Climate][clim-img]][clim-url] + +[![NPM][npm-img]][npm-url] + +# haraka-plugin-clamd + +This plug-in implements Anti-Virus scanning with ClamAV using the **clamd** daemon. + +The plug-in will reject any message that ClamAV considers to be a virus. If an error occurs (e.g. clamd not running or a timeout), the message will be deferred with a temporary failure. + +## Configuration + +Copy the default clamd.ini into the Haraka config directory: + +``` +cp node_modules/haraka-plugin-clamd/config/clamd.ini config/clamd.ini +$EDITOR config/clamd.ini +``` + +The following options can be defined in clamd.ini; + +### clamd\_socket (default: localhost:3310) + + N.N.N.N:port, [ipv6::literal]:port, host:port or /path/to/socket of + the clamd daemon. + + Multiple hosts can be listed separated by comma, semi-colon or spaces. + + If :port is omitted it defaults to 3310. + + On connection error or timeout the next host in the list will be tried. + When the host list is exhausted, the message will be deferred with + a temporary failure. + + +### randomize\_host\_order (default: false) + + If this is set then the list of hosts with be randomized before a + connection is attempted. + + +### only\_with\_attachments (default: false) + + Set this option to only scan messages that contain non-textual + attachments. This is a performance optimization, however it will + prevent ClamAV from detecting threats such as Phishing in plain-text + or HTML messages. + + +### connect\_timeout (default: 10) + + Timeout connection to host after this many seconds. A timeout will + cause the next host in the list to be tried. Once all hosts have + been tried then a temporary failure will be returned. + + +### timeout (default: 30) + + Post-connection timeout if there is no activity on the socket after + this many seconds. A timeout will cause the message to be rejected + with a tempoary failure. + + +### max\_size (default: 26214400) + + The maximum size of message that should be sent to clamd in bytes. + This option should not be larger than the StreamMaxLength value in + clamd.conf as clamd will stop scanning once this limit is reached. + If the clamd limit is reached the plug-in will log a notice that + this has happened and will allow the message though. + +### [reject] + +An optional reject section can offer control over when to reject connections. +The default settings are shown. ClamAV recommends that hits coming from +SafeBrowsing / Phishing / Heuristics, Potentially Unwanted Applications, and +UNOFFICIAL be used only for scoring. + + * virus=true + * error=true + +The following reject options are disabled by default in clamd.conf. With a +default ClamAV install, these will have no effect. When an admin enables in +clamd.conf, Haraka with then, by default, reject such messages. Adjust these +settings to suit. + + * Broken.Executable=true + * Structured=true + * Encrypted=true + * PUA=true + * OLE2=true + * Safebrowsing=true + * UNOFFICIAL=true + +The following options are enabled by default in clamd but ClamAV suggests +using them only for scoring. + + * Phishing=false + +## [check] + +The optional check section can allow skipping ClamAV check for remote connection +meeting following criteria. + +- authenticated + + Default: true + + If true, messages from authenticated users will be scanned. + +- private\_ip + + Default: true + + If true, messages from private IPs will be scanned. + +- local\_ip + + Default: true + + If true, messages from localhost will be scanned. + +- relay + + Default: true + + If true, messages that are to be relayed will be scanned. + +## clamd.excludes + + This file can contain a list of virus name patterns that when matched, are + not rejected by this plugin. An X-Haraka-Virus: header will be inserted + containing the virus name. This header can then be used for scoring + in other plugins. + + The format of the file is one pattern per line. Comments are prefixed + with #. Matches are case-insensitive. + + Patterns are expressed using wildcards (e.g. * and ?) or + via regexp by enclosing the pattern in //. + + To negate a match (e.g. reject if it matches), prefix the match with !. + Negative matches are always tested first. + + Example: + +``` +# Always reject test signatures +!*.TestSig_* +# Skip all unofficial signatures +*.UNOFFICIAL +# Phishing +Heuristics.Phishing.* +``` + + + +[ci-img]: https://github.com/haraka/haraka-plugin-clamd/actions/workflows/ci.yml/badge.svg +[ci-url]: https://github.com/haraka/haraka-plugin-clamd/actions/workflows/ci.yml +[clim-img]: https://codeclimate.com/github/haraka/haraka-plugin-clamd/badges/gpa.svg +[clim-url]: https://codeclimate.com/github/haraka/haraka-plugin-clamd +[npm-img]: https://nodei.co/npm/haraka-plugin-clamd.png +[npm-url]: https://www.npmjs.com/package/haraka-plugin-clamd diff --git a/config/clamd.ini b/config/clamd.ini new file mode 100644 index 0000000..2a92888 --- /dev/null +++ b/config/clamd.ini @@ -0,0 +1,2 @@ + +[main] diff --git a/index.js b/index.js new file mode 100644 index 0000000..48c208f --- /dev/null +++ b/index.js @@ -0,0 +1,384 @@ +// 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]); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..caa6edb --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "haraka-plugin-clamd", + "version": "1.0.0", + "description": "Haraka plugin that scans emails with clamd", + "main": "index.js", + "files": [ + "CHANGELOG.md", + "config" + ], + "scripts": { + "format": "npm run prettier:fix && npm run lint:fix", + "lint": "npx eslint@^8 *.js test", + "lint:fix": "npx eslint@^8 *.js test --fix", + "prettier": "npx prettier . --check", + "prettier:fix": "npx prettier . --write --log-level=warn", + "test": "node --test", + "versions": "npx dependency-version-checker check", + "versions:fix": "npx dependency-version-checker update" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/haraka/haraka-plugin-clamd.git" + }, + "keywords": [ + "haraka", + "plugin", + "clamd" + ], + "author": "Haraka Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/haraka/haraka-plugin-clamd/issues" + }, + "homepage": "https://github.com/haraka/haraka-plugin-clamd#readme", + "dependencies": { + "haraka-net-utils": "^1.7.0", + "haraka-utils": "^1.1.3" + }, + "devDependencies": { + "@haraka/eslint-config": "1.1.3", + "haraka-test-fixtures": "1.3.5" + } +} diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..4c996ec --- /dev/null +++ b/test/index.js @@ -0,0 +1,212 @@ +'use strict'; + +const assert = require('node:assert') +const net = require('node:net'); +const { beforeEach, describe, it } = require('node:test') + +const fixtures = require('haraka-test-fixtures'); + +const _set_up = (t, done) => { + + this.plugin = new fixtures.plugin('clamd'); + this.plugin.register(); + + this.connection = fixtures.connection.createConnection(); + this.connection.init_transaction(); + + done(); +} + +describe('plugins/clamd', () => { + + describe('load_clamd_ini', () => { + beforeEach(_set_up) + + it('none', () => { + assert.deepEqual([], this.plugin.skip_list); + }) + + it('defaults', () => { + const cfg = this.plugin.cfg.main; + assert.equal('localhost:3310', cfg.clamd_socket); + assert.equal(30, cfg.timeout); + assert.equal(10, cfg.connect_timeout); + assert.equal(26214400, cfg.max_size); + assert.equal(false, cfg.only_with_attachments); + assert.equal(false, cfg.randomize_host_order); + }) + + it('reject opts', () => { + assert.equal(true, this.plugin.rejectRE.test('Encrypted.')); + assert.equal(true, this.plugin.rejectRE.test('Heuristics.Structured.')); + assert.equal(true, this.plugin.rejectRE.test( + 'Heuristics.Structured.CreditCardNumber')); + assert.equal(true, this.plugin.rejectRE.test('Broken.Executable.')); + assert.equal(true, this.plugin.rejectRE.test('PUA.')); + assert.equal(true, this.plugin.rejectRE.test( + 'Heuristics.OLE2.ContainsMacros')); + assert.equal(true, this.plugin.rejectRE.test('Heuristics.Safebrowsing.')); + assert.equal(true, this.plugin.rejectRE.test( + 'Heuristics.Safebrowsing.Suspected-phishing_safebrowsing.clamav.net')); + assert.equal(true, this.plugin.rejectRE.test( + 'Sanesecurity.Junk.50402.UNOFFICIAL')); + assert.equal(false, this.plugin.rejectRE.test( + 'Sanesecurity.UNOFFICIAL.oops')); + assert.equal(false, this.plugin.rejectRE.test('Phishing')); + assert.equal(false, this.plugin.rejectRE.test( + 'Heuristics.Phishing.Email.SpoofedDomain')); + assert.equal(false, this.plugin.rejectRE.test('Suspect.Executable')); + assert.equal(false, this.plugin.rejectRE.test('MattWuzHere')); + }) + }) + + describe('hook_data', () => { + beforeEach(_set_up) + + it('only_with_attachments, false', (t, done) => { + assert.equal(false, this.plugin.cfg.main.only_with_attachments); + this.plugin.hook_data(() => { + assert.equal(false, this.connection.transaction.parse_body); + done(); + }, this.connection); + }) + + it('only_with_attachments, true', (t, done) => { + this.plugin.cfg.main.only_with_attachments=true; + this.connection.transaction.attachment_hooks = () => {}; + this.plugin.hook_data(() => { + assert.equal(true, this.plugin.cfg.main.only_with_attachments); + assert.equal(true, this.connection.transaction.parse_body); + done(); + }, this.connection); + }) + }) + + describe('hook_data_post', () => { + beforeEach(_set_up) + + it('skip attachment', (t, done) => { + this.connection.transaction.notes = { clamd_found_attachment: false }; + this.plugin.cfg.main.only_with_attachments=true; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + + it('skip authenticated', (t, done) => { + this.connection.notes.auth_user = 'user'; + this.plugin.cfg.check.authenticated = false; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + + it('checks local IP', (t, done) => { + this.connection.remote.is_local = true; + this.plugin.cfg.check.local_ip = true; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length === 0); + done(); + }, this.connection); + }) + + it('skips local IP', (t, done) => { + this.connection.remote.is_local = true; + this.plugin.cfg.check.local_ip = false; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + + it('checks private IP', (t, done) => { + this.connection.remote.is_private = true; + this.plugin.cfg.check.private_ip = true; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length === 0); + done(); + }, this.connection); + }) + + it('skips private IP', (t, done) => { + this.connection.remote.is_private = true; + this.plugin.cfg.check.private_ip = false; + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + + it('checks public ip', (t, done) => { + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length === 0); + done(); + }, this.connection); + }) + + it('skip localhost if check.local_ip = false and check.private_ip = true', (t, done) => { + this.connection.remote.is_local = true; + this.connection.remote.is_private = true; + + this.plugin.cfg.check.local_ip = false; + this.plugin.cfg.check.private_ip = true; + + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + + it('checks localhost if check.local_ip = true and check.private_ip = false', (t, done) => { + this.connection.remote.is_local = true; + this.connection.remote.is_private = true; + + this.plugin.cfg.check.local_ip = true; + this.plugin.cfg.check.private_ip = false; + + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length === 0); + done(); + }, this.connection); + }) + + it('message too big', (t, done) => { + this.connection.transaction.data_bytes=513; + this.plugin.cfg.main.max_size=512; + + this.plugin.hook_data_post(() => { + assert.ok(this.connection.transaction.results.get('clamd').skip.length > 0); + done(); + }, this.connection); + }) + }) + + describe('send_clamd_predata', () => { + beforeEach(_set_up) + + it('writes the proper commands to clamd socket', (t, done) => { + const server = new net.createServer((socket) => { + socket.on('data', (data) => { + assert.ok(data.toString(), `zINSTREAM\0Received: from Haraka clamd plugin\r\n`) + // console.log(`${data.toString()}`) + }) + socket.on('end', () => { + done() + }) + }) + + server.listen(65535, () => { + const client = new net.Socket(); + client.connect(65535, () => { + this.plugin.send_clamd_predata(client, () => { + client.end() + }) + }) + }) + + server.unref() + }) + }) +})