Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.88% covered (success)
98.88%
88 / 89
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
DnsSpfDkimProvider
98.86% covered (success)
98.86%
87 / 88
85.71% covered (warning)
85.71%
6 / 7
25
0.00% covered (danger)
0.00%
0 / 1
 inspect
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
11
 buildInvalidDomainResult
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 buildSpfFailResult
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 resolveDkimStatus
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 buildVerificationSummary
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 queryDnsRecords
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 findTxtRecord
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Integrations\Infrastructure\Provider;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Domain\Contract\SecurityCheckResult;
12use Throwable;
13
14/**
15 * Native DNS & Authentication-Results Inspector for SPF, DKIM, and DMARC verification.
16 */
17final class DnsSpfDkimProvider
18{
19    private const string PROVIDER_NAME = 'DNS / SPF / DKIM Inspector';
20
21    /**
22     * Inspects sender email domain and authentication headers for SPF, DKIM, and DMARC compliance.
23     *
24     * @param string                $senderDomain Sender domain name (e.g. 'company.com').
25     * @param string|null           $senderIp     Optional sender IP address.
26     * @param array<string, string> $headers      Email message headers.
27     * @return SecurityCheckResult
28     */
29    public function inspect(string $senderDomain, ?string $senderIp = null, array $headers = []): SecurityCheckResult
30    {
31        $domain = strtolower(trim($senderDomain));
32        if ($domain === '' || !str_contains($domain, '.')) {
33            return $this->buildInvalidDomainResult($domain);
34        }
35
36        $authParts = [];
37        if (!empty($headers['authentication-results'])) {
38            $authParts[] = (string) $headers['authentication-results'];
39        }
40        if (!empty($headers['received-spf'])) {
41            $authParts[] = (string) $headers['received-spf'];
42        }
43        $authHeader = strtolower(implode(' ', $authParts));
44        $headerSpfPass = str_contains($authHeader, 'spf=pass');
45        $headerSpfFail = str_contains($authHeader, 'spf=fail') || str_contains($authHeader, 'spf=softfail');
46        $headerDkimPass = str_contains($authHeader, 'dkim=pass');
47        $headerDkimFail = str_contains($authHeader, 'dkim=fail');
48
49        if ($headerSpfFail) {
50            return $this->buildSpfFailResult($domain, $authHeader, $headerDkimPass, $headerDkimFail);
51        }
52
53        [$spfRecord, $dmarcRecord] = $this->queryDnsRecords($domain);
54
55        $hasSpf = $spfRecord !== null || $headerSpfPass;
56        $hasDmarc = $dmarcRecord !== null;
57        $dkimStatus = $this->resolveDkimStatus($headerDkimPass, $headerDkimFail, 'unknown');
58
59        $isSafe = $hasSpf && ($dkimStatus !== 'fail');
60        $status = $isSafe ? 'safe' : 'suspicious';
61        $summary = $this->buildVerificationSummary($domain, $isSafe, $hasDmarc);
62
63        return new SecurityCheckResult(
64            type: 'spf_dkim',
65            target: $domain,
66            isSafe: $isSafe,
67            status: $status,
68            summary: $summary,
69            provider: self::PROVIDER_NAME,
70            details: [
71                'spf_record'   => $spfRecord,
72                'dmarc_record' => $dmarcRecord,
73                'dkim_status'  => $dkimStatus,
74                'sender_ip'    => $senderIp,
75            ],
76            cachedTtl: 43200,
77            threatScore: $isSafe ? 0 : 50
78        );
79    }
80
81    private function buildInvalidDomainResult(string $domain): SecurityCheckResult
82    {
83        return new SecurityCheckResult(
84            type: 'spf_dkim',
85            target: $domain,
86            isSafe: false,
87            status: 'suspicious',
88            summary: 'Sender domain is invalid or does not have a valid FQDN record',
89            provider: self::PROVIDER_NAME,
90            details: ['reason' => 'Invalid domain format'],
91            cachedTtl: 3600,
92            threatScore: 50
93        );
94    }
95
96    private function buildSpfFailResult(
97        string $domain,
98        string $authHeader,
99        bool $headerDkimPass,
100        bool $headerDkimFail
101    ): SecurityCheckResult {
102        $dkim = $this->resolveDkimStatus($headerDkimPass, $headerDkimFail, 'none');
103
104        return new SecurityCheckResult(
105            type: 'spf_dkim',
106            target: $domain,
107            isSafe: false,
108            status: 'dangerous',
109            summary: "SPF FAIL: Sender server is not authorized to send on behalf of {$domain}",
110            provider: self::PROVIDER_NAME,
111            details: [
112                'spf'         => 'fail',
113                'dkim'        => $dkim,
114                'auth_header' => $authHeader,
115            ],
116            cachedTtl: 43200,
117            threatScore: 85
118        );
119    }
120
121    private function resolveDkimStatus(bool $headerDkimPass, bool $headerDkimFail, string $default): string
122    {
123        if ($headerDkimPass) {
124            return 'pass';
125        }
126        if ($headerDkimFail) {
127            return 'fail';
128        }
129
130        return $default;
131    }
132
133    private function buildVerificationSummary(string $domain, bool $isSafe, bool $hasDmarc): string
134    {
135        if ($isSafe) {
136            $dmarcSuffix = $hasDmarc ? ', DMARC: OK' : '';
137            return "Domain authorization verified (SPF: PASS{$dmarcSuffix})";
138        }
139
140        return "Inconclusive SPF/DMARC verification for domain {$domain}";
141    }
142
143    /**
144     * @return array{0: string|null, 1: string|null}
145     */
146    private function queryDnsRecords(string $domain): array
147    {
148        $spfRecord = null;
149        $dmarcRecord = null;
150
151        try {
152            $spfRecord = $this->findTxtRecord($domain, 'v=spf1');
153            $dmarcRecord = $this->findTxtRecord('_dmarc.' . $domain, 'v=dmarc1');
154        } catch (Throwable) {
155            // Gracefully ignore local DNS timeout errors
156        }
157
158        return [$spfRecord, $dmarcRecord];
159    }
160
161    private function findTxtRecord(string $hostname, string $prefix): ?string
162    {
163        $records = @dns_get_record($hostname, DNS_TXT);
164        if (!is_array($records)) {
165            return null;
166        }
167
168        foreach ($records as $rec) {
169            $txt = (string) ($rec['txt'] ?? $rec['entries'][0] ?? '');
170            if (str_starts_with(strtolower($txt), $prefix)) {
171                return $txt;
172            }
173        }
174
175        return null;
176    }
177}