From d78bca84e8e8e30438fe9b82b654ce11c0e6541a Mon Sep 17 00:00:00 2001 From: skodak Date: Thu, 15 Jan 2009 20:16:12 +0000 Subject: [PATCH] MDL-17222 backported security report into 1.9.x --- admin/report/security/db/access.php | 36 + admin/report/security/index.php | 113 +++ admin/report/security/lib.php | 1009 +++++++++++++++++++++++++++ admin/report/security/settings.php | 3 + admin/report/security/version.php | 29 + lang/en_utf8/report_security.php | 137 ++++ theme/standard/styles_color.css | 11 + version.php | 2 +- 8 files changed, 1339 insertions(+), 1 deletion(-) create mode 100644 admin/report/security/db/access.php create mode 100644 admin/report/security/index.php create mode 100644 admin/report/security/lib.php create mode 100644 admin/report/security/settings.php create mode 100644 admin/report/security/version.php create mode 100644 lang/en_utf8/report_security.php diff --git a/admin/report/security/db/access.php b/admin/report/security/db/access.php new file mode 100644 index 00000000000..8534dd81e46 --- /dev/null +++ b/admin/report/security/db/access.php @@ -0,0 +1,36 @@ + array( + 'riskbitmask' => RISK_CONFIG, + 'captype' => 'read', + 'contextlevel' => CONTEXT_SYSTEM, + 'legacy' => array( + 'admin' => CAP_ALLOW + ), + ) +); diff --git a/admin/report/security/index.php b/admin/report/security/index.php new file mode 100644 index 00000000000..f243abbeb40 --- /dev/null +++ b/admin/report/security/index.php @@ -0,0 +1,113 @@ +dirroot.'/'.$CFG->admin.'/report/security/lib.php'); +require_once($CFG->libdir.'/adminlib.php'); + +require_login(); + +$issue = optional_param('issue', '', PARAM_FILE); // show detailed info about one issue only + +$issues = report_security_get_issue_list(); + +// test if issue valid string +if (array_search($issue, $issues, true) === false) { + $issue = ''; +} + +// Print the header. +admin_externalpage_setup('reportsecurity'); +admin_externalpage_print_header(); + +print_heading(get_string('reportsecurity', 'report_security')); + +$strok = ''.get_string('statusok', 'report_security').''; +$strinfo = ''.get_string('statusinfo', 'report_security').''; +$strwarning = ''.get_string('statuswarning', 'report_security').''; +$strserious = ''.get_string('statusserious', 'report_security').''; +$strcritical = ''.get_string('statuscritical', 'report_security').''; + +$strissue = get_string('issue', 'report_security'); +$strstatus = get_string('status', 'report_security'); +$strdesc = get_string('description', 'report_security'); +$strconfig = get_string('configuration', 'report_security'); + +$statusarr = array(REPORT_SECURITY_OK => $strok, + REPORT_SECURITY_INFO => $strinfo, + REPORT_SECURITY_WARNING => $strwarning, + REPORT_SECURITY_SERIOUS => $strserious, + REPORT_SECURITY_CRITICAL => $strcritical); + +$url = "$CFG->wwwroot/$CFG->admin/report/security/index.php"; + +if ($issue and ($result = $issue(true))) { + $table = new object(); + $table->head = array($strissue, $strstatus, $strdesc, $strconfig); + $table->size = array('30%', '10%', '50%', '10%' ); + $table->align = array('left', 'left', 'left', 'left'); + $table->width = '90%'; + $table->data = array(); + + // print detail of one issue only + $row = array(); + $row[0] = $result->name; + $row[1] = $statusarr[$result->status]; + $row[2] = $result->info; + $row[3] = is_null($result->link) ? ' ' : $result->link; + + $table->data[] = $row; + + print_table($table); + + print_box($result->details, 'generalbox boxwidthnormal boxaligncenter'); // TODO: add proper css + + print_continue($url); + +} else { + $table = new object(); + $table->head = array($strissue, $strstatus, $strdesc); + $table->size = array('30%', '10%', '60%' ); + $table->align = array('left', 'left', 'left'); + $table->width = '90%'; + $table->data = array(); + + foreach ($issues as $issue) { + $result = $issue(false); + if (!$result) { + // ignore this test + continue; + } + $row = array(); + $row[0] = "$result->name"; + $row[1] = $statusarr[$result->status]; + $row[2] = $result->info; + + $table->data[] = $row; + } + print_table($table); +} + +print_footer(); \ No newline at end of file diff --git a/admin/report/security/lib.php b/admin/report/security/lib.php new file mode 100644 index 00000000000..c8a75820af5 --- /dev/null +++ b/admin/report/security/lib.php @@ -0,0 +1,1009 @@ +libdir/adminlib.php"); + + +define('REPORT_SECURITY_OK', 'ok'); +define('REPORT_SECURITY_INFO', 'info'); +define('REPORT_SECURITY_WARNING', 'warning'); +define('REPORT_SECURITY_SERIOUS', 'serious'); +define('REPORT_SECURITY_CRITICAL', 'critical'); + + +function report_security_get_issue_list() { + return array( + 'report_security_check_globals', + 'report_security_check_unsecuredataroot', + 'report_security_check_displayerrors', + 'report_security_check_noauth', + 'report_security_check_embed', + 'report_security_check_mediafilterswf', + 'report_security_check_openprofiles', + 'report_security_check_google', + 'report_security_check_passwordpolicy', + 'report_security_check_emailchangeconfirmation', + 'report_security_check_cookiesecure', + 'report_security_check_configrw', + 'report_security_check_riskxss', + 'report_security_check_riskadmin', + 'report_security_check_defaultuserrole', + 'report_security_check_guestrole', + 'report_security_check_frontpagerole', + 'report_security_check_defaultcourserole', + 'report_security_check_courserole', + + ); +} + +///============================================= +/// Issue checks +///============================================= + + +/** + * Verifies register globals PHP setting. + * @param bool $detailed + * @return object result + */ +function report_security_check_globals($detailed=false) { + $result = new object(); + $result->issue = 'report_security_check_globals'; + $result->name = get_string('check_globals_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + if (ini_get_bool('register_globals')) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_globals_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_globals_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_globals_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies unsupported noauth setting + * @param bool $detailed + * @return object result + */ +function report_security_check_noauth($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_noauth'; + $result->name = get_string('check_noauth_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=manageauths\">".get_string('authsettings', 'admin').''; + + if (is_enabled_auth('none')) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_noauth_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_noauth_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_noauth_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies if password policy set + * @param bool $detailed + * @return object result + */ +function report_security_check_passwordpolicy($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_passwordpolicy'; + $result->name = get_string('check_passwordpolicy_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=sitepolicies\">".get_string('sitepolicies', 'admin').''; + + if (empty($CFG->passwordpolicy)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_passwordpolicy_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_passwordpolicy_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_passwordpolicy_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies sloppy embedding - this should have been removed long ago!! + * @param bool $detailed + * @return object result + */ +function report_security_check_embed($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_embed'; + $result->name = get_string('check_embed_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=sitepolicies\">".get_string('sitepolicies', 'admin').''; + + if (!empty($CFG->allowobjectembed)) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_embed_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_embed_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_embed_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies sloppy swf embedding - this should have been removed long ago!! + * @param bool $detailed + * @return object result + */ +function report_security_check_mediafilterswf($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_mediafilterswf'; + $result->name = get_string('check_mediafilterswf_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=filtersettingfiltermediaplugin\">".get_string('filtersettings', 'admin').''; + + if (!empty($CFG->textfilters)) { + $activefilters = explode(',', $CFG->textfilters); + } else { + $activefilters = array(); + } + + if (array_search('filter/mediaplugin', $activefilters) !== false and !empty($CFG->filter_mediaplugin_enable_swf)) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_mediafilterswf_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_mediafilterswf_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_mediafilterswf_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies fatal misconfiguration of dataroot + * @param bool $detailed + * @return object result + */ +function report_security_check_unsecuredataroot($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_unsecuredataroot'; + $result->name = get_string('check_unsecuredataroot_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + $insecuredataroot = is_dataroot_insecure(true); + + if ($insecuredataroot == INSECURE_DATAROOT_WARNING) { + $result->status = REPORT_SECURITY_SERIOUS; + $result->info = get_string('check_unsecuredataroot_warning', 'report_security', $CFG->dataroot); + + } else if ($insecuredataroot == INSECURE_DATAROOT_ERROR) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_unsecuredataroot_error', 'report_security', $CFG->dataroot); + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_unsecuredataroot_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_unsecuredataroot_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies disaplying of errors - problem for lib files and 3rd party code + * because we can not disable debugging in these scripts (they do not include config.php) + * @param bool $detailed + * @return object result + */ +function report_security_check_displayerrors($detailed=false) { + $result = new object(); + $result->issue = 'report_security_check_displayerrors'; + $result->name = get_string('check_displayerrors_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + if (defined('WARN_DISPLAY_ERRORS_ENABLED')) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_displayerrors_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_displayerrors_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_displayerrors_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies open profiles - originaly open by default, not anymore because spammer abused it a lot + * @param bool $detailed + * @return object result + */ +function report_security_check_openprofiles($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_openprofiles'; + $result->name = get_string('check_openprofiles_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=sitepolicies\">".get_string('sitepolicies', 'admin').''; + + if (empty($CFG->forcelogin) and empty($CFG->forceloginforprofiles)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_openprofiles_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_openprofiles_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_openprofiles_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies google access not combined with disabled guest access + * because attackers might gain guest access by modifying browser signature. + * @param bool $detailed + * @return object result + */ +function report_security_check_google($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_google'; + $result->name = get_string('check_google_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=sitepolicies\">".get_string('sitepolicies', 'admin').''; + + if (empty($CFG->opentogoogle)) { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_google_ok', 'report_security'); + } else if (!empty($CFG->guestloginbutton)) { + $result->status = REPORT_SECURITY_INFO; + $result->info = get_string('check_google_info', 'report_security'); + } else { + $result->status = REPORT_SECURITY_SERIOUS; + $result->info = get_string('check_google_error', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_google_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies email confirmation - spammers were changing mails very often + * @param bool $detailed + * @return object result + */ +function report_security_check_emailchangeconfirmation($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_emailchangeconfirmation'; + $result->name = get_string('check_emailchangeconfirmation_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=sitepolicies\">".get_string('sitepolicies', 'admin').''; + + if (empty($CFG->emailchangeconfirmation)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_emailchangeconfirmation_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_emailchangeconfirmation_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_emailchangeconfirmation_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies if https enabled only secure cookies allowed, + * this prevents redirections and sending of cookies to unsecure port. + * @param bool $detailed + * @return object result + */ +function report_security_check_cookiesecure($detailed=false) { + global $CFG; + + if (strpos($CFG->wwwroot, 'https://') !== 0) { + return null; + } + + $result = new object(); + $result->issue = 'report_security_check_cookiesecure'; + $result->name = get_string('check_cookiesecure_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=httpsecurity\">".get_string('httpsecurity', 'admin').''; + + if (empty($CFG->cookiesecure)) { + $result->status = REPORT_SECURITY_SERIOUS; + $result->info = get_string('check_cookiesecure_error', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_cookiesecure_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_cookiesecure_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies config.php is not writable anymore after installation, + * config files were changed on several outdated server. + * @param bool $detailed + * @return object result + */ +function report_security_check_configrw($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_configrw'; + $result->name = get_string('check_configrw_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + if (is_writable($CFG->dirroot.'/config.php')) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_configrw_warning', 'report_security'); + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_configrw_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_configrw_details', 'report_security'); + } + + return $result; +} + +/** + * Lists all users with XSS risk, it would be great to combine this with risk trusts in user table, + * unfortunately nobody implemented user trust UI yet :-( + * @param bool $detailed + * @return object result + */ +function report_security_check_riskxss($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_riskxss'; + $result->name = get_string('check_riskxss_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = REPORT_SECURITY_WARNING; + $result->link = null; + + $sqlfrom = "FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + JOIN {$CFG->prefix}context c ON c.id = rc.contextid + JOIN {$CFG->prefix}context sc ON (sc.path = c.path OR sc.path LIKE ".sql_concat('c.path', "'/%'").") + JOIN {$CFG->prefix}role_assignments ra ON (ra.contextid = sc.id AND ra.roleid = rc.roleid) + JOIN {$CFG->prefix}user u ON u.id = ra.userid + WHERE ".sql_bitand('cap.riskbitmask', RISK_XSS)." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND u.deleted = 0"; + + $count = count_records_sql("SELECT COUNT(DISTINCT u.id) $sqlfrom"); + + $result->info = get_string('check_riskxss_warning', 'report_security', $count); + + if ($detailed) { + $users = get_records_sql("SELECT DISTINCT u.id, u.firstname, u.lastname, u.picture, u.imagealt $sqlfrom"); + foreach ($users as $uid=>$user) { + $users[$uid] = fullname($user); + } + $users = implode(', ', $users); + $result->details = get_string('check_riskxss_details', 'report_security', $users); + } + + return $result; +} + +/** + * Verifies sanity of default user role. + * @param bool $detailed + * @return object result + */ +function report_security_check_defaultuserrole($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_defaultuserrole'; + $result->name = get_string('check_defaultuserrole_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=userpolicies\">".get_string('userpolicies', 'admin').'';; + + if (!$default_role = get_record('role', 'id', $CFG->defaultuserroleid)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_defaultuserrole_notset', 'report_security'); + $result->details = $result->info; + + return $result; + } + + // first test if do anything enabled - that would be really crazy! + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $default_role->id"; + + $anythingcount = count_records_sql($sql); + + // risky caps - usually very dangerous + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + WHERE ".sql_bitand('cap.riskbitmask', (RISK_XSS | RISK_CONFIG | RISK_DATALOSS))." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $default_role->id"; + + $riskycount = count_records_sql($sql); + + // default role can not have view cap in all courses - this would break moodle badly + $viewcap = record_exists('role_capabilities', 'roleid', $default_role->id, 'permission', CAP_ALLOW, 'capability', 'moodle/course:view'); + + // it may have either no or 'user' legacy type - nothing else, or else it would break during upgrades badly + $legacyok = false; + $sql = "SELECT rc.capability, 1 + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability LIKE 'moodle/legacy:%' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $default_role->id"; + $legacycaps = get_records_sql($sql); + if (!$legacycaps) { + $legacyok = true; + } else if (count($legacycaps) == 1 and isset($legacycaps['moodle/legacy:user'])) { + $legacyok = true; + } + + if ($anythingcount or $riskycount or $viewcap or !$legacyok) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_defaultuserrole_error', 'report_security', format_string($default_role->name)); + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_defaultuserrole_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_defaultuserrole_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies sanity of guest role + * @param bool $detailed + * @return object result + */ +function report_security_check_guestrole($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_guestrole'; + $result->name = get_string('check_guestrole_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=userpolicies\">".get_string('userpolicies', 'admin').'';; + + if (!$guest_role = get_record('role', 'id', $CFG->guestroleid)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_guestrole_notset', 'report_security'); + $result->details = $result->info; + + return $result; + } + + // first test if do anything enabled - that would be really crazy! + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $guest_role->id"; + + $anythingcount = count_records_sql($sql); + + // risky caps - usually very dangerous + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + WHERE ".sql_bitand('cap.riskbitmask', (RISK_XSS | RISK_CONFIG | RISK_DATALOSS))." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $guest_role->id"; + + $riskycount = count_records_sql($sql); + + // it may have either no or 'guest' legacy type - nothing else, or else it would break during upgrades badly + $legacyok = false; + $sql = "SELECT rc.capability, 1 + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability LIKE 'moodle/legacy:%' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $guest_role->id"; + $legacycaps = get_records_sql($sql); + if (!$legacycaps) { + $legacyok = true; + } else if (count($legacycaps) == 1 and isset($legacycaps['moodle/legacy:guest'])) { + $legacyok = true; + } + + if ($anythingcount or $riskycount or !$legacyok) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_guestrole_error', 'report_security', format_string($guest_role->name)); + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_guestrole_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_guestrole_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies sanity of frontpage role + * @param bool $detailed + * @return object result + */ +function report_security_check_frontpagerole($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_frontpagerole'; + $result->name = get_string('check_frontpagerole_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=frontpagesettings\">".get_string('frontpagesettings','admin').'';; + + if (!$frontpage_role = get_record('role', 'id', $CFG->defaultfrontpageroleid)) { + $result->status = REPORT_SECURITY_INFO; + $result->info = get_string('check_frontpagerole_notset', 'report_security'); + $result->details = get_string('check_frontpagerole_details', 'report_security'); + + return $result; + } + + // first test if do anything enabled - that would be really crazy! + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $frontpage_role->id"; + + $anythingcount = count_records_sql($sql); + + // risky caps - usually very dangerous + $sql = "SELECT COUNT(DISTINCT rc.contextid) + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + WHERE ".sql_bitand('cap.riskbitmask', (RISK_XSS | RISK_CONFIG | RISK_DATALOSS))." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $frontpage_role->id"; + + $riskycount = count_records_sql($sql); + + // there is no legacy role type for frontpage yet - anyway we can not allow teachers or admins there! + $sql = "SELECT rc.capability, 1 + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability LIKE 'moodle/legacy:%' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $frontpage_role->id"; + $legacycaps = get_records_sql($sql); + $legacyok = (!isset($legacycaps['moodle/legacy:teacher']) + and !isset($legacycaps['moodle/legacy:editingteacher']) + and !isset($legacycaps['moodle/legacy:coursecreator']) + and !isset($legacycaps['moodle/legacy:admin'])); + + if ($anythingcount or $riskycount or !$legacyok) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_frontpagerole_error', 'report_security', format_string($frontpage_role->name)); + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_frontpagerole_ok', 'report_security'); + } + + if ($detailed) { + $result->details = get_string('check_frontpagerole_details', 'report_security'); + } + + return $result; +} + +/** + * Verifies sanity of site default course role. + * @param bool $detailed + * @return object result + */ +function report_security_check_defaultcourserole($detailed=false) { + global $CFG; + + $problems = array(); + + $result = new object(); + $result->issue = 'report_security_check_defaultcourserole'; + $result->name = get_string('check_defaultcourserole_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = "wwwroot/$CFG->admin/settings.php?section=userpolicies\">".get_string('userpolicies', 'admin').'';; + + if ($detailed) { + $result->details = get_string('check_defaultcourserole_details', 'report_security'); + } + + if (!$student_role = get_record('role', 'id', $CFG->defaultcourseroleid)) { + $result->status = REPORT_SECURITY_WARNING; + $result->info = get_string('check_defaultcourserole_notset', 'report_security'); + $result->details = get_string('check_defaultcourserole_details', 'report_security'); + + return $result; + } + + // first test if do anything enabled - that would be really crazy! + $sql = "SELECT DISTINCT rc.contextid + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $student_role->id"; + + if ($anything_contexts = get_records_sql($sql)) { + foreach($anything_contexts as $contextid) { + if ($contextid == SYSCONTEXTID) { + $a = "$CFG->wwwroot/$CFG->admin/roles/define.php?action=view&roleid=$CFG->defaultcourseroleid"; + } else { + $a = "$CFG->wwwroot/$CFG->admin/roles/override.php?contextid=$contextid&roleid=$CFG->defaultcourseroleid"; + } + $problems[] = get_string('check_defaultcourserole_anything', 'report_security', $a); + } + } + + // risky caps - usually very dangerous + $sql = "SELECT DISTINCT rc.contextid + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + WHERE ".sql_bitand('cap.riskbitmask', (RISK_XSS | RISK_CONFIG | RISK_DATALOSS))." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $student_role->id"; + + if ($riskycontexts = get_records_sql($sql)) { + foreach($riskycontexts as $contextid=>$unused) { + if ($contextid == SYSCONTEXTID) { + $a = "$CFG->wwwroot/$CFG->admin/roles/define.php?action=view&roleid=$CFG->defaultcourseroleid"; + } else { + $a = "$CFG->wwwroot/$CFG->admin/roles/override.php?contextid=$contextid&roleid=$CFG->defaultcourseroleid"; + } + $problems[] = get_string('check_defaultcourserole_risky', 'report_security', $a); + } + } + + // course creator or administrator does not make any sense here + $sql = "SELECT rc.capability, 1 + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability LIKE 'moodle/legacy:%' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid = $student_role->id"; + $legacycaps = get_records_sql($sql); + if (isset($legacycaps['moodle/legacy:coursecreator']) or isset($legacycaps['moodle/legacy:admin'])) { + $problems[] = get_string('check_defaultcourserole_legacy', 'report_security'); + } + + if ($problems) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_defaultcourserole_error', 'report_security', format_string($student_role->name)); + if ($detailed) { + $result->details .= ""; + } + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_defaultcourserole_ok', 'report_security'); + } + + return $result; +} + +/** + * Verifies sanity of default roles in courses. + * @param bool $detailed + * @return object result + */ +function report_security_check_courserole($detailed=false) { + global $CFG, $SITE; + + $problems = array(); + + $result = new object(); + $result->issue = 'report_security_check_courserole'; + $result->name = get_string('check_courserole_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + if ($detailed) { + $result->details = get_string('check_courserole_details', 'report_security'); + } + + // get list of all student roles selected in courses excluding the default course role + $sql = "SELECT r.* + FROM {$CFG->prefix}role r + JOIN {$CFG->prefix}course c ON c.defaultrole = r.id + WHERE c.id <> $SITE->id AND r.id <> $CFG->defaultcourseroleid"; + + if (!$student_roles = get_records_sql($sql)) { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_courserole_notyet', 'report_security'); + $result->details = get_string('check_courserole_details', 'report_security'); + + return $result; + } + + $roleids = array_keys($student_roles); + + // first test if do anything enabled - that would be really crazy!!!!!! + $inroles = implode(',', $roleids); + $sql = "SELECT rc.roleid, rc.contextid + FROM {$CFG->prefix}role_capabilities rc + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid IN ($inroles) + GROUP BY rc.roleid, rc.contextid + ORDER BY rc.roleid, rc.contextid"; + + $rs = get_recordset_sql($sql); + foreach($rs as $res) { + $roleid = $res->roleid; + $contextid = $res->contextid; + if ($contextid == SYSCONTEXTID) { + $a = "$CFG->wwwroot/$CFG->admin/roles/define.php?action=view&roleid=$roleid"; + } else { + $a = "$CFG->wwwroot/$CFG->admin/roles/override.php?contextid=$contextid&roleid=$roleid"; + } + $problems[] = get_string('check_courserole_anything', 'report_security', $a); + } + $rs->close(); + + // risky caps in any level - usually very dangerous!! + $inroles = implode(',', $roleids); + $sql = "SELECT rc.roleid, rc.contextid + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}capabilities cap ON cap.name = rc.capability + WHERE ".sql_bitand('cap.riskbitmask', (RISK_XSS | RISK_CONFIG | RISK_DATALOSS))." <> 0 + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid IN ($inroles) + GROUP BY rc.roleid, rc.contextid + ORDER BY rc.roleid, rc.contextid"; + $rs = get_recordset_sql($sql); + foreach($rs as $res) { + $roleid = $res->roleid; + $contextid = $res->contextid; + if ($contextid == SYSCONTEXTID) { + $a = "$CFG->wwwroot/$CFG->admin/roles/define.php?action=view&roleid=$roleid"; + } else { + $a = "$CFG->wwwroot/$CFG->admin/roles/override.php?contextid=$contextid&roleid=$roleid"; + } + $problems[] = get_string('check_courserole_risky', 'report_security', $a); + } + $rs->close(); + + // course creator or administrator does not make any sense here! + $inroles = implode(',', $roleids); + $sql = "SELECT DISTINCT rc.roleid + FROM {$CFG->prefix}role_capabilities rc + WHERE (rc.capability = 'moodle/legacy:coursecreator' OR rc.capability = 'moodle/legacy:admin') + AND rc.permission = ".CAP_ALLOW." + AND rc.roleid IN ($inroles)"; + if ($legacys = get_records_sql($sql)) { + foreach ($legacys as $roleid=>$unused) { + $a = "$CFG->wwwroot/$CFG->admin/roles/define.php?action=view&roleid=$roleid"; + $problems[] = get_string('check_defaultcourserole_legacy', 'report_security', $a); + } + } + + + if ($problems) { + $result->status = REPORT_SECURITY_CRITICAL; + $result->info = get_string('check_courserole_error', 'report_security'); + if ($detailed) { + $result->details .= ""; + } + + } else { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_courserole_ok', 'report_security'); + } + + return $result; +} + +/** + * Lists all admins. + * @param bool $detailed + * @return object result + */ +function report_security_check_riskadmin($detailed=false) { + global $CFG; + + $result = new object(); + $result->issue = 'report_security_check_riskadmin'; + $result->name = get_string('check_riskadmin_name', 'report_security'); + $result->info = null; + $result->details = null; + $result->status = null; + $result->link = null; + + $sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.picture, u.imagealt + FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}role_assignments ra ON (ra.contextid = rc.contextid AND ra.roleid = rc.roleid) + JOIN {$CFG->prefix}user u ON u.id = ra.userid + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND u.deleted = 0 + AND rc.contextid = ".SYSCONTEXTID.""; + + $admins = get_records_sql($sql); + + $sqlfrom = "FROM {$CFG->prefix}role_capabilities rc + JOIN {$CFG->prefix}context c ON c.id = rc.contextid + JOIN {$CFG->prefix}context sc ON (sc.path = c.path OR sc.path LIKE ".sql_concat('c.path', "'/%'").") + JOIN {$CFG->prefix}role_assignments ra ON (ra.contextid = sc.id AND ra.roleid = rc.roleid) + JOIN {$CFG->prefix}user u ON u.id = ra.userid + WHERE rc.capability = 'moodle/site:doanything' + AND rc.permission = ".CAP_ALLOW." + AND u.deleted = 0 + AND ra.contextid <> ".SYSCONTEXTID.""; + + $count = count_records_sql("SELECT COUNT(DISTINCT u.id) $sqlfrom"); + + if (!$count) { + $result->status = REPORT_SECURITY_OK; + $result->info = get_string('check_riskadmin_ok', 'report_security', count($admins)); + + if ($detailed) { + foreach ($admins as $uid=>$user) { + $admins[$uid] = fullname($user); + } + $admins = implode(', ', $admins); + $result->details = get_string('check_riskadmin_detailsok', 'report_security', $admins); + } + + } else { + $result->status = REPORT_SECURITY_WARNING; + $a = (object)array('admincount'=>count($admins), 'unsupcount'=>$count); + $result->info = get_string('check_riskadmin_warning', 'report_security', $a); + + if ($detailed) { + foreach ($admins as $uid=>$user) { + $admins[$uid] = fullname($user); + } + $admins = implode(', ', $admins); + $users = get_records_sql("SELECT DISTINCT u.id, u.firstname, u.lastname, u.picture, u.imagealt $sqlfrom"); + foreach ($users as $uid=>$user) { + $users[$uid] = fullname($user); + } + $users = implode(', ', $users); + $a = (object)array('admins'=>$admins, 'unsupported'=>$users); + $result->details = get_string('check_riskadmin_detailswarning', 'report_security', $a); + } + } + + return $result; +} diff --git a/admin/report/security/settings.php b/admin/report/security/settings.php new file mode 100644 index 00000000000..71bf1488f11 --- /dev/null +++ b/admin/report/security/settings.php @@ -0,0 +1,3 @@ +add('reports', new admin_externalpage('reportsecurity', get_string('reportsecurity', 'report_security'), "$CFG->wwwroot/$CFG->admin/report/security/index.php",'report/security:view')); diff --git a/admin/report/security/version.php b/admin/report/security/version.php new file mode 100644 index 00000000000..7a7fe0954b5 --- /dev/null +++ b/admin/report/security/version.php @@ -0,0 +1,29 @@ +version = 2007101500; +$plugin->requires = 2007101533; + +?> diff --git a/lang/en_utf8/report_security.php b/lang/en_utf8/report_security.php new file mode 100644 index 00000000000..0cbf0c52a52 --- /dev/null +++ b/lang/en_utf8/report_security.php @@ -0,0 +1,137 @@ +It is recommended that the file permissions of config.php are changed after installation so that the file cannot be modified by the web server. +Please note that this measure does not improve security of the server significantly, though it may slow down or limit general exploits.

