Merge branch 'MDL-67377-master' of git://github.com/ferranrecio/moodle
[moodle.git] / lib / antivirus / clamav / classes / scanner.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * ClamAV antivirus integration.
19  *
20  * @package    antivirus_clamav
21  * @copyright  2015 Ruslan Kabalin, Lancaster University.
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace antivirus_clamav;
27 defined('MOODLE_INTERNAL') || die();
29 /** Default socket timeout */
30 define('ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT', 10);
31 /** Default socket data stream chunk size */
32 define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 1024);
34 /**
35  * Class implementing ClamAV antivirus.
36  * @copyright  2015 Ruslan Kabalin, Lancaster University.
37  * @copyright  2019 Didier Raboud, Liip AG.
38  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39  */
40 class scanner extends \core\antivirus\scanner {
41     /**
42      * Are the necessary antivirus settings configured?
43      *
44      * @return bool True if all necessary config settings been entered
45      */
46     public function is_configured() {
47         if ($this->get_config('runningmethod') === 'commandline') {
48             return (bool)$this->get_config('pathtoclam');
49         } else if ($this->get_config('runningmethod') === 'unixsocket') {
50             return (bool)$this->get_config('pathtounixsocket');
51         } else if ($this->get_config('runningmethod') === 'tcpsocket') {
52             return (bool)$this->get_config('tcpsockethost') && (bool)$this->get_config('tcpsocketport');
53         }
54         return false;
55     }
57     /**
58      * Scan file.
59      *
60      * This method is normally called from antivirus manager (\core\antivirus\manager::scan_file).
61      *
62      * @param string $file Full path to the file.
63      * @param string $filename Name of the file (could be different from physical file if temp file is used).
64      * @return int Scanning result constant.
65      */
66     public function scan_file($file, $filename) {
67         if (!is_readable($file)) {
68             // This should not happen.
69             debugging('File is not readable.');
70             return self::SCAN_RESULT_ERROR;
71         }
73         // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
74         // if not, use default process.
75         $runningmethod = $this->get_config('runningmethod');
76         switch ($runningmethod) {
77             case 'unixsocket':
78             case 'tcpsocket':
79                 $return = $this->scan_file_execute_socket($file, $runningmethod);
80                 break;
81             case 'commandline':
82                 $return = $this->scan_file_execute_commandline($file);
83                 break;
84             default:
85                 // This should not happen.
86                 debugging('Unknown running method.');
87                 return self::SCAN_RESULT_ERROR;
88         }
90         if ($return === self::SCAN_RESULT_ERROR) {
91             $this->message_admins($this->get_scanning_notice());
92             // If plugin settings require us to act like virus on any error,
93             // return SCAN_RESULT_FOUND result.
94             if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
95                 return self::SCAN_RESULT_FOUND;
96             }
97         }
98         return $return;
99     }
101     /**
102      * Scan data.
103      *
104      * @param string $data The variable containing the data to scan.
105      * @return int Scanning result constant.
106      */
107     public function scan_data($data) {
108         // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
109         // if not, use default process.
110         $runningmethod = $this->get_config('runningmethod');
111         if (in_array($runningmethod, array('unixsocket', 'tcpsocket'))) {
112             $return = $this->scan_data_execute_socket($data, $runningmethod);
114             if ($return === self::SCAN_RESULT_ERROR) {
115                 $this->message_admins($this->get_scanning_notice());
116                 // If plugin settings require us to act like virus on any error,
117                 // return SCAN_RESULT_FOUND result.
118                 if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
119                     return self::SCAN_RESULT_FOUND;
120                 }
121             }
122             return $return;
123         } else {
124             return parent::scan_data($data);
125         }
126     }
128     /**
129      * Returns a Unix domain socket destination url
130      *
131      * @return string The socket url, fit for stream_socket_client()
132      */
133     private function get_unixsocket_destination() {
134         return 'unix://' . $this->get_config('pathtounixsocket');
135     }
137     /**
138      * Returns a Internet domain socket destination url
139      *
140      * @return string The socket url, fit for stream_socket_client()
141      */
142     private function get_tcpsocket_destination() {
143         return 'tcp://' . $this->get_config('tcpsockethost') . ':' . $this->get_config('tcpsocketport');
144     }
146     /**
147      * Returns the string equivalent of a numeric clam error code
148      *
149      * @param int $returncode The numeric error code in question.
150      * @return string The definition of the error code
151      */
152     private function get_clam_error_code($returncode) {
153         $returncodes = array();
154         $returncodes[0] = 'No virus found.';
155         $returncodes[1] = 'Virus(es) found.';
156         $returncodes[2] = ' An error occured'; // Specific to clamdscan.
157         // All after here are specific to clamscan.
158         $returncodes[40] = 'Unknown option passed.';
159         $returncodes[50] = 'Database initialization error.';
160         $returncodes[52] = 'Not supported file type.';
161         $returncodes[53] = 'Can\'t open directory.';
162         $returncodes[54] = 'Can\'t open file. (ofm)';
163         $returncodes[55] = 'Error reading file. (ofm)';
164         $returncodes[56] = 'Can\'t stat input file / directory.';
165         $returncodes[57] = 'Can\'t get absolute path name of current working directory.';
166         $returncodes[58] = 'I/O error, please check your filesystem.';
167         $returncodes[59] = 'Can\'t get information about current user from /etc/passwd.';
168         $returncodes[60] = 'Can\'t get information about user \'clamav\' (default name) from /etc/passwd.';
169         $returncodes[61] = 'Can\'t fork.';
170         $returncodes[63] = 'Can\'t create temporary files/directories (check permissions).';
171         $returncodes[64] = 'Can\'t write to temporary directory (please specify another one).';
172         $returncodes[70] = 'Can\'t allocate and clear memory (calloc).';
173         $returncodes[71] = 'Can\'t allocate memory (malloc).';
174         if (isset($returncodes[$returncode])) {
175             return $returncodes[$returncode];
176         }
177         return get_string('unknownerror', 'antivirus_clamav');
178     }
180     /**
181      * Scan file using command line utility.
182      *
183      * @param string $file Full path to the file.
184      * @return int Scanning result constant.
185      */
186     public function scan_file_execute_commandline($file) {
187         $pathtoclam = trim($this->get_config('pathtoclam'));
189         if (!file_exists($pathtoclam) or !is_executable($pathtoclam)) {
190             // Misconfigured clam, notify admins.
191             $notice = get_string('invalidpathtoclam', 'antivirus_clamav', $pathtoclam);
192             $this->set_scanning_notice($notice);
193             return self::SCAN_RESULT_ERROR;
194         }
196         $clamparam = ' --stdout ';
197         // If we are dealing with clamdscan, clamd is likely run as a different user
198         // that might not have permissions to access your file.
199         // To make clamdscan work, we use --fdpass parameter that passes the file
200         // descriptor permissions to clamd, which allows it to scan given file
201         // irrespective of directory and file permissions.
202         if (basename($pathtoclam) == 'clamdscan') {
203             $clamparam .= '--fdpass ';
204         }
205         // Execute scan.
206         $cmd = escapeshellcmd($pathtoclam).$clamparam.escapeshellarg($file);
207         exec($cmd, $output, $return);
208         // Return variable will contain execution return code. It will be 0 if no virus is found,
209         // 1 if virus is found, and 2 or above for the error. Return codes 0 and 1 correspond to
210         // SCAN_RESULT_OK and SCAN_RESULT_FOUND constants, so we return them as it is.
211         // If there is an error, it gets stored as scanning notice and function
212         // returns SCAN_RESULT_ERROR.
213         if ($return > self::SCAN_RESULT_FOUND) {
214             $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code($return));
215             $notice .= "\n\n". implode("\n", $output);
216             $this->set_scanning_notice($notice);
217             return self::SCAN_RESULT_ERROR;
218         }
220         return (int)$return;
221     }
223     /**
224      * Scan file using sockets.
225      *
226      * @param string $file Full path to the file.
227      * @param string $type Either 'tcpsocket' or 'unixsocket'
228      * @return int Scanning result constant.
229      */
230     public function scan_file_execute_socket($file, $type) {
231         switch ($type) {
232             case "tcpsocket":
233                 $socketurl = $this->get_tcpsocket_destination();
234                 break;
235             case "unixsocket":
236                 $socketurl = $this->get_unixsocket_destination();
237                 break;
238             default;
239                 // This should not happen.
240                 debugging('Unknown socket type.');
241                 return self::SCAN_RESULT_ERROR;
242         }
244         $socket = stream_socket_client($socketurl,
245                 $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
246         if (!$socket) {
247             // Can't open socket for some reason, notify admins.
248             $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
249             $this->set_scanning_notice($notice);
250             return self::SCAN_RESULT_ERROR;
251         } else {
252             if ($type == "unixsocket") {
253                 // Execute scanning. We are running SCAN command and passing file as an argument,
254                 // it is the fastest option, but clamav user need to be able to access it, so
255                 // we give group read permissions first and assume 'clamav' user is in web server
256                 // group (in Debian the default webserver group is 'www-data').
257                 // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
258                 // this is to avoid unexpected newline characters on different systems.
259                 $perms = fileperms($file);
260                 chmod($file, 0640);
262                 // Actual scan.
263                 fwrite($socket, "nSCAN ".$file."\n");
264                 // Get ClamAV answer.
265                 $output = stream_get_line($socket, 4096);
267                 // After scanning we revert permissions to initial ones.
268                 chmod($file, $perms);
269             } else if ($type == "tcpsocket") {
270                 // Execute scanning by passing the entire file through the TCP socket.
271                 // This is not fast, but is the only possibility over a network.
272                 // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
273                 // this is to avoid unexpected newline characters on different systems.
275                 // Actual scan.
276                 fwrite($socket, "nINSTREAM\n");
278                 // Open the file for reading.
279                 $fhandle = fopen($file, 'rb');
280                 while (!feof($fhandle)) {
281                     // Read it by chunks; write them to the TCP socket.
282                     $chunk = fread($fhandle, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
283                     $size = pack('N', strlen($chunk));
284                     fwrite($socket, $size);
285                     fwrite($socket, $chunk);
286                 }
287                 // Terminate streaming.
288                 fwrite($socket, pack('N', 0));
289                 // Get ClamAV answer.
290                 $output = stream_get_line($socket, 4096);
292                 fclose($fhandle);
293             }
294             // Free up the ClamAV socket.
295             fclose($socket);
296             // Parse the output.
297             return $this->parse_socket_response($output);
298         }
299     }
301     /**
302      * Scan data socket.
303      *
304      * We are running INSTREAM command and passing data stream in chunks.
305      * The format of the chunk is: <length><data> where <length> is the size of the following
306      * data in bytes expressed as a 4 byte unsigned integer in network byte order and <data>
307      * is the actual chunk. Streaming is terminated by sending a zero-length chunk.
308      * Do not exceed StreamMaxLength as defined in clamd.conf, otherwise clamd will
309      * reply with INSTREAM size limit exceeded and close the connection.
310      *
311      * @param string $data The variable containing the data to scan.
312      * @param string $type Either 'tcpsocket' or 'unixsocket'
313      * @return int Scanning result constant.
314      */
315     public function scan_data_execute_socket($data, $type) {
316         switch ($type) {
317             case "tcpsocket":
318                 $socketurl = $this->get_tcpsocket_destination();
319                 break;
320             case "unixsocket":
321                 $socketurl = $this->get_unixsocket_destination();
322                 break;
323             default;
324                 // This should not happen.
325                 debugging('Unknown socket type!');
326                 return self::SCAN_RESULT_ERROR;
327         }
329         $socket = stream_socket_client($socketurl, $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
330         if (!$socket) {
331             // Can't open socket for some reason, notify admins.
332             $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
333             $this->set_scanning_notice($notice);
334             return self::SCAN_RESULT_ERROR;
335         } else {
336             // Initiate data stream scanning.
337             // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
338             // this is to avoid unexpected newline characters on different systems.
339             fwrite($socket, "nINSTREAM\n");
340             // Send data in chunks of ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE size.
341             while (strlen($data) > 0) {
342                 $chunk = substr($data, 0, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
343                 $data = substr($data, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
344                 $size = pack('N', strlen($chunk));
345                 fwrite($socket, $size);
346                 fwrite($socket, $chunk);
347             }
348             // Terminate streaming.
349             fwrite($socket, pack('N', 0));
351             $output = stream_get_line($socket, 4096);
352             fclose($socket);
354             // Parse the output.
355             return $this->parse_socket_response($output);
356         }
357     }
359     /**
360      * Parse socket command response.
361      *
362      * @param string $output The socket response.
363      * @return int Scanning result constant.
364      */
365     private function parse_socket_response($output) {
366         $splitoutput = explode(': ', $output);
367         $message = trim($splitoutput[1]);
368         if ($message === 'OK') {
369             return self::SCAN_RESULT_OK;
370         } else {
371             $parts = explode(' ', $message);
372             $status = array_pop($parts);
373             if ($status === 'FOUND') {
374                 return self::SCAN_RESULT_FOUND;
375             } else {
376                 $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
377                 $notice .= "\n\n" . $output;
378                 $this->set_scanning_notice($notice);
379                 return self::SCAN_RESULT_ERROR;
380             }
381         }
382     }
384     /**
385      * Scan data using Unix domain socket.
386      *
387      * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
388      * @see antivirus_clamav\scanner::scan_data_execute_socket()
389      *
390      * @param string $data The variable containing the data to scan.
391      * @return int Scanning result constant.
392      */
393     public function scan_data_execute_unixsocket($data) {
394         debugging('antivirus_clamav\scanner::scan_data_execute_unixsocket() is deprecated. ' .
395                   'Use antivirus_clamav\scanner::scan_data_execute_socket() instead.', DEBUG_DEVELOPER);
396         return $this->scan_data_execute_socket($data, "unixsocket");
397     }
399     /**
400      * Scan file using Unix domain socket.
401      *
402      * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
403      * @see antivirus_clamav\scanner::scan_file_execute_socket()
404      *
405      * @param string $file Full path to the file.
406      * @return int Scanning result constant.
407      */
408     public function scan_file_execute_unixsocket($file) {
409         debugging('antivirus_clamav\scanner::scan_file_execute_unixsocket() is deprecated. ' .
410                   'Use antivirus_clamav\scanner::scan_file_execute_socket() instead.', DEBUG_DEVELOPER);
411         return $this->scan_file_execute_socket($file, "unixsocket");
412     }