Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.10.x will end 8 November 2021 (12 months).
  • Bug fixes for security issues in 3.10.x will end 9 May 2022 (18 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 310]

   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/>.
  16  
  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   */
  24  
  25  namespace antivirus_clamav;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /** Default socket timeout */
  30  define('ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT', 10);
  31  /** Default socket data stream chunk size (32Mb: 32 * 1024 * 1024) */
  32  define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 33554432);
  33  
  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      }
  56  
  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          }
  72  
  73          // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
  74          // if not, use default process.
  75          $maxtries = get_config('antivirus_clamav', 'tries');
  76          $tries = 0;
  77          do {
  78              $runningmethod = $this->get_config('runningmethod');
  79              $tries++;
  80              switch ($runningmethod) {
  81                  case 'unixsocket':
  82                  case 'tcpsocket':
  83                      $return = $this->scan_file_execute_socket($file, $runningmethod);
  84                      break;
  85                  case 'commandline':
  86                      $return = $this->scan_file_execute_commandline($file);
  87                      break;
  88                  default:
  89                      // This should not happen.
  90                      throw new \coding_exception('Unknown running method.');
  91              }
  92          } while ($return == self::SCAN_RESULT_ERROR && $tries < $maxtries);
  93  
  94          $notice = get_string('tries_notice', 'antivirus_clamav',
  95              ['tries' => $tries, 'notice' => $this->get_scanning_notice()]);
  96          $this->set_scanning_notice($notice);
  97  
  98          if ($return === self::SCAN_RESULT_ERROR) {
  99              $this->message_admins($this->get_scanning_notice());
 100              // If plugin settings require us to act like virus on any error,
 101              // return SCAN_RESULT_FOUND result.
 102              if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
 103                  return self::SCAN_RESULT_FOUND;
 104              } else if ($this->get_config('clamfailureonupload') === 'tryagain') {
 105                  // Do not upload the file, just give a message to the user to try again later.
 106                  unlink($file);
 107                  throw new \core\antivirus\scanner_exception('antivirusfailed', '', ['item' => $filename],
 108                          null, 'antivirus_clamav');
 109              }
 110          }
 111          return $return;
 112      }
 113  
 114      /**
 115       * Scan data.
 116       *
 117       * @param string $data The variable containing the data to scan.
 118       * @return int Scanning result constant.
 119       */
 120      public function scan_data($data) {
 121          // We can do direct stream scanning if unixsocket or tcpsocket running methods are in use,
 122          // if not, use default process.
 123          $runningmethod = $this->get_config('runningmethod');
 124          if (in_array($runningmethod, array('unixsocket', 'tcpsocket'))) {
 125              $return = $this->scan_data_execute_socket($data, $runningmethod);
 126  
 127              if ($return === self::SCAN_RESULT_ERROR) {
 128                  $this->message_admins($this->get_scanning_notice());
 129                  // If plugin settings require us to act like virus on any error,
 130                  // return SCAN_RESULT_FOUND result.
 131                  if ($this->get_config('clamfailureonupload') === 'actlikevirus') {
 132                      return self::SCAN_RESULT_FOUND;
 133                  }
 134              }
 135              return $return;
 136          } else {
 137              return parent::scan_data($data);
 138          }
 139      }
 140  
 141      /**
 142       * Returns a Unix domain socket destination url
 143       *
 144       * @return string The socket url, fit for stream_socket_client()
 145       */
 146      private function get_unixsocket_destination() {
 147          return 'unix://' . $this->get_config('pathtounixsocket');
 148      }
 149  
 150      /**
 151       * Returns a Internet domain socket destination url
 152       *
 153       * @return string The socket url, fit for stream_socket_client()
 154       */
 155      private function get_tcpsocket_destination() {
 156          return 'tcp://' . $this->get_config('tcpsockethost') . ':' . $this->get_config('tcpsocketport');
 157      }
 158  
 159      /**
 160       * Returns the string equivalent of a numeric clam error code
 161       *
 162       * @param int $returncode The numeric error code in question.
 163       * @return string The definition of the error code
 164       */
 165      private function get_clam_error_code($returncode) {
 166          $returncodes = array();
 167          $returncodes[0] = 'No virus found.';
 168          $returncodes[1] = 'Virus(es) found.';
 169          $returncodes[2] = ' An error occured'; // Specific to clamdscan.
 170          // All after here are specific to clamscan.
 171          $returncodes[40] = 'Unknown option passed.';
 172          $returncodes[50] = 'Database initialization error.';
 173          $returncodes[52] = 'Not supported file type.';
 174          $returncodes[53] = 'Can\'t open directory.';
 175          $returncodes[54] = 'Can\'t open file. (ofm)';
 176          $returncodes[55] = 'Error reading file. (ofm)';
 177          $returncodes[56] = 'Can\'t stat input file / directory.';
 178          $returncodes[57] = 'Can\'t get absolute path name of current working directory.';
 179          $returncodes[58] = 'I/O error, please check your filesystem.';
 180          $returncodes[59] = 'Can\'t get information about current user from /etc/passwd.';
 181          $returncodes[60] = 'Can\'t get information about user \'clamav\' (default name) from /etc/passwd.';
 182          $returncodes[61] = 'Can\'t fork.';
 183          $returncodes[63] = 'Can\'t create temporary files/directories (check permissions).';
 184          $returncodes[64] = 'Can\'t write to temporary directory (please specify another one).';
 185          $returncodes[70] = 'Can\'t allocate and clear memory (calloc).';
 186          $returncodes[71] = 'Can\'t allocate memory (malloc).';
 187          if (isset($returncodes[$returncode])) {
 188              return $returncodes[$returncode];
 189          }
 190          return get_string('unknownerror', 'antivirus_clamav');
 191      }
 192  
 193      /**
 194       * Scan file using command line utility.
 195       *
 196       * @param string $file Full path to the file.
 197       * @return int Scanning result constant.
 198       */
 199      public function scan_file_execute_commandline($file) {
 200          $pathtoclam = trim($this->get_config('pathtoclam'));
 201  
 202          if (!file_exists($pathtoclam) or !is_executable($pathtoclam)) {
 203              // Misconfigured clam, notify admins.
 204              $notice = get_string('invalidpathtoclam', 'antivirus_clamav', $pathtoclam);
 205              $this->set_scanning_notice($notice);
 206              return self::SCAN_RESULT_ERROR;
 207          }
 208  
 209          $clamparam = ' --stdout ';
 210          // If we are dealing with clamdscan, clamd is likely run as a different user
 211          // that might not have permissions to access your file.
 212          // To make clamdscan work, we use --fdpass parameter that passes the file
 213          // descriptor permissions to clamd, which allows it to scan given file
 214          // irrespective of directory and file permissions.
 215          if (basename($pathtoclam) == 'clamdscan') {
 216              $clamparam .= '--fdpass ';
 217          }
 218          // Execute scan.
 219          $cmd = escapeshellcmd($pathtoclam).$clamparam.escapeshellarg($file);
 220          exec($cmd, $output, $return);
 221          // Return variable will contain execution return code. It will be 0 if no virus is found,
 222          // 1 if virus is found, and 2 or above for the error. Return codes 0 and 1 correspond to
 223          // SCAN_RESULT_OK and SCAN_RESULT_FOUND constants, so we return them as it is.
 224          // If there is an error, it gets stored as scanning notice and function
 225          // returns SCAN_RESULT_ERROR.
 226          if ($return > self::SCAN_RESULT_FOUND) {
 227              $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code($return));
 228              $notice .= "\n\n". implode("\n", $output);
 229              $this->set_scanning_notice($notice);
 230              return self::SCAN_RESULT_ERROR;
 231          } else {
 232              $notice = "\n\n". implode("\n", $output);
 233              $this->set_scanning_notice($notice);
 234          }
 235  
 236          return (int)$return;
 237      }
 238  
 239      /**
 240       * Scan file using sockets.
 241       *
 242       * @param string $file Full path to the file.
 243       * @param string $type Either 'tcpsocket' or 'unixsocket'
 244       * @return int Scanning result constant.
 245       */
 246      public function scan_file_execute_socket($file, $type) {
 247          switch ($type) {
 248              case "tcpsocket":
 249                  $socketurl = $this->get_tcpsocket_destination();
 250                  break;
 251              case "unixsocket":
 252                  $socketurl = $this->get_unixsocket_destination();
 253                  break;
 254              default;
 255                  // This should not happen.
 256                  debugging('Unknown socket type.');
 257                  return self::SCAN_RESULT_ERROR;
 258          }
 259  
 260          $socket = stream_socket_client($socketurl,
 261                  $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
 262          if (!$socket) {
 263              // Can't open socket for some reason, notify admins.
 264              $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
 265              $this->set_scanning_notice($notice);
 266              return self::SCAN_RESULT_ERROR;
 267          } else {
 268              if ($type == "unixsocket") {
 269                  // Execute scanning. We are running SCAN command and passing file as an argument,
 270                  // it is the fastest option, but clamav user need to be able to access it, so
 271                  // we give group read permissions first and assume 'clamav' user is in web server
 272                  // group (in Debian the default webserver group is 'www-data').
 273                  // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
 274                  // this is to avoid unexpected newline characters on different systems.
 275                  $perms = fileperms($file);
 276                  chmod($file, 0640);
 277  
 278                  // Actual scan.
 279                  fwrite($socket, "nSCAN ".$file."\n");
 280                  // Get ClamAV answer.
 281                  $output = stream_get_line($socket, 4096);
 282  
 283                  // After scanning we revert permissions to initial ones.
 284                  chmod($file, $perms);
 285              } else if ($type == "tcpsocket") {
 286                  // Execute scanning by passing the entire file through the TCP socket.
 287                  // This is not fast, but is the only possibility over a network.
 288                  // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
 289                  // this is to avoid unexpected newline characters on different systems.
 290  
 291                  // Actual scan.
 292                  fwrite($socket, "nINSTREAM\n");
 293  
 294                  // Open the file for reading.
 295                  $fhandle = fopen($file, 'rb');
 296                  while (!feof($fhandle)) {
 297                      // Read it by chunks; write them to the TCP socket.
 298                      $chunk = fread($fhandle, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
 299                      $size = pack('N', strlen($chunk));
 300                      fwrite($socket, $size);
 301                      fwrite($socket, $chunk);
 302                  }
 303                  // Terminate streaming.
 304                  fwrite($socket, pack('N', 0));
 305                  // Get ClamAV answer.
 306                  $output = stream_get_line($socket, 4096);
 307  
 308                  fclose($fhandle);
 309              }
 310              // Free up the ClamAV socket.
 311              fclose($socket);
 312              // Parse the output.
 313              return $this->parse_socket_response($output);
 314          }
 315      }
 316  
 317      /**
 318       * Scan data socket.
 319       *
 320       * We are running INSTREAM command and passing data stream in chunks.
 321       * The format of the chunk is: <length><data> where <length> is the size of the following
 322       * data in bytes expressed as a 4 byte unsigned integer in network byte order and <data>
 323       * is the actual chunk. Streaming is terminated by sending a zero-length chunk.
 324       * Do not exceed StreamMaxLength as defined in clamd.conf, otherwise clamd will
 325       * reply with INSTREAM size limit exceeded and close the connection.
 326       *
 327       * @param string $data The variable containing the data to scan.
 328       * @param string $type Either 'tcpsocket' or 'unixsocket'
 329       * @return int Scanning result constant.
 330       */
 331      public function scan_data_execute_socket($data, $type) {
 332          switch ($type) {
 333              case "tcpsocket":
 334                  $socketurl = $this->get_tcpsocket_destination();
 335                  break;
 336              case "unixsocket":
 337                  $socketurl = $this->get_unixsocket_destination();
 338                  break;
 339              default;
 340                  // This should not happen.
 341                  debugging('Unknown socket type!');
 342                  return self::SCAN_RESULT_ERROR;
 343          }
 344  
 345          $socket = stream_socket_client($socketurl, $errno, $errstr, ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT);
 346          if (!$socket) {
 347              // Can't open socket for some reason, notify admins.
 348              $notice = get_string('errorcantopensocket', 'antivirus_clamav', "$errstr ($errno)");
 349              $this->set_scanning_notice($notice);
 350              return self::SCAN_RESULT_ERROR;
 351          } else {
 352              // Initiate data stream scanning.
 353              // Using 'n' as command prefix is forcing clamav to only treat \n as newline delimeter,
 354              // this is to avoid unexpected newline characters on different systems.
 355              fwrite($socket, "nINSTREAM\n");
 356              // Send data in chunks of ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE size.
 357              while (strlen($data) > 0) {
 358                  $chunk = substr($data, 0, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
 359                  $data = substr($data, ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE);
 360                  $size = pack('N', strlen($chunk));
 361                  fwrite($socket, $size);
 362                  fwrite($socket, $chunk);
 363              }
 364              // Terminate streaming.
 365              fwrite($socket, pack('N', 0));
 366  
 367              $output = stream_get_line($socket, 4096);
 368              fclose($socket);
 369  
 370              // Parse the output.
 371              return $this->parse_socket_response($output);
 372          }
 373      }
 374  
 375      /**
 376       * Parse socket command response.
 377       *
 378       * @param string $output The socket response.
 379       * @return int Scanning result constant.
 380       */
 381      private function parse_socket_response($output) {
 382          $splitoutput = explode(': ', $output);
 383          $message = trim($splitoutput[1]);
 384          if ($message === 'OK') {
 385              return self::SCAN_RESULT_OK;
 386          } else {
 387              $parts = explode(' ', $message);
 388              $status = array_pop($parts);
 389              if ($status === 'FOUND') {
 390                  $notice = "\n\n" . $output;
 391                  $this->set_scanning_notice($notice);
 392                  return self::SCAN_RESULT_FOUND;
 393              } else {
 394                  $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
 395                  $notice .= "\n\n" . $output;
 396                  $this->set_scanning_notice($notice);
 397                  return self::SCAN_RESULT_ERROR;
 398              }
 399          }
 400      }
 401  
 402      /**
 403       * Scan data using Unix domain socket.
 404       *
 405       * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
 406       * @see antivirus_clamav\scanner::scan_data_execute_socket()
 407       *
 408       * @param string $data The variable containing the data to scan.
 409       * @return int Scanning result constant.
 410       */
 411      public function scan_data_execute_unixsocket($data) {
 412          debugging('antivirus_clamav\scanner::scan_data_execute_unixsocket() is deprecated. ' .
 413                    'Use antivirus_clamav\scanner::scan_data_execute_socket() instead.', DEBUG_DEVELOPER);
 414          return $this->scan_data_execute_socket($data, "unixsocket");
 415      }
 416  
 417      /**
 418       * Scan file using Unix domain socket.
 419       *
 420       * @deprecated since Moodle 3.9 MDL-64075 - please do not use this function any more.
 421       * @see antivirus_clamav\scanner::scan_file_execute_socket()
 422       *
 423       * @param string $file Full path to the file.
 424       * @return int Scanning result constant.
 425       */
 426      public function scan_file_execute_unixsocket($file) {
 427          debugging('antivirus_clamav\scanner::scan_file_execute_unixsocket() is deprecated. ' .
 428                    'Use antivirus_clamav\scanner::scan_file_execute_socket() instead.', DEBUG_DEVELOPER);
 429          return $this->scan_file_execute_socket($file, "unixsocket");
 430      }
 431  
 432  }