From adbe92ce0a4dffeb18384a1a1b7ca3403fbe3068 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Sun, 15 Mar 2020 20:09:48 +1100 Subject: [PATCH] MDL-66222 antivirus: Added antivirus failure reporting --- admin/settings/plugins.php | 23 ++ lang/en/antivirus.php | 29 ++ lib/antivirus/clamav/classes/scanner.php | 5 + lib/classes/antivirus/manager.php | 40 ++- lib/classes/antivirus/quarantine.php | 264 ++++++++++++++++++ lib/classes/antivirus/scanner.php | 62 +++- lib/classes/event/antivirus_data_infected.php | 75 +++++ lib/classes/event/antivirus_file_infected.php | 75 +++++ lib/classes/task/antivirus_cleanup_task.php | 62 ++++ lib/db/install.xml | 10 + lib/db/messages.php | 5 + lib/db/tasks.php | 9 + lib/db/upgrade.php | 20 ++ lib/tests/antivirus_test.php | 126 +++++++++ .../classes/output/infectedfiles_table.php | 219 +++++++++++++++ .../infectedfiles/classes/output/renderer.php | 54 ++++ .../classes/privacy/provider.php | 47 ++++ report/infectedfiles/index.php | 36 +++ .../lang/en/report_infectedfiles.php | 34 +++ .../infectedfiles/manage_infected_files.php | 77 +++++ report/infectedfiles/settings.php | 32 +++ report/infectedfiles/version.php | 30 ++ 22 files changed, 1326 insertions(+), 8 deletions(-) create mode 100644 lib/classes/antivirus/quarantine.php create mode 100644 lib/classes/event/antivirus_data_infected.php create mode 100644 lib/classes/event/antivirus_file_infected.php create mode 100644 lib/classes/task/antivirus_cleanup_task.php create mode 100644 report/infectedfiles/classes/output/infectedfiles_table.php create mode 100644 report/infectedfiles/classes/output/renderer.php create mode 100644 report/infectedfiles/classes/privacy/provider.php create mode 100644 report/infectedfiles/index.php create mode 100644 report/infectedfiles/lang/en/report_infectedfiles.php create mode 100644 report/infectedfiles/manage_infected_files.php create mode 100644 report/infectedfiles/settings.php create mode 100644 report/infectedfiles/version.php diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index cc23ff3d015..ca99ec81c67 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -167,6 +167,29 @@ 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'); diff --git a/lang/en/antivirus.php b/lang/en/antivirus.php index 634b0c76a31..170d15f8eb7 100644 --- a/lang/en/antivirus.php +++ b/lang/en/antivirus.php @@ -24,9 +24,38 @@ $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['confirmdelete'] = 'Do you really want to delete this file'; +$string['confirmdeleteall'] = 'Do you really want to delete all files'; $string['datastream'] = 'Data'; +$string['datainfecteddesc'] = 'There is a virus infected data'; +$string['datainfectedname'] = 'Data infected'; $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['fileinfectedname'] = 'File infected'; +$string['incidencedetails'] = 'Infected file detected: +Report: {$a->report} +File name: {$a->filename} +File size: {$a->filesize} +File content hash: {$a->contenthash} +File content type: {$a->contenttype} +Uploaded by: {$a->author} +IP: {$a->ipaddress} +REFERER: {$a->referer} +Date: {$a->date} +{$a->notice}'; +$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['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!'; diff --git a/lib/antivirus/clamav/classes/scanner.php b/lib/antivirus/clamav/classes/scanner.php index 047cb49525b..72427434ec4 100644 --- a/lib/antivirus/clamav/classes/scanner.php +++ b/lib/antivirus/clamav/classes/scanner.php @@ -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)); diff --git a/lib/classes/antivirus/manager.php b/lib/classes/antivirus/manager.php index 0f785135f1f..780e12a97be 100644 --- a/lib/classes/antivirus/manager.php +++ b/lib/classes/antivirus/manager.php @@ -67,11 +67,28 @@ 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); if ($result === $antivirus::SCAN_RESULT_FOUND) { - // Infection found. + // Infection found, send notification. + $notice = $antivirus->get_scanning_notice(); + $incidencedetails = $antivirus->get_incidence_details($file, $filename, $notice); + $antivirus->message_admins($notice, FORMAT_MOODLE, 'infected'); + + // Move to quarantine folder. + $zipfile = \core\antivirus\quarantine::quarantine_file($file, $filename, $incidencedetails, $notice); + + // Log file infected event. + $params = array( + 'context' => \context_system::instance(), + 'relateduserid' => $USER->id, + 'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidencedetails' => $incidencedetails], + ); + $event = \core\event\antivirus_file_infected::create($params); + $event->trigger(); + if ($deleteinfected) { unlink($file); } @@ -83,15 +100,34 @@ 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); if ($result === $antivirus::SCAN_RESULT_FOUND) { + // Infection found, send notification. + $filename = get_string('datastream', 'antivirus'); + $notice = $antivirus->get_scanning_notice(); + $incidencedetails = $antivirus->get_incidence_details('', $filename, $notice); + $antivirus->message_admins($notice, FORMAT_MOODLE, 'infected'); + + // Copy data to quarantine folder. + $zipfile = \core\antivirus\quarantine::quarantine_data($data, $filename, $incidencedetails, $notice); + + // Log file infected event. + $params = array( + 'context' => \context_system::instance(), + 'relateduserid' => $USER->id, + 'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidencedetails' => $incidencedetails], + ); + $event = \core\event\antivirus_data_infected::create($params); + $event->trigger(); + throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => get_string('datastream', 'antivirus'))); } } diff --git a/lib/classes/antivirus/quarantine.php b/lib/classes/antivirus/quarantine.php new file mode 100644 index 00000000000..c8a038df593 --- /dev/null +++ b/lib/classes/antivirus/quarantine.php @@ -0,0 +1,264 @@ +. + +/** + * Quarantine file + * + * @package core_antivirus + * @author Nathan Nguyen + * @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 + * @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'; + + /** Incidence details file */ + const FILE_HTML_DETAILS = '_details.html'; + + /** Incidence 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 $incidencedetails incidence details + * @param string $notice notice details + * @throws \dml_exception + */ + public static function quarantine_file($file, $filename, $incidencedetails, $notice) { + if (!self::is_allowed_quarantine()) { + return; + } + // 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($incidencedetails, 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 $incidencedetails incidence details + * @param string $notice notice details + * @throws \dml_exception + */ + public static function quarantine_data($data, $filename, $incidencedetails, $notice) { + if (!self::is_allowed_quarantine()) { + return; + } + // 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($incidencedetails, 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_allowed_quarantine() { + return !empty(get_config("antivirus", "enablequarantine")); + } + + /** + * Get quarantine folder + * + * @return string path of quarantine folder + */ + public static function get_quarantine_folder() { + 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; + } + + /** + * Download quarantined file + * + * @param string $filename name of file to be downloaded + */ + public static function download_quarantined_file($filename) { + $file = self::get_quarantine_folder() . $filename; +// send_file($file, $filename); + } + + /** + * Delete quarantined file + * + * @param string $filename name of file to be deleted + */ + public static function delete_quarantined_file($filename) { + self::delete_infected_file_record($filename); + $file = self::get_quarantine_folder() . $filename; + if (file_exists($file)) { +// unlink($file); + } + } + + /** + * Download all quarantined files + * + */ + public static function download_all_quarantined_files() { + $files = new \DirectoryIterator(self::get_quarantine_folder()); + // Add all infected file 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()) { + $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() { + $files = new \DirectoryIterator(self::get_quarantine_folder()); + $filestosort = []; + 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($timetocleanup) { + $files = new \DirectoryIterator(self::get_quarantine_folder()); + // Clean up the folder. + foreach ($files as $file) { + $filename = $file->getFilename(); + if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) { + $modifiedtime = $file->getMTime(); + if ($modifiedtime <= $timetocleanup) { + unlink($file->getPathname()); + self::delete_infected_file_record($filename); + } + } + } + } + + /** + * 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($filename, $zipfile, $reason) { + global $DB, $USER; + $record = new \stdClass(); + $record->filename = $filename; + $record->quarantinedfile = $zipfile; + $record->author = fullname($USER); + $record->reason = $reason; + $record->timecreated = time(); + $DB->insert_record('infected_files', $record); + } + + /** + * Delete an infected_file_record + * + * @param string $zipfile quarantined file name + * @throws \dml_exception + */ + private static function delete_infected_file_record($zipfile) { + global $DB; + $DB->delete_records('infected_files', ['quarantinedfile' => $zipfile]); + } +} diff --git a/lib/classes/antivirus/scanner.php b/lib/classes/antivirus/scanner.php index d42185c15f0..546b4a76e45 100644 --- a/lib/classes/antivirus/scanner.php +++ b/lib/classes/antivirus/scanner.php @@ -133,27 +133,77 @@ abstract class scanner { * Email admins about antivirus scan outcomes. * * @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)); + $notifyemail = get_config('antivirus', 'notifyemail'); + if (!empty($notifyemail)) { + $user = new \stdClass(); + $user->id = -1; + $user->email = $notifyemail; + email_to_user($user, get_admin(), $subject, $noticehtml); + return; + } + $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); } } -} \ No newline at end of file + + /** + * Return incidence details + * + * @param string $file full path to the file + * @param string $filename original name of the file + * @param string $notice notice from antivirus + * @return string the incidence details + * @throws \coding_exception + */ + public function get_incidence_details($file = '', $filename = '', $notice = '') { + global $USER; + if (empty($notice)) { + $notice = $this->get_scanning_notice(); + } + $content = new \stdClass(); + $unknown = get_string('unknown', 'antivirus');; + $content->filename = !empty($filename) ? $filename : $unknown; + if (!empty($file)) { + $content->filesize = 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(); + $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(); + return get_string('incidencedetails', 'antivirus', $content); + } +} diff --git a/lib/classes/event/antivirus_data_infected.php b/lib/classes/event/antivirus_data_infected.php new file mode 100644 index 00000000000..9bcfdeca0c5 --- /dev/null +++ b/lib/classes/event/antivirus_data_infected.php @@ -0,0 +1,75 @@ +. + +/** + * Data infected event + * + * @package core + * @author Nathan Nguyen + * @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 + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class antivirus_data_infected 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() { + return isset($this->other['incidencedetails']) ? + format_text($this->other['incidencedetails'], FORMAT_MOODLE) : 'Infected data'; + } + + /** + * 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/antivirus_file_infected.php b/lib/classes/event/antivirus_file_infected.php new file mode 100644 index 00000000000..1e2e71a280e --- /dev/null +++ b/lib/classes/event/antivirus_file_infected.php @@ -0,0 +1,75 @@ +. + +/** + * Fle infected event + * + * @package core + * @author Nathan Nguyen + * @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 + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class antivirus_file_infected 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() { + return isset($this->other['incidencedetails']) ? + format_text($this->other['incidencedetails'], FORMAT_MOODLE) : 'Infected file'; + } + + /** + * 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'); + } +} diff --git a/lib/classes/task/antivirus_cleanup_task.php b/lib/classes/task/antivirus_cleanup_task.php new file mode 100644 index 00000000000..1fc278c1a85 --- /dev/null +++ b/lib/classes/task/antivirus_cleanup_task.php @@ -0,0 +1,62 @@ +. + +/** + * Clean up task for core antivirus + * + * @package core_antivirus + * @author Nathan Nguyen + * @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 + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class antivirus_cleanup_task extends \core\task\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); + } + +} diff --git a/lib/db/install.xml b/lib/db/install.xml index 0e0c4f0d49d..45070361411 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -4275,5 +4275,15 @@ + + + + + + + + + +
\ No newline at end of file diff --git a/lib/db/messages.php b/lib/db/messages.php index b89298865d7..2ccd8ef2ac7 100644 --- a/lib/db/messages.php +++ b/lib/db/messages.php @@ -142,4 +142,9 @@ $messageproviders = array ( 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF, ), ], + + // Infected files. + 'infected' => array ( + 'capability' => 'moodle/site:config' + ), ); diff --git a/lib/db/tasks.php b/lib/db/tasks.php index 171d0a5b87e..b2ad9fb71ce 100644 --- a/lib/db/tasks.php +++ b/lib/db/tasks.php @@ -401,4 +401,13 @@ $tasks = array( 'dayofweek' => '*', 'month' => '*' ), + array( + 'classname' => 'core\task\antivirus_cleanup_task', + 'blocking' => 0, + 'minute' => '0', + 'hour' => '0', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' + ), ); diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index a61f5e9a388..4ff74de05fb 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2539,5 +2539,25 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2020072300.01); } + if ($oldversion < 2020080500.01) { + // Define table to store virus infected details. + $table = new xmldb_table('infected_files'); + + // Adding fields. + $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_CHAR, 255, null, XMLDB_NOTNULL, null, null); + $table->add_field('author', XMLDB_TYPE_TEXT, null, 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. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + upgrade_main_savepoint(true, 2020080500.01); + } return true; } diff --git a/lib/tests/antivirus_test.php b/lib/tests/antivirus_test.php index 91920290038..96fb1ce8b7c 100644 --- a/lib/tests/antivirus_test.php +++ b/lib/tests/antivirus_test.php @@ -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/infectedfiles_table.php b/report/infectedfiles/classes/output/infectedfiles_table.php new file mode 100644 index 00000000000..28d4063aa46 --- /dev/null +++ b/report/infectedfiles/classes/output/infectedfiles_table.php @@ -0,0 +1,219 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace report_infectedfiles\output; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/tablelib.php'); + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @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'), + 'quarantinedfile' => get_string('quarantinedfile', '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) { + 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); + } + } + + /** + * Custom actions column + * + * @param \stdClass $row an incidence record + * @return string content of action column + * @throws \coding_exception + * @throws \moodle_exception + */ + protected function col_actions($row) { + global $OUTPUT; + $filename = $row->quarantinedfile; + $zipfile = \core\antivirus\quarantine::get_quarantine_folder() . $filename; + if (!file_exists($zipfile)) { + return ''; + } + $links = ''; + $managefilepage = new \moodle_url('/report/infectedfiles/manage_infected_files.php'); + + // Download. + $downloadparams = ['filename' => $filename, 'action' => 'download', 'sesskey' => sesskey()]; + $downloadurl = new \moodle_url($managefilepage, $downloadparams); + $icon = $OUTPUT->pix_icon('t/download', get_string('download')); + $downloadlink = \html_writer::link($downloadurl, $icon); + $links .= ' ' . $downloadlink; + + // Delete. + $deleteparams = ['filename' => $filename, 'action' => 'confirmdelete', 'sesskey' => sesskey()]; + $deleteurl = new \moodle_url($managefilepage, $deleteparams); + $icon = $OUTPUT->pix_icon('t/delete', get_string('delete')); + $deletelink = \html_writer::link($deleteurl, $icon); + $links .= ' ' . $deletelink; + + return $links; + } + + /** + * Custom time column + * + * @param \stdClass $row an incidence record + * @return string time created in user-friendly format + */ + protected function col_timecreated($row) { + 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 + * @throws \coding_exception + * @throws \moodle_exception + */ + public function display($pagesize, $useinitialsbar, $downloadhelpbutton='') { + $this->out($pagesize, $useinitialsbar, $downloadhelpbutton); + + $managefilepage = new \moodle_url('/report/infectedfiles/manage_infected_files.php'); + // Delete All. + $button = \html_writer::tag('button', get_string('deleteall'), ['class' => 'btn btn-primary']); + $deleteallparams = ['action' => 'confirmdeleteall', 'sesskey' => sesskey()]; + $deleteallurl = new \moodle_url($managefilepage, $deleteallparams); + echo \html_writer::link($deleteallurl, $button); + + echo " "; + + // Download All. + $button = \html_writer::tag('button', get_string('downloadall'), ['class' => 'btn btn-primary']); + $downloadallparams = ['action' => 'downloadall', 'sesskey' => sesskey()]; + $downloadallurl = new \moodle_url($managefilepage, $downloadallparams); + echo \html_writer::link($downloadallurl, $button); + } + +} diff --git a/report/infectedfiles/classes/output/renderer.php b/report/infectedfiles/classes/output/renderer.php new file mode 100644 index 00000000000..8affaecb823 --- /dev/null +++ b/report/infectedfiles/classes/output/renderer.php @@ -0,0 +1,54 @@ +. + +/** + * Infected file report renderer + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace report_infectedfiles\output; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Infected file report renderer + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @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 index 00000000000..c4a85be9df8 --- /dev/null +++ b/report/infectedfiles/classes/privacy/provider.php @@ -0,0 +1,47 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace report_infectedfiles\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * This plugin does not contain any personal data + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } +} diff --git a/report/infectedfiles/index.php b/report/infectedfiles/index.php new file mode 100644 index 00000000000..e6b7f9a5eab --- /dev/null +++ b/report/infectedfiles/index.php @@ -0,0 +1,36 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @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'); + +admin_externalpage_setup('reportinfectedfiles', '', null, '', array('pagelayout' => 'report')); +$page = optional_param('page', 0, PARAM_INT); +echo $OUTPUT->header(); +echo $OUTPUT->heading(get_string('infectedfiles', 'report_infectedfiles')); +$table = new \report_infectedfiles\output\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 index 00000000000..bc284cbed85 --- /dev/null +++ b/report/infectedfiles/lang/en/report_infectedfiles.php @@ -0,0 +1,34 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +$string['author'] = 'Author'; +$string['confirmdelete'] = 'Do you really want to delete this file'; +$string['confirmdeleteall'] = 'Do you really want to delete all files'; +$string['filename'] = 'File name'; +$string['infectedfiles'] = 'Infected files'; +$string['privacy:metadata'] = 'This plugin does not contain any personal data'; +$string['pluginname'] = 'Infected files'; +$string['quarantinedfile'] = 'Quarantined file'; +$string['reason'] = 'Failure reason'; +$string['timecreated'] = 'Time created'; diff --git a/report/infectedfiles/manage_infected_files.php b/report/infectedfiles/manage_infected_files.php new file mode 100644 index 00000000000..052b79326e9 --- /dev/null +++ b/report/infectedfiles/manage_infected_files.php @@ -0,0 +1,77 @@ +. + +/** + * Manage infected files. + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__.'/../../config.php'); +require_once($CFG->libdir.'/adminlib.php'); +use \core\antivirus\quarantine; + +require_admin(); +require_sesskey(); + +$action = optional_param('action', '', PARAM_TEXT); +$reportpage = new moodle_url('/report/infectedfiles/index.php'); +$thispage = new moodle_url('/report/infectedfiles/manage_infected_files.php'); +$PAGE->set_context(context_system::instance()); +$PAGE->set_url($thispage); +$PAGE->navbar->add(get_string('infectedfiles', 'report_infectedfiles'), $reportpage); + +switch ($action) { + case 'download': + $filename = required_param('filename', PARAM_TEXT); + quarantine::download_quarantined_file($filename); + case 'downloadall': + quarantine::download_all_quarantined_files(); + case 'confirmdelete': + $filename = required_param('filename', PARAM_TEXT); + $deleteparams = ['filename' => $filename, 'action' => 'delete', 'sesskey' => sesskey()]; + $confirmeddelete = new single_button(new moodle_url($thispage, $deleteparams), get_string('delete'), 'post'); + $cancel = new single_button(new moodle_url($reportpage), get_string('cancel'), 'post'); + echo $OUTPUT->header(); + echo $OUTPUT->heading(get_string('infectedfiles', 'report_infectedfiles')); + echo $OUTPUT->confirm(get_string('confirmdelete', 'report_infectedfiles'), $confirmeddelete, $cancel); + echo $OUTPUT->footer(); + die; + case 'delete': + $filename = required_param('filename', PARAM_TEXT); + quarantine::delete_quarantined_file($filename); + redirect($reportpage); + case 'confirmdeleteall': + require_sesskey(); + $deleteallparams = ['action' => 'deleteall', 'sesskey' => sesskey()]; + $confirmeddeleteall = new single_button(new moodle_url($thispage, $deleteallparams), get_string('delete'), 'post'); + $cancel = new single_button(new moodle_url($reportpage), get_string('cancel'), 'post'); + echo $OUTPUT->header(); + echo $OUTPUT->heading(get_string('infectedfiles', 'report_infectedfiles')); + echo $OUTPUT->confirm(get_string('confirmdeleteall', 'report_infectedfiles'), $confirmeddeleteall, $cancel); + echo $OUTPUT->footer(); + die; + case 'deleteall': + require_sesskey(); + // Remove file until current time. + quarantine::clean_up_quarantine_folder(time()); + redirect($reportpage); + default: + break; +} diff --git a/report/infectedfiles/settings.php b/report/infectedfiles/settings.php new file mode 100644 index 00000000000..d849a3c3b15 --- /dev/null +++ b/report/infectedfiles/settings.php @@ -0,0 +1,32 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @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 index 00000000000..4a5b21bf80e --- /dev/null +++ b/report/infectedfiles/version.php @@ -0,0 +1,30 @@ +. + +/** + * Infected file report + * + * @package report_infectedfiles + * @author Nathan Nguyen + * @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';