'; +$string['check_configrw_name'] = 'Writable config.php'; +$string['check_configrw_ok'] = 'config.php can not be modified by PHP scripts.'; +$string['check_configrw_warning'] = 'PHP scripts may modify config.php.'; + +$string['check_cookiesecure_details'] = '

If you enable https communication it is recommended that you also enable secure cookies. You should also add permanent redirection from http to https.

'; +$string['check_cookiesecure_error'] = 'Please enable secure cookies'; +$string['check_cookiesecure_name'] = 'Secure cookies'; +$string['check_cookiesecure_ok'] = 'Secure cookies enabled.'; + +$string['check_courserole_anything'] = 'The do anything capability must not be allowed in this context.'; +$string['check_courserole_details'] = '

Each course has one default enrolment role specified. Please make sure no risky capabilities are allowed for this role.

+

The only supported legacy type for the default course role is Student.

'; +$string['check_courserole_error'] = 'Incorrectly defined default course roles detected!'; +$string['check_courserole_legacy'] = 'Unsupported legacy type detected in the role.'; +$string['check_courserole_name'] = 'Default course roles'; +$string['check_courserole_notyet'] = 'Used only default course role.'; +$string['check_courserole_ok'] = 'Default course role definitions OK.'; +$string['check_courserole_risky'] = 'Risky capabilities detected in context.'; + +$string['check_defaultcourserole_anything'] = 'The do anything capability must not be allowed in this context.'; +$string['check_defaultcourserole_details'] = '

