From e8e4a5706b7ea6b63d67f69e4ca1ca590c86a52d Mon Sep 17 00:00:00 2001 From: Daniel Seifert Date: Mon, 3 Jun 2024 08:52:22 +0200 Subject: [PATCH] initial --- LICENSE.md | 21 +++ composer.json | 38 +++++ src/DMARCCheck.php | 111 +++++++++++++ src/Enum/DMARCMechanism.php | 30 ++++ src/Mechanism/AbstractMechanism.php | 49 ++++++ src/Mechanism/DMARC/DkimAlignment.php | 33 ++++ src/Mechanism/DMARC/ForensicReportOptions.php | 48 ++++++ src/Mechanism/DMARC/Percentage.php | 23 +++ src/Mechanism/DMARC/RejectPolicy.php | 35 ++++ .../DMARC/ReportUriAggregateData.php | 31 ++++ src/Mechanism/DMARC/ReportUriForensicData.php | 31 ++++ src/Mechanism/DMARC/ReportingFormat.php | 23 +++ src/Mechanism/DMARC/ReportingInterval.php | 23 +++ src/Mechanism/DMARC/SpfAlignment.php | 33 ++++ src/Mechanism/DMARC/SubRejectPolicy.php | 35 ++++ src/Model/DMARCRecord.php | 156 ++++++++++++++++++ src/Model/DMARCResult.php | 67 ++++++++ 17 files changed, 787 insertions(+) create mode 100644 LICENSE.md create mode 100644 composer.json create mode 100644 src/DMARCCheck.php create mode 100644 src/Enum/DMARCMechanism.php create mode 100644 src/Mechanism/AbstractMechanism.php create mode 100644 src/Mechanism/DMARC/DkimAlignment.php create mode 100644 src/Mechanism/DMARC/ForensicReportOptions.php create mode 100644 src/Mechanism/DMARC/Percentage.php create mode 100644 src/Mechanism/DMARC/RejectPolicy.php create mode 100644 src/Mechanism/DMARC/ReportUriAggregateData.php create mode 100644 src/Mechanism/DMARC/ReportUriForensicData.php create mode 100644 src/Mechanism/DMARC/ReportingFormat.php create mode 100644 src/Mechanism/DMARC/ReportingInterval.php create mode 100644 src/Mechanism/DMARC/SpfAlignment.php create mode 100644 src/Mechanism/DMARC/SubRejectPolicy.php create mode 100644 src/Model/DMARCRecord.php create mode 100644 src/Model/DMARCResult.php diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7e38837 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 D3 Data Development (Inh. Thomas Dartsch) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5181b46 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "d3/mailauthenticationcheck", + "description": "Checks for configured mail athentication methods like SPF, DKIM and DMARC", + "type": "library", + "keywords": [ + "SPF", + "DKIM", + "DMARC", + "e-mail", + "dns", + "record", + "authentication" + ], + "authors": [ + { + "name": "D3 Data Development (Inh. Thomas Dartsch)", + "email": "info@shopmodule.com", + "homepage": "https://www.d3data.de", + "role": "Owner" + } + ], + "support": { + "email": "support@shopmodule.com" + }, + "homepage": "https://www.oxidmodule.com/", + "license": [ + "MIT" + ], + "require": { + "php": ">=7.4", + "mika56/spfcheck": "^2.1.1" + }, + "autoload": { + "psr-4": { + "D3\\MailAuthenticationCheck\\": "src" + } + } +} diff --git a/src/DMARCCheck.php b/src/DMARCCheck.php new file mode 100644 index 0000000..73db8ae --- /dev/null +++ b/src/DMARCCheck.php @@ -0,0 +1,111 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck; + +use D3\MailAuthenticationCheck\Mechanism\DMARC\RejectPolicy; +use D3\MailAuthenticationCheck\Model\DMARCRecord as Record; +use D3\MailAuthenticationCheck\Model\DMARCResult as Result; +use Mika56\SPFCheck\DNS\DNSRecordGetterInterface; +use Mika56\SPFCheck\Exception\DNSLookupException; +use Mika56\SPFCheck\Model\Query; + +class DMARCCheck +{ + protected DNSRecordGetterInterface $DNSRecordGetter; + + public function __construct(DNSRecordGetterInterface $DNSRecordGetter) + { + $this->DNSRecordGetter = $DNSRecordGetter; + } + + /** + * @param string $domainName + * @return Record[] + * @throws DNSLookupException + */ + public function getDomainDMARCRecords(string $domainName): array + { + $result = []; + + if (!str_starts_with($domainName, '_dmarc.')) { + $domainName = '_dmarc.'.$domainName; + } + + $records = $this->DNSRecordGetter->resolveTXT($domainName); + + foreach ($records as $record) { + $txt = strtolower($record); + // An DMARC record can be empty (default policy) + if ($txt == 'v=dmarc1;' || str_starts_with($txt, 'v=dmarc1; ')) { + $result[] = new Record($record); + } + } + + return $result; + } + + public function getResult(Query $query): Result + { + return $this->doGetResult($query); + } + + private function doGetResult(Query $query): Result + { + $domainName = $query->getDomainName(); + + $result??= new Result(); + + if(empty($domainName)) { + $result->setResult(Result::NONE); + return $result; + } + + try { + $records = $this->getDomainDMARCRecords( $domainName); + } catch (DNSLookupException $e) { + $result->setResult(Result::TEMPERROR); + + return $result; + } + + if (count($records) == 0) { + $result->setResult(Result::NONE); + + return $result; + } + if (count($records) > 1) { + $result->setResult(Result::PERMERROR); + + return $result; + } + + $record = $records[0]; + $result->setRecord($record); + if (!$record->isValid()) { + $result->setResult(Result::PERMERROR); + + return $result; + } + + foreach ($record->getTerms() as $term) { + if ( $term instanceof RejectPolicy) { + $result->setResult($term->getValue()); + } + } + + return $result; + } +} \ No newline at end of file diff --git a/src/Enum/DMARCMechanism.php b/src/Enum/DMARCMechanism.php new file mode 100644 index 0000000..362da89 --- /dev/null +++ b/src/Enum/DMARCMechanism.php @@ -0,0 +1,30 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Enum; + +abstract class DMARCMechanism +{ + public const REJECT_POLICY = 'p'; + public const SUB_REJECT_POLICY = 'sp'; + public const REPORT_URI_AGGREGATE = 'rua'; + public const REPORT_URI_FORENSIC = 'ruf'; + public const SPF_ALIGNMENT = 'aspf'; + public const DKIM_ALIGNMENT = 'adkim'; + public const REPORTING_INTERVAL = 'ri'; + public const REPORTING_FORMAT = 'rf'; + public const FORENSIC_REPORT_OPTIONS = 'fo'; + public const PERCENTAGE = 'pct'; +} diff --git a/src/Mechanism/AbstractMechanism.php b/src/Mechanism/AbstractMechanism.php new file mode 100644 index 0000000..c978c79 --- /dev/null +++ b/src/Mechanism/AbstractMechanism.php @@ -0,0 +1,49 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism; + +use Mika56\SPFCheck\Mechanism\AbstractMechanism as SpfAbstractMechanism; + +abstract class AbstractMechanism extends SpfAbstractMechanism +{ + private string $qualifier; + private string $content; + + /** + * @param string $rawTerm + * @param string $qualifier + * @param string $termContent + */ + public function __construct(string $rawTerm, string $qualifier, string $termContent) + { + parent::__construct($rawTerm, $qualifier); + $this->qualifier = $qualifier; + $this->content = $termContent; + } + + /** + * @return string + */ + public function getQualifier(): string + { + return $this->qualifier; + } + + public function __toString(): string + { + return $this->content; + } +} diff --git a/src/Mechanism/DMARC/DkimAlignment.php b/src/Mechanism/DMARC/DkimAlignment.php new file mode 100644 index 0000000..09469ce --- /dev/null +++ b/src/Mechanism/DMARC/DkimAlignment.php @@ -0,0 +1,33 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class DkimAlignment extends AbstractMechanism +{ + public const DEFAULT = 'r'; + + public function isStrict(): bool + { + return strtolower((string) $this) === 's'; + } + + public function isRelaxed(): bool + { + return !$this->isStrict(); + } +} diff --git a/src/Mechanism/DMARC/ForensicReportOptions.php b/src/Mechanism/DMARC/ForensicReportOptions.php new file mode 100644 index 0000000..0f44081 --- /dev/null +++ b/src/Mechanism/DMARC/ForensicReportOptions.php @@ -0,0 +1,48 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class ForensicReportOptions extends AbstractMechanism +{ + public const DEFAULT = '0'; + + public function getList(): array + { + return explode(':', trim(strtolower((string) $this))); + } + + public function SpfAndDkimAlignmentFailed(): bool + { + return in_array('0', $this->getList()); + } + + public function SpfOrDkimAlignmentFailed(): bool + { + return in_array('1', $this->getList()); + } + + public function DkimFailed(): bool + { + return in_array('d', $this->getList()); + } + + public function SpfFailed(): bool + { + return in_array('s', $this->getList()); + } +} diff --git a/src/Mechanism/DMARC/Percentage.php b/src/Mechanism/DMARC/Percentage.php new file mode 100644 index 0000000..32a5b4e --- /dev/null +++ b/src/Mechanism/DMARC/Percentage.php @@ -0,0 +1,23 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class Percentage extends AbstractMechanism +{ + public const DEFAULT = 100; +} diff --git a/src/Mechanism/DMARC/RejectPolicy.php b/src/Mechanism/DMARC/RejectPolicy.php new file mode 100644 index 0000000..5fea2f7 --- /dev/null +++ b/src/Mechanism/DMARC/RejectPolicy.php @@ -0,0 +1,35 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; +use D3\MailAuthenticationCheck\Model\DMARCResult; + +class RejectPolicy extends AbstractMechanism +{ + public function getValue(): string + { + switch (strtolower((string) $this)) { + case 'quarantine': + return DMARCResult::REJECT_QUARANTINE; + case 'reject': + return DMARCResult::REJECT_REJECT; + case 'none': + default: + return DMARCResult::REJECT_NONE; + } + } +} diff --git a/src/Mechanism/DMARC/ReportUriAggregateData.php b/src/Mechanism/DMARC/ReportUriAggregateData.php new file mode 100644 index 0000000..07ab4ac --- /dev/null +++ b/src/Mechanism/DMARC/ReportUriAggregateData.php @@ -0,0 +1,31 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class ReportUriAggregateData extends AbstractMechanism +{ + public function getList(): array + { + return array_map( + function($item) { + return preg_replace('@^mailto:@', '', $item); + }, + explode(',', strtolower((string) $this)) + ); + } +} diff --git a/src/Mechanism/DMARC/ReportUriForensicData.php b/src/Mechanism/DMARC/ReportUriForensicData.php new file mode 100644 index 0000000..c7b02a7 --- /dev/null +++ b/src/Mechanism/DMARC/ReportUriForensicData.php @@ -0,0 +1,31 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class ReportUriForensicData extends AbstractMechanism +{ + public function getList(): array + { + return array_map( + function($item) { + return preg_replace('@^mailto:@', '', $item); + }, + explode(',', strtolower((string) $this)) + ); + } +} diff --git a/src/Mechanism/DMARC/ReportingFormat.php b/src/Mechanism/DMARC/ReportingFormat.php new file mode 100644 index 0000000..e55a6aa --- /dev/null +++ b/src/Mechanism/DMARC/ReportingFormat.php @@ -0,0 +1,23 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class ReportingFormat extends AbstractMechanism +{ + public const DEFAULT = 'afrf'; +} diff --git a/src/Mechanism/DMARC/ReportingInterval.php b/src/Mechanism/DMARC/ReportingInterval.php new file mode 100644 index 0000000..2a39781 --- /dev/null +++ b/src/Mechanism/DMARC/ReportingInterval.php @@ -0,0 +1,23 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class ReportingInterval extends AbstractMechanism +{ + public const DEFAULT = 86400; +} diff --git a/src/Mechanism/DMARC/SpfAlignment.php b/src/Mechanism/DMARC/SpfAlignment.php new file mode 100644 index 0000000..d1fd94a --- /dev/null +++ b/src/Mechanism/DMARC/SpfAlignment.php @@ -0,0 +1,33 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; + +class SpfAlignment extends AbstractMechanism +{ + public const DEFAULT = 'r'; + + public function isStrict(): bool + { + return strtolower((string) $this) === 's'; + } + + public function isRelaxed(): bool + { + return !$this->isStrict(); + } +} diff --git a/src/Mechanism/DMARC/SubRejectPolicy.php b/src/Mechanism/DMARC/SubRejectPolicy.php new file mode 100644 index 0000000..9475c45 --- /dev/null +++ b/src/Mechanism/DMARC/SubRejectPolicy.php @@ -0,0 +1,35 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Mechanism\DMARC; + +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; +use D3\MailAuthenticationCheck\Model\DMARCResult; + +class SubRejectPolicy extends AbstractMechanism +{ + public function getValue(): string + { + switch (strtolower((string) $this)) { + case 'quarantine': + return DMARCResult::REJECT_QUARANTINE; + case 'reject': + return DMARCResult::REJECT_REJECT; + case 'none': + default: + return DMARCResult::REJECT_NONE; + } + } +} diff --git a/src/Model/DMARCRecord.php b/src/Model/DMARCRecord.php new file mode 100644 index 0000000..a644c51 --- /dev/null +++ b/src/Model/DMARCRecord.php @@ -0,0 +1,156 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Model; + +use D3\MailAuthenticationCheck\Enum\DMARCMechanism as Mechanism; +use D3\MailAuthenticationCheck\Mechanism\AbstractMechanism; +use D3\MailAuthenticationCheck\Mechanism\DMARC\DkimAlignment; +use D3\MailAuthenticationCheck\Mechanism\DMARC\Percentage; +use D3\MailAuthenticationCheck\Mechanism\DMARC\ReportingFormat; +use D3\MailAuthenticationCheck\Mechanism\DMARC\ReportingInterval; +use D3\MailAuthenticationCheck\Mechanism\DMARC\SpfAlignment; +use D3\MailAuthenticationCheck\Mechanism\DMARC\ForensicReportOptions; +use D3\MailAuthenticationCheck\Mechanism\DMARC\RejectPolicy; +use D3\MailAuthenticationCheck\Mechanism\DMARC\ReportUriAggregateData; +use D3\MailAuthenticationCheck\Mechanism\DMARC\ReportUriForensicData; +use D3\MailAuthenticationCheck\Mechanism\DMARC\SubRejectPolicy; +use LogicException; + +class DMARCRecord +{ + private string $rawRecord; + + public function __construct(string $rawRecord = '') + { + $this->rawRecord = $rawRecord; + } + + public function getRawRecord(): string + { + return $this->rawRecord; + } + + /** + * @return AbstractMechanism[] + */ + public function getTerms(): iterable + { + $terms = explode(' ', $this->rawRecord); + + array_shift($terms); // Remove first part (v=DMARC1) + + foreach ($terms as $term) { + if(empty($term)) { + continue; + } + + preg_match('`^(?(p|rua|ruf|sp|aspf|adkim|fo|ri|rf|pct))=(?[^;]*);*$`U', $term, $matches); + + switch(strtolower($matches['qualifier'])) { + case Mechanism::REJECT_POLICY: + yield new RejectPolicy($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::SUB_REJECT_POLICY: + yield new SubRejectPolicy($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::REPORT_URI_AGGREGATE: + yield new ReportUriAggregateData($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::REPORT_URI_FORENSIC: + yield new ReportUriForensicData($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::SPF_ALIGNMENT: + yield new SpfAlignment($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::DKIM_ALIGNMENT: + yield new DkimAlignment($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::REPORTING_INTERVAL: + yield new ReportingInterval($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::REPORTING_FORMAT: + yield new ReportingFormat($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::FORENSIC_REPORT_OPTIONS: + yield new ForensicReportOptions($term, $matches['qualifier'], $matches['term']); + break; + case Mechanism::PERCENTAGE: + yield new Percentage($term, $matches['qualifier'], $matches['term']); + break; + default: + throw new LogicException('Unknown mechanism '.$matches['qualifier']); + } + } + } + + public function isValid(): bool + { + return (bool) preg_match( + '/(^v=DMARC1;)?( +(p=(none|quarantine|reject);|rua=mailto:([^;]*);|ruf=mailto:([^;]*);|sp=(none|quarantine|reject);|aspf=s;|adkim=s;|fo=((?!:)(:?\w+)+);))/i', + $this->rawRecord + ); + } + + /** + * @return RejectPolicy + * @throws LogicException + */ + public function getRejectPolicy(): RejectPolicy + { + /** @var RejectPolicy $mechanism */ + $mechanism = $this->getTerm(RejectPolicy::class); + return $mechanism; + } + + /** + * @return ReportUriAggregateData + * @throws LogicException + */ + public function getReportUriAggregate(): ReportUriAggregateData + { + /** @var ReportUriAggregateData $mechanism */ + $mechanism = $this->getTerm(ReportUriAggregateData::class); + return $mechanism; + } + + /** + * @return ReportUriForensicData + * @throws LogicException + */ + public function getReportUriForensic(): ReportUriForensicData + { + /** @var ReportUriForensicData $mechanism */ + $mechanism = $this->getTerm(ReportUriForensicData::class); + return $mechanism; + } + + /** + * @param string $className + * + * @return AbstractMechanism + * @throws LogicException + */ + public function getTerm(string $className): AbstractMechanism + { + foreach ($this->getTerms() as $term) { + if ( $term instanceof $className ) { + return $term; + } + } + + throw new LogicException('undefined term '.$className); + } +} diff --git a/src/Model/DMARCResult.php b/src/Model/DMARCResult.php new file mode 100644 index 0000000..3d53026 --- /dev/null +++ b/src/Model/DMARCResult.php @@ -0,0 +1,67 @@ + + * @link https://www.oxidmodule.com + */ + +declare(strict_types=1); + +namespace D3\MailAuthenticationCheck\Model; + +use D3\MailAuthenticationCheck\Model\DMARCRecord as Record; + +class DMARCResult +{ + public const REJECT_NONE = 'none'; + public const REJECT_QUARANTINE = 'quarantine'; + public const REJECT_REJECT = 'reject'; + + public const NONE = 'None'; + public const TEMPERROR = 'TempError'; + public const PERMERROR = 'PermError'; + + private string $result; + + protected Record $record; + + public function hasResult(): bool + { + return isset($this->result); + } + + public function getResult(): string + { + return $this->result; + } + + /** + * @internal + */ + public function setRecord(Record $record): self + { + $this->record = $record; + + return $this; + } + + public function getRecord(): Record + { + return $this->record ?? new Record(''); + } + + /** + * @internal + */ + public function setResult(string $result): self + { + $this->result = $result; + return $this; + } +}