Merge branch 'MDL-66222-antivirus-reporting' of https://github.com/Peterburnett/moodl...
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Aug 2020 01:36:33 +0000 (09:36 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Aug 2020 01:36:33 +0000 (09:36 +0800)
25 files changed:
admin/settings/plugins.php
lang/en/antivirus.php
lang/en/moodle.php
lib/antivirus/clamav/classes/scanner.php
lib/classes/antivirus/manager.php
lib/classes/antivirus/quarantine.php [new file with mode: 0644]
lib/classes/antivirus/scanner.php
lib/classes/event/virus_infected_data_detected.php [new file with mode: 0644]
lib/classes/event/virus_infected_file_detected.php [new file with mode: 0644]
lib/classes/plugin_manager.php
lib/classes/task/antivirus_cleanup_task.php [new file with mode: 0644]
lib/db/install.xml
lib/db/messages.php
lib/db/tasks.php
lib/db/upgrade.php
lib/templates/infected_file_email.mustache [new file with mode: 0644]
lib/tests/antivirus_test.php
report/infectedfiles/classes/output/renderer.php [new file with mode: 0644]
report/infectedfiles/classes/privacy/provider.php [new file with mode: 0644]
report/infectedfiles/classes/table/infectedfiles_table.php [new file with mode: 0644]
report/infectedfiles/index.php [new file with mode: 0644]
report/infectedfiles/lang/en/report_infectedfiles.php [new file with mode: 0644]
report/infectedfiles/settings.php [new file with mode: 0644]
report/infectedfiles/version.php [new file with mode: 0644]
version.php

index 3e7c68c..249bd53 100644 (file)
@@ -167,6 +167,42 @@ if ($hassiteconfig) {
     $ADMIN->add('modules', new admin_category('antivirussettings', new lang_string('antiviruses', 'antivirus')));
     $temp = new admin_settingpage('manageantiviruses', new lang_string('antivirussettings', 'antivirus'));
     $temp->add(new admin_setting_manageantiviruses());
+
+    // Common settings.
+    $temp->add(new admin_setting_heading('antiviruscommonsettings', new lang_string('antiviruscommonsettings', 'antivirus'), ''));
+
+    // Alert email.
+    $temp->add(
+        new admin_setting_configtext(
+            'antivirus/notifyemail',
+            new lang_string('notifyemail', 'antivirus'),
+            new lang_string('notifyemail_help', 'antivirus'),
+            '',
+            PARAM_EMAIL
+        )
+    );
+
+    // Enable quarantine.
+    $temp->add(
+        new admin_setting_configcheckbox(
+            'antivirus/enablequarantine',
+            new lang_string('enablequarantine', 'antivirus'),
+            new lang_string('enablequarantine_help', 'antivirus',
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_FOLDER),
+            0
+        )
+    );
+
+    // Quarantine time.
+    $temp->add(
+        new admin_setting_configduration(
+            'antivirus/quarantinetime',
+            new lang_string('quarantinetime', 'antivirus'),
+            new lang_string('quarantinetime_desc', 'antivirus'),
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME
+        )
+    );
+
     $ADMIN->add('antivirussettings', $temp);
     $plugins = core_plugin_manager::instance()->get_plugins_of_type('antivirus');
     core_collator::asort_objects_by_property($plugins, 'displayname');
index 634b0c7..907bf31 100644 (file)
 
 $string['actantivirushdr'] = 'Available antivirus plugins';
 $string['antiviruses'] = 'Antivirus plugins';
+$string['antiviruscommonsettings'] = 'Common antivirus settings';
 $string['antivirussettings'] = 'Manage antivirus plugins';
 $string['configantivirusplugins'] = 'Please choose the antivirus plugins you wish to use and arrange them in order of being applied.';
 $string['datastream'] = 'Data';
+$string['datainfecteddesc'] = 'Infected data was detected.';
+$string['datainfectedname'] = 'Data infected';
+$string['emailadditionalinfo'] = 'Additional details returned from the virus engine: ';
+$string['emailauthor'] = 'Uploaded by: ';
+$string['emailcontenthash'] = 'Content hash: ';
+$string['emailcontenttype'] = 'Content type: ';
+$string['emaildate'] = 'Date uploaded: ';
+$string['emailfilename'] = 'Filename: ';
+$string['emailfilesize'] = 'File size: ';
+$string['emailgeoinfo'] = 'Geolocation: ';
+$string['emailinfectedfiledetected'] = 'Infected file detected';
+$string['emailipaddress'] = 'IP Address: ';
+$string['emailreferer'] = 'Referer: ';
+$string['emailreport'] = 'Report: ';
+$string['emailscanner'] = 'Scanner: ';
+$string['emailscannererrordetected'] = 'A scanner error occured';
 $string['emailsubject'] = '{$a} :: Antivirus notification';
+$string['enablequarantine'] = 'Enable quarantine';
+$string['enablequarantine_help'] = 'When quarantine is enabled, any files which are detected as viruses will be kept in a quarantine folder for later inspection ([dataroot]/{$a}).
+The upload into Moodle will still fail.
+If you have any file system level virus scanning in place, the quarantine folder should be excluded from the antivirus check to avoid detecting the quarantined files.';
+$string['fileinfecteddesc'] = 'An infected file was detected.';
+$string['fileinfectedname'] = 'File infected';
+$string['notifyemail'] = 'Antivirus alert email';
+$string['notifyemail_help'] = 'If set, then only the specified email will be notified when a virus is detected.
+If blank, then all site admins will be notified by email when a virus is detected.';
 $string['privacy:metadata'] = 'The Antivirus system does not store any personal data.';
+$string['quarantinedisabled'] = 'Quarantine disabled, file not stored.';
+$string['quarantinedfiles'] = 'Antivirus quarantined files';
+$string['quarantinetime'] = 'Maximum quarantine time';
+$string['quarantinetime_desc'] = 'Quarantined files older than specified period will be removed.';
+$string['taskcleanup'] = 'Clean up quarantined files.';
+$string['unknown'] = 'Unknown';
 $string['virusfound'] = '{$a->item} has been scanned by a virus checker and found to be infected!';
index 24d2c03..9d7065e 100644 (file)
@@ -1240,6 +1240,7 @@ $string['messageprovider:gradenotifications'] = 'Grade notifications';
 $string['messageprovider:messagecontactrequests'] = 'Message contact requests notification';
 $string['messageprovider:notices'] = 'Notices about minor problems';
 $string['messageprovider:notices_help'] = 'These are notices that an administrator might be interested in seeing.';
+$string['messageprovider:infected'] = 'Antivirus failure notifications.';
 $string['messageprovider:insights'] = 'Insights generated by prediction models';
 $string['messageprovider:instantmessage'] = 'Personal messages between users';
 $string['messageprovider:instantmessage_help'] = 'This section configures what happens to messages that are sent to you directly from other users on this site.';
index 047cb49..7242743 100644 (file)
@@ -228,6 +228,9 @@ class scanner extends \core\antivirus\scanner {
             $notice .= "\n\n". implode("\n", $output);
             $this->set_scanning_notice($notice);
             return self::SCAN_RESULT_ERROR;
+        } else {
+            $notice = "\n\n". implode("\n", $output);
+            $this->set_scanning_notice($notice);
         }
 
         return (int)$return;
@@ -384,6 +387,8 @@ class scanner extends \core\antivirus\scanner {
             $parts = explode(' ', $message);
             $status = array_pop($parts);
             if ($status === 'FOUND') {
+                $notice = "\n\n" . $output;
+                $this->set_scanning_notice($notice);
                 return self::SCAN_RESULT_FOUND;
             } else {
                 $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
index 0f78513..d17c42e 100644 (file)
@@ -67,15 +67,50 @@ class manager {
      * @return void
      */
     public static function scan_file($file, $filename, $deleteinfected) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_file($file, $filename);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_file($file, $filename);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+                throw $e;
+            }
+
+            $notice = $antivirus->get_scanning_notice();
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
-                // Infection found.
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Move to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_file($file, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_file_detected::create($params);
+                $event->trigger();
+
                 if ($deleteinfected) {
                     unlink($file);
                 }
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => $filename));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -83,16 +118,56 @@ class manager {
     /**
      * Scan data steam using all enabled antiviruses, throws exception in case of infected data.
      *
-     * @param string $data The varaible containing the data to scan.
+     * @param string $data The variable containing the data to scan.
      * @throws \core\antivirus\scanner_exception If data is infected.
      * @return void
      */
     public static function scan_data($data) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_data($data);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_data($data);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $filename = get_string('datastream', 'antivirus');
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                throw $e;
+            }
+
+            $filename = get_string('datastream', 'antivirus');
+            $notice = $antivirus->get_scanning_notice();
+
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Copy data to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_data($data, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_data_detected::create($params);
+                $event->trigger();
+
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => get_string('datastream', 'antivirus')));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -125,4 +200,53 @@ class manager {
         }
         return $antiviruses;
     }
+
+    /**
+     * This function puts all relevant information into the messages required, and sends them.
+     *
+     * @param \core\antivirus\scanner $antivirus the scanner engine.
+     * @param string $incidentdetails details of the incident.
+     * @return void
+     */
+    public static function send_antivirus_messages(\core\antivirus\scanner $antivirus, string $incidentdetails) {
+        $messages = $antivirus->get_messages();
+
+        // If there is no messages, and a virus is found, we should generate one, then send it.
+        if (empty($messages)) {
+            $antivirus->message_admins($antivirus->get_scanning_notice(), FORMAT_MOODLE, 'infected');
+            $messages = $antivirus->get_messages();
+        }
+
+        foreach ($messages as $message) {
+
+            // Check if the information is already in the current scanning notice.
+            if (!empty($antivirus->get_scanning_notice()) &&
+                strpos($antivirus->get_scanning_notice(), $message->fullmessage) === false) {
+                // This is some extra information. We should append this to the end of the incident details.
+                $incidentdetails .= \html_writer::tag('pre', $message->fullmessage);
+            }
+
+            // Now update the message to the detailed version, and format.
+            $message->name = 'infected';
+            $message->fullmessagehtml = $incidentdetails;
+            $message->fullmessageformat = FORMAT_MOODLE;
+            $message->fullmessage = format_text_email($incidentdetails, $message->fullmessageformat);
+
+            // Now we must check if message is going to a real account.
+            // It may be an email that needs to be sent to non-user address.
+            if ($message->userto->id === -1) {
+                // If this doesnt exist, send a regular email.
+                email_to_user(
+                    $message->userto,
+                    get_admin(),
+                    $message->subject,
+                    $message->fullmessage,
+                    $message->fullmessagehtml
+                );
+            } else {
+                // And now we can send.
+                message_send($message);
+            }
+        }
+    }
 }