The default student role for course enrolment specifies the default role for courses. Please make sure no risky capabilities are allowed in this role.

+

The only supported legacy type for default role is Student.

'; +$string['check_defaultcourserole_error'] = 'Incorrectly defined default course role \"$a\" detected!'; +$string['check_defaultcourserole_legacy'] = 'Unsupported legacy type detected.'; +$string['check_defaultcourserole_name'] = 'Site default course role'; +$string['check_defaultcourserole_notset'] = 'Default role is not set.'; +$string['check_defaultcourserole_ok'] = 'Site default role definition OK.'; +$string['check_defaultcourserole_risky'] = 'Risky capabilities detected in context.'; + +$string['check_defaultuserrole_details'] = '

All logged in users are given capabilities of the default user role. Please make sure no risky capabilities are allowed in this role.

+

The only supported legacy type for the default user role is Authenticated user. The course view capability must not be enabled.

'; +$string['check_defaultuserrole_error'] = 'Incorrectly defined default user role \"$a\" detected!'; +$string['check_defaultuserrole_name'] = 'Registered user role'; +$string['check_defaultuserrole_notset'] = 'Default role is not set.'; +$string['check_defaultuserrole_ok'] = 'Registered user role definition ok.'; + +$string['check_displayerrors_details'] = '

Enabling the PHP setting display_errors is not recommended on production sites because error messages can reveal sensitive information about your server.

