├── .eslintrc ├── .gitignore ├── Gruntfile.js ├── package.json ├── LICENSE ├── test └── avast-client-test.js ├── lib ├── line-reader.js └── avast-client.js ├── docs └── avast-protocol.txt └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": 0 4 | }, 5 | "extends": "nodemailer" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | .npmrc 5 | config/production.* 6 | config/development.* 7 | package-lock.json* 8 | scan.sock* 9 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(grunt) { 4 | // Project configuration. 5 | grunt.initConfig({ 6 | eslint: { 7 | all: ['lib/**/*.js', 'Gruntfile.js'] 8 | } 9 | }); 10 | 11 | // Load the plugin(s) 12 | grunt.loadNpmTasks('grunt-eslint'); 13 | 14 | // Tasks 15 | grunt.registerTask('default', ['eslint']); 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "avast-client", 3 | "version": "1.0.0", 4 | "description": "Client for Avast scanner", 5 | "main": "lib/avast-client.js", 6 | "scripts": { 7 | "test": "grunt" 8 | }, 9 | "keywords": ["avast", "virus scan"], 10 | "author": "Andris Reinman", 11 | "license": "MIT", 12 | "devDependencies": { 13 | "eslint-config-nodemailer": "^1.2.0", 14 | "grunt": "^1.0.2", 15 | "grunt-cli": "^1.2.0", 16 | "grunt-eslint": "^20.1.0" 17 | }, 18 | "dependencies": { 19 | "npmlog": "^4.1.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Andris Reinman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 11 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 13 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 14 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 15 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 16 | SOFTWARE. 17 | -------------------------------------------------------------------------------- /test/avast-client-test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | 'use strict'; 4 | 5 | const AvastClient = require('../lib/avast-client'); 6 | const npmlog = require('npmlog'); 7 | 8 | npmlog.level = 'silly'; 9 | 10 | // run in app root assuming example.com has Avast Core running: 11 | // rm -rf scan.sock && ssh -nNT -L ./scan.sock:/var/run/avast/scan.sock example.com 12 | // NB! tunneling the socket does not work for scanning as the file needs to be written to server 13 | const SOCKET_ADDR = __dirname + '/../scan.sock'; 14 | 15 | let ac = new AvastClient({ address: SOCKET_ADDR }); 16 | ac.setFlags({ allfiles: true }, console.log); 17 | ac.getFlags(console.log); 18 | 19 | ac.checkUrl('http://www.neti.ee/', console.log); 20 | ac.checkUrl('http://www.avast.com/eng/test-url-blocker.html', console.log); 21 | 22 | ac.setSensitivity({ pup: true }, console.log); 23 | ac.getSensitivity(console.log); 24 | 25 | ac.setPack({ zip: true, kriips: true }, console.log); 26 | ac.getPack(console.log); 27 | 28 | ac.getVPS(console.log); 29 | 30 | ac.scan('message.eml', 'tere', console.log); 31 | -------------------------------------------------------------------------------- /lib/line-reader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { Writable } = require('stream'); 4 | 5 | class LineReader extends Writable { 6 | constructor(lineHandler, options) { 7 | options = options || {}; 8 | options.writableObjectMode = false; 9 | options.readableObjectMode = true; 10 | super(options); 11 | 12 | this.lineHandler = lineHandler; 13 | this.lineNum = 0; 14 | this.readBuffer = []; 15 | this.lineBuffer = []; 16 | this.readPos = 0; 17 | } 18 | 19 | finalizeLine(final) { 20 | if (final && this.lineBuffer.length) { 21 | let line = Buffer.concat(this.lineBuffer); 22 | this.lineBuffer = []; 23 | return line; 24 | } 25 | 26 | return false; 27 | } 28 | 29 | readLine(final) { 30 | if (!this.readBuffer.length) { 31 | return this.finalizeLine(final); 32 | } 33 | 34 | let reading = true; 35 | let curBuf = this.readBuffer[0]; 36 | let curBufLen = curBuf.length; 37 | let startPos = this.readPos; 38 | while (reading) { 39 | if (this.readPos >= curBufLen) { 40 | // reached end of one chunk 41 | if (startPos) { 42 | // part of the chunk 43 | this.lineBuffer.push(curBuf.slice(startPos)); 44 | } else { 45 | // entire chunk 46 | this.lineBuffer.push(curBuf); 47 | } 48 | 49 | this.readBuffer.shift(); 50 | this.readPos = 0; 51 | if (!this.readBuffer.length) { 52 | // nothing more to read but still not end of line 53 | return this.finalizeLine(final); 54 | } 55 | curBuf = this.readBuffer[0]; 56 | continue; 57 | } 58 | 59 | let c = curBuf[this.readPos++]; 60 | if (c === 0x0a) { 61 | // line break! 62 | this.lineBuffer.push(curBuf.slice(startPos, this.readPos)); 63 | let line = Buffer.concat(this.lineBuffer); 64 | this.lineBuffer = []; 65 | return line; 66 | } 67 | } 68 | } 69 | 70 | readLines(final, callback) { 71 | let readNextLine = () => { 72 | let line = this.readLine(final); 73 | if (!line) { 74 | return callback(); 75 | } 76 | 77 | let nl = 0; 78 | if (line.length >= 1 && line[line.length - 1] === 0x0a) { 79 | nl++; 80 | if (line.length >= 2 && line[line.length - 2] === 0x0d) { 81 | nl++; 82 | } 83 | } 84 | 85 | if (nl) { 86 | line = line.slice(0, line.length - nl); 87 | } 88 | 89 | this.lineHandler(line, err => { 90 | if (err) { 91 | return this.emit('error', err); 92 | } 93 | setImmediate(readNextLine); 94 | }); 95 | }; 96 | 97 | readNextLine(); 98 | } 99 | 100 | _write(chunk, encoding, callback) { 101 | this.readBuffer.push(chunk); 102 | this.readLines(false, callback); 103 | } 104 | 105 | _final(callback) { 106 | this.readLines(true, callback); 107 | } 108 | } 109 | 110 | module.exports = LineReader; 111 | -------------------------------------------------------------------------------- /docs/avast-protocol.txt: -------------------------------------------------------------------------------- 1 | output of: `man avast-protocol` 2 | ----------------------------- 3 | 4 | AVAST-PROTOCOL(5) File Formats Manual AVAST-PROTOCOL(5) 5 | 6 | NAME 7 | avast-protocol - Avast UNIX socket communication protocol 8 | 9 | SYNOPSIS 10 | nc -U /var/run/avast/scan.sock 11 | socat /var/run/avast/scan.sock - 12 | 13 | DESCRIPTION 14 | avast(1) uses a text based protocol for communication with the scan service daemon over the UNIX socket. This manual page briefly describes the protocol. 15 | 16 | GENERAL PROTOCOL RULES 17 | The communication consists of command-response pairs and is line-based. The new line terminator is CRLF. The general command syntax is: 18 | []... 19 | 20 | Responses may be numerical only, or may contain additional output data. Numerical responses have the format: 21 | 22 | 23 | The output data format is: 24 | 25 | 26 | Output data, are always encapsulated between numerical responses 210 (DATA) and the final numerical response for the command. Delimiters such as , or CR/LF are backslash escaped, when present in the data or command argument. 27 | 28 | The communication from the service starts with a numeric welcome message, 220. The protocol commands are case-insensitive. 29 | 30 | RESPONSE CODES 31 | 200 OK 32 | 210 DATA 33 | 220 Welcome message 34 | 451 Engine error 35 | 466 License error 36 | 501 Syntax error 37 | 520 URL blocked 38 | 39 | COMMANDS 40 | SCAN Scan a file/directory. 41 | 42 | Synopsis: 43 | SCAN 44 | 45 | The format of the data message lines is: 46 | [] 47 | 48 | The has a format of "'['']'.0", where can be one of: "+" - file is OK, "E" - error during scan and "L" - infection found. is the depth when scanning inside archives (0 for common non-archive files). 49 | 50 | The follows the "E" and "L" cases. The "L" case info has the format "0". The "E" case info has the format "Error". 51 | 52 | Example: 53 | > scan /etc 54 | 55 | 210 SCAN DATA 56 | SCAN /etc/fstab [+]0.0 57 | SCAN /etc/shadow [E]0.0 Error 13 Permission\ denied 58 | SCAN /etc/eicar.com [L]0.0 0 EICAR\ Test-NOT\ virus!!! 59 | ... 60 | 200 SCAN OK 61 | 62 | VPS Get the virus definitions (VPS) version. 63 | 64 | Synopsis: 65 | VPS 66 | 67 | Example: 68 | > VPS 69 | 70 | 210 VPS DATA 71 | VPS 15051301 72 | 200 VPS OK 73 | 74 | PACK Get/set packer options. 75 | 76 | Synopsis: 77 | PACK [+|-...] 78 | 79 | Use + to enable a specific packer and - to disable it. When invoked without an argument, the packer set is displayed, but not changed. The same mechanism applies to the FLAGS and SENSITIVITY commands. 80 | 81 | Example: 82 | > PACK -zip -iso 83 | 84 | 210 PACK DATA 85 | PACK +mime -zip +arj +rar ... +7zip -iso +dmg 86 | 200 PACK OK 87 | 88 | FLAGS Get/set scan flags. 89 | 90 | Synopsis: 91 | FLAGS [+|-...] 92 | 93 | Example: 94 | > FLAGS +fullfiles 95 | 96 | 210 FLAGS DATA 97 | FLAGS +fullfiles +allfiles -scandevices 98 | 200 FLAGS OK 99 | 100 | SENSITIVITY 101 | Get/set scan sensitivity. 102 | 103 | Synopsis: 104 | SENSITIVITY [+|-...] 105 | 106 | Example: 107 | > SENSITIVITY +pup 108 | 109 | 210 SENSITIVITY DATA 110 | SENSITIVITY +worm +trojan +adware +spyware ... +pup 111 | 200 SENSITIVITY OK 112 | 113 | EXCLUDE 114 | Exclude path from scans. 115 | 116 | Synopsis: 117 | EXCLUDE 118 | 119 | Paths omitted by exclusion are reported with error 42019 - Skipped due to exclusion settings. is matched as a string prefix thus e.g. "/usr/lib/" excludes nothing because the "/usr/lib" scan path does not match and any "/usr/lib/anything" subpath also does not match. 120 | may contain wild cards ("*"). 121 | 122 | Example: 123 | > EXCLUDE /tmp 124 | 125 | 210 EXCLUDE DATA 126 | EXCLUDE /tmp 127 | 200 EXCLUDE OK 128 | 129 | CHECKURL 130 | Check whether a given URL is malicious. 131 | 132 | Synopsis: CHECKURL 133 | 134 | Example: 135 | > CHECKURL http://www.google.com 136 | 200 CHECKURL OK 137 | 138 | > CHECKURL http://www.avast.com/eng/test-url-blocker.html 139 | 520 CHECKURL URL blocked 140 | 141 | SEE ALSO 142 | avast(1), nc(1), socat(1) 143 | 144 | AUTHOR 145 | Martin Tuma (tuma@avast.com) 146 | 147 | 2.2.0 2017-03-24 AVAST-PROTOCOL(5) 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # avast-client 2 | 3 | Connects to [Avast scanner daemon](https://www.avast.com/linux-server-antivirus) and scans files for viruses. 4 | 5 | ``` 6 | const AvastClient = require('avast-client'); 7 | const scanner = new AvastClient(); 8 | scanner.scan('virus.exe', fs.readFileSync('virus.exe'), (err, response)=>{ 9 | console.log(err || response); 10 | // you can keep using the same scanner instance until you call quit() 11 | scanner.quit(); 12 | }); 13 | ``` 14 | 15 | ## Methods 16 | 17 | ### getFlags 18 | 19 | Return current flags as an object with key:value pairs where key is a flag and value is a boolean indicating if the flag is set or not 20 | 21 | ```javascript 22 | scanner.getFlags((err, flags) => { 23 | console.log(flags); 24 | }); 25 | ``` 26 | 27 | Example response 28 | 29 | ```javascript 30 | { fullfiles: false, allfiles: true, scandevices: false } 31 | ``` 32 | 33 | ### setFlags 34 | 35 | Allows to change flags. Returns current flags as an object with key:value pairs where key is a flag and value is a boolean indicating if the flag is set or not 36 | 37 | ```javascript 38 | scanner.setFlags({ allfiles: false, fullfiles: true }, (err, flags) => { 39 | console.log(flags); 40 | }); 41 | ``` 42 | 43 | Example response 44 | 45 | ```javascript 46 | { fullfiles: true, allfiles: false, scandevices: false } 47 | ``` 48 | 49 | ### getSensitivity 50 | 51 | Return current sensitvity as an object with key:value pairs where key is an option and value is a boolean indicating if the sensitivity option is set or not 52 | 53 | ```javascript 54 | scanner.getSensitivity((err, opts) => { 55 | console.log(opts); 56 | }); 57 | ``` 58 | 59 | Example response 60 | 61 | ```javascript 62 | { worm: true, 63 | trojan: true, 64 | adware: true, 65 | spyware: true, 66 | dropper: true, 67 | kit: true, 68 | joke: true, 69 | dangerous: true, 70 | dialer: true, 71 | rootkit: true, 72 | exploit: true, 73 | pup: true, 74 | suspicious: true, 75 | pube: true } 76 | ``` 77 | 78 | ### setSensitivity 79 | 80 | Allows to change sensitvity options. Return current sensitvity as an object with key:value pairs where key is an option and value is a boolean indicating if the sensitivity option is set or no 81 | 82 | ```javascript 83 | scanner.setSensitivity({ dialer: false }, (err, opts) => { 84 | console.log(opts); 85 | }); 86 | ``` 87 | 88 | Example response 89 | 90 | ```javascript 91 | { worm: true, 92 | trojan: true, 93 | adware: true, 94 | spyware: true, 95 | dropper: true, 96 | kit: true, 97 | joke: true, 98 | dangerous: true, 99 | dialer: false, 100 | rootkit: true, 101 | exploit: true, 102 | pup: true, 103 | suspicious: true, 104 | pube: true } 105 | ``` 106 | 107 | ### getPack 108 | 109 | Return current packer settings as an object with key:value pairs where key is an option and value is a boolean indicating if the packer option is set or not 110 | 111 | ```javascript 112 | scanner.getPack((err, opts) => { 113 | console.log(opts); 114 | }); 115 | ``` 116 | 117 | Example response 118 | 119 | ```javascript 120 | { mime: true, 121 | zip: true, 122 | arj: true, 123 | rar: true, 124 | cab: true, 125 | tar: true, 126 | gz: true, 127 | bzip2: true, 128 | ace: true, 129 | arc: true, 130 | zoo: true, 131 | lharc: true, 132 | chm: true, 133 | cpio: true, 134 | rpm: true, 135 | '7zip': true, 136 | iso: true, 137 | tnef: true, 138 | dbx: true, 139 | sys: true, 140 | ole: true, 141 | exec: true, 142 | winexec: true, 143 | install: true, 144 | dmg: true } 145 | ``` 146 | 147 | ### setPack 148 | 149 | Allows to change packer options. Return current packer settings as an object with key:value pairs where key is an option and value is a boolean indicating if the packer option is set or not 150 | 151 | ```javascript 152 | scanner.setPack({ mime: false }, (err, opts) => { 153 | console.log(opts); 154 | }); 155 | ``` 156 | 157 | Example response 158 | 159 | ```javascript 160 | { mime: false, 161 | zip: true, 162 | arj: true, 163 | rar: true, 164 | cab: true, 165 | tar: true, 166 | gz: true, 167 | bzip2: true, 168 | ace: true, 169 | arc: true, 170 | zoo: true, 171 | lharc: true, 172 | chm: true, 173 | cpio: true, 174 | rpm: true, 175 | '7zip': true, 176 | iso: true, 177 | tnef: true, 178 | dbx: true, 179 | sys: true, 180 | ole: true, 181 | exec: true, 182 | winexec: true, 183 | install: true, 184 | dmg: true } 185 | ``` 186 | 187 | ### checkUrl 188 | 189 | Allows to check if an url is blacklisted or not 190 | 191 | ```javascript 192 | scanner.checkUrl('http://www.google.com/', (err, status) => { 193 | if (status) { 194 | console.log('URL is OK'); 195 | } else { 196 | console.log('URL is blacklisted'); 197 | } 198 | }); 199 | ``` 200 | 201 | ### getVPS 202 | 203 | Get current virus definitions version. Return the version number as a string. 204 | 205 | ```javascript 206 | scanner.getVPS((err, version) => { 207 | console.log(version); 208 | }); 209 | ``` 210 | 211 | ### scan 212 | 213 | Scans a buffer or a stream and returns scan result 214 | 215 | scanner.scan(filename, data, callback) 216 | 217 | Where 218 | 219 | * **filename** is a name of the file. This is not real path, it just indicates the type of the data to be scanned 220 | * **data** is file contents, either a Buffer, a String (ascii or utf8) or a Stream 221 | * **callback** (_err_, _response_) is the function to run once scanning is complete 222 | * **err** is the error respone if scanning failed for some system error 223 | * **response** is scan response object 224 | * **status** is either 'clean', 'infected' or 'error' 225 | * **message** is either the injection or error message 226 | * **path** is set if the infected file was found from a container, eg the scanned file was a zip file 227 | 228 | ```javascript 229 | scanner.scan('message.eml', fs.readFileSync('/var/mail/message.eml'), (err, result) => { 230 | console.log(result); 231 | }); 232 | ``` 233 | 234 | Example response 235 | 236 | ```javascript 237 | { status: 'infected', message: 'Win32:Malware-gen' } 238 | ``` 239 | 240 | ### quit 241 | 242 | Closes the socket to the daemon and does not allow to use this instance anymore. 243 | 244 | ## License 245 | 246 | **MIT** 247 | -------------------------------------------------------------------------------- /lib/avast-client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const LineReader = require('./line-reader'); 4 | const net = require('net'); 5 | const npmlog = require('npmlog'); 6 | const path = require('path'); 7 | const os = require('os'); 8 | const fs = require('fs'); 9 | const crypto = require('crypto'); 10 | 11 | class AvastClient { 12 | constructor(options) { 13 | options = options || {}; 14 | 15 | this.connection = false; 16 | this._connecting = false; 17 | this.initialized = false; 18 | 19 | this._commandQueue = []; 20 | 21 | this.lineReader = false; 22 | this._lineReaderEnded = false; 23 | 24 | this._quitting = false; 25 | 26 | this._processing = false; 27 | this._current = false; 28 | 29 | this._data = false; // if true then expects data lines 30 | 31 | this._initializeCallback = false; 32 | 33 | this.address = path.join(options.address || '/var/run/avast/scan.sock'); 34 | this.tmpdir = options.tmpdir || os.tmpdir(); 35 | 36 | this.logger = options.logger || npmlog; 37 | } 38 | 39 | run(command, callback) { 40 | if (this._quitting) { 41 | return callback(new Error('QUIT called')); 42 | } else if (command === 'QUIT') { 43 | this._quitting = true; 44 | } 45 | 46 | this._commandQueue.push({ 47 | command, 48 | prefix: command 49 | .toString() 50 | .split(/\s/) 51 | .shift() 52 | .toUpperCase(), 53 | response: [], 54 | callback 55 | }); 56 | 57 | if (!this._processing) { 58 | this._sendCommand(); 59 | } 60 | } 61 | 62 | _parseFlagsResponse(value) { 63 | let response = {}; 64 | [].concat(value || []).forEach(str => { 65 | (str || '') 66 | .toString() 67 | .trim() 68 | .split(/\s+/) 69 | .forEach(entry => { 70 | if (!entry) { 71 | return; 72 | } 73 | 74 | let sign = entry.charAt(0); 75 | let key = entry.substr(1).toLowerCase(); 76 | 77 | if (!key) { 78 | return; 79 | } 80 | 81 | if (sign === '-') { 82 | response[key] = false; 83 | } else if (sign === '+') { 84 | response[key] = true; 85 | } 86 | }); 87 | }); 88 | return response; 89 | } 90 | 91 | _sendCommand(continueProcessing) { 92 | if (this._processing && !continueProcessing) { 93 | return; 94 | } 95 | 96 | if (!this._commandQueue.length) { 97 | this._processing = false; 98 | return; 99 | } 100 | 101 | if (this._quitting && this._commandQueue[0].command === 'QUIT') { 102 | let quitter = this._commandQueue.shift(); 103 | 104 | // return errors for all queued requests 105 | while (this._commandQueue.length) { 106 | let cur = this._commandQueue.shift(); 107 | setImmediate(() => cur.callback(new Error('QUIT called'))); 108 | } 109 | 110 | if (!this.connection) { 111 | // nothing to do here 112 | return quitter.callback(); 113 | } 114 | 115 | // put the QUIT command back to the queue 116 | this._commandQueue.unshift(quitter); 117 | } 118 | 119 | this._processing = true; 120 | let getConnection = done => { 121 | if (this.connection) { 122 | return done(); 123 | } 124 | this._connect(done); 125 | }; 126 | 127 | let tryCount = 0; 128 | let tryConnect = () => { 129 | getConnection(err => { 130 | if (err) { 131 | tryCount++; 132 | if (tryCount < 5) { 133 | return setTimeout(tryConnect, tryCount * 100); 134 | } 135 | 136 | // return errors for all queued requests 137 | while (this._commandQueue.length) { 138 | let cur = this._commandQueue.shift(); 139 | setImmediate(() => cur.callback(err)); 140 | } 141 | return; 142 | } 143 | 144 | this._current = this._commandQueue.shift(); 145 | 146 | let command = this._current.command; 147 | this.logger.verbose('Avast', 'C: %s', this._current.command.toString().trim()); 148 | 149 | if (typeof command === 'string') { 150 | command = Buffer.from(command + '\n'); 151 | } else if (command && Buffer.isBuffer(command)) { 152 | command = Buffer.concat([command, Buffer.from('\n')]); 153 | } 154 | 155 | try { 156 | this.connection.write(command); 157 | } catch (E) { 158 | this.logger.error('Avast', E); 159 | } 160 | }); 161 | }; 162 | tryConnect(); 163 | } 164 | 165 | _connect(callback) { 166 | let returned = false; 167 | let timer = false; 168 | let done = (...args) => { 169 | clearTimeout(timer); 170 | this._initializeCallback = false; 171 | if (returned) { 172 | return; 173 | } 174 | returned = true; 175 | callback(...args); 176 | }; 177 | 178 | this._connecting = true; 179 | this.initialized = false; 180 | 181 | this._initializeCallback = done; 182 | 183 | this.logger.info('Avast', 'Connecting to %s', this.address); 184 | let connection = (this.connection = net.createConnection(this.address, () => { 185 | if (returned) { 186 | // already returned 187 | try { 188 | connection.end(); 189 | } catch (E) { 190 | // ignore 191 | } 192 | return; 193 | } 194 | 195 | this.logger.info('Avast', 'Connection established to %s', this.address); 196 | 197 | this._connecting = false; 198 | this._lineReader = new LineReader((line, done) => this.readLine(line, done)); 199 | this._lineReaderEnded = false; 200 | connection.pipe(this._lineReader); 201 | })); 202 | 203 | connection.once('error', err => { 204 | this.logger.error('Avast', 'Connection error. %s', err.message); 205 | this.connection = false; 206 | done(err); 207 | }); 208 | 209 | connection.once('end', () => { 210 | this.logger.info('Avast', 'Connection closed to %s', this.address); 211 | this.connection = false; 212 | if (!returned) { 213 | return done(new Error('Unexpected connection close')); 214 | } 215 | }); 216 | 217 | timer = setTimeout(() => { 218 | done(new Error('TIMEOUT')); 219 | }, 10 * 1000); 220 | } 221 | 222 | _closeLineReader() { 223 | if (this._lineReader && !this._lineReaderEnded) { 224 | this._lineReaderEnded = true; 225 | try { 226 | this._lineReader.end(); 227 | } catch (E) { 228 | // ignore 229 | } 230 | } 231 | } 232 | 233 | quit(callback) { 234 | callback = callback || (() => false); 235 | this.run('QUIT', err => { 236 | if (err) { 237 | return callback(err); 238 | } 239 | return callback(); 240 | }); 241 | } 242 | 243 | scan(filename, data, callback) { 244 | this._getFile(filename, data, (err, file) => { 245 | if (err) { 246 | return callback(err); 247 | } 248 | 249 | let fpath = file.path; 250 | this.run('SCAN ' + this._escapeParam(fpath), (err, response) => { 251 | fs.unlink(file.path, err => { 252 | if (err) { 253 | this.logger.error('Avast', 'DELERR path=%s size=%s error=%s', file.path, file.size, err.message); 254 | } 255 | }); 256 | 257 | if (err) { 258 | return callback(err); 259 | } 260 | 261 | return setImmediate(() => callback(null, this._parseScanResponse(response))); 262 | }); 263 | }); 264 | } 265 | 266 | _parseScanResponse(list) { 267 | let response = {}; 268 | let infection = false; 269 | 270 | (list || []).forEach(row => { 271 | let tabPos = row.indexOf('\t'); 272 | if (tabPos < 0) { 273 | // ??? 274 | return; 275 | } 276 | 277 | let filename = row.substr(0, tabPos); 278 | let status = this._parseScanStatus(row.substr(tabPos + 1)); 279 | 280 | filename = this._unescapeParam(filename); 281 | let subParts = filename.split('|>'); 282 | subParts.shift(); 283 | 284 | response[subParts.join('/') || '.'] = status; 285 | 286 | if (!infection && status && status.status === 'infected') { 287 | infection = status; 288 | if (subParts.length) { 289 | status.path = subParts.join('/'); 290 | } 291 | } 292 | }); 293 | 294 | if (infection) { 295 | return infection; 296 | } 297 | 298 | return response['.'] || { status: false }; 299 | } 300 | 301 | _parseScanStatus(str) { 302 | let match = (str || '') 303 | .toString() 304 | .trim() 305 | .match(/^\[(.)\]\s*([\d.]+)(?:\s+(0|Error\s+\d+)\s+(.*))?/); 306 | 307 | if (!match) { 308 | return { status: false }; 309 | } 310 | 311 | switch (match[1]) { 312 | case '+': 313 | return { status: 'clean' }; 314 | case 'E': 315 | return { status: 'error', code: (match[3] && Number(match[3].substr(6))) || 0, message: this._unescapeParam(match[4] || '') }; 316 | case 'L': 317 | return { status: 'infected', message: this._unescapeParam(match[4] || '') }; 318 | default: 319 | return { status: false }; 320 | } 321 | } 322 | 323 | _getFile(filename, data, callback) { 324 | filename = (filename || '').toString().trim(); 325 | let extension = (filename && path.parse(filename).ext) || '.bin'; 326 | 327 | let tmpfile = path.join(this.tmpdir, Date.now() + crypto.randomBytes(12).toString('hex')) + extension; 328 | if (typeof data === 'string') { 329 | data = Buffer.from(data); 330 | } 331 | 332 | if (Buffer.isBuffer(data)) { 333 | return fs.writeFile(tmpfile, data, err => { 334 | if (err) { 335 | return callback(err); 336 | } 337 | callback(null, { path: tmpfile, size: data.length }); 338 | }); 339 | } 340 | 341 | if (data && typeof data === 'object' && typeof data.pipe === 'function') { 342 | // seems like a stream 343 | let returned = false; 344 | 345 | let targetFile = fs.createWriteStream(tmpfile); 346 | targetFile.once('error', err => { 347 | if (returned) { 348 | return; 349 | } 350 | returned = true; 351 | callback(err); 352 | }); 353 | targetFile.once('finish', () => { 354 | if (returned) { 355 | return; 356 | } 357 | returned = true; 358 | callback(null, { path: tmpfile, size: -1 }); 359 | }); 360 | data.once('error', err => { 361 | targetFile.emit('error', err); 362 | }); 363 | data.pipe(targetFile); 364 | return; 365 | } 366 | 367 | return setImmediate(() => callback(new Error('Invalid input'))); 368 | } 369 | 370 | _unescapeParam(str) { 371 | return (str || '').toString().replace(/\\(.)/g, (m, c) => { 372 | switch (c) { 373 | case ' ': 374 | return ' '; 375 | case 'n': 376 | return '\n'; 377 | case 'r': 378 | return '\r'; 379 | case 't': 380 | return '\t'; 381 | case 'b': 382 | return '\b'; 383 | } 384 | return m; 385 | }); 386 | } 387 | 388 | _escapeParam(str) { 389 | return (str || '').toString().replace(/\s/g, c => { 390 | switch (c) { 391 | case '\r': 392 | return '\\r'; 393 | case '\n': 394 | return '\\n'; 395 | case '\t': 396 | return '\\t'; 397 | case ' ': 398 | return '\\ '; 399 | default: 400 | return ''; 401 | } 402 | }); 403 | } 404 | 405 | _getFlagLike(command, callback) { 406 | this.run((command || '').toUpperCase().trim(), (err, response) => { 407 | if (err) { 408 | return callback(err); 409 | } 410 | return callback(null, this._parseFlagsResponse(response)); 411 | }); 412 | } 413 | 414 | _setFlagLike(command, values, callback) { 415 | let paramsEntry = []; 416 | Object.keys(values || {}).forEach(key => { 417 | let sign = values[key]; 418 | key = (key || '') 419 | .toString() 420 | .toLowerCase() 421 | .replace(/\s+/g, ''); 422 | if (key && sign) { 423 | paramsEntry.push((sign ? '+' : '-') + key); 424 | } 425 | }); 426 | 427 | this.run((command || '').toUpperCase().trim() + (paramsEntry.length ? ' ' + paramsEntry.join(' ') : ''), (err, response) => { 428 | if (err) { 429 | if (err.status === 501) { 430 | let error = new Error('Invalid argument for ' + (command || '').toUpperCase().trim()); 431 | error.status = err.status; 432 | return callback(error); 433 | } 434 | return callback(err); 435 | } 436 | return callback(null, this._parseFlagsResponse(response)); 437 | }); 438 | } 439 | 440 | getFlags(callback) { 441 | this._getFlagLike('FLAGS', callback); 442 | } 443 | 444 | setFlags(flags, callback) { 445 | this._setFlagLike('FLAGS', flags, callback); 446 | } 447 | 448 | getSensitivity(callback) { 449 | this._getFlagLike('SENSITIVITY', callback); 450 | } 451 | 452 | setSensitivity(sensitivities, callback) { 453 | this._setFlagLike('SENSITIVITY', sensitivities, callback); 454 | } 455 | 456 | getPack(callback) { 457 | this._getFlagLike('PACK', callback); 458 | } 459 | 460 | setPack(packers, callback) { 461 | this._setFlagLike('PACK', packers, callback); 462 | } 463 | 464 | getVPS(callback) { 465 | this.run('VPS', (err, response) => { 466 | if (err) { 467 | return callback(err); 468 | } 469 | let version = (response && response.length && response[0]) || false; 470 | return callback(null, version); 471 | }); 472 | } 473 | 474 | checkUrl(url, callback) { 475 | this.run('CHECKURL ' + this._escapeParam(url), (err, response) => { 476 | if (err) { 477 | return callback(err); 478 | } 479 | return callback(null, response !== 520); 480 | }); 481 | } 482 | 483 | readLine(line, done) { 484 | if (!line || !line.length) { 485 | return done(); 486 | } 487 | line = line.toString(); 488 | 489 | this.logger.verbose('Avast', 'S: %s', line); 490 | 491 | if (!this.initialized) { 492 | if (line.indexOf('220 DAEMON') === 0) { 493 | this.initialized = true; 494 | this._initializeCallback(); 495 | return setImmediate(done); 496 | } else { 497 | this._initializeCallback(new Error(line)); 498 | } 499 | } 500 | 501 | if (/^\d{3} /.test(line)) { 502 | // done 503 | if (this._current) { 504 | let cur = this._current; 505 | let statusCode = Number(line.substr(0, 3)); 506 | if (statusCode === 210) { 507 | this._data = true; 508 | // expecting further data 509 | return done(); 510 | } 511 | this._data = false; 512 | 513 | if (statusCode === 520) { 514 | // url blocked 515 | this._current = false; 516 | setImmediate(() => cur.callback(null, 520)); 517 | setImmediate(() => this._sendCommand(true)); 518 | return done(); 519 | } 520 | 521 | if ((statusCode < 200 || statusCode >= 300) && !(cur.command.indexOf('SCAN') === 0 && statusCode === 451)) { 522 | let error = new Error(line.replace(/^\d{3}( [^\s]+\s)?/, '')); 523 | error.status = statusCode; 524 | setImmediate(() => cur.callback(error)); 525 | } else { 526 | setImmediate(() => cur.callback(null, cur.response)); 527 | } 528 | this._current = false; 529 | setImmediate(() => this._sendCommand(true)); 530 | } 531 | return done(); 532 | } 533 | 534 | if (this._data) { 535 | // waiting for data 536 | let spacePos = line.indexOf(' '); 537 | let prefix; 538 | if (spacePos >= 0) { 539 | prefix = line.substr(0, spacePos).toUpperCase(); 540 | line = line.substr(spacePos + 1).trim(); 541 | } else { 542 | prefix = line; 543 | line = ''; 544 | } 545 | 546 | if (prefix === this._current.prefix && line) { 547 | this._current.response.push(line); 548 | } 549 | } 550 | 551 | done(); 552 | } 553 | } 554 | 555 | module.exports = AvastClient; 556 | --------------------------------------------------------------------------------