This commit is contained in:
Daniel Seifert 2024-06-03 08:52:22 +02:00
commit e8e4a5706b
17 changed files with 787 additions and 0 deletions

21
LICENSE.md Normal file
View File

@ -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.

38
composer.json Normal file
View File

@ -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"
}
}
}

111
src/DMARCCheck.php Normal file
View File

@ -0,0 +1,111 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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';
}

View File

@ -0,0 +1,49 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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();
}
}

View File

@ -0,0 +1,48 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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());
}
}

View File

@ -0,0 +1,23 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}

View File

@ -0,0 +1,35 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}
}
}

View File

@ -0,0 +1,31 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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))
);
}
}

View File

@ -0,0 +1,31 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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))
);
}
}

View File

@ -0,0 +1,23 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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';
}

View File

@ -0,0 +1,23 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}

View File

@ -0,0 +1,33 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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();
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}
}
}

156
src/Model/DMARCRecord.php Normal file
View File

@ -0,0 +1,156 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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('`^(?<qualifier>(p|rua|ruf|sp|aspf|adkim|fo|ri|rf|pct))=(?<term>[^;]*);*$`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);
}
}

67
src/Model/DMARCResult.php Normal file
View File

@ -0,0 +1,67 @@
<?php
/**
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* https://www.d3data.de
*
* @copyright (C) D3 Data Development (Inh. Thomas Dartsch)
* @author D3 Data Development - Daniel Seifert <info@shopmodule.com>
* @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;
}
}