'; +$string['check_displayerrors_error'] = 'The PHP setting to display errors is enabled. It is recommended that this is disabled.'; +$string['check_displayerrors_name'] = 'Displaying of PHP errors'; +$string['check_displayerrors_ok'] = 'Displaying of PHP errors disabled.'; + +$string['check_emailchangeconfirmation_details'] = '

It is recommended that an email confirmation step is required when users change their email address in their profile. If disabled, spammers may try to exploit the server for resending of spam.

'; +$string['check_emailchangeconfirmation_error'] = 'Users may enter any email address.'; +$string['check_emailchangeconfirmation_name'] = 'Email change confirmation'; +$string['check_emailchangeconfirmation_ok'] = 'Confirmation of change of email address in user profile.'; + +$string['check_embed_details'] = '

Unlimited object embedding is very dangerous - any registered user may launch an XSS attack against other server users. This setting should be disabled on production servers.

'; +$string['check_embed_error'] = 'Unlimited object embedding enabled - this is very dangerous for the majority of servers.'; +$string['check_embed_name'] = 'Allow EMBED and OBJECT'; +$string['check_embed_ok'] = 'Unlimited object embedding is not allowed.'; + +$string['check_frontpagerole_details'] = '

The default frontpage role is given to all registered users for frontpage activities. Please make sure no risky capabilities are allowed for this role.