diff --git a/lib/classes/antivirus/quarantine.php b/lib/classes/antivirus/quarantine.php
new file mode 100644 (file)
index 0000000..e3136ff
--- /dev/null
@@ -0,0 +1,326 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\antivirus;
+
+defined('MOODLE_INTERNAL') || die();
+require_once($CFG->libdir.'/filelib.php');
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class quarantine {
+
+    /** Default quarantine folder */
+    const DEFAULT_QUARANTINE_FOLDER = 'antivirus_quarantine';
+
+    /** Zip infected file  */
+    const FILE_ZIP_INFECTED = '_infected_file.zip';
+
+    /** Zip all infected file */
+    const FILE_ZIP_ALL_INFECTED = '_all_infected_files.zip';
+
+    /** Incident details file */
+    const FILE_HTML_DETAILS = '_details.html';
+
+    /** Incident details file */
+    const DEFAULT_QUARANTINE_TIME = DAYSECS * 28;
+
+    /** Date format in filename */
+    const FILE_NAME_DATE_FORMAT = '%Y%m%d%H%M%S';
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $file infected file.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_file(string $file, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_pathname($filename, $file);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $data data which is infected.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_data(string $data, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_string($filename, $data);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Check if the virus quarantine is allowed
+     *
+     * @return bool
+     * @throws \dml_exception
+     */
+    public static function is_quarantine_enabled() : bool {
+        return !empty(get_config("antivirus", "enablequarantine"));
+    }
+
+    /**
+     * Get quarantine folder
+     *
+     * @return string path of quarantine folder
+     */
+    private static function get_quarantine_folder() : string {
+        global $CFG;
+        $quarantinefolder = $CFG->dataroot . DIRECTORY_SEPARATOR . self::DEFAULT_QUARANTINE_FOLDER;
+        if (!file_exists($quarantinefolder)) {
+            make_upload_directory(self::DEFAULT_QUARANTINE_FOLDER);
+        }
+        return $quarantinefolder . DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * Checks whether a file exists inside the antivirus quarantine folder.
+     *
+     * @param string $filename the filename to check.
+     * @return boolean whether file exists.
+     */
+    public static function quarantined_file_exists(string $filename) : bool {
+        $folder = self::get_quarantine_folder();
+        return file_exists($folder . $filename);
+    }
+
+    /**
+     * Download quarantined file.
+     *
+     * @param int $fileid the id of file to be downloaded.
+     */
+    public static function download_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be downloaded.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        $file = self::get_quarantine_folder() . $filename;
+        send_file($file, $filename);
+    }
+
+    /**
+     * Delete quarantined file.
+     *
+     * @param int $fileid id of file to be deleted.
+     */
+    public static function delete_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be deleted.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        // Delete the file from the folder.
+        $file = self::get_quarantine_folder() . $filename;
+        if (file_exists($file)) {
+            unlink($file);
+        }
+
+        // Now we are finished with the record, delete the quarantine information.
+        self::delete_infected_file_record($fileid);
+    }
+
+    /**
+     * Download all quarantined files.
+     *
+     * @return void
+     */
+    public static function download_all_quarantined_files() {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Add all infected files to a zip file.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT);
+        $zipfilename = $date . self::FILE_ZIP_ALL_INFECTED;
+        $zipfilepath = self::get_quarantine_folder() . DIRECTORY_SEPARATOR . $zipfilename;
+        $tempfilestocleanup = [];
+
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            foreach ($files as $file) {
+                if (!$file->isDot()) {
+                    // Only send the actual files.
+                    $filename = $file->getFilename();
+                    $filepath = $file->getPathname();
+                    $ziparchive->add_file_from_pathname($filename, $filepath);
+                }
+            }
+            $ziparchive->close();
+        }
+
+        // Clean up temp files.
+        foreach ($tempfilestocleanup as $tempfile) {
+            if (file_exists($tempfile)) {
+                unlink($tempfile);
+            }
+        }
+
+        send_temp_file($zipfilepath, $zipfilename);
+    }
+
+    /**
+     * Return array of quarantined files.
+     *
+     * @return array list of quarantined files.
+     */
+    public static function get_quarantined_files() : array {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        $filestosort = [];
+
+        // Grab all files that match the naming structure.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $filestosort[$filename] = $file->getPathname();
+            }
+        }
+
+        krsort($filestosort, SORT_NATURAL);
+        return $filestosort;
+    }
+
+    /**
+     * Clean up quarantine folder
+     *
+     * @param int $timetocleanup time to clean up
+     */
+    public static function clean_up_quarantine_folder(int $timetocleanup) {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Clean up the folder.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+
+            // Only delete files that match the correct name structure.
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $modifiedtime = $file->getMTime();
+
+                if ($modifiedtime <= $timetocleanup) {
+                    unlink($file->getPathname());
+                }
+            }
+        }
+
+        // Lastly cleanup the infected files table as well.
+        self::clean_up_infected_records($timetocleanup);
+    }
+
+    /**
+     * This function removes any stale records from the infected files table.
+     *
+     * @param int $timetocleanup the time to cleanup from
+     * @return void
+     */
+    private static function clean_up_infected_records(int $timetocleanup) {
+        global $DB;
+
+        $select = "timecreated <= ?";
+        $DB->delete_records_select('infected_files', $select, [$timetocleanup]);
+    }
+
+    /**
+     * Create an infected file record
+     *
+     * @param string $filename original file name
+     * @param string $zipfile quarantined file name
+     * @param string $reason failure reason
+     * @throws \dml_exception
+     */
+    private static function create_infected_file_record(string $filename, string $zipfile, string $reason) {
+        global $DB, $USER;
+
+        $record = new \stdClass();
+        $record->filename = $filename;
+        $record->quarantinedfile = $zipfile;
+        $record->userid = $USER->id;
+        $record->reason = $reason;
+        $record->timecreated = time();
+
+        $DB->insert_record('infected_files', $record);
+    }
+
+    /**
+     * Delete the database record for an infected file.
+     *
+     * @param int $fileid quarantined file id
+     * @throws \dml_exception
+     */
+    private static function delete_infected_file_record(int $fileid) {
+        global $DB;
+        $DB->delete_records('infected_files', ['id' => $fileid]);
+    }
+}
index d42185c..462684f 100644 (file)
@@ -25,6 +25,7 @@
 namespace core\antivirus;
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '../../../../iplookup/lib.php');
 
 /**
  * Base abstract antivirus scanner class.
@@ -46,6 +47,8 @@ abstract class scanner {
     protected $config;
     /** @var string scanning notice */
     protected $scanningnotice = '';
