plugins/clamd.js
// clamd
const sock = require('./line_socket');
const utils = require('haraka-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;
});
return 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 sock.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
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]);
}