+

It is recommended that a special role is created for this purpose and a legacy type role is not used.

'; +$string['check_frontpagerole_error'] = 'Incorrectly defined frontpage role \"$a\" detected!'; +$string['check_frontpagerole_name'] = 'Frontpage role'; +$string['check_frontpagerole_notset'] = 'Frontpage role is not set.'; +$string['check_frontpagerole_ok'] = 'Frontpage role definition OK.'; + +$string['check_globals_details'] = '

Register globals is considered to be a highly insecure PHP setting.

+

register_globals=off must be set in PHP configuration. This setting is controlled by editing your php.ini, Apache/IIS configuration or .htaccess file.

'; +$string['check_globals_error'] = 'Register globals MUST be disabled. Please fix the server PHP settings immediately!'; +$string['check_globals_name'] = 'Register globals'; +$string['check_globals_ok'] = 'Register globals are disabled.'; + +$string['check_google_details'] = '

The Open to Google setting enables search engines to enter courses with guest access. There is no point in enabling this setting if guest login not allowed.

'; +$string['check_google_error'] = 'Search engines guest access allowed and guest access disabled.'; +$string['check_google_info'] = 'Search engines may enter as guests.'; +$string['check_google_name'] = 'Open to Google'; +$string['check_google_ok'] = 'Search engines guest access not enabled.'; + +$string['check_guestrole_details'] = '

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.