+    /** @var array any admin messages generated by a plugin. */
+    protected $messages = [];
 
     /**
      * Class constructor.
@@ -130,30 +133,109 @@ abstract class scanner {
     }
 
     /**
-     * Email admins about antivirus scan outcomes.
+     * This function pushes given messages into the message queue, which will be sent by the antivirus manager.
      *
      * @param string $notice The body of the email to be sent.
+     * @param string $format The body format.
+     * @param string $eventname event name
      * @return void
+     * @throws \coding_exception
+     * @throws \moodle_exception
      */
-    public function message_admins($notice) {
-
+    public function message_admins($notice, $format = FORMAT_PLAIN, $eventname = 'errors') {
+        $noticehtml = $format !== FORMAT_PLAIN ? format_text($notice, $format) : '';
         $site = get_site();
 
         $subject = get_string('emailsubject', 'antivirus', format_string($site->fullname));
-        $admins = get_admins();
+        $notifyemail = get_config('antivirus', 'notifyemail');
+        // If one email address is specified, construct a message to fake account.
+        if (!empty($notifyemail)) {
+            $user = new \stdClass();
+            $user->id = -1;
+            $user->email = $notifyemail;
+            $user->mailformat = 1;
+            $admins = [$user];
+        } else {
+            // Otherwise, we message all admins.
+            $admins = get_admins();
+        }
+
         foreach ($admins as $admin) {
             $eventdata = new \core\message\message();
             $eventdata->courseid          = SITEID;
             $eventdata->component         = 'moodle';
-            $eventdata->name              = 'errors';
+            $eventdata->name              = $eventname;
             $eventdata->userfrom          = get_admin();
             $eventdata->userto            = $admin;
             $eventdata->subject           = $subject;
             $eventdata->fullmessage       = $notice;
-            $eventdata->fullmessageformat = FORMAT_PLAIN;
-            $eventdata->fullmessagehtml   = '';
+            $eventdata->fullmessageformat = $format;
+            $eventdata->fullmessagehtml   = $noticehtml;
             $eventdata->smallmessage      = '';
-            message_send($eventdata);
+
+            // Now add the message to an array to be sent by the antivirus manager.
+            $this->messages[] = $eventdata;
         }
     }
-}
\ No newline at end of file
+
+    /**
+     * Return incident details
+     *
+     * @param string $file full path to the file
+     * @param string $filename original name of the file
+     * @param string $notice notice from antivirus
+     * @param string $virus if this template is due to a virus found.
+     * @return string the incident details
+     * @throws \coding_exception
+     */
+    public function get_incident_details($file = '', $filename = '', $notice = '', $virus = true) {
+        global $OUTPUT, $USER;
+        if (empty($notice)) {
+            $notice = $this->get_scanning_notice();
+        }
+        $classname = get_class($this);
+        $component = explode('\\', $classname)[0];
+
+        $content = new \stdClass();
+        $unknown = get_string('unknown', 'antivirus');
+        $content->header = get_string('emailinfectedfiledetected', 'antivirus');
+        $content->filename = !empty($filename) ? $filename : $unknown;
+        $content->scanner = $component;
+        // Check for empty file, or file not uploaded.
+        if (!empty($file) && filesize($file) !== false) {
+            $content->filesize = display_size(filesize($file));
+            $content->contenthash = \file_storage::hash_from_string(file_get_contents($file));
+            $content->contenttype = mime_content_type($file);
+        } else {
+            $content->filesize = $unknown;
+            $content->contenthash = $unknown;
+            $content->contenttype = $unknown;
+        }
+
+        $content->author = \core_user::is_real_user($USER->id) ? fullname($USER) . " ($USER->username)" : $unknown;
+        $content->ipaddress = getremoteaddr();
+        $geoinfo = iplookup_find_location(getremoteaddr());
+        $content->geoinfo = $geoinfo['city'] . ', ' . $geoinfo['country'];
+        $content->date = userdate(time(), get_string('strftimedatetimeshort'));
+        $content->referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : $unknown;
+        $content->notice = $notice;
+        $report = new \moodle_url('/report/infectedfiles/index.php');
+        $content->report = $report->out();
+
+        // If this is not due to a virus, we need to change the header line.
+        if (!$virus) {
+            $content->header = get_string('emailscannererrordetected', 'antivirus');
+        }
+
+        return $OUTPUT->render_from_template('core/infected_file_email', $content);
+    }
+
+    /**
+     * Getter method for messages queued by the antivirus scanner.
+     *
+     * @return array
+     */
+    public function get_messages() : array {
+        return $this->messages;
+    }
+}
diff --git a/lib/classes/event/virus_infected_data_detected.php b/lib/classes/event/virus_infected_data_detected.php
new file mode 100644 (file)
index 0000000..66d3fe3
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_data_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('datainfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('datainfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
diff --git a/lib/classes/event/virus_infected_file_detected.php b/lib/classes/event/virus_infected_file_detected.php
new file mode 100644 (file)
index 0000000..3acd52a
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_file_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('fileinfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('fileinfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
index 4d69a12..886b442 100644 (file)
@@ -1966,8 +1966,8 @@ class core_plugin_manager {
 
             'report' => array(
                 'backups', 'competency', 'completion', 'configlog', 'courseoverview', 'eventlist',
-                'insights', 'log', 'loglive', 'outline', 'participation', 'progress', 'questioninstances',
-                'security', 'stats', 'status', 'performance', 'usersessions'
+                'infectedfiles', 'insights', 'log', 'loglive', 'outline', 'participation', 'progress',
+                'questioninstances', 'security', 'stats', 'status', 'performance', 'usersessions'
             ),
 
             'repository' => array(
diff --git a/lib/classes/task/antivirus_cleanup_task.php b/lib/classes/task/antivirus_cleanup_task.php
new file mode 100644 (file)
index 0000000..6a321d3
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class antivirus_cleanup_task extends scheduled_task {
+
+    /**
+     * Get a descriptive name for this task.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('taskcleanup', 'antivirus');
+    }
+
+    /**
+     * Processes workflows.
+     */
+    public function execute() {
+        $quarantinetime = get_config('antivirus', 'quarantinetime');
+        if (empty($quarantinetime)) {
+            $quarantinetime = \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME;
+            set_config('quarantinetime', $quarantinetime, 'antivirus');
+        }
+        $timetocleanup = time() - $quarantinetime;
+        \core\antivirus\quarantine::clean_up_quarantine_folder($timetocleanup);
+    }
+
+}
index e40f3eb..43e82bc 100644 (file)
         <INDEX NAME="instance" UNIQUE="false" FIELDS="contextid, contenttype, instanceid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="infected_files" COMMENT="Table to store infected file details.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="filename" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Original file name"/>
+        <FIELD NAME="quarantinedfile" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Quarantine zip file"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user that uploaded the infected file."/>
+        <FIELD NAME="reason" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="The reason for the antivirus failure"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The time the infected file was uploaded."/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="Foreign key for the userid"/>
+      </KEYS>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index b892988..eeef52e 100644 (file)
@@ -142,4 +142,9 @@ $messageproviders = array (
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
         ),
     ],
+
+    // Infected files.
+    'infected' => array(
+        'capability'  => 'moodle/site:config',
+    ),
 );
index 171d0a5..9c473ea 100644 (file)
@@ -401,4 +401,13 @@ $tasks = array(
         'dayofweek' => '*',
         'month' => '*'
     ),
+    array(
+        'classname' => 'core\task\antivirus_cleanup_task',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => '0',
+        'day' => '*',
+        'dayofweek' => '*',
+        'month' => '*',
+    ),
 );
index 53310c9..60ac970 100644 (file)
@@ -2661,5 +2661,28 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020082200.02);
     }
 
