@internxt/scan
Version:
Use Node JS to scan files on your server with ClamAV's clamscan/clamdscan binary or via TCP to a remote server or local UNIX Domain socket. This is especially useful for scanning uploaded files provided by un-trusted sources.
1,156 lines (1,025 loc) • 65 kB
JavaScript
/* eslint-disable prettier/prettier */
/* eslint-disable no-useless-catch */
/* eslint-disable prefer-destructuring */
/* eslint-disable no-plusplus */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
/* eslint-disable consistent-return */
/* eslint-disable no-loop-func */
/* eslint-disable no-control-regex */
/* eslint-disable no-async-promise-executor */
/*!
* Node - Clam
* Copyright(c) 2013-2024 Kyle Farris <kylefarris@gmail.com>
* MIT Licensed
*/
// Module dependencies.
const os = require('os');
const net = require('net');
const fs = require('fs');
const nodePath = require('path'); // renamed to prevent conflicts in `scanDir`
const tls = require('tls');
const { promisify } = require('util');
const { execFile } = require('child_process');
const { Readable } = require('stream');
const { Socket } = require('dgram');
const fsPromises = require('fs').promises;
const NodeClamError = require('./lib/NodeClamError');
const NodeClamTransform = require('./lib/NodeClamTransform');
const getFiles = require('./lib/getFiles');
const isPermissionError = require('./lib/isPermissionError');
// Re-named `fs` promise methods to prevent conflicts while keeping short names
const fsAccess = fsPromises.access;
const fsReadfile = fsPromises.readFile;
// const fsReaddir = fsPromises.readdir;
const fsStat = fsPromises.stat;
// Convert some stuff to promises
const cpExecFile = promisify(execFile);
/**
* NodeClam class definition.
*
* @class
* @public
* @typicalname NodeClam
*/
class NodeClam {
/**
* This sets up all the defaults of the instance but does not
* necessarily return an initialized instance. Use `.init` for that.
*/
constructor() {
this.initialized = false;
this.debugLabel = 'inxt-clamScan';
this.defaultScanner = 'clamdscan';
this.activeSockets = [];
// Configuration Settings
this.defaults = Object.freeze({
removeInfected: false,
quarantineInfected: false,
scanLog: null,
debugMode: false,
fileList: null,
scanRecursively: true,
clamscan: {
path: '/usr/bin/clamscan',
scanArchives: true,
db: null,
active: true,
},
clamdscan: {
socket: false,
host: false,
port: false,
timeout: 180000,
localFallback: true,
path: '/usr/bin/clamdscan',
configFile: null,
multiscan: true,
reloadDb: false,
active: true,
bypassTest: false,
tls: false,
},
preference: this.defaultScanner,
});
this.settings = { ...this.defaults };
}
/**
* Initialization method.
*
* @public
* @param {object} [options] - User options for the Clamscan module
* @param {boolean} [options.removeInfected=false] - If true, removes infected files when found
* @param {boolean|string} [options.quarantineInfected=false] - If not false, should be a string to a path to quarantine infected files
* @param {string} [options.scanLog=null] - Path to a writeable log file to write scan results into
* @param {boolean} [options.debugMode=false] - If true, *a lot* of info will be spewed to the logs
* @param {string} [options.fileList=null] - Path to file containing list of files to scan (for `scanFiles` method)
* @param {boolean} [options.scanRecursively=true] - If true, deep scan folders recursively (for `scanDir` method)
* @param {object} [options.clamscan] - Options specific to the clamscan binary
* @param {string} [options.clamscan.path='/usr/bin/clamscan'] - Path to clamscan binary on your server
* @param {string} [options.clamscan.db=null] - Path to a custom virus definition database
* @param {boolean} [options.clamscan.scanArchives=true] - If true, scan archives (ex. zip, rar, tar, dmg, iso, etc...)
* @param {boolean} [options.clamscan.active=true] - If true, this module will consider using the clamscan binary
* @param {object} [options.clamdscan] - Options specific to the clamdscan binary
* @param {string} [options.clamdscan.socket=false] - Path to socket file for connecting via TCP
* @param {string} [options.clamdscan.host=false] - IP of host to connec to TCP interface
* @param {string} [options.clamdscan.port=false] - Port of host to use when connecting via TCP interface
* @param {number} [options.clamdscan.timeout=60000] - Timeout for scanning files
* @param {boolean} [options.clamdscan.localFallback=false] - If false, do not fallback to a local binary-method of scanning
* @param {string} [options.clamdscan.path='/usr/bin/clamdscan'] - Path to the `clamdscan` binary on your server
* @param {string} [options.clamdscan.configFile=null] - Specify config file if it's in an usual place
* @param {boolean} [options.clamdscan.multiscan=true] - If true, scan using all available cores
* @param {boolean} [options.clamdscan.reloadDb=false] - If true, will re-load the DB on ever call (slow)
* @param {boolean} [options.clamdscan.active=true] - If true, this module will consider using the `clamdscan` binary
* @param {boolean} [options.clamdscan.bypassTest=false] - If true, check to see if socket is avaliable
* @param {boolean} [options.clamdscan.tls=false] - If true, connect to a TLS-Termination proxy in front of ClamAV
* @param {object} [options.preference='clamdscan'] - If preferred binary is found and active, it will be used by default
* @param {Function} [cb = null] - Callback method. Prototype: `(err, <instance of NodeClam>)`
* @returns {Promise<object>} An initated instance of NodeClam
* @example
*/
async init(options = {}, cb = null) {
let hasCb = false;
// Verify second param, if supplied, is a function
if (cb && typeof cb !== 'function') {
throw new NodeClamError(
'Invalid cb provided to init method. Second paramter, if provided, must be a function!'
);
} else if (cb && typeof cb === 'function') {
hasCb = true;
}
return new Promise(async (resolve, reject) => {
// No need to re-initialize
if (this.initialized === true) return hasCb ? cb(null, this) : resolve(this);
// Override defaults with user preferences
const settings = {};
if (Object.prototype.hasOwnProperty.call(options, 'clamscan') && Object.keys(options.clamscan).length > 0) {
settings.clamscan = { ...this.defaults.clamscan, ...options.clamscan };
delete options.clamscan;
}
if (
Object.prototype.hasOwnProperty.call(options, 'clamdscan') &&
Object.keys(options.clamdscan).length > 0
) {
settings.clamdscan = { ...this.defaults.clamdscan, ...options.clamdscan };
delete options.clamdscan;
}
this.settings = { ...this.defaults, ...settings, ...options };
if (this.settings && 'debugMode' in this.settings && this.settings.debugMode === true)
console.log(`${this.debugLabel}: DEBUG MODE ON`);
// Backwards compatibilty section
if ('quarantinePath' in this.settings && this.settings.quarantinePath) {
this.settings.quarantineInfected = this.settings.quarantinePath;
}
// Determine whether to use clamdscan or clamscan
this.scanner = this.defaultScanner;
// If scanner preference is not defined or is invalid, fallback to streaming scan or completely fail
if (
('preference' in this.settings && typeof this.settings.preference !== 'string') ||
!['clamscan', 'clamdscan'].includes(this.settings.preference)
) {
// If no valid scanner is found (but a socket/port/host is), disable the fallback to a local CLI scanning method
if (this.settings.clamdscan.socket || this.settings.clamdscan.port || this.settings.clamdscan.host) {
this.settings.clamdscan.localFallback = false;
} else {
const err = new NodeClamError(
'Invalid virus scanner preference defined and no valid socket/port/host option provided!'
);
return hasCb ? cb(err, null) : reject(err);
}
}
// Set 'clamscan' as the scanner preference if it's specified as such and activated
// OR if 'clamdscan is the preference but inactivated and clamscan is activated
if (
// If preference is 'clamscan' and clamscan is active
('preference' in this.settings &&
this.settings.preference === 'clamscan' &&
'clamscan' in this.settings &&
'active' in this.settings.clamscan &&
this.settings.clamscan.active === true) || // OR ... // If preference is 'clamdscan' and it's NOT active but 'clamscan' is...
(this.settings.preference === 'clamdscan' &&
'clamdscan' in this.settings &&
'active' in this.settings.clamdscan &&
this.settings.clamdscan.active !== true &&
'clamscan' in this.settings &&
'active' in this.settings.clamscan &&
this.settings.clamscan.active === true)
) {
// Set scanner to clamscan
this.scanner = 'clamscan';
}
// Check to make sure preferred scanner exists and actually is a clamscan binary
try {
// If scanner binary doesn't exist...
if (!(await this._isClamavBinary(this.scanner))) {
// Fall back to other option:
if (
this.scanner === 'clamdscan' &&
this.settings.clamscan.active === true &&
(await this._isClamavBinary('clamscan'))
) {
this.scanner = 'clamscan';
} else if (
this.scanner === 'clamscan' &&
this.settings.clamdscan.active === true &&
(await this._isClamavBinary('clamdscan'))
) {
this.scanner = 'clamdscan';
} else {
// If preferred scanner is not a valid binary but there is a socket/port/host option, disable
// failover to local CLI implementation
if (
!this.settings.clamdscan.socket &&
!this.settings.clamdscan.port &&
!this.settings.clamdscan.host
) {
const err = new NodeClamError(
'No valid & active virus scanning binaries are active and available and no socket/port/host option provided!'
);
return hasCb ? cb(err, null) : reject(err);
}
this.settings.clamdscan.localFallback = false;
}
}
} catch (err) {
return hasCb ? cb(err, null) : reject(err);
}
// Make sure quarantineInfected path exists at specified location
if (
!this.settings.clamdscan.socket &&
!this.settings.clamdscan.port &&
!this.settings.clamdscan.host &&
((this.settings.clamdscan.active === true && this.settings.clamdscan.localFallback === true) ||
this.settings.clamscan.active === true) &&
this.settings.quarantineInfected
) {
try {
await fsAccess(this.settings.quarantineInfected, fs.constants.R_OK);
} catch (e) {
if (this.settings.debugMode) console.log(`${this.debugLabel} error:`, e);
const err = new NodeClamError(
{ err: e },
`Quarantine infected path (${this.settings.quarantineInfected}) is invalid.`
);
return hasCb ? cb(err, null) : reject(err);
}
}
// If using clamscan, make sure definition db exists at specified location
if (
!this.settings.clamdscan.socket &&
!this.settings.clamdscan.port &&
!this.settings.clamdscan.host &&
this.scanner === 'clamscan' &&
this.settings.clamscan.db
) {
try {
await fsAccess(this.settings.clamscan.db, fs.constants.R_OK);
} catch (err) {
if (this.settings.debugMode) console.log(`${this.debugLabel} error:`, err);
// throw new Error(`Definitions DB path (${this.settings.clamscan.db}) is invalid.`);
this.settings.clamscan.db = null;
}
}
// Make sure scanLog exists at specified location
if (
((!this.settings.clamdscan.socket && !this.settings.clamdscan.port && !this.settings.clamdscan.host) ||
((this.settings.clamdscan.socket || this.settings.clamdscan.port || this.settings.clamdscan.host) &&
this.settings.clamdscan.localFallback === true &&
this.settings.clamdscan.active === true) ||
(this.settings.clamdscan.active === false && this.settings.clamscan.active === true) ||
this.preference) &&
this.settings.scanLog
) {
try {
await fsAccess(this.settings.scanLog, fs.constants.R_OK);
} catch (err) {
// console.log("DID NOT Find scan log!");
// foundScanLog = false;
if (this.settings.debugMode) console.log(`${this.debugLabel} error:`, err);
// throw new Error(`Scan Log path (${this.settings.scanLog}) is invalid.` + err);
this.settings.scanLog = null;
}
}
// Check the availability of the clamd service if socket or host/port are provided
if (
this.scanner === 'clamdscan' &&
this.settings.clamdscan.bypassTest === false &&
(this.settings.clamdscan.socket || this.settings.clamdscan.port || this.settings.clamdscan.host)
) {
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Initially testing socket/tcp connection to clamscan server.`);
try {
const client = await this.ping();
client.end();
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Established connection to clamscan server!`);
} catch (err) {
return hasCb ? cb(err, null) : reject(err);
}
}
// if (foundScanLog === false) console.log("No Scan Log: ", this.settings);
// Build clam flags
this.clamFlags = this._buildClamFlags(this.scanner, this.settings);
// if (foundScanLog === false) console.log("No Scan Log: ", this.settings);
// This ClamScan instance is now initialized
this.initialized = true;
// Return instance based on type of expected response (callback vs promise)
return hasCb ? cb(null, this) : resolve(this);
});
}
/**
* Allows one to create a new instances of clamscan with new options.
*
* @public
* @param {object} [options = {}] - Same options as the `init` method
* @param {Function} [cb = null] - What to do after reset (repsponds with reset instance of NodeClam)
* @returns {Promise<object>} A reset instance of NodeClam
*/
reset(options = {}, cb = null) {
let hasCb = false;
// Verify second param, if supplied, is a function
if (cb && typeof cb !== 'function') {
throw new NodeClamError(
'Invalid cb provided to `reset`. Second paramter, if provided, must be a function!'
);
} else if (cb && typeof cb === 'function') {
hasCb = true;
}
this.initialized = false;
this.settings = { ...this.defaults };
return new Promise(async (resolve, reject) => {
try {
await this.init(options);
return hasCb ? cb(null, this) : resolve(this);
} catch (err) {
return hasCb ? cb(err, null) : reject(err);
}
});
}
// *****************************************************************************
// Builds out the args to pass to execFile
// -----
// @param String|Array item The file(s) / directory(ies) to append to the args
// @api Private
// *****************************************************************************
/**
* Builds out the args to pass to `execFile`.
*
* @private
* @param {string|Array} item - The file(s) / directory(ies) to append to the args
* @returns {string|Array} The string or array of arguments
* @example
* this._buildClamArgs('--version');
*/
_buildClamArgs(item) {
let args = this.clamFlags.slice();
if (typeof item === 'string') args.push(item);
if (Array.isArray(item)) args = args.concat(item);
return args;
}
/**
* Builds out the flags based on the configuration the user provided.
*
* @private
* @param {string} scanner - The scanner to use (clamscan or clamdscan)
* @param {object} settings - The settings used to build the flags
* @returns {string} The concatenated clamav flags
* @example
* // Build clam flags
* this.clamFlags = this._buildClamFlags(this.scanner, this.settings);
*/
_buildClamFlags(scanner, settings) {
const flagsArray = ['--no-summary'];
// Flags specific to clamscan
if (scanner === 'clamscan') {
flagsArray.push('--stdout');
// Remove infected files
if (settings.removeInfected === true) {
flagsArray.push('--remove=yes');
} else {
flagsArray.push('--remove=no');
}
// Database file
if (
'clamscan' in settings &&
typeof settings.clamscan === 'object' &&
'db' in settings.clamscan &&
settings.clamscan.db &&
typeof settings.clamscan.db === 'string'
)
flagsArray.push(`--database=${settings.clamscan.db}`);
// Scan archives
if (settings.clamscan.scanArchives === true) {
flagsArray.push('--scan-archive=yes');
} else {
flagsArray.push('--scan-archive=no');
}
// Recursive scanning (flag is specific, feature is not)
if (settings.scanRecursively === true) {
flagsArray.push('-r');
} else {
flagsArray.push('--recursive=no');
}
}
// Flags specific to clamdscan
else if (scanner === 'clamdscan') {
flagsArray.push('--fdpass');
// Remove infected files
if (settings.removeInfected === true) flagsArray.push('--remove');
// Specify a config file
if (
'clamdscan' in settings &&
typeof settings.clamdscan === 'object' &&
'configFile' in settings.clamdscan &&
settings.clamdscan.configFile &&
typeof settings.clamdscan.configFile === 'string'
)
flagsArray.push(`--config-file=${settings.clamdscan.configFile}`);
// Turn on multi-threaded scanning
if (settings.clamdscan.multiscan === true) flagsArray.push('--multiscan');
// Reload the virus DB
if (settings.clamdscan.reloadDb === true) flagsArray.push('--reload');
}
// ***************
// Common flags
// ***************
// Remove infected files
if (settings.removeInfected !== true) {
if (
'quarantineInfected' in settings &&
settings.quarantineInfected &&
typeof settings.quarantineInfected === 'string'
) {
flagsArray.push(`--move=${settings.quarantineInfected}`);
}
}
// Write info to a log
if ('scanLog' in settings && settings.scanLog && typeof settings.scanLog === 'string')
flagsArray.push(`--log=${settings.scanLog}`);
// Read list of files to scan from a file
if ('fileList' in settings && settings.fileList && typeof settings.fileList === 'string')
flagsArray.push(`--file-list=${settings.fileList}`);
// Build the String
return flagsArray;
}
/**
* Create socket connection to a remote(or local) clamav daemon.
*
* @private
* @param {string} [label] - A label you can provide for debugging
* @returns {Promise<Socket>} A Socket/TCP connection to ClamAV
* @example
* const client = this._initSocket('whatever');
*/
_initSocket(label = '') {
return new Promise((resolve, reject) => {
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Attempting to establish socket/TCP connection for "${label}"`);
// Create a new Socket connection to Unix socket or remote server (in that order)
let client;
// Setup socket connection timeout (default: 20 seconds).
const timeout = this.settings.clamdscan.timeout ? this.settings.clamdscan.timeout : 20000;
// The fastest option is a local Unix socket
if (this.settings.clamdscan.port) {
// If a host is specified (usually for a remote host)
if (this.settings.clamdscan.host) {
if (this.settings.clamdscan.tls) {
client = tls.connect({
host: this.settings.clamdscan.host,
port: this.settings.clamdscan.port,
// Activate SNI
// servername: this.settings.clamdscan.host,
timeout,
});
} else {
client = net.createConnection({
host: this.settings.clamdscan.host,
port: this.settings.clamdscan.port,
timeout,
});
}
}
// Host can be ignored since the default is `localhost`
else if (this.settings.tls) {
client = tls.connect({ port: this.settings.clamdscan.port, timeout });
} else {
client = net.createConnection({ port: this.settings.clamdscan.port, timeout });
}
}
// No valid option to connection can be determined
else
throw new NodeClamError(
'Unable not establish connection to clamd service: No socket or host/port combo provided!'
);
// Set the socket timeout if specified
if (this.settings.clamdscan.timeout) client.setTimeout(this.settings.clamdscan.timeout);
this.activeSockets.push(client);
// Setup socket client listeners
client
.on('connect', () => {
// Some basic debugging stuff...
// Determine information about what server the client is connected to
if (client.remotePort && client.remotePort.toString() === this.settings.clamdscan.port.toString()) {
if (this.settings.debugMode)
console.log(
`${this.debugLabel}: using remote server: ${client.remoteAddress}:${client.remotePort}`
);
} else if (this.settings.clamdscan.socket) {
if (this.settings.debugMode)
console.log(
`${this.debugLabel}: using local unix domain socket: ${this.settings.clamdscan.socket}`
);
} else if (this.settings.debugMode) {
const { port, address } = client.address();
console.log(`${this.debugLabel}: meta port value: ${port} vs ${client.remotePort}`);
console.log(`${this.debugLabel}: meta address value: ${address} vs ${client.remoteAddress}`);
console.log(`${this.debugLabel}: something is not working...`);
}
resolve(client);
})
.on('timeout', () => {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Socket/Host connection timed out.`);
reject(new Error('Connection to host has timed out.'));
client.end();
})
.on('close', () => {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Socket/Host connection closed.`);
})
.on('error', (e) => {
console.log('ERROR IN INIT SOCKET: ', e);
if (this.settings.debugMode) console.error(`${this.debugLabel}: Socket/Host connection failed:`, e);
reject(e);
});
});
}
closeAllSockets() {
return new Promise((resolve) => {
for (const socket of this.activeSockets) {
if (!socket.destroyed) {
console.log('DESTROYING SOCKETS');
socket.destroy();
}
}
this.activeSockets = [];
resolve('');
});
}
/**
* Checks to see if a particular binary is a clamav binary. The path for the
* binary must be specified in the NodeClam config at `init`. If you have a
* config file in an unusual place, make sure you specify that in `init` configs
* as well.
*
* @private
* @param {string} scanner - The ClamAV scanner (clamscan or clamdscan) to verify
* @returns {Promise<boolean>} True if binary is a ClamAV binary, false if not.
* @example
* const clamscanIsGood = this._isClamavBinary('clamscan');
*/
async _isClamavBinary(scanner) {
const { path = null, configFile = null } = this.settings[scanner];
if (!path) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Could not determine path for clamav binary.`);
return false;
}
const versionCmds = {
clamdscan: ['--version'],
clamscan: ['--version'],
};
if (configFile) {
versionCmds[scanner].push(`--config-file=${configFile}`);
}
try {
await fsAccess(path, fs.constants.R_OK);
const { stdout } = await cpExecFile(path, versionCmds[scanner]);
if (stdout.toString().match(/ClamAV/) === null) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Could not verify the ${scanner} binary.`);
return false;
}
return true;
} catch (err) {
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Could not verify the ${scanner} binary.`, err);
return false;
}
}
/**
* Test to see if ab object is a readable stream.
*
* @private
* @param {object} obj - Object to test "streaminess" of
* @returns {boolean} Returns `true` if provided object is a stream; `false` if not.
* @example
* // Yay!
* const isStream = this._isReadableStream(someStream);
*
* // Nay!
* const isString = this._isReadableString('foobar');
*/
_isReadableStream(obj) {
if (!obj || typeof obj !== 'object') return false;
return typeof obj.pipe === 'function' && typeof obj._readableState === 'object';
}
/**
* Alias `ping()` for backwards-compatibility with older package versions.
*
* @private
* @alias ping
* @param {Function} [cb] - Callback function
* @returns {Promise<object>} A copy of the Socket/TCP client
*/
_ping(cb = null) {
return this.ping(cb);
}
/**
* This is what actually processes the response from clamav.
*
* @private
* @param {string} result - The ClamAV result to process and interpret
* @param {string} [file=null] - The name of the file/path that was scanned
* @returns {object} Contains `isInfected` boolean and `viruses` array
* @example
* const args = this._buildClamArgs('/some/file/here');
* execFile(this.settings[this.scanner].path, args, (err, stdout, stderr) => {
* const { isInfected, viruses } = this._processResult(stdout, file);
* console.log('Infected? ', isInfected);
* });
*/
_processResult(result, file = null) {
let timeout = false;
// The result value must be a string otherwise we can't parse it
if (typeof result !== 'string') {
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Invalid stdout from scanner (not a string): `, result);
console.log('RESULT IS NOT A STRING: ', result);
throw new Error('Invalid result to process (not a string)');
}
// Clean up the result string so that its predictably parseable
result = result.trim();
// If the result string looks like 'Anything Here: OK\n', the scanned file is not infected
// eslint-disable-next-line no-control-regex
if (/:\s+OK(\u0000|[\r\n])?$/.test(result)) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: File is OK!`);
return { isInfected: false, viruses: [], file, resultString: result, timeout };
}
// If the result string looks like 'Anything Here: SOME VIRUS FOUND\n', the file is infected
// eslint-disable-next-line no-control-regex
if (/:\s+(.+)FOUND(\u0000|[\r\n])?/gm.test(result)) {
if (this.settings.debugMode) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Scan Response: `, result);
if (this.settings.debugMode) console.log(`${this.debugLabel}: File is INFECTED!`);
}
// Parse out the name of the virus(es) found...
const viruses = Array.from(
new Set(
result
// eslint-disable-next-line no-control-regex
.split(/(\u0000|[\r\n])/)
.map((v) => (/:\s+(.+)FOUND$/gm.test(v) ? v.replace(/(.+:\s+)(.+)FOUND/gm, '$2').trim() : null))
.filter((v) => !!v)
)
);
return { isInfected: true, viruses, file, resultString: result, timeout };
}
// If the result of the scan ends with "ERROR", there was an error (file permissions maybe)
if (/^(.+)ERROR(\u0000|[\r\n])?/gm.test(result)) {
const error = result.replace(/^(.+)ERROR/gm, '$1').trim();
if (this.settings.debugMode) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Error Response: `, error);
if (this.settings.debugMode) console.log(`${this.debugLabel}: File may be INFECTED!`);
}
console.log('ERROR IN PROCESS RESULT: ', error);
return new NodeClamError({ error }, `An error occurred while scanning the piped-through stream: ${error}`);
}
// This will occur in the event of a timeout (rare)
if (result === 'COMMAND READ TIMED OUT') {
timeout = true;
if (this.settings.debugMode) {
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Scanning file has timed out. Message: `, result);
if (this.settings.debugMode) console.log(`${this.debugLabel}: File may be INFECTED!`);
}
return { isInfected: null, viruses: [], file, resultString: result, timeout };
}
if (this.settings.debugMode) {
if (this.settings.debugMode) console.log(`${this.debugLabel}: Error Response: `, result);
if (this.settings.debugMode) console.log(`${this.debugLabel}: File may be INFECTED!`);
}
return { isInfected: false, viruses: [], file, resultString: result, timeout };
}
/**
* Quick check to see if the remote/local socket is working. Callback/Resolve
* response is an instance to a ClamAV socket client.
*
* @public
* @name ping
* @param {Function} [cb] - What to do after the ping
* @returns {Promise<object>} A copy of the Socket/TCP client
*/
ping(cb) {
let hasCb = false;
// Verify second param, if supplied, is a function
if (cb && typeof cb !== 'function')
throw new NodeClamError('Invalid cb provided to ping. Second parameter must be a function!');
// Making things simpler
if (cb && typeof cb === 'function') hasCb = true;
// Setup the socket client variable
let client;
// eslint-disable-next-line consistent-return
return new Promise(async (resolve, reject) => {
try {
client = await this._initSocket('ping');
if (this.settings.debugMode)
console.log(`${this.debugLabel}: Established connection to clamscan server!`);
client.write('PING');
let dataReceived = false;
client.on('end', () => {
if (!dataReceived) {
const err = new NodeClamError('Did not get a PONG response from clamscan server.');
if (hasCb) cb(err, null);
else reject(err);
}
});
client.on('data', (data) => {
if (data.toString().trim() === 'PONG') {
dataReceived = true;
if (this.settings.debugMode) console.log(`${this.debugLabel}: PONG!`);
return hasCb ? cb(null, client) : resolve(client);
}
// I'm not even sure this case is possible, but...
const err = new NodeClamError(
data,
'Could not establish connection to the remote clamscan server.'
);
return hasCb ? cb(err, null) : reject(err);
});
client.on('error', (err) => {
if (this.settings.debugMode) {
console.log(`${this.debugLabel}: Could not connect to the clamscan server.`, err);
}
return hasCb ? cb(err, null) : reject(err);
});
} catch (err) {
return hasCb ? cb(err, false) : reject(err);
}
});
}
/**
* Establish the clamav version of a local or remote clamav daemon.
*
* @public
* @param {Function} [cb] - What to do when version is established
* @returns {Promise<string>} - The version of ClamAV that is being interfaced with
* @example
* // Callback example
* clamscan.getVersion((err, version) => {
* if (err) return console.error(err);
* console.log(`ClamAV Version: ${version}`);
* });
*
* // Promise example
* const clamscan = new NodeClam().init();
* const version = await clamscan.getVersion();
* console.log(`ClamAV Version: ${version}`);
*/
getVersion(cb) {
let hasCb = false;
// Verify second param, if supplied, is a function
if (cb && typeof cb !== 'function')
throw new NodeClamError('Invalid cb provided to scanStream. Second paramter must be a function!');
// Making things simpler
if (cb && typeof cb === 'function') hasCb = true;
// eslint-disable-next-line consistent-return
return new Promise(async (resolve, reject) => {
// Function for falling back to running a scan locally via a child process
// If user wants to connect via socket or TCP...
if (
this.scanner === 'clamdscan' &&
(this.settings.clamdscan.socket || this.settings.clamdscan.port || this.settings.clamdscan.host)
) {
const chunks = [];
let client;
try {
client = await this._initSocket('getVersion');
client.write('nVERSION\n');
// ClamAV is sending stuff to us
client.on('data', (chunk) => chunks.push(chunk));
client.on('end', () => {
const response = Buffer.concat(chunks);
client.end();
return hasCb ? cb(null, response.toString()) : resolve(response.toString());
});
} catch (err) {
if (client && 'readyState' in client && client.readyState) client.end();
return hasCb ? cb(err, null) : reject(err);
}
}
});
}
/**
* This method allows you to scan a single file. It supports a callback and Promise API.
* If no callback is supplied, a Promise will be returned. This method will likely
* be the most common use-case for this module.
*
* @public
* @param {string} file - Path to the file to check
* @param {Function} [cb = null] - What to do after the scan
* @returns {Promise<object>} Object like: `{ file: String, isInfected: Boolean, viruses: Array }`
* @example
* // Callback Example
* clamscan.isInfected('/a/picture/for_example.jpg', (err, file, isInfected, viruses) => {
* if (err) return console.error(err);
*
* if (isInfected) {
* console.log(`${file} is infected with ${viruses.join(', ')}.`);
* }
* });
*
* // Promise Example
* clamscan.isInfected('/a/picture/for_example.jpg').then(result => {
* const {file, isInfected, viruses} = result;
* if (isInfected) console.log(`${file} is infected with ${viruses.join(', ')}.`);
* }).then(err => {
* console.error(err);
* });
*
* // Async/Await Example
* const {file, isInfected, viruses} = await clamscan.isInfected('/a/picture/for_example.jpg');
*/
async isInfected(file = '') {
// At this point for the hybrid Promise/CB API to work, everything needs to be wrapped
// in a Promise that will be returned
// eslint-disable-next-line consistent-return
// Verify string is passed to the file parameter
if (typeof file !== 'string' || (typeof file === 'string' && file.trim() === '')) {
const err = new NodeClamError({ file }, 'Invalid or empty file name provided.');
throw err;
}
// Clean file name
file = file.trim();
// See if we can find/read the file
// -----
// NOTE: Is it even valid to do this since, in theory, the
// file's existance or permission could change between this check
// and the actual scan (even if it's highly unlikely)?
//-----
try {
const handle = await fsPromises.open(file, 'r');
await handle.close();
} catch (e) {
const err = new NodeClamError({ err: e, file }, 'Could not access file to scan!');
console.log('ACCESS ERROR:', e);
throw err;
}
try {
await fsAccess(file, fs.constants.R_OK);
} catch (e) {
const err = new NodeClamError({ err: e, file }, 'Could not find file to scan!');
throw err;
}
// Make sure the "file" being scanned is actually a file and not a directory (or something else)
try {
const stats = await fsStat(file);
const isDirectory = stats.isDirectory();
const isFile = stats.isFile();
// If it's not a file or a directory, fail now
if (!isFile && !isDirectory) {
throw Error(`${file} is not a valid file or directory.`);
}
// If it's a directory/path, scan it using the `scanDir` method instead
else if (!isFile && isDirectory) {
const { isInfected } = await this.scanDir(file);
return { file, isInfected, viruses: [] };
}
} catch (err) {
throw err;
}
// If user wants to scan via socket or TCP...
if (this.settings.clamdscan.port || this.settings.clamdscan.host) {
let stream;
try {
// Convert file to stream
stream = await fs.createReadStream(file);
const isInfected = await this.scanStream(stream);
// Attempt to scan the stream.
return { ...isInfected, file };
} catch (err) {
const error = new NodeClamError(
{ err, file },
`ERROR WHILE SCANNING FILES VIA STREAM IN IS INFECTED FUNCTION: ${err}`
);
console.log('ERROR SCANNING STREAM: ', error);
throw error;
} finally {
if (stream && !stream.destroyed) {
stream.destroy();
}
}
}
}
async scanFile(filePath) {
try {
const scannedFile = await this.isInfected(filePath);
return scannedFile;
} catch (err) {
let error = err;
if (err instanceof NodeClamError && err.data?.err instanceof Error) {
error = err.data.err;
}
if (!isPermissionError(error)) {
console.error(`Error scanning file ${filePath}:`, error);
throw error;
}
}
}
/**
* Scans an array of files or paths. You must provide the full paths of the
* files and/or paths. Also enables the ability to scan a file list.
*
* This is essentially a wrapper for isInfected that simplifies the process
* of scanning many files or directories.
*
* **NOTE:** The only way to get per-file notifications is through the callback API.
*
* @public
* @param {Array} files - A list of files or paths (full paths) to be scanned
* @param {Function} [endCb] - What to do after the scan completes
* @param {Function} [fileCb] - What to do after each file has been scanned
* @returns {Promise<object>} Object like: `{ goodFiles: Array, badFiles: Array, errors: Object, viruses: Array }`
*/
scanFiles(files = [], endCb = null, fileCb = null) {
const self = this;
let hasCb = false;
// Verify third param, if supplied, is a function
if (fileCb && typeof fileCb !== 'function')
throw new NodeClamError(
'Invalid file callback provided to `scanFiles`. Third parameter, if provided, must be a function!'
);
// Verify second param, if supplied, is a function
if (endCb && typeof endCb !== 'function') {
throw new NodeClamError(
'Invalid end-scan callback provided to `scanFiles`. Second parameter, if provided, must be a function!'
);
} else if (endCb && typeof endCb === 'function') {
hasCb = true;
}
// We should probably have some reasonable limit on the number of files to scan
if (files && Array.isArray(files) && files.length > 1000000)
throw new NodeClamError(
{ numFiles: files.length },
'NodeClam has halted because more than 1 million files were about to be scanned. We suggest taking a different approach.'
);
// At this point for a hybrid Promise/CB API to work, everything needs to be wrapped
// in a Promise that will be returned
return new Promise(async (resolve, reject) => {
const errors = {};
let goodFiles = [];
let badFiles = [];
let origNumFiles = 0;
// This is the function that actually scans the files
// eslint-disable-next-line consistent-return
const doScan = async (theFiles) => {
const numFiles = theFiles.length;
if (self.settings.debugMode)
console.log(`${this.debugLabel}: Scanning a list of ${numFiles} passed files.`, theFiles);
// Slower but more verbose/informative way...
if (fileCb && typeof fileCb === 'function') {
// Scan files in parallel chunks of 10
const chunkSize = 10;
let results = [];
let scannedCount = 0;
while (theFiles.length > 0) {
const chunk = theFiles.length > chunkSize ? theFiles.splice(0, chunkSize) : theFiles.splice(0);
const chunkResults = [];
for (const file of chunk) {
try {
const result = await this.isInfected(file);
scannedCount++;
const progressRatio = ((scannedCount / numFiles) * 100).toFixed(2);
fileCb(null, file, result.isInfected, result.viruses, scannedCount, progressRatio);
chunkResults.push({ ...result, file });
} catch (err) {
let error = err;
if (err instanceof NodeClamError && err.data?.err instanceof Error) {
error = err.data.err;
}
if (isPermissionError(error)) {
console.warn(`File ${file} skipped due to EBUSY or permission issue.`);
scannedCount++;
const progressRatio = ((scannedCount / numFiles) * 100).toFixed(2);
fileCb(null, file, false, [], scannedCount, progressRatio);
chunkResults.push({ file, isInfected: false, viruses: [] });
} else {
console.error(`Error scanning file ${file}:`, error);
reject(error);
}
}
}
results = results.concat(chunkResults);
}
// Build out the good and bad files arrays
results.forEach((v) => {
if (v[1] === true) badFiles.push(v[0]);
else if (v[1] === false) goodFiles.push(v[0]);
else if (v[1] instanceof Error) {
// eslint-disable-next-line prefer-destructuring
errors[v[0]] = v[1];
}
});
// Make sure the number of results matches the original number of files to be scanned
if (numFiles !== results.length) {
const errMsg = 'The number of results did not match the number of files to scan!';
return hasCb
? endCb(new NodeClamError(errMsg), goodFiles, badFiles, {}, [])
: reject(new NodeClamError({ goodFiles, badFiles }, errMsg));
}
// Make sure the list of bad and good files is unique...(just for good measure)
badFiles = Array.from(new Set(badFiles));
goodFiles = Array.from(new Set(goodFiles));
if (self.settings.debugMode) {
console.log(`${self.debugLabel}: Scan Complete!`);
console.log(`${self.debugLabel}: Num Bad Files: `, badFiles.length);
console.log(`${self.debugLabel}: Num Good Files: `, goodFiles.length);
}
return hasCb
? endCb(null, goodFiles, badFiles, {}, [])