// Provenance: kylefarris/clamscan (fixed). // repo: kylefarris/clamscan // commit: 5f557c970817fe8c578ec3f7ad3bcbcef4cf5538 // parent: d474da7b89c0d3397cd681e79c7bcc592bff144b // commit_url: https://github.com/kylefarris/clamscan/commit/5f557c970817fe8c578ec3f7ad3bcbcef4cf5538 // cve: CVE-2020-7613 // license: MIT // function: local_scan // relpath: index.js // provenance: human // mechanism: source_reaches_sink // upstream_start: 69 class NodeClam { constructor() { this.initialized = false; this.debug_label = 'node-clam'; this.default_scanner = 'clamdscan'; // Configuration Settings this.defaults = Object.freeze({ remove_infected: false, quarantine_infected: false, scan_log: null, debug_mode: false, file_list: null, scan_recursively: true, clamscan: { path: '/usr/bin/clamscan', scan_archives: true, db: null, active: true }, clamdscan: { socket: false, host: false, port: false, timeout: 60000, // 60 seconds local_fallback: true, path: '/usr/bin/clamdscan', config_file: null, multiscan: true, reload_db: false, active: true, bypass_test: false, }, preference: this.default_scanner }); this.settings = Object.assign({}, this.defaults); } // **************************************************************************** // Initialize Method // ----- // @param Object options User options for the Clamscan module // @param Function cb (optional) Callback method // @return Promise If no callback is provided, a Promise is returned // **************************************************************************** async init(options={}, cb) { let has_cb = 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') { has_cb = true; } return new Promise(async (resolve, reject) => { // No need to re-initialize if (this.initialized === true) return (has_cb ? cb(null, this) : resolve(this)); // Override defaults with user preferences let settings = {}; if (Object.prototype.hasOwnProperty.call(options, 'clamscan') && Object.keys(options.clamscan).length > 0) { settings.clamscan = Object.assign({}, this.defaults.clamscan, options.clamscan); delete options.clamscan; } if (Object.prototype.hasOwnProperty.call(options, 'clamdscan') && Object.keys(options.clamdscan).length > 0) { settings.clamdscan = Object.assign({}, this.defaults.clamdscan, options.clamdscan); delete options.clamdscan; } this.settings = Object.assign({}, this.defaults, settings, options); if (this.settings && 'debug_mode' in this.settings && this.settings.debug_mode === true) console.log(`${this.debug_label}: DEBUG MODE ON`); // Backwards compatibilty section if ('quarantine_path' in this.settings && this.settings.quarantine_path) { this.settings.quarantine_infected = this.settings.quarantine_path; } // Determine whether to use clamdscan or clamscan this.scanner = this.default_scanner; // 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/host is), disable the fallback to a local CLI scanning method if (this.settings.clamdscan.socket || this.settings.clamdscan.host) { this.settings.clamdscan.local_fallback = false; } else { const err = new NodeClamError('Invalid virus scanner preference defined and no valid host/socket option provided!'); return (has_cb ? 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._is_clamav_binary(this.scanner)) { // Fall back to other option: if (this.scanner === 'clamdscan' && this.settings.clamscan.active === true && await this._is_clamav_binary('clamscan')) { this.scanner = 'clamscan'; } else if (this.scanner === 'clamscan' && this.settings.clamdscan.active === true && await this._is_clamav_binary('clamdscan')) { this.scanner = 'clamdscan'; } else { // If preferred scanner is not a valid binary but there is a socket/host option, disable // failover to local CLI implementation if (this.settings.clamdscan.socket || this.settings.clamdscan.host) { this.settings.clamdscan.local_fallback = false; } else { const err = new NodeClamError('No valid & active virus scanning binaries are active and available and host/socket option provided!'); return (has_cb ? cb(err, null) : reject(err)); } } } } catch (err) { return (has_cb ? cb(err, null) : reject(err)); } // Make sure quarantine_infected path exists at specified location if ((!this.settings.clamdscan.socket && !this.settings.clamdscan.host && ((this.settings.clamdscan.active === true && this.settings.clamdscan.local_fallback === true) || (this.settings.clamscan.active === true))) && this.settings.quarantine_infected) { try { await fs_access(this.settings.quarantine_infected, fs.constants.R_OK); } catch (e) { if (this.settings.debug_mode) console.log(`${this.debug_label} error:`, err); const err = new NodeClamError({err: e}, `Quarantine infected path (${this.settings.quarantine_infected}) is invalid.`); return (has_cb ? cb(err, null) : reject(err)); } } // If using clamscan, make sure definition db exists at specified location if (!this.settings.clamdscan.socket && !this.settings.clamdscan.host && this.scanner === 'clamscan' && this.settings.clamscan.db) { try { await fs_access(this.settings.clamscan.db, fs.constants.R_OK); } catch (err) { if (this.settings.debug_mode) console.log(`${this.debug_label} error:`, err); //throw new Error(`Definitions DB path (${this.settings.clamscan.db}) is invalid.`); this.settings.clamscan.db = null; } } // Make sure scan_log exists at specified location if ( ( (!this.settings.clamdscan.socket && !this.settings.clamdscan.host) || ( (this.settings.clamdscan.socket || this.settings.clamdscan.host) && this.settings.clamdscan.local_fallback === true && this.settings.clamdscan.active === true ) || (this.settings.clamdscan.active === false && this.settings.clamscan.active === true) || (this.preference) ) && this.settings.scan_log ) { try { await fs_access(this.settings.scan_log, fs.constants.R_OK); } catch (err) { //console.log("DID NOT Find scan log!"); //found_scan_log = false; if (this.settings.debug_mode) console.log(`${this.debug_label} error:`, err); //throw new Error(`Scan Log path (${this.settings.scan_log}) is invalid.` + err); this.settings.scan_log = null; } } // Check the availability of the clamd service if socket or host/port are provided if (this.scanner === 'clamdscan' && this.settings.clamdscan.bypass_test === false && (this.settings.clamdscan.socket || this.settings.clamdscan.host || this.settings.clamdscan.port)) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Initially testing socket/tcp connection to clamscan server.`); try { await this._ping(); if (this.settings.debug_mode) console.log(`${this.debug_label}: Established connection to clamscan server for testing!`); } catch (err) { return (has_cb ? cb(err, null) : reject(err)); } } //if (found_scan_log === false) console.log("No Scan Log: ", this.settings); // Build clam flags this.clam_flags = this._build_clam_flags(this.scanner, this.settings); //if (found_scan_log === 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 (has_cb ? cb(null, this) : resolve(this)); }); } // **************************************************************************** // Allows one to create a new instances of clamscan with new options // ----- // // **************************************************************************** reset(options={}, cb) { let has_cb = 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') { has_cb = true; } this.initialized = false; this.settings = Object.assign({}, this.defaults); return new Promise(async (resolve, reject) => { try { await this.init(options); return (has_cb ? cb(null, this) : resolve(this)); } catch (err) { return (has_cb ? 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 // ***************************************************************************** _build_clam_args(item) { let args = this.clam_flags.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 // ----- // @access Private // @param String scanner The scanner to use (clamscan or clamdscan) // @param Object settings The settings used to build the flags // @return String The concatenated clamav flags // ***************************************************************************** _build_clam_flags(scanner, settings) { const flags_array = ['--no-summary']; // Flags specific to clamscan if (scanner === 'clamscan') { flags_array.push('--stdout'); // Remove infected files if (settings.remove_infected === true) { flags_array.push('--remove=yes'); } else { flags_array.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') flags_array.push(`--database=${settings.clamscan.db}`); // Scan archives if (settings.clamscan.scan_archives === true) { flags_array.push('--scan-archive=yes'); } else { flags_array.push('--scan-archive=no'); } // Recursive scanning (flag is specific, feature is not) if (settings.scan_recursively === true) { flags_array.push('-r'); } else { flags_array.push('--recursive=no'); } } // Flags specific to clamdscan else if (scanner === 'clamdscan') { flags_array.push('--fdpass'); // Remove infected files if (settings.remove_infected === true) flags_array.push('--remove'); // Specify a config file if ('clamdscan' in settings && typeof settings.clamdscan === 'object' && 'config_file' in settings.clamdscan && settings.clamdscan.config_file && typeof settings.clamdscan.config_file === 'string') flags_array.push(`--config-file=${settings.clamdscan.config_file}`); // Turn on multi-threaded scanning if (settings.clamdscan.multiscan === true) flags_array.push('--multiscan'); // Reload the virus DB if (settings.clamdscan.reload_db === true) flags_array.push('--reload'); } // *************** // Common flags // *************** // Remove infected files if (settings.remove_infected !== true) { if ('quarantine_infected' in settings && settings.quarantine_infected && typeof settings.quarantine_infected === 'string') flags_array.push(`--move=${settings.quarantine_infected}`); } // Write info to a log if ('scan_log' in settings && settings.scan_log && typeof settings.scan_log === 'string') flags_array.push(`--log=${settings.scan_log}`); // Read list of files to scan from a file if ('file_list' in settings && settings.file_list && typeof settings.file_list === 'string') flags_array.push(`--file-list=${settings.file_list}`); // Build the String return flags_array; } // **************************************************************************** // Create socket connection to a remote (or local) clamav daemon. // ----- // @return Promise // **************************************************************************** _init_socket() { return new Promise((resolve, reject) => { // Create a new Socket connection to Unix socket or remote server (in that order) let client; // The fastest option is a local Unix socket if (this.settings.clamdscan.socket) client = net.createConnection({path: this.settings.clamdscan.socket}); // If a port is specified, we're going to be connecting via TCP else if (this.settings.clamdscan.port) { // If a host is specified (usually for a remote host) if (this.settings.clamdscan.host) { client = net.createConnection({host: this.settings.clamdscan.host, port: this.settings.clamdscan.port}); } // Host can be ignored since the default is `localhost` else { client = net.createConnection({port: this.settings.clamdscan.port}); } } // 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); // 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.debug_mode) console.log(`${this.debug_label}: using remote server: ${client.remoteAddress}:${client.remotePort}`); } else if (this.settings.clamdscan.socket) { if (this.settings.debug_mode) console.log(`${this.debug_label}: using local unix domain socket: ${this.settings.clamdscan.socket}`); } else { if (this.settings.debug_mode) { const {port, address} = client.address(); console.log(`${this.debug_label}: meta port value: ${port} vs ${client.remotePort}`); console.log(`${this.debug_label}: meta address value: ${address} vs ${client.remoteAddress}`); console.log(`${this.debug_label}: something is not working...`); } } return resolve(client); }) .on('timeout', () => { if (this.settings.debug_mode) console.log(`${this.debug_label}: Socket connection timed out.`); client.end(); }) .on('close', () => { if (this.settings.debug_mode) console.log(`${this.debug_label}: Socket connection closed.`); }) .on('error', (e) => { reject(e); }); }); } // **************************************************************************** // Checks to see if a particular path contains a clamav binary // ----- // @param String scanner Scanner (clamscan or clamdscan) to check // @return Promise // **************************************************************************** async _is_clamav_binary(scanner) { const path = this.settings[scanner].path || null; if (!path) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Could not determine path for clamav binary.`); return false; } const version_cmds = { clamdscan: `${path} --version`, clamscan: `${path} --version`, }; try { await fs_access(path, fs.constants.R_OK); version_cmds_exec = version_cmds[scanner].split(' '); const {stdout} = await cp_execfile(version_cmds_exec[0], [version_cmds_exec[1]]); if (stdout.toString().match(/ClamAV/) === null) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Could not verify the ${scanner} binary.`); return false; } return true; } catch (err) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Could not verify the ${scanner} binary.`); return false; } } // **************************************************************************** // Really basic method to check if the configured `host` is actually the localhost // machine. It's not flawless but a decently acurate check for our purposes. // ----- // @access Private // @return Boolean TRUE: Is localhost; FALSE: is not localhost. // **************************************************************************** _is_localhost() { return ['127.0.0.1','localhost', os.hostname()].includes(this.settings.clamdscan.host); } // **************************************************************************** // Test to see if ab object is a readable stream. // ----- // @access Private // @param Object obj Object to test "streaminess" // @return Boolean TRUE: Is stream; FALSE: is not stream. // **************************************************************************** _is_readable_stream(obj) { if (!obj || typeof obj !== 'object') return false; return typeof obj.pipe === 'function' && typeof obj._readableState === 'object'; } // **************************************************************************** // Quick check to see if the remote/local socket is working. Callback/Resolve // response is an instance to a ClamAV socket client. // ----- // @access Private // @param Function cb (optional) What to do after the ping // @return Promise // **************************************************************************** _ping(cb) { let has_cb = 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') has_cb = true; return new Promise(async (resolve, reject) => { try { const client = await this._init_socket('test_availability'); if (this.settings.debug_mode) console.log(`${this.debug_label}: Established connection to clamscan server for testing!`); client.write('PING'); client.on('data', data => { if (data.toString().trim() === 'PONG') { if (this.settings.debug_mode) console.log(`${this.debug_label}: PONG!`); return (has_cb ? 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 (has_cb ? cb(err, null) : reject(err)); }); } catch (err) { return (has_cb ? cb(err, false) : resolve(err)); } }); } // **************************************************************************** // This is what actually processes the response from clamav // ----- // @access Private // ----- // @param String result The ClamAV result to process and interpret // @param String file The name of the file/path that was scanned // @return Object Object containing `is_infected` Boolean and `viruses` Array // **************************************************************************** _process_result(result, file=null) { if (typeof result !== 'string') { if (this.settings.debug_mode) console.log(`${this.debug_label}: Invalid stdout from scanner (not a string): `, result); throw new Error('Invalid result to process (not a string)'); } result = result.trim(); // eslint-disable-next-line no-control-regex if (/:\s+OK(\u0000|[\r\n])?$/.test(result)) { if (this.settings.debug_mode) console.log(`${this.debug_label}: File is OK!`); return { is_infected: false, viruses: [], file, resultString: result }; } // eslint-disable-next-line no-control-regex if (/:\s+(.+)FOUND(\u0000|[\r\n])?/gm.test(result)) { if (this.settings.debug_mode) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Scan Response: `, result); if (this.settings.debug_mode) console.log(`${this.debug_label}: File is INFECTED!`); } // Parse out the name of the virus(es) found... // eslint-disable-next-line no-control-regex const viruses = result.split(/(\u0000|[\r\n])/).map(v => /:\s+(.+)FOUND$/gm.test(v) ? v.replace(/(.+:\s+)(.+)FOUND/gm, '$2').trim() : null).filter(v => !!v); return { is_infected: true, viruses, file, resultString: result }; } if (/^(.+)ERROR/gm.test(result)) { const error = result.replace(/^(.+)ERROR/gm, '$1').trim(); if (this.settings.debug_mode) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Error Response: `, error); if (this.settings.debug_mode) console.log(`${this.debug_label}: File may be INFECTED!`); } return new NodeClamError({error}, `An error occurred while scanning the piped-through stream: ${error}`); } if (this.settings.debug_mode) { if (this.settings.debug_mode) console.log(`${this.debug_label}: Error Response: `, result); if (this.settings.debug_mode) console.log(`${this.debug_label}: File may be INFECTED!`); } return { is_infected: null, viruses: [], file, resultString: result }; } // **************************************************************************** // Establish the clamav version of a local or remote clamav daemon // ----- // @param Function cb (optional) What to do when version is established // @return Promise // **************************************************************************** get_version(cb) { const self = this; let has_cb = false; // Verify second param, if supplied, is a function if (cb && typeof cb !== 'function') throw new NodeClamError('Invalid cb provided to scan_stream. Second paramter must be a function!'); // Making things simpler if (cb && typeof cb === 'function') has_cb = true; return new Promise(async (resolve, reject) => { // Function for falling back to running a scan locally via a child process const local_fallback = async () => { const command = `${self.settings[self.scanner].path} ${self._build_clam_args('--version').join(' ')}`; if (self.settings.debug_mode) { console.log(`${this.debug_label}: Configured clam command: ${command}`); } // Execute the clam binary with the proper flags try { const {stdout, stderr} = await cp_execfile(command); if (stderr) { const err = new NodeClamError({stderr, file: null}, 'ClamAV responded with an unexpected response when requesting version.'); if (self.settings.debug_mode) console.log(`${this.debug_label}: `, err); return (has_cb ? cb(err, null, null) : reject(err)); } else { return (has_cb ? cb(null, stdout) : resolve(stdout)); } } catch (e) { if (Object.prototype.hasOwnProperty.call(e, 'code') && e.code === 1) { return (has_cb ? cb(null, null) : resolve(null, null)); } else { const err = new NodeClamError({err: e}, 'There was an error requestion ClamAV version.'); if (self.settings.debug_mode) console.log(`${this.debug_label}: `, err); return (has_cb ? cb(err, null) : reject(err)); } } }; // If user wants to connect via socket or TCP... if (this.scanner === 'clamdscan' && (this.settings.clamdscan.socket || this.settings.clamdscan.host)) { const chunks = []; try { const client = await this._init_socket(); 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); return (has_cb ? cb(null, response.toString()) : resolve(response.toString())); }); } catch (err) { if (this.settings.clamdscan.local_fallback === true) { return local_fallback(); } else { return (has_cb ? cb(err, null) : resolve(err)); } } } else { return local_fallback(); } }); } // **************************************************************************** // Checks if a particular file is infected. // ----- // @param String file Path to the file to check // @param Function cb (optional) What to do after the scan // @return Promise // **************************************************************************** is_infected(file='', cb) { const self = this; let has_cb = false; // Verify second param, if supplied, is a function if (cb && typeof cb !== 'function') { throw new NodeClamError('Invalid cb provided to is_infected. Second paramter, if provided, must be a function!'); } else if (cb && typeof cb === 'function') { has_cb = true; } // At this point for the hybrid Promise/CB API to work, everything needs to be wrapped // in a Promise that will be returned return new Promise(async (resolve, reject) => { // 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.'); return (has_cb ? cb(err, file, null, []) : reject(err)); } // Clean file name file = file.trim().replace(/ /g, ' '); // This is the function used for scanning viruses using the clamd command directly const local_scan = () => { //console.log("Doing local scan..."); if (self.settings.debug_mode) console.log(`${this.debug_label}: Scanning ${file}`); // Build the actual command to run const args = self._build_clam_args(file); if (self.settings.debug_mode) console.log(`${this.debug_label}: Configured clam command: ${self.settings[self.scanner].path}`, args.join(' ')); // Execute the clam binary with the proper flags // NOTE: The async/await version of this will not allow us to capture the virus(es) name(s). execFile(self.settings[self.scanner].path, args, (err, stdout, stderr) => { const {is_infected, viruses} = self._process_result(stdout, file); // It may be a real error or a virus may have been found. if (err) { // Code 1 is when a virus is found... It's not really an "error", per se... if (Object.prototype.hasOwnProperty.call(err, 'code') && err.code === 1) { return (has_cb ? cb(null, file, true, viruses) : resolve({file, is_infected, viruses})); } else { const error = new NodeClamError({file, err, is_infected: null}, `There was an error scanning the file (ClamAV Error Code: ${err.code})`); if (self.settings.debug_mode) console.log(`${this.debug_label}`, error); return (has_cb ? cb(error, file, null, []) : reject(error)); } } // Not sure in what scenario a `stderr` would show up, but, it's worth handling here else if (stderr) { const err = new NodeClamError({stderr, file}, 'The file was scanned but ClamAV responded with an unexpected response.'); if (self.settings.debug_mode) console.log(`${this.debug_label}: `, err); return (has_cb ? cb(err, file, null, viruses) : resolve({file, is_infected, viruses})); } // No viruses were found! else { try { return (has_cb ? cb(null, file, is_infected, viruses) : resolve({file, is_infected, viruses})); } catch (e) { const err = new NodeClamError({file, err: e, is_infected: null}, 'There was an error processing the results from ClamAV'); return (has_cb ? cb(err, file, null, []) : reject(err)); } } }); }; // 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 { await fs_access(file, fs.constants.R_OK); } catch (e) { const err = new NodeClamError({err: e, file}, 'Could not find file to scan!'); return (has_cb ? cb(err, file, true) : reject(err)); } // Make sure the "file" being scanned is actually a file and not a directory (or something else) try { const stats = await fs_stat(file); const is_directory = stats.isDirectory(); const is_file = stats.isFile(); // If it's not a file or a directory, fail now if (!is_file && !is_directory) { throw Error(`${file} is not a valid file or directory.`); } // If it's a directory/path, scan it using the `scan_dir` method instead else if (!is_file && is_directory) { const {is_infected} = await this.scan_dir(file); return (has_cb ? cb(null, file, is_infected, []) : resolve({file, is_infected, viruses: []})); } } catch (err) { return (has_cb ? cb(err, file, null) : reject(err)); } // If user wants to scan via socket or TCP... if (this.settings.clamdscan.socket || this.settings.clamdscan.host) { // console.log("Yep"); // Scan using local unix domain socket (much simpler/faster process--especially with MULTISCAN enabled) if (this.settings.clamdscan.socket) { try { const client = await this._init_socket('is_infected'); if (this.settings.debug_mode) console.log(`${this.debug_label}: scanning with local domain socket now.`); if (this.settings.clamdscan.multiscan === true) { // Use Multiple threads (faster) client.write(`MULTISCAN ${file}`); } else { // Use single or default # of threads (potentially slower) client.write(`SCAN ${file}`); } client.on('data', async data => { if (this.settings.debug_mode) console.log(`${this.debug_label}: Received response from remote clamd service.`); try { const result = this._process_result(data.toString(), file); if (result instanceof Error) throw result; const {is_infected, viruses} = result; return (has_cb ? cb(null, file, is_infected, viruses) : resolve({file, is_infected, viruses})); } catch (err) { // Fallback to local if that's an option if (this.settings.clamdscan.local_fallback === true) return await local_scan(); return (has_cb ? cb(err, file, null, []) : reject(err)); } }); } catch (err) { // Fallback to local if that's an option if (this.settings.clamdscan.local_fallback === true) return await local_scan(); return (has_cb ? cb(err, file, null, []) : reject(err)); } } // Scan using remote host/port and TCP protocol (must stream the file) else { // Convert file to stream const stream = fs.createReadStream(file); // Attempt to scan the stream. try { const is_infected = await this.scan_stream(stream); return (has_cb ? cb(null, file, is_infected, []) : resolve(Object.assign({file}, is_infected))); } catch (e) { // Fallback to local if that's an option if (this.settings.clamdscan.local_fallback === true) return await local_scan(); // Otherwise, fail const err = new NodeClamError({err: e, file}, 'Could not scan file via TCP or locally!'); return (has_cb ? cb(err, file, null, []) : reject(err)); } finally { // Kill file stream on response stream.destroy(); } } } // If the user just wants to scan locally... else { try { return await local_scan(); } catch (err) { return (has_cb ? cb(err, file, null) : reject(err)); } } }); } // **************************************************************************** // This will return a Node Stream object that will allow a user to pass a stream // THROUGH this module and on to something else whereby if a virus is detected // mid-stream, the entire pipeline haults and an error event is emitted. // ----- // @param Stream stream A valid NodeJS stream object to pipe through ClamAV // @return Stream A Transform stream // **************************************************************************** passthrough() { const me = this; // A chunk counter for debugging let _scan_complete = false; let _av_waiting = null; let _av_scan_time = false; // DRY method for clearing the interval and counter related to scan times const clear_scan_benchmark = () => { if (_av_waiting) clearInterval(_av_waiting); _av_waiting = null; _av_scan_time = 0; }; // Return a Transform stream so this can act as a "man-in-the-middle" // for the streaming pipeline. // Ex. upload_stream.pipe().pipe(destination_stream) return new Transform({ // This should be fired on each chunk received async transform(chunk, encoding, cb) { // DRY method for handling each chunk as it comes in const do_transform = () => { // Write data to our fork stream. If it fails, // emit a 'drain' event if (!this._fork_stream.write(chunk)) { this._fork_stream.once('drain', () => { cb(null, chunk); }); } else { // Push data back out to whatever is listening (if anything) // and let Node know we're ready for more data cb(null, chunk); } }; // DRY method for handling errors when the arise from the // ClamAV Socket connection const handle_error = (err, is_infected=null, result=null) => { this._fork_stream.unpipe(); this._fork_stream.destroy(); this._clamav_transform.destroy(); clear_scan_benchmark(); // Finding an infected file isn't really an error... if (is_infected === true) { if (_scan_complete === false) { _scan_complete = true; this.emit('scan-complete', result); } this.emit('stream-infected', result); // just another way to catch an infected stream } else { this.emit('error', err); } }; // If we haven't initialized a socket connection to ClamAV yet, // now is the time... if (!this._clamav_socket) { // We're using a PassThrough stream as a middle man to fork the input // into two paths... (1) ClamAV and (2) The final destination. this._fork_stream = new PassThrough(); // Instantiate our custom Transform stream that coddles // chunks into the correct format for the ClamAV socket. this._clamav_transform = new NodeClamTransform({}, me.settings.debug_mode); // Setup an array to collect the responses from ClamAV this._clamav_response_chunks = []; try { // Get a connection to the ClamAV Socket this._clamav_socket = await me._init_socket('passthrough'); if (me.settings.debug_mode) console.log(`${me.debug_label}: ClamAV Socket Initialized...`); // Setup a pipeline that will pass chunks through our custom Tranform and on to ClamAV this._fork_stream.pipe(this._clamav_transform).pipe(this._clamav_socket); // When the CLamAV socket connection is closed (could be after 'end' or because of an error)... this._clamav_socket.on('close', hadError => { if (me.settings.debug_mode) console.log(`${me.debug_label}: ClamAV socket has been closed! Because of Error:`, hadError); }) // When the ClamAV socket connection ends (receives chunk) .on('end', () => { if (me.settings.debug_mode) console.log(`${me.debug_label}: ClamAV socket has received the last chunk!`); // Process the collected chunks const response = Buffer.concat(this._clamav_response_chunks); const result = me._process_result(response.toString('utf8'), null); this._clamav_response_chunks = []; if (me.settings.debug_mode) { console.log(`${me.debug_label}: Result of scan:`, result); console.log(`${me.debug_label}: It took ${_av_scan_time} seconds to scan the file(s).`); clear_scan_benchmark(); } // NOTE: "scan-complete" could be called by the `handle_error` method. // We don't want to to double-emit this message. if (_scan_complete === false) { _scan_complete = true; this.emit('scan-complete', result); } }) // When the ClamAV socket is ready to receive packets (this will probably never fire here) .on('ready', () => { if (me.settings.debug_mode) console.log(`${me.debug_label}: ClamAV socket ready to receive`); }) // When we are officially connected to the ClamAV socket (probably will never fire here) .on('connect', () => { if (me.settings.debug_mode) console.log(`${me.debug_label}: Connected to ClamAV socket`); }) // If an error is emitted from the ClamAV socket .on('error', err => { console.error(`${me.debug_label}: Error emitted from ClamAV socket: `, err); handle_error(err); }) // If ClamAV is sending stuff to us (ie, an "OK", "Virus FOUND", or "ERROR") .on('data', cv_chunk => { // Push this chunk to our results collection array this._clamav_response_chunks.push(cv_chunk); if (me.settings.debug_mode) console.log(`${me.debug_label}: Got result!`, cv_chunk.toString()); // Parse what we've gotten back from ClamAV so far... const response = Buffer.concat(this._clamav_response_chunks); const result = me._process_result(response.toString(), null); // If there's an error supplied or if we detect a virus, stop stream immediately. if (result instanceof NodeClamError || (typeof result === 'object' && 'is_infected' in result && result.is_infected === true)) { // If a virus is detected... if (typeof result === 'object' && 'is_infected' in result && result.is_infected === true) { // handle_error(new NodeClamError(result, `Virus(es) found! ${'viruses' in result && Array.isArray(result.viruses) ? `Suspects: ${result.viruses.join(', ')}` : ''}`)); handle_error(null, true, result); } // If any other kind of error is detected... else { handle_error(result); } } // For debugging purposes, spit out what was processed (if anything). else { if (me.settings.debug_mode) console.log(`${me.debug_label}: Processed Result: `, result, response.toString()); } }); if (me.settings.debug_mode) console.log(`${me.debug_label}: Doing initial transform!`); // Handle the chunk do_transform(); } catch (err) { // If there's an issue connecting to the ClamAV socket, this is where that's handled console.error(`${me.debug_label}: Error initiating socket to ClamAV: `, err); } } else { //if (me.settings.debug_mode) console.log(`${me.debug_label}: Doing transform: ${++counter}`); // Handle the chunk do_transform(); } }, // This is what is called when the input stream has dried up flush(cb) { if (me.settings.debug_mode) console.log(`${me.debug_label}: Done with the full pipeline.`); // Keep track of how long it's taking to scan a file.. _av_waiting = null; _av_scan_time = 0; if (me.settings.debug_mode) { _av_waiting = setInterval(() => { _av_scan_time += 1; if (_av_scan_time % 5 === 0) console.log(`${me.debug_label}: ClamAV has been scanning for ${_av_scan_time} seconds...`); }, 1000); } // TODO: Investigate why this needs to be done in order // for the ClamAV socket to be closed (why NodeClamTransform's // `_flush` method isn't getting called) // If the incoming stream is empty, transform() won't have been called, so we won't // have a socket here. if (this._clamav_socket && this._clamav_socket.writable === true) { const size = Buffer.alloc(4); size.writeInt32BE(0, 0); this._clamav_socket.write(size, cb); } } }); } // **************************************************************************** // Just an alias to `is_infected` // **************************************************************************** scan_file(file, cb) { return this.is_infected(file, cb); } // **************************************************************************** // 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. // ----- // @param Array files A list of files or paths (full paths) to be scanned. // @param Function end_cb What to do after the scan // @param Function file_cb What to do after each file has been scanned // @return Promise // **************************************************************************** scan_files(files=[], end_cb=null, file_cb=null) { const self = this; let has_cb = false; // Verify third param, if supplied, is a function if (file_cb && typeof file_cb !== 'function') throw new NodeClamError('Invalid file callback provided to `scan_files`. Third paramter, if provided, must be a function!'); // Verify second param, if supplied, is a function if (end_cb && typeof end_cb !== 'function') { throw new NodeClamError('Invalid end-scan callback provided to `scan_files`. Second paramter, if provided, must be a function!'); } else if (end_cb && typeof end_cb === 'function') { has_cb = 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({num_files: files.length}, 'NodeClam has haulted 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 bad_files = []; let good_files = []; let viruses = []; let orig_num_files = 0; // The function that parses the stdout from clamscan/clamdscan const parse_stdout = (err, stdout) => { // Get Virus List let viruses = stdout.trim().split(String.fromCharCode(10)).map(v => /FOUND\n?$/.test(v) ? v.replace(/(.+):\s+(.+)FOUND\n?$/, '$2').trim() : null).filter(v => !!v); stdout.trim() .split(String.fromCharCode(10)) .forEach(result => { if (/^[-]+$/.test(result)) return; //console.log("PATH: " + result) let path = result.match(/^(.*): /); if (path && path.length > 0) { path = path[1]; } else { path = ''; } // eslint-disable-next-line no-control-regex if (/\s+OK(\u0000|[\r\n])$/.test(result)) { if (self.settings.debug_mode) console.log(`${this.debug_label}: ${path} is OK!`); good_files.push(path); } else { if (self.settings.debug_mode) console.log(`${this.debug_label}: ${path} is INFECTED!`); bad_files.push(path); } }); bad_files = Array.from(new Set(bad_files)); good_files = Array.from(new Set(good_files)); viruses = Array.from(new Set(viruses)); // return (has_cb ? end_cb(err, [], [], {}, []) : reject(err)); if (err) return (has_cb ? end_cb(err, [], bad_files, {}, []) : reject(new NodeClamError({bad_files}, err))); return (has_cb ? end_cb(null, good_files, bad_files, {}, viruses) : resolve({good_files, bad_files, viruses, errors: null})); }; // Use this method when scanning using local binaries const local_scan = async () => { // Get array of escaped file names const items = files.map(file => file.replace(/ /g,'\\ ')); // Build the actual command to run const command = `${self.settings[self.scanner].path} ${self._build_clam_args(items).join(' ')}`; if (self.settings.debug_mode) if (self.settings.debug_mode) console.log(`${self.debug_label}: Configured clam command: ${command}}`); // Execute the clam binary with the proper flags execFile(command, (err, stdout, stderr) => { if (self.settings.debug_mode) console.log(`${this.debug_label}: stdout:`, stdout); if (err) return parse_stdout(err, stdout); if (stderr) { if (self.settings.debug_mode) console.log(`${this.debug_label}: `, stderr); if (stderr.length > 0) { bad_files = stderr.split(os.EOL).map(err_line => { const match = err_line.match(/^ERROR: Can't access file (.*)+$/); if (match !== null && match.length > 1 && typeof match[1] === 'string') return match[1]; return ''; }); bad_files = bad_files.filter(v => !!v); } } return parse_stdout(null, stdout); }); }; // This is the function that actually scans the files const do_scan = async files => { const num_files = files.length; if (self.settings.debug_mode) console.log(`${this.debug_label}: Scanning a list of ${num_files} passed files.`); // Slower but more verbose/informative way... if (file_cb && typeof file_cb === 'function') { // Scan files in parallel chunks of 10 const chunk_size = 10; let results = []; while (files.length > 0) { let chunk = []; if (files.length > chunk_size) { chunk = files.splice(0, chunk_size); } else { chunk = files.splice(0); } // Scan 10 files then move to the next set... const chunk_results = await Promise.all(chunk.map(file => this.is_infected(file).catch(e => e))); // Re-map results back to their filenames const chunk_results_mapped = chunk_results.map((v,i) => [chunk[i], v]); // Trigger file-callback for each file that was just scanned chunk_results_mapped.forEach(v => file_cb(null, v[0], v[1])); // Add mapped chunk results to overall scan results array results = results.concat(chunk_results_mapped); } // Build out the good and bad files arrays results.forEach(v => { if (v[1] === true) bad_files.push(v[0]); else if (v[1] === false) good_files.push(v[0]); else if (v[1] instanceof Error) { errors[v[0]] = v[1]; } }); // Make sure the number of results matches the original number of files to be scanned if (num_files !== results.length) { const err_msg = 'The number of results did not match the number of files to scan!'; return (has_cb ? end_cb(new NodeClamError(err_msg), good_files, bad_files, {}, []) : reject(new NodeClamError({good_files, bad_files}, err_msg))); } // Make sure the list of bad and good files is unique...(just for good measure) bad_files = Array.from(new Set(bad_files)); good_files = Array.from(new Set(good_files)); if (self.settings.debug_mode) { console.log(`${self.debug_label}: Scan Complete!`); console.log(`${self.debug_label}: Num Bad Files: `, bad_files.length); console.log(`${self.debug_label}: Num Good Files: `, good_files.length); } return (has_cb ? end_cb(null, good_files, bad_files, {}, []) : resolve({good_files, bad_files, errors: null, viruses: []})); } // The quicker but less-talkative way else { let all_files = []; // This is where we scan every file/path in the `all_files` array once it's been fully populated const finish_scan = async () => { // Make sure there are no dupes, falsy values, or non-strings... just because we can all_files = Array.from(new Set(all_files.filter(v => !!v))).filter(v => typeof v === 'string'); // If file list is empty, return error if (all_files.length <= 0) { const err = new NodeClamError('No valid files provided to scan!'); return (has_cb ? end_cb(err, [], [], {}, []) : reject(err)); } // If scanning via sockets, use that method, otherwise use `local_scan` if (self.settings.clamdscan.socket || self.settings.clamdscan.port) { const chunk_size = 10; let results = []; while (all_files.length > 0) { let chunk = []; if (all_files.length > chunk_size) { chunk = all_files.splice(0, chunk_size); } else { chunk = all_files.splice(0); } // Scan 10 files then move to the next set... const chunk_results = await Promise.all(chunk.map(file => self.is_infected(file).catch(e => e))); // Re-map results back to their filenames const chunk_results_mapped = chunk_results.map((v,i) => [chunk[i], v]); //const chunk_results_mapped = chunk_results; // Add mapped chunk results to overall scan results array results = results.concat(chunk_results_mapped); } // Build out the good and bad files arrays results.forEach(v => { if (v[1] instanceof Error) errors[v[0]] = v[1]; else if (typeof v[1] === 'object' && 'is_infected' in v[1] && v[1].is_infected === true) { bad_files.push(v[1].file); if ('viruses' in v[1] && Array.isArray(v[1].viruses) && v[1].viruses.length > 0) { viruses = viruses.concat(v[1].viruses); } } else if (typeof v[1] === 'object' && 'is_infected' in v[1] && v[1].is_infected === false) { good_files.push(v[1].file); } }); // Make sure the list of bad and good files is unique...(just for good measure) bad_files = Array.from(new Set(bad_files)); good_files = Array.from(new Set(good_files)); viruses = Array.from(new Set(viruses)); if (self.settings.debug_mode) { console.log(`${self.debug_label}: Scan Complete!`); console.log(`${self.debug_label}: Num Bad Files: `, bad_files.length); console.log(`${self.debug_label}: Num Good Files: `, good_files.length); console.log(`${self.debug_label}: Num Viruses: `, viruses.length); } return (has_cb ? end_cb(null, good_files, bad_files, errors, viruses) : resolve({errors, good_files, bad_files, viruses})); } else { return local_scan(); } }; // If clamdscan is the preferred binary but we don't want to scan recursively // then we need to convert all path entries to a list of files found in the // first layer of that path if (this.scan_recursively === false && this.scanner === 'clamdscan') { const chunk_size = 10; while (files.length > 0) { let chunk = []; if (files.length > chunk_size) { chunk = files.splice(0, chunk_size); } else { chunk = files.splice(0); } // Scan 10 files then move to the next set... const chunk_results = await Promise.all(chunk.map(file => fs_stat(file).catch(e => e))); // Add each file to `all_files` array // chunk_results.forEach(async (v,i) => { for (let i in chunk_results) { const v = chunk_results[i]; // If the result is an error, add it to the error // object and skip adding this file to the `all_files` array if (v instanceof Error) { errors[chunk[i]] = v; } else if (v.isFile()) { all_files.push(chunk[i]); } else if (v.isDirectory()) { const rgx = new RegExp(`^(?!${v})(.+)$`); try { const contents = (await fs_readdir(chunk[i], {withFileTypes: true})).filter(x => x.isFile()).map(x => x.name.replace(rgx, `${v}/${x.name}`)); all_files = all_files.concat(contents); } catch (e) { errors[chunk[i]] = e; } } } // Scan the files in the all_files array return finish_scan(); } } else { // Just scan all the files all_files = files; // Scan the files in the all_files array return finish_scan(); } } }; // If string is provided in files param, forgive them... create a single element array if (typeof files === 'string' && files.trim().length > 0) { files = files.trim().split(',').map(v => v.trim()); } // If the files array is actually an array, do some additional validation if (Array.isArray(files)) { // Keep track of the original number of files specified orig_num_files = files.length; // Remove any empty or non-string elements files = files.filter(v => !!v).filter(v => typeof v === 'string'); // If any items specified were not valid strings, fail... if (files.length < orig_num_files) { const err = new NodeClamError({num_files: files.length, orig_num_files}, 'You\'ve specified at least one invalid item to the files list (first parameter) of the `scan_files` method.'); // console.log("Files: ", files); // console.log("Num Files: ", files.length); // console.log("Original Num Files: ", orig_num_files); return (has_cb ? end_cb(err, [], [], {}, []) : reject(err)); } } // Do some parameter validation if (!Array.isArray(files) || files.length <= 0) { // Before failing completely, check if there is a file list specified if (!('file_list' in this.settings) || !this.settings.file_list) { const err = new NodeClamError({files, settings: this.settings}, 'No files provided to scan and no file list provided!'); return (has_cb ? end_cb(err, [], [], {}, []) : reject(err)); } // If the file list is specified, read it in and scan listed files... try { const data = (await fs_readfile(this.settings.file_list)).toString().split(os.EOL); return do_scan(data); } catch (e) { const err = new NodeClamError({err: e, file_list: this.settings.file_list}, `No files provided and file list was provided but could not be found! ${e}`); return (has_cb ? end_cb(err, [], [], {}, []) : reject(err)); } } else { return do_scan(files); } }); } // **************************************************************************** // Scans an entire directory. Provides 3 params to end callback: Error, path // scanned, and whether its infected or not. To scan multiple directories, pass // them as an array to the `scan_files` method. // ----- // NOTE: While possible, it is NOT advisable to use the file_cb parameter when // using the clamscan binary. Doing so with clamdscan is okay, however. This // method also allows for non-recursive scanning with the clamdscan binary. // ----- // @param String path The directory to scan files of // @param Function end_cb (optional) What to do when all files have been scanned // @param Function file_cb (optional) What to do after each file has been scanned // @return Promise // **************************************************************************** scan_dir(path='', end_cb=null, file_cb=null) { const self = this; let has_cb = false; // Verify second param, if supplied, is a function if (end_cb && typeof end_cb !== 'function') { throw new NodeClamError('Invalid end-scan callback provided to `scan_dir`. Second paramter, if provided, must be a function!'); } else if (end_cb && typeof end_cb === 'function') { has_cb = true; } // At this point for the hybrid Promise/CB API to work, everything needs to be wrapped // in a Promise that will be returned return new Promise(async (resolve, reject) => { // Verify `path` provided is a string if (typeof path !== 'string' || (typeof path === 'string' && path.trim() === '')) { const err = new NodeClamError({path}, 'Invalid path provided! Path must be a string!'); return (has_cb ? end_cb(err, [], []) : reject(err)); } // Normalize and then trim trailing slash path = node_path.normalize(path).replace(/\/$/, ''); // Make sure path exists... try { await fs_access(path, fs.constants.R_OK); } catch (e) { const err = new NodeClamError({path, err:e}, 'Invalid path specified to scan!'); return (has_cb ? end_cb(err, [], []) : reject(err)); } // Execute the clam binary with the proper flags const local_scan = () => { execFile(self.settings[self.scanner].path, self._build_clam_args(path), (err, stdout, stderr) => { const {is_infected, viruses} = self._process_result(stdout, path); if (err) { // Error code 1 means viruses were found... if (Object.prototype.hasOwnProperty.call(err, 'code') && err.code === 1) { return (has_cb ? end_cb(null, [], [path], viruses) : resolve({path, is_infected, bad_files: [path], good_files: [], viruses})); } else { const error = new NodeClamError({path, err}, 'There was an error scanning the path or processing the result.'); return (has_cb ? end_cb(error, [], [], []) : reject(error)); } } if (stderr) { console.error(`${self.debug_label} error: `, stderr); return (has_cb ? end_cb(null, [], [], []) : resolve({stderr, path, is_infected, good_files: [], bad_files: [], viruses})); } const good_files = (is_infected ? [] : [path]); const bad_files = (is_infected ? [path] : []); return (has_cb ? end_cb(null, good_files, bad_files, viruses) : resolve({path, is_infected, good_files, bad_files, viruses})); }); }; // Get all files recursively using `scan_files` if (this.settings.scan_recursively === true && (typeof file_cb === 'function' || !has_cb)) { try { const {stdout, stderr} = await cp_execfile('find', [path]); if (stderr) { if (this.settings.debug_mode) console.log(`${this.debug_label}: `, stderr); return (has_cb ? end_cb(null, [], []) : resolve({stderr, path, is_infected: null, good_files: [], bad_files: [], viruses: []})); } const files = stdout.trim().split(os.EOL).map(path => path.replace(/ /g,'\\ ').trim()); return this.scan_files(files, end_cb, file_cb); } catch (e) { const err = new NodeClamError({path, err: e}, 'There was an issue scanning the path specified!'); return (has_cb ? end_cb(err, [], []) : reject(err)); } } // Clamdscan always does recursive, so, here's a way to avoid that if you want (will call `scan_files` method) else if (this.settings.scan_recursively === false && this.scanner === 'clamdscan') { try { const all_files = (await fs_readdir(path)).filter(async file => (await fs_stat(file)).isFile()); return this.scan_files(all_files, end_cb, file_cb); } catch (e) { const err = new NodeClamError({path, err: e}, 'Could not read the file listing of the path provided.'); return (has_cb ? end_cb(err, [], []) : reject(err)); } } // If you don't care about individual file progress (which is very slow for clamscan but fine for clamdscan...) // NOTE: This section WILL scan recursively else if (typeof file_cb !== 'function' || !has_cb) { // Scan locally via socket (either TCP or Unix socket) // This is much simpler/faster process--potentially even more with MULTISCAN enabled) if (this.settings.clamdscan.socket || (this.settings.clamdscan.port && this._is_localhost())) { try { const client = await this._init_socket(); if (this.settings.debug_mode) console.log(`${this.debug_label}: scanning path with local domain socket now.`); if (this.settings.clamdscan.multiscan === true) { // Use Multiple threads (faster) client.write(`MULTISCAN ${path}`); } else { // Use single or default # of threads (potentially slower) client.write(`SCAN ${path}`); } // Where to buffer string response (not a real "Buffer", per se...) const chunks = []; // Read output of the ClamAV socket to see what it's saying and when // it's done saying it (FIN) client // ClamAV is sending stuff to us .on('data', chunk => { chunks.push(chunk); }) // ClamAV is done sending stuff to us .on('end', async () => { if (this.settings.debug_mode) console.log(`${this.debug_label}: Received response from remote clamd service.`); const response = Buffer.concat(chunks); const result = this._process_result(response.toString(), path); if (result instanceof Error) { // Fallback to local if that's an option if (this.settings.clamdscan.local_fallback === true) return local_scan(); const err = new NodeClamError({path, err: result}, 'There was an issue scanning the path provided.'); return (has_cb ? end_cb(err, [], []) : reject(err)); } const {is_infected, viruses} = result; const good_files = (is_infected ? [] : [path]); const bad_files = (is_infected ? [path] : []); return (has_cb ? end_cb(null, good_files, bad_files, viruses) : resolve({path, is_infected, good_files, bad_files, viruses})); }); } catch (e) { const err = new NodeClamError({path, err: e}, 'There was an issue scanning the path provided.'); return (has_cb ? end_cb(err, [], []) : reject(err)); } } // Scan path recursively using remote host/port and TCP protocol (must stream every single file to it...) // WARNING: This is going to be really slow else if (this.settings.clamdscan.port && !this._is_localhost()) { const results = []; try { const {stdout, stderr} = await cp_execfile('find', [path]); if (stderr) { if (this.settings.debug_mode) console.log(`${this.debug_label}: `, stderr); return (has_cb ? end_cb(null, [], []) : resolve({stderr, path, is_infected, good_files: [], bad_files: [], viruses: []})); } // Get the proper recursive list of files from the path const files = stdout.split('\n').map(path => path.replace(/ /g,'\\ ')); // Send files to remote server in parallel chunks of 10 const chunk_size = 10; while (files.length > 0) { let chunk = []; if (files.length > chunk_size) { chunk = files.splice(0, chunk_size); } else { chunk = files.splice(0); } // Scan 10 files then move to the next set... results.concat(await Promise.all(chunk.map(file => this.scan_stream(fs.createReadStream(file))))); } // If even a single file is infected, the whole directory is infected const is_infected = results.any(v => v === false); const good_files = (is_infected ? [] : [path]); const bad_files = (is_infected ? [path] : []); return (has_cb ? end_cb(null, good_files, bad_files) : resolve({path, is_infected, good_files, bad_files, viruses: []})); } catch (e) { const err = new NodeClamError({path, err: e}, 'Invalid path provided! Path must be a string!'); return (has_cb ? end_cb(err, [], []) : reject(err)); } } // Scan locally else { local_scan(); } } }); } // **************************************************************************** // Scans a node Stream object // ----- // @param Stream stream The stream to scan // @param Function callback (optional) What to do when the socket responds with results // @return Promise // **************************************************************************** scan_stream(stream, cb) { let has_cb = false; // Verify second param, if supplied, is a function if (cb && typeof cb !== 'function') throw new NodeClamError('Invalid cb provided to scan_stream. Second paramter must be a function!'); // Making things simpler if (cb && typeof cb === 'function') has_cb = true; return new Promise(async (resolve, reject) => { let finished = false; // Verify stream is passed to the first parameter if (!this._is_readable_stream(stream)) { const err = new NodeClamError({stream}, 'Invalid stream provided to scan.'); return (has_cb ? cb(err, null) : reject(err)); } else { if (this.settings.debug_mode) console.log(`${this.debug_label}: Provided stream is readable.`); } // Verify that they have a valid socket or host/port config if (!this.settings.clamdscan.socket && (!this.settings.clamdscan.port || !this.settings.clamdscan.host)) { const err = new NodeClamError({clamdscan_settings: this.settings.clamdscan}, 'Invalid information provided to connect to clamav service. A unix socket or port (+ optional host) is required!'); return (has_cb ? cb(err, null) : reject(err)); } // Get a socket client try { // Get an instance of our stream tranform that coddles // the chunks from the incoming stream to what ClamAV wants const transform = new NodeClamTransform({}, this.settings.debug_mode); // Get a socket const socket = await this._init_socket(); // Pipe the stream through our transform and into the ClamAV socket stream.pipe(transform).pipe(socket); // Setup the listeners for the stream stream // The stream has dried up .on('end', () => { if (this.settings.debug_mode) console.log(`${this.debug_label}: The input stream has dried up.`); finished = true; stream.destroy(); }) // There was an error with the stream (ex. uploader closed browser) .on('error', err => { if (this.settings.debug_mode) console.log(`${this.debug_label}: There was an error with the input stream (maybe uploader closed browser?).`, err); return (has_cb ? cb(err, null) : reject(err)); }); // Where to buffer string response (not a real "Buffer", per se...) const chunks = []; // Read output of the ClamAV socket to see what it's saying and when // it's done saying it (FIN) socket // ClamAV is sending stuff to us .on('data', chunk => { if (this.settings.debug_mode) console.log(`${this.debug_label}: Received output from ClamAV Socket.`); if (!stream.isPaused()) stream.pause(); chunks.push(chunk); }) .on('close', hadError => { if (this.settings.debug_mode) console.log(`${this.debug_label}: ClamAV socket has been closed!`, hadError); }) .on('error', err => { console.error(`${this.debug_label}: Error emitted from ClamAV socket: `, err); return (has_cb ? cb(err, null) : reject(err)); }) // ClamAV is done sending stuff to us .on('end', () => { if (this.settings.debug_mode) console.log(`${this.debug_label}: ClamAV is done scanning.`); const response = Buffer.concat(chunks); if (!finished) { const err = new NodeClamError('Scan aborted. Reply from server: ' + response.toString('utf8')); return (has_cb ? cb(err, null) : reject(err)); } else { if (this.settings.debug_mode) console.log(`${this.debug_label}: Raw Response: ${response.toString('utf8')}`); const result = this._process_result(response.toString('utf8'), null); return (has_cb ? cb(null, result) : resolve(result)); } }); } catch (err) { return (has_cb ? cb(err, null) : reject(err)); } }); } }