Merge branch 'MDL-71627-add-antivirus-check-api-and-notification-levels' of https://github.com/keevan/moodle

This commit is contained in:
Eloy Lafuente (stronk7) 2021-08-30 12:39:29 +02:00
commit 457c2026ed
11 changed files with 453 additions and 17 deletions

View File

@ -182,6 +182,26 @@ if ($hassiteconfig) {
)
);
// Notify level.
$temp->add(new admin_setting_configselect('antivirus/notifylevel',
get_string('notifylevel', 'antivirus'), '', core\antivirus\scanner::SCAN_RESULT_ERROR, [
core\antivirus\scanner::SCAN_RESULT_ERROR => get_string('notifylevelerror', 'antivirus'),
core\antivirus\scanner::SCAN_RESULT_FOUND => get_string('notifylevelfound', 'antivirus')
]),
);
// Threshold for check displayed on the /report/status/index.php page.
$url = new moodle_url('/report/status/index.php');
$link = html_writer::link($url, get_string('pluginname', 'report_status'));
$temp->add(
new admin_setting_configduration(
'antivirus/threshold',
new lang_string('threshold', 'antivirus'),
get_string('threshold_desc', 'antivirus', $link),
20 * MINSECS
)
);
// Enable quarantine.
$temp->add(
new admin_setting_configcheckbox(

View File

@ -28,6 +28,8 @@ $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['dataerrordesc'] = 'Data scanner error occurred.';
$string['dataerrorname'] = 'Data scanner error';
$string['datainfecteddesc'] = 'Infected data was detected.';
$string['datainfectedname'] = 'Data infected';
$string['emailadditionalinfo'] = 'Additional details returned from the virus engine: ';
@ -45,17 +47,26 @@ $string['emailreport'] = 'Report: ';
$string['emailscanner'] = 'Scanner: ';
$string['emailscannererrordetected'] = 'A scanner error occured';
$string['emailsubject'] = '{$a} :: Antivirus notification';
$string['enablequarantine'] = 'Enable quarantine';
$string['enablequarantine_help'] = 'If enabled, any files which are detected as viruses will be placed in a quarantine folder ([dataroot]/{$a}) for later inspection. The upload into Moodle will 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['enablequarantine'] = 'Enable quarantine';
$string['fileerrordesc'] = 'File scanner error occurred.';
$string['fileerrorname'] = 'File scanner error';
$string['fileinfecteddesc'] = 'An infected file was detected.';
$string['fileinfectedname'] = 'File infected';
$string['notifyemail'] = 'Antivirus alert notification email';
$string['notifyemail_help'] = 'The email address for notifications of when a virus is detected. If left blank, then all site administrators will be sent notifications.';
$string['notifyemail'] = 'Antivirus alert notification email';
$string['notifylevel_help'] = 'The different levels of information you want to be notified about';
$string['notifylevel'] = 'Notify Level';
$string['notifylevelfound'] = 'Notify when threats detected';
$string['notifylevelerror'] = 'Notify on threats and scan issues';
$string['privacy:metadata'] = 'The Antivirus system does not store any personal data.';
$string['quarantinedisabled'] = 'Quarantine is disabled. The file is not stored.';
$string['quarantinedfiles'] = 'Antivirus quarantined files';
$string['quarantinetime'] = 'Maximum quarantine time';
$string['quarantinedisabled'] = 'Quarantine is disabled. The file is not stored.';
$string['quarantinetime_desc'] = 'Quarantined files older than the specified period will be removed.';
$string['quarantinetime'] = 'Maximum quarantine time';
$string['threshold_desc'] = 'Controls how far back to check against previous results for errors/warnings/etc, which can be viewed here ({$a}).';
$string['threshold'] = 'Threshold for status check';
$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!';

View File

@ -2042,6 +2042,7 @@ $string['statusinfo'] = 'Info';
$string['statusna'] = 'N/A';
$string['statusok'] = 'OK';
$string['statuserror'] = 'Error';
$string['statusunknown'] = 'Unknown';
$string['statuswarning'] = 'Warning';
$string['stringsnotset'] = 'The following strings are not defined in {$a}';
$string['studentnotallowed'] = 'Sorry, but you can not enter this course as \'{$a}\'';

View File

@ -34,6 +34,7 @@ defined('MOODLE_INTERNAL') || die();
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manager {
/**
* Returns list of enabled antiviruses.
*
@ -69,15 +70,31 @@ class manager {
public static function scan_file($file, $filename, $deleteinfected) {
global $USER;
$antiviruses = self::get_enabled();
$notifylevel = (int)get_config('antivirus', 'notifylevel');
foreach ($antiviruses as $antivirus) {
// 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);
// Log scan error event.
$params = [
'context' => \context_system::instance(),
'relateduserid' => $USER->id,
'other' => ['filename' => $filename, 'incidentdetails' => $incidentdetails],
];
$event = \core\event\antivirus_scan_file_error::create($params);
$event->trigger();
// If there was a scanner exception (such as ClamAV denying
// upload), send messages (on error and above), and rethrow.
if ($notifylevel === $antivirus::SCAN_RESULT_ERROR) {
$notice = $antivirus->get_scanning_notice();
self::send_antivirus_messages($antivirus, $incidentdetails);
}
throw $e;
}
@ -121,7 +138,20 @@ class manager {
} 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);
// Log scan error event.
$params = [
'context' => \context_system::instance(),
'relateduserid' => $USER->id,
'other' => ['filename' => $filename, 'incidentdetails' => $incidentdetails],
];
$event = \core\event\antivirus_scan_file_error::create($params);
$event->trigger();
// Send a notification if required (error or above).
if ($notifylevel === $antivirus::SCAN_RESULT_ERROR) {
self::send_antivirus_messages($antivirus, $incidentdetails);
}
}
}
}
@ -136,16 +166,30 @@ class manager {
public static function scan_data($data) {
global $USER;
$antiviruses = self::get_enabled();
$notifylevel = (int)get_config('antivirus', 'notifylevel');
foreach ($antiviruses as $antivirus) {
// 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);
// Log scan error event.
$params = [
'context' => \context_system::instance(),
'relateduserid' => $USER->id,
'other' => ['filename' => $filename, 'incidentdetails' => $incidentdetails],
];
$event = \core\event\antivirus_scan_file_error::create($params);
$event->trigger();
// If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
if ($notifylevel === $antivirus::SCAN_RESULT_ERROR) {
$notice = $antivirus->get_scanning_notice();
$filename = get_string('datastream', 'antivirus');
self::send_antivirus_messages($antivirus, $incidentdetails);
}
throw $e;
}
@ -188,7 +232,20 @@ class manager {
} 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);
// Log scan error event.
$params = [
'context' => \context_system::instance(),
'relateduserid' => $USER->id,
'other' => ['filename' => $filename, 'incidentdetails' => $incidentdetails],
];
$event = \core\event\antivirus_scan_data_error::create($params);
$event->trigger();
// Send a notification if required (error or above).
if ($notifylevel === $antivirus::SCAN_RESULT_ERROR) {
self::send_antivirus_messages($antivirus, $incidentdetails);
}
}
}
}

View File

@ -0,0 +1,123 @@
<?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/>.
namespace core\check\environment;
defined('MOODLE_INTERNAL') || die();
use core\check\check;
use core\check\result;
/**
* Checks status of antivirus scanners by looking back at any recent scans.
*
* @package core
* @category check
* @author Kevin Pham <kevinpham@catalyst-au.net>
* @copyright Catalyst IT, 2021
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class antivirus extends check {
/**
* Get the short check name
*
* @return string
*/
public function get_name(): string {
return get_string('check_antivirus_name', 'report_security');
}
/**
* A link to a place to action this
*
* @return action_link|null
*/
public function get_action_link(): ?\action_link {
return new \action_link(
new \moodle_url('/admin/settings.php', ['section' => 'manageantiviruses']),
get_string('antivirussettings', 'antivirus'));
}
/**
* Return result
* @return result
*/
public function get_result(): result {
global $CFG, $DB;
$details = \html_writer::tag('p', get_string('check_antivirus_details', 'report_security'));
// If no scanners are enabled, then return an NA status since the results do not matter.
if (empty($CFG->antiviruses)) {
$status = result::NA;
$summary = get_string('check_antivirus_info', 'report_security');
return new result($status, $summary, $details);
}
$logmanager = get_log_manager();
$readers = $logmanager->get_readers('\core\log\sql_internal_table_reader');
// If reader is not a sql_internal_table_reader return UNKNOWN since we
// aren't able to fetch the required information. Legacy logs are not
// supported here. They do not hold enough adequate information to be
// used for these checks.
if (empty($readers)) {
$status = result::UNKNOWN;
$summary = get_string('check_antivirus_logstore_not_supported', 'report_security');
return new result($status, $summary, $details);
}
$reader = reset($readers);
// If there has been a recent timestamp within threshold period, then
// set the status to ERROR and describe the problem, e.g. X issues in
// the last N period.
$threshold = get_config('antivirus', 'threshold');
$params = [];
$params['lookback'] = time() - $threshold;
// Type of "targets" to include.
list($targetsqlin, $inparams) = $DB->get_in_or_equal([
'antivirus_scan_file',
'antivirus_scan_data',
], SQL_PARAMS_NAMED);
$params = array_merge($inparams, $params);
// Specify criteria for search.
$selectwhere = "timecreated > :lookback
AND target $targetsqlin
AND action = 'error'";
$totalerrors = $reader->get_events_select_count($selectwhere, $params);
if (!empty($totalerrors)) {
$status = result::ERROR;
$summary = get_string('check_antivirus_error', 'report_security', [
'errors' => $totalerrors,
'lookback' => format_time($threshold)
]);
} else if (!empty($CFG->antiviruses)) {
$status = result::OK;
// Fetch count of enabled antiviruses (we don't care about which ones).
$totalantiviruses = !empty($CFG->antiviruses) ? count(explode(',', $CFG->antiviruses)) : 0;
$summary = get_string('check_antivirus_ok', 'report_security', [
'scanners' => $totalantiviruses,
'lookback' => format_time($threshold)
]);
}
return new result($status, $summary, $details);
}
}

View File

@ -96,6 +96,7 @@ class manager {
$checks = [
new environment\environment(),
new environment\upgradecheck(),
new environment\antivirus(),
];
// Any plugin can add status checks to this report by implementing a callback

View File

@ -0,0 +1,69 @@
<?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/>.
namespace core\event;
defined('MOODLE_INTERNAL') || die();
/**
* Antivirus scan data error event
*
* @package core
* @author Kevin Pham <kevinpham@catalyst-au.net>
* @copyright Catalyst IT, 2021
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class antivirus_scan_data_error 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('dataerrordesc', 'antivirus');
}
}
/**
* Return event name
*
* @return string name
* @throws \coding_exception
*/
public static function get_name() {
return get_string('dataerrorname', 'antivirus');
}
/**
* Return event report link
* @return \moodle_url
* @throws \moodle_exception
*/
public function get_url() {
return new \moodle_url('/report/infectedfiles/index.php');
}
}

View File

@ -0,0 +1,70 @@
<?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/>.
namespace core\event;
defined('MOODLE_INTERNAL') || die();
/**
* Antivirus scan file error event
*
* @package core
* @author Kevin Pham <kevinpham@catalyst-au.net>
* @copyright Catalyst IT, 2021
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class antivirus_scan_file_error 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('fileerrordesc', 'antivirus');
}
}
/**
* Return event name
*
* @return string name
* @throws \coding_exception
*/
public static function get_name() {
return get_string('fileerrorname', 'antivirus');
}
/**
* Return event report link
* @return \moodle_url
* @throws \moodle_exception
*/
public function get_url() {
return new \moodle_url('/report/infectedfiles/index.php');
}
}

View File

@ -14,19 +14,22 @@
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/testable_antivirus.php');
/**
* Tests for antivirus manager.
*
* @package core_antivirus
* @category phpunit
* @category test
* @copyright 2016 Ruslan Kabalin, Lancaster University.
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class antivirus_test extends advanced_testcase {
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/fixtures/testable_antivirus.php');
class core_antivirus_testcase extends advanced_testcase {
/**
* @var string Path to the tempfile created for use with AV scanner tests
*/
protected $tempfile;
protected function setUp(): void {
@ -42,6 +45,28 @@ class core_antivirus_testcase extends advanced_testcase {
touch($this->tempfile);
}
/**
* Enable logging.
*
* @return void
*/
protected function enable_logging() {
$this->preventResetByRollback();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
set_config('buffersize', 0, 'logstore_standard');
set_config('logguests', 1, 'logstore_standard');
}
/**
* Return check api status for the antivirus check.
*
* @return string Based on status of \core\check\result.
*/
protected function get_check_api_antivirus_status_result() {
$av = new \core\check\environment\antivirus();
return $av->get_result()->status;
}
protected function tearDown(): void {
@unlink($this->tempfile);
}
@ -70,6 +95,57 @@ class core_antivirus_testcase extends advanced_testcase {
$this->assertFileExists($this->tempfile);
}
// Check API for NA status i.e. when no scanners are enabled.
public function test_antivirus_check_na() {
global $CFG;
$CFG->antiviruses = '';
// Enable logs.
$this->enable_logging();
set_config('enabled_stores', 'logstore_standard', 'tool_log');
// Run mock scanning.
$this->assertFileExists($this->tempfile);
$this->assertEmpty(\core\antivirus\manager::scan_file($this->tempfile, 'OK', true));
$this->assertEquals(\core\check\result::NA, $this->get_check_api_antivirus_status_result());
// File expected to remain in place.
$this->assertFileExists($this->tempfile);
}
// Check API for UNKNOWN status i.e. when the system's logstore reader is not '\core\log\sql_internal_table_reader'.
public function test_antivirus_check_unknown() {
// Run mock scanning.
$this->assertFileExists($this->tempfile);
$this->assertEmpty(\core\antivirus\manager::scan_file($this->tempfile, 'OK', true));
$this->assertEquals(\core\check\result::UNKNOWN, $this->get_check_api_antivirus_status_result());
// File expected to remain in place.
$this->assertFileExists($this->tempfile);
}
// Check API for OK status i.e. antivirus enabled, logstore is ok, no scanner issues occurred recently.
public function test_antivirus_check_ok() {
// Enable logs.
$this->enable_logging();
// Run mock scanning.
$this->assertFileExists($this->tempfile);
$this->assertEmpty(\core\antivirus\manager::scan_file($this->tempfile, 'OK', true));
$this->assertEquals(\core\check\result::OK, $this->get_check_api_antivirus_status_result());
// File expected to remain in place.
$this->assertFileExists($this->tempfile);
}
// Check API for ERROR status i.e. scanner issue within a certain timeframe/threshold.
public function test_antivirus_check_error() {
global $USER, $DB;
// Enable logs.
$this->enable_logging();
// Set threshold / lookback.
// Run mock scanning.
$this->assertFileExists($this->tempfile);
$this->assertEmpty(\core\antivirus\manager::scan_file($this->tempfile, 'ERROR', true));
$this->assertEquals(\core\check\result::ERROR, $this->get_check_api_antivirus_status_result());
// File expected to remain in place.
$this->assertFileExists($this->tempfile);
}
public function test_manager_scan_file_virus() {
// Run mock scanning without deleting infected file.
$this->assertFileExists($this->tempfile);

View File

@ -66,6 +66,14 @@ $string['check_crawlers_error'] = 'Search engine access is allowed but guest acc
$string['check_crawlers_info'] = 'Search engines may enter as guests.';
$string['check_crawlers_name'] = 'Open to search engines';
$string['check_crawlers_ok'] = 'Search engine access is not enabled.';
$string['check_antivirus_details'] = 'This status checks whether or not there has been a recent error detected based on the threshold set in the main antivirus settings.';
$string['check_antivirus_error'] = '{$a->errors} errors have been detected within the last {$a->lookback}';
$string['check_antivirus_info'] = 'No antivirus scanners are currently enabled';
$string['check_antivirus_name'] = 'Antivirus';
$string['check_antivirus_ok'] = '{$a->scanners} antivirus scanner(s) enabled, no issues have been detected in the last {$a->lookback}';
$string['check_antivirus_logstore_not_supported'] = 'Unable to verify state of antivirus scanners due to the type of log store chosen';
$string['check_dotfiles_info'] = 'All dotfiles except /.well-known/* should not be public';
$string['check_dirindex_info'] = 'Directory index should not be enabled';
$string['check_guestrole_details'] = '<p>The guest role is used for guests, not logged in users and temporary guest course access. Please make sure no risky capabilities are allowed in this role.</p>

View File

@ -24,7 +24,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2021052500; // The current plugin version (Date: YYYYMMDDXX).
$plugin->version = 2021052501; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2021052500; // Requires this Moodle version.
$plugin->component = 'report_status'; // Full name of the plugin (used for diagnostics).