+    if ($oldversion < 2020082200.03) {
+        // Define table to store virus infected details.
+        $table = new xmldb_table('infected_files');
+
+        // Adding fields to table infected_files.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('filename', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('quarantinedfile', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('reason', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table infected_files.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+
+        // Conditionally launch create table for infected_files.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+        upgrade_main_savepoint(true, 2020082200.03);
+    }
+
     return true;
 }
diff --git a/lib/templates/infected_file_email.mustache b/lib/templates/infected_file_email.mustache
new file mode 100644 (file)
index 0000000..f538386
--- /dev/null
@@ -0,0 +1,80 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/infected_file_email
+
+    Moodle template for infected files emails.
+
+    Context variables required for this template:
+    * report - Hyperlink to the report page,
+    * scanner - Scanning engine that found this file,
+    * filename - Name of the infected file,
+    * filesize - Size of the file,
+    * contenthash - File content hash,
+    * contenttype - Type of uploaded file,
+    * author - User that uploaded the file,
+    * ipaddress - IP address the file was uploaded from,
+    * geoinfo - Geo information about IP address,
+    * referer - The referring page,
+    * identityproviders - List of identiy providers,
+    * date - Date of upload
+    * notice - Notice returned from the scanning engine
+    * additionalinfo - Any additional information from the scanning engine
+
+    Example context (json):
+    {
+        "header": "Infected file detected",
+        "report": "http://example.moodle/report/infectedfiles/index.php",
+        "scanner": "antivirus_clamav",
+        "filename": "virus.txt",
+        "filesize": 100,
+        "contenthash": "3395856ce81f2b7382dee72602f798b642f14140",
+        "contenttype": "text/plain",
+        "author": "Example User (exampleuser)",
+        "ipaddress": "192.168.0.1",
+        "geoinfo": "Brisbane, Australia",
+        "referer": "http://example.moodle/user/files.php",
+        "date": "28/05/20, 11:19",
+        "notice": "Clamav scanning has tried 1 time(s). ClamAV has failed to run.",
+        "additionalinfo": "Here is the output from ClamAV: /tmp/phpElIcr2: Not a regular file ERROR"
+    }
+}}
+
+<div>
+<b>{{header}}</b>
+    <div>
+        <p><b>{{#str}} emailreport, antivirus {{/str}}</b><a href={{report}}>{{report}}</a></p>
+        <p><b>{{#str}} emailscanner, antivirus {{/str}}</b>{{scanner}}</p>
+        <br>
+        <p><b>{{#str}} emailfilename, antivirus {{/str}}</b>{{filename}}</p>
+        <p><b>{{#str}} emailfilesize, antivirus {{/str}}</b>{{filesize}}</p>
+        <p><b>{{#str}} emailcontenthash, antivirus {{/str}}</b>{{contenthash}}</p>
+        <p><b>{{#str}} emailcontenttype, antivirus {{/str}}</b>{{contenttype}}</p>
+        <p><b>{{#str}} emaildate, antivirus {{/str}}</b>{{date}}</p>
+        <br>
+        <p><b>{{#str}} emailauthor, antivirus {{/str}}</b>{{author}}</p>
+        <p><b>{{#str}} emailipaddress, antivirus {{/str}}</b>{{ipaddress}}</p>
+        <p><b>{{#str}} emailgeoinfo, antivirus {{/str}}</b>{{geoinfo}}</p>
+        <p><b>{{#str}} emailreferer, antivirus {{/str}}</b><a href={{referer}}>{{referer}}</a></p>
+    </div>
+    <div>
+        <br>
+        <pre>{{notice}}</pre>
+        <br>
+        <p><b>{{#str}} emailadditionalinfo, antivirus {{/str}} </b></p>
+    </div>
+</div>
index 9192029..96fb1ce 100644 (file)
@@ -85,6 +85,67 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->assertFileNotExists($this->tempfile);
     }
 
+    public function test_manager_send_message_to_user_email_scan_file_virus() {
+        $sink = $this->redirectEmails();
+        $exception = null;
+        try {
+            set_config('notifyemail', 'fake@example.com', 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_file_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_file_virus() {
+        try {
+            set_config('enablequarantine', true, 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+    public function test_manager_none_quarantine_file_virus() {
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
     public function test_manager_scan_data_no_virus() {
         // Run mock scanning.
         $this->assertEmpty(\core\antivirus\manager::scan_data('OK'));
@@ -100,4 +161,69 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->expectException(\core\antivirus\scanner_exception::class);
         $this->assertEmpty(\core\antivirus\manager::scan_data('FOUND'));
     }
+
+    public function test_manager_send_message_to_user_email_scan_data_virus() {
+        $sink = $this->redirectEmails();
+        set_config('notifyemail', 'fake@example.com', 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_data_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_data_virus() {
+        set_config('enablequarantine', true, 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+
+    public function test_manager_none_quarantine_data_virus() {
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // No Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
 }
diff --git a/report/infectedfiles/classes/output/renderer.php b/report/infectedfiles/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..a820166
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\output;
+use report_infectedfiles\table\infectedfiles_table;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render the table
+     *
+     * @param infectedfiles_table $table table of infected files
+     * @return false|string return html code of the table
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    protected function render_infectedfiles_table(infectedfiles_table $table) {
+        ob_start();
+        $table->display($table->pagesize, false);
+        $o = ob_get_contents();
+        ob_end_clean();
+        return $o;
+    }
+}
diff --git a/report/infectedfiles/classes/privacy/provider.php b/report/infectedfiles/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..3631c1d
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+        \core_privacy\local\metadata\provider,
+        request\plugin\provider,
+        request\core_userlist_provider {
+
+    /**
+     * This plugin stores the userid of infected users.
+     *
+     * @param collection $collection the collection object to add data to.
+     * @return collection The populated collection.
+     */
+    public static function get_metadata(collection $collection) : collection {
+        $collection->add_database_table(
+            'infected_files',
+            [
+                'userid' => 'privacy:metadata:infected_files:userid',
+                'filename' => 'privacy:metadata:infected_files:filename',
+                'timecreated' => 'privacy:metadata:infected_files:timecreated',
+            ],
+            'privacy:metadata:infected_files'
+        );
+
+        return $collection;
+    }
+
+    /**
+     * This function gets the contexts containing data for a userid.
+     *
+     * @param int $userid The userid to get contexts for.
+     * @return request\contextlist the context list for the user.
+     */
+    public static function get_contexts_for_userid(int $userid) : request\contextlist {
+        $contextlist = new request\contextlist();
+
+        // The system context is the only context where information is stored.
+        $contextlist->add_system_context();
+        return $contextlist;
+    }
+
+    /**
+     * This function exports user data on infected files from the contextlist provided.
+     *
+     * @param request\approved_contextlist $contextlist
+     * @return void
+     */
+    public static function export_user_data(request\approved_contextlist $contextlist) {
+        global $DB;
+
+        foreach ($contextlist as $context) {
+            // We only export from system context.
+            if ($context->contextlevel === CONTEXT_SYSTEM) {
+
+                $userid = $contextlist->get_user()->id;
+                $exportdata = [];
+
+                $records = $DB->get_records('infected_files', ['userid' => $userid]);
+                foreach ($records as $record) {
+                    // Export only the data that does not expose internal information.
+                    $data = [];
+                    $data['userid'] = $record->userid;
+                    $data['timecreated'] = $record->timecreated;
+                    $data['filename'] = $record->filename;
+
+                    $exportdata[] = $data;
+                }
+
+                // Now export this data in the infected files table as subcontext.
+                request\writer::with_context($context)->export_data(
+                    [get_string('privacy:metadata:infected_files_subcontext', 'report_infectedfiles')],
+                    (object) $exportdata
+                );
+            }
+        }
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param \context $context the context to delete for.
+     * @return void
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        return;
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param \core_privacy\local\request\approved_contextlist $contextlist the contextlist to delete for.
+     * @return void
+     */
+    public static function delete_data_for_user(request\approved_contextlist $contextlist) {
+        return;
+    }
+
+    /**
+     * This gets the list of users inside of the provided context. In this case, its only system context
+     * which contains users.
+     *
+     * @param \core_privacy\local\request\userlist $userlist
+     * @return void
+     */
+    public static function get_users_in_context(request\userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if ($context->contextlevel === CONTEXT_SYSTEM) {
+            // If we are checking system context, we need to get all distinct userids from the table.
+            $sql = 'SELECT DISTINCT userid
+                      FROM {infected_files}';
+
+            $userlist->add_from_sql('userid', $sql, []);
+        }
+    }
+
+    /**
+     * As this report tracks potential attempted security violations,
+     * This data should not be deleted at request. This would allow for an
+     * avenue for a malicious user to cover their tracks. This function deliberately
+     * does no deletes.
+     *
+     * @param request\approved_userlist $userlist
+     * @return void
+     */
+    public static function delete_data_for_users(request\approved_userlist $userlist) {
+        return;
+    }
+}
diff --git a/report/infectedfiles/classes/table/infectedfiles_table.php b/report/infectedfiles/classes/table/infectedfiles_table.php
new file mode 100644 (file)
index 0000000..79e5f7a
--- /dev/null
@@ -0,0 +1,260 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\table;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/tablelib.php');
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class infectedfiles_table extends \table_sql implements \renderable {
+    /**
+     * Table constructor
+     *
+     * @param int $uniqueid table id
+     * @param \moodle_url $url page url
+     * @param int $page current page
+     * @param int $perpage number or record per page
+     * @throws \coding_exception
+     */
+    public function __construct($uniqueid, \moodle_url $url, $page = 0, $perpage = 30) {
+        parent::__construct($uniqueid);
+
+        $this->set_attribute('class', 'report_infectedfiles');
+
+        // Set protected properties.
+        $this->pagesize = $perpage;
+        $this->page = $page;
+
+        // Define columns in the table.
+        $this->define_table_columns();
+
+        // Define configs.
+        $this->define_table_configs($url);
+    }
+
+    /**
+     * Table columns and corresponding headers
+     *
+     * @throws \coding_exception
+     */
+    protected function define_table_columns() {
+        $cols = array(
+            'filename' => get_string('filename', 'report_infectedfiles'),
+            'author' => get_string('author', 'report_infectedfiles'),
+            'reason' => get_string('reason', 'report_infectedfiles'),
+            'timecreated' => get_string('timecreated', 'report_infectedfiles'),
+            'actions' => get_string('actions'),
+        );
+
+        $this->define_columns(array_keys($cols));
+        $this->define_headers(array_values($cols));
+    }
+
+    /**
+     * Define table configuration
+     *
+     * @param \moodle_url $url
+     */
+    protected function define_table_configs(\moodle_url $url) {
+        // Set table url.
+        $this->define_baseurl($url);
+
+        // Set table configs.
+        $this->collapsible(false);
+        $this->sortable(false);
+        $this->pageable(true);
+    }
+
+    /**
+     * Builds the SQL query.
+     *
+     * @param bool $count When true, return the count SQL.
+     * @return array containing sql to use and an array of params.
+     */
+    protected function get_sql_and_params($count = false) : array {
+        if ($count) {
+            $select = "COUNT(1)";
+        } else {
+            $select = "*";
+        }
+
+        $sql = "SELECT $select
+                  FROM {infected_files}";
+
+        $params = array();
+
+        if (!$count) {
+            $sql .= " ORDER BY timecreated DESC";
+        }
+
+        return array($sql, $params);
+    }
+
+    /**
+     * Get data.
+     *
+     * @param int $pagesize number of records to fetch
+     * @param bool $useinitialsbar initial bar
+     * @throws \dml_exception
+     */
+    public function query_db($pagesize, $useinitialsbar = true) {
+        global $DB;
+
+        list($countsql, $countparams) = $this->get_sql_and_params(true);
+        list($sql, $params) = $this->get_sql_and_params();
+        $total = $DB->count_records_sql($countsql, $countparams);
+        $this->pagesize($pagesize, $total);
+        $this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size());
+
+        // Set initial bars.
+        if ($useinitialsbar) {
+            $this->initialbars($total > $pagesize);
+        }
+    }
+
+    /**
+     * Column to display the authors fullname from userid.
+     *
+     * @param \stdClass $row the row from sql.
+     * @return string the authors name.
+     */
+    protected function col_author($row) : string {
+        // Get user fullname from ID.
+        $user = \core_user::get_user($row->userid);
+        $url = new \moodle_url('/user/profile.php', ['id' => $row->userid]);
+        return \html_writer::link($url, fullname($user));
+    }
+
+    /**
+     * Column to display the failure reason.
+     *
+     * @param \stdClass $row the row from sql.
+     * @return string the formatted reason.
+     */
+    protected function col_reason($row) {
+        return format_text($row->reason);
+    }
+
+    /**
+     * Custom actions column
+     *
+     * @param \stdClass $row an incident record.
+     * @return string content of action column.
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    protected function col_actions($row) : string {
+        global $OUTPUT;
+        $filename = $row->quarantinedfile;
+        $fileid = $row->id;
+        // If the file isn't found, we can do nothing in this column.
+        // This shouldn't happen, unless the file is manually deleted from the server externally.
+        if (!\core\antivirus\quarantine::quarantined_file_exists($filename)) {
+            return '';
+        }
+        $links = '';
+        $managefilepage = new \moodle_url('/report/infectedfiles/index.php');
+
+        // Download.
+        $downloadparams = ['file' => $fileid, 'action' => 'download', 'sesskey' => sesskey()];
+        $downloadurl = new \moodle_url($managefilepage, $downloadparams);
+
+        $downloadconfirm = new \confirm_action(get_string('confirmdownload', 'report_infectedfiles'));
+        $links .= $OUTPUT->action_icon(
+            $downloadurl,
+            new \pix_icon('t/download', get_string('download')),
+            $downloadconfirm
+        );
+
+        // Delete.
+        $deleteparams = ['file' => $fileid, 'action' => 'delete', 'sesskey' => sesskey()];
+        $deleteurl = new \moodle_url($managefilepage, $deleteparams);
+        $deleteconfirm = new \confirm_action(get_string('confirmdelete', 'report_infectedfiles'));
+        $links .= $OUTPUT->action_icon(
+            $deleteurl,
+            new \pix_icon('t/delete', get_string('delete')),
+            $deleteconfirm
+        );
+
+        return $links;
+    }
+
+    /**
+     * Custom time column.
+     *
+     * @param \stdClass $row an incident record.
+     * @return string time created in user-friendly format.
+     */
+    protected function col_timecreated($row) : string {
+        return userdate($row->timecreated);
+    }
+
+    /**
+     * Display table with download all and delete all buttons
+     *
+     * @param int $pagesize number or records perpage
+     * @param bool $useinitialsbar use the bar or not
+     * @param string $downloadhelpbutton help button
+     * @return void
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    public function display($pagesize, $useinitialsbar, $downloadhelpbutton='') {
+        global $OUTPUT;
+        // Output the table, and then display buttons.
+        $this->out($pagesize, $useinitialsbar, $downloadhelpbutton);
+        $managefilepage = new \moodle_url('/report/infectedfiles/index.php');
+
+        // If there are no rows, dont bother rendering extra buttons.
+        if (empty($this->rawdata)) {
+            return;
+        }
+
+        // Delete All.
+        $deleteallparams = ['action' => 'deleteall', 'sesskey' => sesskey()];
+        $deleteallurl = new \moodle_url($managefilepage, $deleteallparams);
+        $deletebutton = new \single_button($deleteallurl, get_string('deleteall'), 'post', true);
+        $deletebutton->add_confirm_action(get_string('confirmdeleteall', 'report_infectedfiles'));
+        echo $OUTPUT->render($deletebutton);
+
+        echo "&nbsp";
+
+        // Download All.
+        $downloadallparams = ['action' => 'downloadall', 'sesskey' => sesskey()];
+        $downloadallurl = new \moodle_url($managefilepage, $downloadallparams);
+        $downloadbutton = new \single_button($downloadallurl, get_string('downloadall'), 'post', true);
+        $downloadbutton->add_confirm_action(get_string('confirmdownloadall', 'report_infectedfiles'));
+        echo $OUTPUT->render($downloadbutton);
+    }
+
+}
diff --git a/report/infectedfiles/index.php b/report/infectedfiles/index.php
new file mode 100644 (file)
index 0000000..5f36af7
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__.'/../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+use \core\antivirus\quarantine;
+
+admin_externalpage_setup('reportinfectedfiles', '', null, '', array('pagelayout' => 'report'));
+
+$action = optional_param('action', '', PARAM_TEXT);
+// If action exists, we need to process the actions safely.
+if (!empty($action)) {
+    // Nothing can be done without a valid sesskey.
+    require_sesskey();
+    // For any cancel actions, just reload the page clean.
+    $cancel = $PAGE->url;
+
+    // Decide on page action.
+    switch ($action) {
+        case 'download':
+            $fileid = required_param('file', PARAM_INT);
+            quarantine::download_quarantined_file($fileid);
+            break;
+
+        case 'downloadall':
+            quarantine::download_all_quarantined_files();
+            break;
+
+        case 'delete':
+            $fileid = required_param('file', PARAM_INT);
+            quarantine::delete_quarantined_file($fileid);
+            break;
+
+        case 'deleteall':
+            // Remove file until current time.
+            quarantine::clean_up_quarantine_folder(time());
+            break;
+    }
+
+    // Reload page cleanly once actions are processed.
+    redirect($PAGE->url);
+}
+
+// Once actions are dealt with, display the page.
+$page = optional_param('page', 0, PARAM_INT);
+
+echo $OUTPUT->header();
+echo $OUTPUT->heading(get_string('infectedfiles', 'report_infectedfiles'));
+$table = new \report_infectedfiles\table\infectedfiles_table('report-infectedfiles-report-table', $PAGE->url, $page);
+$table->define_baseurl($PAGE->url);
+echo $PAGE->get_renderer('report_infectedfiles')->render($table);
+echo $OUTPUT->footer();
diff --git a/report/infectedfiles/lang/en/report_infectedfiles.php b/report/infectedfiles/lang/en/report_infectedfiles.php
new file mode 100644 (file)
index 0000000..dd487fa
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+$string['author'] = 'Author';
+$string['confirmdelete'] = 'Do you really wish to delete this file?';
+$string['confirmdeleteall'] = 'Do you really wish to delete all files?';
+$string['confirmdownload'] = 'Do you really wish to download this file?';
+$string['confirmdownloadall'] = 'Do you really wish to download all files?';
+$string['filename'] = 'File name';
+$string['infectedfiles'] = 'Antivirus failures';
+$string['privacy:metadata:infected_files'] = 'This table stores information on antivirus failures detected by the system.';
+$string['privacy:metadata:infected_files:filename'] = 'The name of the infected file uploaded by the user.';
+$string['privacy:metadata:infected_files:timecreated'] = 'The timestamp of when a user uploaded an infected file.';
+$string['privacy:metadata:infected_files:userid'] = 'The userid of the user who uploaded an infected file.';
+$string['privacy:metadata:infected_files_subcontext'] = 'Antivirus failures';
+$string['pluginname'] = 'Infected files';
+$string['quarantinedfile'] = 'Quarantined file';
+$string['reason'] = 'Failure reason';
+$string['timecreated'] = 'Time created';
diff --git a/report/infectedfiles/settings.php b/report/infectedfiles/settings.php
new file mode 100644 (file)
index 0000000..d849a3c
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$ADMIN->add('reports', new admin_externalpage('reportinfectedfiles',
+    get_string('infectedfiles', 'report_infectedfiles'),
+    "$CFG->wwwroot/report/infectedfiles/index.php"));
+
+$settings = null;
diff --git a/report/infectedfiles/version.php b/report/infectedfiles/version.php
new file mode 100644 (file)
index 0000000..4a5b21b
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$plugin->version   = 2020031800;
+$plugin->requires  = 2019111200;
+$plugin->component = 'report_infectedfiles';
index b178957..1e7dc1c 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020082200.02;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020082200.03;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '3.10dev (Build: 20200822)';// Human-friendly version name