+

The only supported legacy type for guest role is Guest.

'; +$string['check_guestrole_error'] = 'Incorrectly defined guest role \"$a\" detected!'; +$string['check_guestrole_name'] = 'Guest role'; +$string['check_guestrole_notset'] = 'Guest role is not set.'; +$string['check_guestrole_ok'] = 'Guest role definition OK.'; + +$string['check_mediafilterswf_details'] = '

Automatic swf embedding is very dangerous - any registered user may launch an XSS attack against other server users. Please disable it on production servers.

'; +$string['check_mediafilterswf_error'] = 'Flash media filter is enabled - this is very dangerous for the majority of servers.'; +$string['check_mediafilterswf_name'] = 'Enabled .swf media filter'; +$string['check_mediafilterswf_ok'] = 'Flash media filter is not enabled.'; + +$string['check_noauth_details'] = '

The No authentication plugin is not intended for production sites. Please disable it unless this is a development test site.

'; +$string['check_noauth_error'] = 'The No authentication plugin cannot be used on production sites.'; +$string['check_noauth_name'] = 'No authentication'; +$string['check_noauth_ok'] = 'No authentication plugin is disabled.'; + +$string['check_openprofiles_details'] = '

Open user profiles can be abused by spammers. It is recommended that either Force users to login for profiles or Force users to login are enabled.

