diff --git a/classes/check/dnsspf.php b/classes/check/dnsspf.php new file mode 100644 index 0000000..cf3dc00 --- /dev/null +++ b/classes/check/dnsspf.php @@ -0,0 +1,105 @@ +. +/** + * DNS Email SPF check. + * + * @package tool_heartbeat + * @author Brendan Heywood + * @copyright Catalyst IT 2024 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * + */ + +namespace tool_emailutils\check; +use core\check\check; +use core\check\result; +use tool_emailutils\dns_util; + +/** + * DNS Email SPF check. + * + * @package tool_heartbeat + * @author Brendan Heywood + * @copyright Catalyst IT 2024 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dnsspf extends check { + + /** + * 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/tool/emailutils/dkim.php'), + get_string('dkimmanager', 'tool_emailutils')); + } + + /** + * Get Result. + * + * @return result + */ + public function get_result() : result { + global $DB, $CFG; + + $url = new \moodle_url($CFG->wwwroot); + $domain = $url->get_host(); + + $details = ''; + $status = result::INFO; + $summary = ''; + + $dns = new dns_util(); + + $noreply = $dns->get_noreply(); + $details .= "

No reply email: $noreply

"; + + $noreplydomain = $dns->get_noreply_domain(); + $details .= "

No reply domain: $noreplydomain

"; + + $spf = $dns->get_spf_record(); + + // Does it have an SPF record at all? + if (empty($spf)) { + $summary = 'Missing SPF record'; + $details .= "

$domain does not have an SPF record

"; + return new result(result::ERROR, $summary, $details); + } + + $details .= "

SPF record:
$spf

"; + $status = result::OK; + $summary = 'SPF record exists'; + + $include = get_config('tool_emailutils', 'dnsspfinclude'); + if (!empty($include)) { + $present = $dns->include_present($include); + if ($present) { + $summary = "SPF record exists and has '$present' include"; + $details .= "

Expecting include: $include and matched on $present

"; + } else { + $status = result::ERROR; + $summary = "SPF record exists but is missing '$include' include"; + $details .= "

Expecting include is missing: $include

"; + } + } + + + return new result($status, $summary, $details); + } + +} diff --git a/classes/dns_util.php b/classes/dns_util.php new file mode 100644 index 0000000..2e28f2e --- /dev/null +++ b/classes/dns_util.php @@ -0,0 +1,109 @@ +. + +/** + * SPF utils + * + * @package tool_heartbeat + * @copyright Catalyst IT 2024 + * @author Brendan Heywood + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_emailutils; + +/** + * SPF utils + * + * @package tool_heartbeat + * @copyright Catalyst IT 2024 + * @author Brendan Heywood + * @copyright 2023 onwards Catalyst IT {@link http://www.catalyst-eu.net/} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dns_util { + + /** + * Get no reply + * @return string email + */ + public function get_noreply() { + global $CFG; + + return $CFG->noreplyaddress; + } + + /** + * Get no reply domain + * @return string domain + */ + public function get_noreply_domain() { + global $CFG; + + $noreplydomain = substr($CFG->noreplyaddress, strpos($CFG->noreplyaddress, '@') + 1); + return $noreplydomain; + } + + /** + * Get spf txt record contents + * @return string txt record + */ + public function get_spf_record() { + + $domain = $this->get_noreply_domain(); + $records = dns_get_record($domain, DNS_TXT); + foreach ($records as $record) { + $txt = $record['txt']; + if (substr($txt, 0, 6) == 'v=spf1') { + return $txt; + } + } + return ''; + } + + /** + * Get spf txt record contents + * @return string url + */ + public function get_mxtoolbox_spf_url() { + } + + + /** + * Returns the include if matched + * + * The include can have a wildcard and this will return the actual matched value. + * @param string include domain + * @return string matched include + */ + public function include_present(string $include) { + $txt = $this->get_spf_record(); + + $escaped = preg_quote($include); + + // Allow a * wildcard match. + $escaped = str_replace('\*', '\S*', $escaped); + $regex = "/include:($escaped)/U"; + if (preg_match($regex, $txt, $matches)) { + return $matches[1]; + } + + return ''; + } + +} + diff --git a/lang/en/tool_emailutils.php b/lang/en/tool_emailutils.php index 8360123..899c2e0 100644 --- a/lang/en/tool_emailutils.php +++ b/lang/en/tool_emailutils.php @@ -30,7 +30,8 @@ $string['bouncesreset'] = 'Bounces have been reset for the selected users'; $string['configmissing'] = 'Missing config.php setting ($CFG->handlebounces) please review config-dist.php for more information.'; $string['complaints'] = 'For a list of complaints, search for ".c.invalid"'; -$string['dkimmanager'] = 'DKIM manager'; +$string['dkimmanager'] = 'SPF & DKIM manager'; +$string['checkdnsspf'] = 'DNS Email SPF check'; $string['dkimmanagerhelp'] = '

This shows all DKIM key pairs / selectors available for email signing, including those made by this admin tool or put in place by external tools such as open-dkim. For most systems this is the end to end setup:

  1. First decide and set the $CFG->noreply email as the domain of the reply email is tied to the signing. @@ -42,6 +43,11 @@
  2. Also confirm the DKIM headers validate using a 3rd party tool, such as those provided by Gmail and most email clients
'; +$string['dnssettings'] = 'SPF / DKIM / DMARC DNS settings'; +$string['dnsspfinclude'] = 'SPF include'; +$string['dnsspfinclude_help'] = '

This is an SPF include domain which is expected to be present in the record. For example if this was set to spf.acme.org then the SPF security check would pass if the SPF record was v=spf1 include:spf.ache.org -all.

+

The * char can be used as a wildcard eg *acme.org would also match.

+'; $string['domaindefaultnoreply'] = 'Default noreply'; $string['enabled'] = 'Enabled'; $string['enabled_help'] = 'Allow the plugin to process incoming messages'; @@ -57,7 +63,7 @@ $string['privacy:metadata:tool_emailutils_list'] = 'Information.'; $string['privacy:metadata:tool_emailutils_list:userid'] = 'The ID of the user.'; $string['privacy:metadata:tool_emailutils_list:updatedid'] = 'The ID of updated user.'; -$string['pluginname'] = 'Amazon SES Complaints'; +$string['pluginname'] = 'Email utilities'; $string['resetbounces'] = 'Reset the number of bounces'; $string['sendcount'] = 'Send count'; $string['selectoractive'] = 'Active selector'; @@ -75,6 +81,6 @@ $string['selectordelete'] = 'Delete key pair'; $string['selectordeleted'] = 'Key pair has been deleted'; $string['selectordeleteconfirm'] = 'This will permanently delete this selector\'s private and public keys and is irreversable.'; -$string['settings'] = 'Settings'; +$string['settings'] = 'AWS SES settings'; $string['username'] = 'Username'; $string['username_help'] = 'HTTP Basic Auth Username'; diff --git a/lib.php b/lib.php index 12be8ed..4c86821 100644 --- a/lib.php +++ b/lib.php @@ -35,3 +35,14 @@ function tool_emailutils_bulk_user_actions() { ]; } +/** + * Security checks. + * + * @return array + */ +function tool_emailutils_security_checks() { + return [ + new \tool_emailutils\check\dnsspf(), + ]; +} + diff --git a/settings.php b/settings.php index 66d2b0d..697def3 100644 --- a/settings.php +++ b/settings.php @@ -43,6 +43,20 @@ new moodle_url('/admin/tool/emailutils/index.php') )); + // DNS check settings. + $settings = new admin_settingpage( + 'tool_emailutils_dns', + new lang_string('dnssettings', 'tool_emailutils') + ); + + $settings->add(new admin_setting_configtext( + 'tool_emailutils/dnsspfinclude', + new lang_string('dnsspfinclude', 'tool_emailutils'), + new lang_string('dnsspfinclude_help', 'tool_emailutils'), + '') + ); + $ADMIN->add('tool_emailutils', $settings); + // Plugin Settings Page. $settings = new admin_settingpage( 'tool_emailutils_options', diff --git a/version.php b/version.php index 63bc33e..c4d847f 100644 --- a/version.php +++ b/version.php @@ -33,4 +33,4 @@ 'local_aws' => 2020061500 ]; $plugin->maturity = MATURITY_STABLE; -$plugin->supported = [39, 400]; +$plugin->supported = [39, 403];