Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.
   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   * Quarantine file
  19   *
  20   * @package    core_antivirus
  21   * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
  22   * @copyright  Catalyst IT
  23   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  namespace core\antivirus;
  27  
  28  defined('MOODLE_INTERNAL') || die();
  29  require_once($CFG->libdir.'/filelib.php');
  30  
  31  /**
  32   * Quarantine file
  33   *
  34   * @package    core_antivirus
  35   * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
  36   * @copyright  Catalyst IT
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class quarantine {
  40  
  41      /** Default quarantine folder */
  42      const DEFAULT_QUARANTINE_FOLDER = 'antivirus_quarantine';
  43  
  44      /** Zip infected file  */
  45      const FILE_ZIP_INFECTED = '_infected_file.zip';
  46  
  47      /** Zip all infected file */
  48      const FILE_ZIP_ALL_INFECTED = '_all_infected_files.zip';
  49  
  50      /** Incident details file */
  51      const FILE_HTML_DETAILS = '_details.html';
  52  
  53      /** Incident details file */
  54      const DEFAULT_QUARANTINE_TIME = DAYSECS * 28;
  55  
  56      /** Date format in filename */
  57      const FILE_NAME_DATE_FORMAT = '%Y%m%d%H%M%S';
  58  
  59      /**
  60       * Move the infected file to the quarantine folder.
  61       *
  62       * @param string $file infected file.
  63       * @param string $filename infected file name.
  64       * @param string $incidentdetails incident details.
  65       * @param string $notice notice details.
  66       * @return string|null the name of the newly created quarantined file.
  67       * @throws \dml_exception
  68       */
  69      public static function quarantine_file(string $file, string $filename, string $incidentdetails, string $notice) : ?string {
  70          if (!self::is_quarantine_enabled()) {
  71              return null;
  72          }
  73          // Generate file names.
  74          $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
  75          $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
  76          $detailsfilename = $date . self::FILE_HTML_DETAILS;
  77  
  78          // Create Zip file.
  79          $ziparchive = new \zip_archive();
  80          if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
  81              $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
  82              $ziparchive->add_file_from_pathname($filename, $file);
  83              $ziparchive->close();
  84          }
  85          $zipfile = basename($zipfilepath);
  86          self::create_infected_file_record($filename, $zipfile, $notice);
  87          return $zipfile;
  88      }
  89  
  90      /**
  91       * Move the infected file to the quarantine folder.
  92       *
  93       * @param string $data data which is infected.
  94       * @param string $filename infected file name.
  95       * @param string $incidentdetails incident details.
  96       * @param string $notice notice details.
  97       * @return string|null the name of the newly created quarantined file.
  98       * @throws \dml_exception
  99       */
 100      public static function quarantine_data(string $data, string $filename, string $incidentdetails, string $notice) : ?string {
 101          if (!self::is_quarantine_enabled()) {
 102              return null;
 103          }
 104          // Generate file names.
 105          $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
 106          $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
 107          $detailsfilename = $date . self::FILE_HTML_DETAILS;
 108  
 109          // Create Zip file.
 110          $ziparchive = new \zip_archive();
 111          if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
 112              $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
 113              $ziparchive->add_file_from_string($filename, $data);
 114              $ziparchive->close();
 115          }
 116          $zipfile = basename($zipfilepath);
 117          self::create_infected_file_record($filename, $zipfile, $notice);
 118          return $zipfile;
 119      }
 120  
 121      /**
 122       * Check if the virus quarantine is allowed
 123       *
 124       * @return bool
 125       * @throws \dml_exception
 126       */
 127      public static function is_quarantine_enabled() : bool {
 128          return !empty(get_config("antivirus", "enablequarantine"));
 129      }
 130  
 131      /**
 132       * Get quarantine folder
 133       *
 134       * @return string path of quarantine folder
 135       */
 136      private static function get_quarantine_folder() : string {
 137          global $CFG;
 138          $quarantinefolder = $CFG->dataroot . DIRECTORY_SEPARATOR . self::DEFAULT_QUARANTINE_FOLDER;
 139          if (!file_exists($quarantinefolder)) {
 140              make_upload_directory(self::DEFAULT_QUARANTINE_FOLDER);
 141          }
 142          return $quarantinefolder . DIRECTORY_SEPARATOR;
 143      }
 144  
 145      /**
 146       * Checks whether a file exists inside the antivirus quarantine folder.
 147       *
 148       * @param string $filename the filename to check.
 149       * @return boolean whether file exists.
 150       */
 151      public static function quarantined_file_exists(string $filename) : bool {
 152          $folder = self::get_quarantine_folder();
 153          return file_exists($folder . $filename);
 154      }
 155  
 156      /**
 157       * Download quarantined file.
 158       *
 159       * @param int $fileid the id of file to be downloaded.
 160       */
 161      public static function download_quarantined_file(int $fileid) {
 162          global $DB;
 163  
 164          // Get the filename to be downloaded.
 165          $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
 166          // If file record isnt found, user might be doing something naughty in params, or a stale request.
 167          if (empty($filename)) {
 168              return;
 169          }
 170  
 171          $file = self::get_quarantine_folder() . $filename;
 172          send_file($file, $filename);
 173      }
 174  
 175      /**
 176       * Delete quarantined file.
 177       *
 178       * @param int $fileid id of file to be deleted.
 179       */
 180      public static function delete_quarantined_file(int $fileid) {
 181          global $DB;
 182  
 183          // Get the filename to be deleted.
 184          $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
 185          // If file record isnt found, user might be doing something naughty in params, or a stale request.
 186          if (empty($filename)) {
 187              return;
 188          }
 189  
 190          // Delete the file from the folder.
 191          $file = self::get_quarantine_folder() . $filename;
 192          if (file_exists($file)) {
 193              unlink($file);
 194          }
 195  
 196          // Now we are finished with the record, delete the quarantine information.
 197          self::delete_infected_file_record($fileid);
 198      }
 199  
 200      /**
 201       * Download all quarantined files.
 202       *
 203       * @return void
 204       */
 205      public static function download_all_quarantined_files() {
 206          $files = new \DirectoryIterator(self::get_quarantine_folder());
 207          // Add all infected files to a zip file.
 208          $date = userdate(time(), self::FILE_NAME_DATE_FORMAT);
 209          $zipfilename = $date . self::FILE_ZIP_ALL_INFECTED;
 210          $zipfilepath = self::get_quarantine_folder() . DIRECTORY_SEPARATOR . $zipfilename;
 211          $tempfilestocleanup = [];
 212  
 213          $ziparchive = new \zip_archive();
 214          if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
 215              foreach ($files as $file) {
 216                  if (!$file->isDot()) {
 217                      // Only send the actual files.
 218                      $filename = $file->getFilename();
 219                      $filepath = $file->getPathname();
 220                      $ziparchive->add_file_from_pathname($filename, $filepath);
 221                  }
 222              }
 223              $ziparchive->close();
 224          }
 225  
 226          // Clean up temp files.
 227          foreach ($tempfilestocleanup as $tempfile) {
 228              if (file_exists($tempfile)) {
 229                  unlink($tempfile);
 230              }
 231          }
 232  
 233          send_temp_file($zipfilepath, $zipfilename);
 234      }
 235  
 236      /**
 237       * Return array of quarantined files.
 238       *
 239       * @return array list of quarantined files.
 240       */
 241      public static function get_quarantined_files() : array {
 242          $files = new \DirectoryIterator(self::get_quarantine_folder());
 243          $filestosort = [];
 244  
 245          // Grab all files that match the naming structure.
 246          foreach ($files as $file) {
 247              $filename = $file->getFilename();
 248              if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
 249                  $filestosort[$filename] = $file->getPathname();
 250              }
 251          }
 252  
 253          krsort($filestosort, SORT_NATURAL);
 254          return $filestosort;
 255      }
 256  
 257      /**
 258       * Clean up quarantine folder
 259       *
 260       * @param int $timetocleanup time to clean up
 261       */
 262      public static function clean_up_quarantine_folder(int $timetocleanup) {
 263          $files = new \DirectoryIterator(self::get_quarantine_folder());
 264          // Clean up the folder.
 265          foreach ($files as $file) {
 266              $filename = $file->getFilename();
 267  
 268              // Only delete files that match the correct name structure.
 269              if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
 270                  $modifiedtime = $file->getMTime();
 271  
 272                  if ($modifiedtime <= $timetocleanup) {
 273                      unlink($file->getPathname());
 274                  }
 275              }
 276          }
 277  
 278          // Lastly cleanup the infected files table as well.
 279          self::clean_up_infected_records($timetocleanup);
 280      }
 281  
 282      /**
 283       * This function removes any stale records from the infected files table.
 284       *
 285       * @param int $timetocleanup the time to cleanup from
 286       * @return void
 287       */
 288      private static function clean_up_infected_records(int $timetocleanup) {
 289          global $DB;
 290  
 291          $select = "timecreated <= ?";
 292          $DB->delete_records_select('infected_files', $select, [$timetocleanup]);
 293      }
 294  
 295      /**
 296       * Create an infected file record
 297       *
 298       * @param string $filename original file name
 299       * @param string $zipfile quarantined file name
 300       * @param string $reason failure reason
 301       * @throws \dml_exception
 302       */
 303      private static function create_infected_file_record(string $filename, string $zipfile, string $reason) {
 304          global $DB, $USER;
 305  
 306          $record = new \stdClass();
 307          $record->filename = $filename;
 308          $record->quarantinedfile = $zipfile;
 309          $record->userid = $USER->id;
 310          $record->reason = $reason;
 311          $record->timecreated = time();
 312  
 313          $DB->insert_record('infected_files', $record);
 314      }
 315  
 316      /**
 317       * Delete the database record for an infected file.
 318       *
 319       * @param int $fileid quarantined file id
 320       * @throws \dml_exception
 321       */
 322      private static function delete_infected_file_record(int $fileid) {
 323          global $DB;
 324          $DB->delete_records('infected_files', ['id' => $fileid]);
 325      }
 326  }