'; +$string['check_openprofiles_error'] = 'Anyone can may view user profiles without logging in.'; +$string['check_openprofiles_name'] = 'Open user profiles'; +$string['check_openprofiles_ok'] = 'Login is required before viewing user profiles.'; + +$string['check_passwordpolicy_details'] = '

It is recommended that a password policy is set, since password guessing is very often the easiest way to gain unauthorised access. +Do not make the requirements too strict though, as this can result in users not being able to remember their passwords and either forgetting them or writing them down.

'; +$string['check_passwordpolicy_error'] = 'Password policy not set.'; +$string['check_passwordpolicy_name'] = 'Password policy'; +$string['check_passwordpolicy_ok'] = 'Password policy enabled.'; + +$string['check_riskadmin_detailsok'] = '

Please verify the following list of administrators:
$a

'; +$string['check_riskadmin_detailswarning'] = '

Please verify the following list of administrators:
$a->admins

+

It is recommended to assign administrator role in system context only. Following users have unsupported admin role assignments:
$a->unsupported

'; +$string['check_riskadmin_name'] = 'Administrators'; +$string['check_riskadmin_ok'] = 'Found $a server administrator(s).'; +$string['check_riskadmin_warning'] = 'Found $a->admincount server administrators and $a->unsupcount unsupported admin role assignments.'; + +$string['check_riskxss_details'] = '

