MDL-67377 lib: Moodle changes to mustache upgrade
[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  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
38  */
39 class scanner extends \core\antivirus\scanner {
40     /**
41      * Are the necessary antivirus settings configured?
42      *
43      * @return bool True if all necessary config settings been entered
44      */
45     public function is_configured() {
46         if ($this->get_config('runningmethod') === 'commandline') {
47             return (bool)$this->get_config('pathtoclam');
48         } else if ($this->get_config('runningmethod') === 'unixsocket') {
49             return (bool)$this->get_config('pathtounixsocket');
50         }
51         return false;
52     }
54     /**
55      * Scan file.
56      *
57      * This method is normally called from antivirus manager (\core\antivirus\manager::scan_file).
58      *
59      * @param string $file Full path to the file.
60      * @param string $filename Name of the file (could be different from physical file if temp file is used).
61      * @return int Scanning result constant.
62      */
63     public function scan_file($file, $filename) {
64         if (!is_readable($file)) {
65             // This should not happen.
66             debugging('File is not readable.');
67             return self::SCAN_RESULT_ERROR;
68         }
70         // Execute the scan using preferable method.
71         $method = 'scan_file_execute_' . $this->get_config('runningmethod');
72         if (!method_exists($this, $method)) {
73             throw new \coding_exception('Attempting to call non-existing method ' . $method);
74         }
75         $return = $this->$method($file);
77         if ($return === self::SCAN_RESULT_ERROR) {
78             $this->message_admins($this->get_scanning_notice());
79             // If plugin settings require us to act like virus on any error,
80             // return SCAN_RESULT_FOUND result.
81             if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
82                 return self::SCAN_RESULT_FOUND;
83             }
84         }
85         return $return;
86     }
88     /**
89      * Scan data.
90      *
91      * @param string $data The variable containing the data to scan.
92      * @return int Scanning result constant.
93      */
94     public function scan_data($data) {
95         // We can do direct stream scanning if unixsocket running method is in use,
96         // if not, use default process.
97         if ($this->get_config('runningmethod') === 'unixsocket') {
98             $return = $this->scan_data_execute_unixsocket($data);
100             if ($return === self::SCAN_RESULT_ERROR) {
101                 $this->message_admins($this->get_scanning_notice());
102                 // If plugin settings require us to act like virus on any error,
103                 // return SCAN_RESULT_FOUND result.
104                 if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
105                     return self::SCAN_RESULT_FOUND;
106                 }
107             }
108             return $return;
109         } else {
110             return parent::scan_data($data);
111         }
112     }
114     /**
115      * Returns the string equivalent of a numeric clam error code
116      *
117      * @param int $returncode The numeric error code in question.
118      * @return string The definition of the error code
119      */
120     private function get_clam_error_code($returncode) {
121         $returncodes = array();
122         $returncodes[0] = 'No virus found.';
123         $returncodes[1] = 'Virus(es) found.';
124         $returncodes[2] = ' An error occured'; // Specific to clamdscan.
125         // All after here are specific to clamscan.
126         $returncodes[40] = 'Unknown option passed.';
127         $returncodes[50] = 'Database initialization error.';
128         $returncodes[52] = 'Not supported file type.';
129         $returncodes[53] = 'Can\'t open directory.';
130         $returncodes[54] = 'Can\'t open file. (ofm)';
131         $returncodes[55] = 'Error reading file. (ofm)';
132         $returncodes[56] = 'Can\'t stat input file / directory.';
133         $returncodes[57] = 'Can\'t get absolute path name of current working directory.';
134         $returncodes[58] = 'I/O error, please check your filesystem.';
135         $returncodes[59] = 'Can\'t get information about current user from /etc/passwd.';
136         $returncodes[60] = 'Can\'t get information about user \'clamav\' (default name) from /etc/passwd.';
137         $returncodes[61] = 'Can\'t fork.';
138         $returncodes[63] = 'Can\'t create temporary files/directories (check permissions).';
139         $returncodes[64] = 'Can\'t write to temporary directory (please specify another one).';
140         $returncodes[70] = 'Can\'t allocate and clear memory (calloc).';
141         $returncodes[71] = 'Can\'t allocate memory (malloc).';
142         if (isset($returncodes[$returncode])) {
143             return $returncodes[$returncode];
144         }
145         return get_string('unknownerror', 'antivirus_clamav');
146     }
148     /**
149      * Scan file using command line utility.
150      *
151      * @param string $file Full path to the file.
152      * @return int Scanning result constant.
153      */
154     public function scan_file_execute_commandline($file) {
155         $pathtoclam = trim($this->get_config('pathtoclam'));
157         if (!file_exists($pathtoclam) or !is_executable($pathtoclam)) {
158             // Misconfigured clam, notify admins.
159             $notice = get_string('invalidpathtoclam', 'antivirus_clamav', $pathtoclam);
160             $this->set_scanning_notice($notice);
161             return self::SCAN_RESULT_ERROR;
162         }
164         $clamparam = ' --stdout ';
165         // If we are dealing with clamdscan, clamd is likely run as a different user
166         // that might not have permissions to access your file.
167         // To make clamdscan work, we use --fdpass parameter that passes the file
168         // descriptor permissions to clamd, which allows it to scan given file
169         // irrespective of directory and file permissions.
170         if (basename($pathtoclam) == 'clamdscan') {
171             $clamparam .= '--fdpass ';
172         }
173         // Execute scan.
174         $cmd = escapeshellcmd($pathtoclam).$clamparam.escapeshellarg($file);
175         exec($cmd, $output, $return);
176         // Return variable will contain execution return code. It will be 0 if no virus is found,
177         // 1 if virus is found, and 2 or above for the error. Return codes 0 and 1 correspond to
178         // SCAN_RESULT_OK and SCAN_RESULT_FOUND constants, so we return them as it is.
179         // If there is an error, it gets stored as scanning notice and function
180         // returns SCAN_RESULT_ERROR.
181         if ($return > self::SCAN_RESULT_FOUND) {
182             $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code($return));
183             $notice .= "\n\n". implode("\n", $output);
184             $this->set_scanning_notice($notice);
185             return self::SCAN_RESULT_ERROR;
186         }
188         return (int)$return;
189     }
191     /**
192      * Scan file using Unix domain sockets.
193      *
194      * @param string $file Full path to the file.
195      * @return int Scanning result constant.
196      */
197     public function scan_file_execute_unixsocket($file) {
198         $socket = stream_socket_client('unix://' . $this->get_config('pathtounixsocket'),
199                 $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
200         if (!$socket) {
201             // Can't open socket for some reason, notify admins.
202             $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
203             $this->set_scanning_notice($notice);
204             return self::SCAN_RESULT_ERROR;
205         } else {
206             // Execute scanning. We are running SCAN command and passing file as an argument,
207             // it is the fastest option, but clamav user need to be able to access it, so
208             // we give group read permissions first and assume 'clamav' user is in web server
209             // group (in Debian the default webserver group is 'www-data').
210             // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
211             // this is to avoid unexpected newline characters on different systems.
212             $perms = fileperms($file);
213             chmod($file, 0640);
214             fwrite($socket, "nSCAN ".$file."\n");
215             $output = stream_get_line($socket, 4096);
216             fclose($socket);
217             // After scanning we revert permissions to initial ones.
218             chmod($file, $perms);
219             // Parse the output.
220             return $this->parse_unixsocket_response($output);
221         }
222     }
224     /**
225      * Scan data using unix socket.
226      *
227      * We are running INSTREAM command and passing data stream in chunks.
228      * The format of the chunk is: <length><data> where <length> is the size of the following
229      * data in bytes expressed as a 4 byte unsigned integer in network byte order and <data>
230      * is the actual chunk. Streaming is terminated by sending a zero-length chunk.
231      * Do not exceed StreamMaxLength as defined in clamd.conf, otherwise clamd will
232      * reply with INSTREAM size limit exceeded and close the connection.
233      *
234      * @param string $data The varaible containing the data to scan.
235      * @return int Scanning result constant.
236      */
237     public function scan_data_execute_unixsocket($data) {
238         $socket = stream_socket_client('unix://' . $this->get_config('pathtounixsocket'), $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
239         if (!$socket) {
240             // Can't open socket for some reason, notify admins.
241             $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
242             $this->set_scanning_notice($notice);
243             return self::SCAN_RESULT_ERROR;
244         } else {
245             // Initiate data stream scanning.
246             // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
247             // this is to avoid unexpected newline characters on different systems.
248             fwrite($socket, "nINSTREAM\n");
249             // Send data in chunks of ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE size.
250             while (strlen($data) > 0) {
251                 $chunk = substr($data, 0, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
252                 $data = substr($data, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
253                 $size = pack('N', strlen($chunk));
254                 fwrite($socket, $size);
255                 fwrite($socket, $chunk);
256             }
257             // Terminate streaming.
258             fwrite($socket, pack('N', 0));
260             $output = stream_get_line($socket, 4096);
261             fclose($socket);
263             // Parse the output.
264             return $this->parse_unixsocket_response($output);
265         }
266     }
268     /**
269      * Parse unix socket command response.
270      *
271      * @param string $output The unix socket command response.
272      * @return int Scanning result constant.
273      */
274     private function parse_unixsocket_response($output) {
275         $splitoutput = explode(': ', $output);
276         $message = trim($splitoutput[1]);
277         if ($message === 'OK') {
278             return self::SCAN_RESULT_OK;
279         } else {
280             $parts = explode(' ', $message);
281             $status = array_pop($parts);
282             if ($status === 'FOUND') {
283                 return self::SCAN_RESULT_FOUND;
284             } else {
285                 $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
286                 $notice .= "\n\n" . $output;
287                 $this->set_scanning_notice($notice);
288                 return self::SCAN_RESULT_ERROR;
289             }
290         }
291     }