Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.72% covered (success)
91.72%
144 / 157
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailSecurityScanner
91.67% covered (success)
91.67%
143 / 156
45.45% covered (danger)
45.45%
5 / 11
51.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 scanEmail
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
8
 checkIpWithActiveProvider
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 checkUrlWithActiveProviders
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 findUnsafeUrlResult
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 buildConsolidatedVerdict
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
12
 extractDomain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 loadActiveIntegrations
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
8.19
 getCache
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFromPersistentCache
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
5.40
 setCache
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Integrations\Domain\Contract\IntegrationProviderInterface;
12use App\Modules\Integrations\Domain\Contract\SecurityCheckResult;
13use App\Modules\Integrations\Infrastructure\Provider\AbuseIpDbProvider;
14use App\Modules\Integrations\Infrastructure\Provider\DemoMockSecurityProvider;
15use App\Modules\Integrations\Infrastructure\Provider\DnsSpfDkimProvider;
16use App\Modules\Integrations\Infrastructure\Provider\GoogleSafeBrowsingProvider;
17use App\Modules\Integrations\Infrastructure\Provider\UrlHausProvider;
18use App\Modules\Integrations\Infrastructure\Provider\VirusTotalProvider;
19use App\Modules\Integrations\Infrastructure\Security\SsrfProtectionClient;
20use Nyholm\Psr7\Factory\Psr17Factory;
21use PDO;
22use Throwable;
23use Yiisoft\Cache\CacheInterface;
24use Yiisoft\Db\Connection\ConnectionInterface;
25
26/**
27 * High-performance Email Security Inspection Orchestrator.
28 * Features time-based multi-tier caching (PSR-16) for IP, domain, and URL reputation checks.
29 */
30final class EmailSecurityScanner
31{
32    private const string CACHE_PREFIX = 'sec_v1_';
33
34    /**
35     * @var array<string, SecurityCheckResult> L1 in-memory cache to prevent redundant lookups.
36     */
37    private array $memoryCache = [];
38
39    private readonly DemoMockSecurityProvider $mockProvider;
40    private readonly AbuseIpDbProvider $abuseIpDbProvider;
41    private readonly GoogleSafeBrowsingProvider $safeBrowsingProvider;
42    private readonly VirusTotalProvider $virusTotalProvider;
43    private readonly UrlHausProvider $urlHausProvider;
44    private readonly DnsSpfDkimProvider $dnsProvider;
45
46    public function __construct(
47        private readonly PDO|ConnectionInterface|null $db = null,
48        private readonly ?CacheInterface $cache = null,
49        ?SsrfProtectionClient $httpClient = null
50    ) {
51        $client = $httpClient ?? new SsrfProtectionClient();
52        $this->mockProvider = new DemoMockSecurityProvider();
53        $this->abuseIpDbProvider = new AbuseIpDbProvider($client, $this->mockProvider);
54        $this->safeBrowsingProvider = new GoogleSafeBrowsingProvider($client, $this->mockProvider);
55        $this->virusTotalProvider = new VirusTotalProvider($client, $this->mockProvider);
56        $this->urlHausProvider = new UrlHausProvider($client, $this->mockProvider);
57        $this->dnsProvider = new DnsSpfDkimProvider();
58    }
59
60    /**
61     * Performs comprehensive asynchronous security inspection of an incoming email.
62     *
63     * @param string                $fromAddress Sender address (e.g. 'John <john@bank.com>').
64     * @param string|null           $senderIp    Sender mail server IP address.
65     * @param array<string>         $urls        Array of URLs found inside email body.
66     * @param array<string, string> $headers     Message RFC 822 headers.
67     * @return array<string, mixed> Consolidated verdict and granular breakdown.
68     */
69    public function scanEmail(
70        string $fromAddress,
71        ?string $senderIp,
72        array $urls,
73        array $headers = []
74    ): array {
75        $activeIntegrations = $this->loadActiveIntegrations();
76        $cachedHits = 0;
77        $totalItems = 0;
78        $results = [];
79
80        // 1. Inspect Sender Domain (SPF, DKIM, DMARC)
81        $senderDomain = $this->extractDomain($fromAddress);
82        if ($senderDomain !== '') {
83            $totalItems++;
84            $domainCacheKey = self::CACHE_PREFIX . 'dom_' . hash('sha256', $senderDomain);
85            $cachedDomain = $this->getCache($domainCacheKey);
86
87            if ($cachedDomain !== null) {
88                $cachedHits++;
89                $results['domain'] = $cachedDomain;
90            } else {
91                $domainResult = $this->dnsProvider->inspect($senderDomain, $senderIp, $headers);
92                $this->setCache($domainCacheKey, $domainResult, $domainResult->cachedTtl);
93                $results['domain'] = $domainResult;
94            }
95        }
96
97        // 2. Inspect Sender Server IP Address
98        if (!empty($senderIp) && filter_var($senderIp, FILTER_VALIDATE_IP)) {
99            $totalItems++;
100            $ipCacheKey = self::CACHE_PREFIX . 'ip_' . hash('sha256', $senderIp);
101            $cachedIp = $this->getCache($ipCacheKey);
102
103            if ($cachedIp !== null) {
104                $cachedHits++;
105                $results['ip'] = $cachedIp;
106            } else {
107                $ipResult = $this->checkIpWithActiveProvider($senderIp, $activeIntegrations);
108                $this->setCache($ipCacheKey, $ipResult, $ipResult->cachedTtl);
109                $results['ip'] = $ipResult;
110            }
111        }
112
113        // 3. Inspect Embedded Hyperlinks (Unique URLs)
114        $uniqueUrls = array_unique(array_filter($urls));
115        $urlResults = [];
116
117        foreach ($uniqueUrls as $url) {
118            $totalItems++;
119            $urlCacheKey = self::CACHE_PREFIX . 'url_' . hash('sha256', $url);
120            $cachedUrl = $this->getCache($urlCacheKey);
121
122            if ($cachedUrl !== null) {
123                $cachedHits++;
124                $urlResults[] = $cachedUrl;
125            } else {
126                $urlResult = $this->checkUrlWithActiveProviders($url, $activeIntegrations);
127                $this->setCache($urlCacheKey, $urlResult, $urlResult->cachedTtl);
128                $urlResults[] = $urlResult;
129            }
130        }
131
132        $results['urls'] = $urlResults;
133
134        // 4. Consolidate Overall Security Verdict
135        return $this->buildConsolidatedVerdict($results, $totalItems, $cachedHits);
136    }
137
138    /**
139     * Checks IP with active database provider or mock fallback.
140     *
141     * @param string                            $ip           IP address.
142     * @param array<string, array<string, mixed>>$integrations Active integrations list.
143     * @return SecurityCheckResult
144     */
145    private function checkIpWithActiveProvider(string $ip, array $integrations): SecurityCheckResult
146    {
147        if (isset($integrations['abuseipdb'])) {
148            $integ = $integrations['abuseipdb'];
149            return $this->abuseIpDbProvider->checkIp($ip, $integ['api_key'], $integ['config']);
150        }
151
152        if (isset($integrations['virustotal'])) {
153            $integ = $integrations['virustotal'];
154            return $this->virusTotalProvider->checkIp($ip, $integ['api_key'], $integ['config']);
155        }
156
157        return $this->mockProvider->checkIp($ip, null, []);
158    }
159
160    /**
161     * Checks URL across active URL inspection providers.
162     *
163     * @param string                            $url          Target URL.
164     * @param array<string, array<string, mixed>>$integrations Active integrations list.
165     * @return SecurityCheckResult
166     */
167    private function checkUrlWithActiveProviders(string $url, array $integrations): SecurityCheckResult
168    {
169        $unsafeResult = $this->findUnsafeUrlResult($url, $integrations);
170        if ($unsafeResult !== null) {
171            return $unsafeResult;
172        }
173
174        return $this->mockProvider->checkUrl($url, null, []);
175    }
176
177    /**
178     * @param array<string, array<string, mixed>> $integrations
179     */
180    private function findUnsafeUrlResult(string $url, array $integrations): ?SecurityCheckResult
181    {
182        $providers = [
183            'google_safe_browsing' => $this->safeBrowsingProvider,
184            'urlhaus'              => $this->urlHausProvider,
185            'virustotal'           => $this->virusTotalProvider,
186        ];
187
188        foreach ($providers as $key => $provider) {
189            if (isset($integrations[$key])) {
190                $integ = $integrations[$key];
191                $res = $provider->checkUrl($url, $integ['api_key'], $integ['config']);
192                if (!$res->isSafe) {
193                    return $res;
194                }
195            }
196        }
197
198        return null;
199    }
200
201    /**
202     * Aggregates findings into a single-line summary with green/red status.
203     *
204     * @param array<string, mixed> $results    Inspected items map.
205     * @param int                  $totalItems Total items inspected.
206     * @param int                  $cachedHits Number of items served from cache.
207     * @return array<string, mixed>
208     */
209    private function buildConsolidatedVerdict(array $results, int $totalItems, int $cachedHits): array
210    {
211        $threats = [];
212        $domainResult = $results['domain'] ?? null;
213        $ipResult = $results['ip'] ?? null;
214        $urlResults = (array) ($results['urls'] ?? []);
215
216        if ($domainResult instanceof SecurityCheckResult && !$domainResult->isSafe) {
217            $threats[] = $domainResult->summary;
218        }
219
220        if ($ipResult instanceof SecurityCheckResult && !$ipResult->isSafe) {
221            $threats[] = $ipResult->summary;
222        }
223
224        foreach ($urlResults as $uRes) {
225            if ($uRes instanceof SecurityCheckResult && !$uRes->isSafe) {
226                $threats[] = $uRes->summary;
227            }
228        }
229
230        $hasThreat = !empty($threats);
231        $totalUrls = count($urlResults);
232
233        if ($hasThreat) {
234            $firstThreat = $threats[0];
235            $headline = "Threat detected: {$firstThreat}";
236            $badgeClass = 'danger';
237            $icon = 'bi-shield-fill-exclamation';
238        } else {
239            $urlMsg = $totalUrls > 0 ? "and links ({$totalUrls})" : 'no links';
240            $headline = "Message verified: Sender, SPF/DKIM {$urlMsg} â€“ secure.";
241            $badgeClass = 'success';
242            $icon = 'bi-shield-fill-check';
243        }
244
245        return [
246            'is_safe'           => !$hasThreat,
247            'status'            => $hasThreat ? 'danger' : 'safe',
248            'badge_class'       => $badgeClass,
249            'icon'              => $icon,
250            'headline'          => $headline,
251            'threats'           => $threats,
252            'total_items'       => $totalItems,
253            'cached_items_count'=> $cachedHits,
254            'from_cache'        => $totalItems > 0 && $cachedHits === $totalItems,
255            'domain_result'     => $domainResult?->toArray(),
256            'ip_result'         => $ipResult?->toArray(),
257            'url_results'       => array_map(static fn($r) => $r->toArray(), $urlResults),
258        ];
259    }
260
261    /**
262     * Extracts pure FQDN domain name from email address.
263     *
264     * @param string $address Email address string.
265     * @return string
266     */
267    private function extractDomain(string $address): string
268    {
269        if (preg_match('/<([^>]+)>/', $address, $m)) {
270            $address = $m[1];
271        }
272
273        if (str_contains($address, '@')) {
274            $parts = explode('@', $address);
275            return strtolower(trim(end($parts)));
276        }
277
278        return '';
279    }
280
281    /**
282     * Queries database for active integrations configurations.
283     *
284     * @return array<string, array<string, mixed>>
285     */
286    private function loadActiveIntegrations(): array
287    {
288        if ($this->db === null) {
289            return [];
290        }
291
292        try {
293            if ($this->db instanceof PDO) {
294                $stmt = $this->db->query(
295                    'SELECT driver, api_key, api_url, config_parameters FROM a_mod_integrations_records ' .
296                    "WHERE status = 'active' AND special_access = 1"
297                );
298                $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
299            } else {
300                $rows = $this->db->createCommand(
301                    'SELECT driver, api_key, api_url, config_parameters FROM a_mod_integrations_records ' .
302                    "WHERE status = 'active' AND special_access = 1"
303                )->queryAll();
304            }
305
306            $map = [];
307            foreach ($rows as $row) {
308                $driver = (string) $row['driver'];
309                $config = [];
310                if (!empty($row['config_parameters'])) {
311                    $decoded = json_decode((string) $row['config_parameters'], true);
312                    if (is_array($decoded)) {
313                        $config = $decoded;
314                    }
315                }
316                $map[$driver] = [
317                    'api_key' => $row['api_key'],
318                    'api_url' => $row['api_url'],
319                    'config'  => $config,
320                ];
321            }
322            return $map;
323        } catch (Throwable) {
324            return [];
325        }
326    }
327
328    /**
329     * Retrieves cached SecurityCheckResult from dual-tier cache (L1 Memory, L2 Persistent PSR-16).
330     *
331     * @param string $key Cache key.
332     * @return SecurityCheckResult|null
333     */
334    private function getCache(string $key): ?SecurityCheckResult
335    {
336        if (isset($this->memoryCache[$key])) {
337            return $this->memoryCache[$key];
338        }
339
340        return $this->getFromPersistentCache($key);
341    }
342
343    private function getFromPersistentCache(string $key): ?SecurityCheckResult
344    {
345        if ($this->cache === null) {
346            return null;
347        }
348
349        try {
350            $data = $this->cache->psr()->get($key);
351            if (is_array($data)) {
352                $result = SecurityCheckResult::fromArray($data);
353                $this->memoryCache[$key] = $result;
354                return $result;
355            }
356        } catch (Throwable) {
357            // Gracefully ignore cache backend failures
358        }
359
360        return null;
361    }
362
363    /**
364     * Stores SecurityCheckResult in dual-tier cache with TTL.
365     *
366     * @param string              $key    Cache key.
367     * @param SecurityCheckResult $result Result DTO.
368     * @param int                 $ttl    Time-to-live in seconds.
369     */
370    private function setCache(string $key, SecurityCheckResult $result, int $ttl): void
371    {
372        // 1. Store in L1 in-memory cache
373        $this->memoryCache[$key] = $result;
374
375        if ($this->cache === null) {
376            return;
377        }
378
379        // 2. Store in L2 persistent cache backend
380        try {
381            $this->cache->psr()->set($key, $result->toArray(), $ttl);
382        } catch (Throwable) {
383            // Gracefully ignore cache storage errors
384        }
385    }
386}