RISK_XSS denotes all dangerous capabilities that only trusted users may use.

+

Please verify following list of users and make sure that you trust them completely on this server:
$a

'; +$string['check_riskxss_name'] = 'XSS trusted users'; +$string['check_riskxss_warning'] = 'RISK_XSS - found $a users that have to be trusted.'; + +$string['check_unsecuredataroot_details'] = '

The dataroot directory must not be accessible via web. The best way to make sure the directory is not accessible is to use a directory outside the public web directory.

+

If you move the directory, you need to update the \$CFG->dataroot setting in config.php accordingly.

'; +$string['check_unsecuredataroot_error'] = 'Your dataroot directory $a is in the wrong location and is exposed to the web!'; +$string['check_unsecuredataroot_name'] = 'Insecure dataroot'; +$string['check_unsecuredataroot_ok'] = 'Dataroot directory must not be accessible via the web.'; +$string['check_unsecuredataroot_warning'] = 'Your dataroot directory $a is in the wrong location and might be exposed to the web.'; +?> \ No newline at end of file diff --git a/theme/standard/styles_color.css b/theme/standard/styles_color.css index 870e3c819e4..2d5188930a9 100644 --- a/theme/standard/styles_color.css +++ b/theme/standard/styles_color.css @@ -322,6 +322,17 @@ table.flexible .r1 { background-color: green; } +#admin-report-security-index .statuswarning { + background-color: #f0e000; +} + +#admin-report-security-index .statusserious { + background-color: #f07000; +} + +#admin-report-security-index .statuscritical { + background-color: #f00000; +} .plugincompattable td.ok { color: #008000; diff --git a/version.php b/version.php index 36ebf81e149..d37a97ab237 100644 --- a/version.php +++ b/version.php @@ -6,7 +6,7 @@ // This is compared against the values stored in the database to determine // whether upgrades should be performed (see lib/db/*.php) - $version = 2007101533.01; // YYYYMMDD = date of the 1.9 branch (don't change) + $version = 2007101533.02; // YYYYMMDD = date of the 1.9 branch (don't change) // X = release number 1.9.[0,1,2,3...] // Y.YY = micro-increments between releases