Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
VirusTotalProvider
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
6 / 6
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkIp
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 checkDomain
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 checkUrl
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 queryVirusTotalEndpoint
100.00% covered (success)
100.00%
62 / 62
100.00% covered (success)
100.00%
1 / 1
8
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\IntegrationProviderInterface;
12use App\Modules\Integrations\Domain\Contract\SecurityCheckResult;
13use App\Modules\Integrations\Infrastructure\Security\SsrfProtectionClientInterface;
14use Throwable;
15
16/**
17 * VirusTotal API v3 Security Provider for comprehensive multi-engine threat analysis.
18 */
19final class VirusTotalProvider implements IntegrationProviderInterface
20{
21    public function __construct(
22        private readonly SsrfProtectionClientInterface $httpClient,
23        private readonly DemoMockSecurityProvider $mockProvider
24    ) {
25    }
26
27    public function getDriver(): string
28    {
29        return 'virustotal';
30    }
31
32    public function checkIp(string $ip, ?string $apiKey, array $config = []): SecurityCheckResult
33    {
34        $isMock = !empty($config['mock_mode'])
35            || empty($apiKey)
36            || str_starts_with((string) $apiKey, 'demo_');
37
38        if ($isMock) {
39            return $this->mockProvider->checkIp($ip, $apiKey, $config);
40        }
41
42        $timeout = (int) ($config['timeout_seconds'] ?? 3);
43        $endpoint = 'https://www.virustotal.com/api/v3/ip_addresses/' . urlencode($ip);
44
45        return $this->queryVirusTotalEndpoint($endpoint, 'ip', $ip, (string) $apiKey, $timeout, $config);
46    }
47
48    public function checkDomain(string $domain, ?string $apiKey, array $config = []): SecurityCheckResult
49    {
50        $isMock = !empty($config['mock_mode'])
51            || empty($apiKey)
52            || str_starts_with((string) $apiKey, 'demo_');
53
54        if ($isMock) {
55            return $this->mockProvider->checkDomain($domain, $apiKey, $config);
56        }
57
58        $timeout = (int) ($config['timeout_seconds'] ?? 3);
59        $endpoint = 'https://www.virustotal.com/api/v3/domains/' . urlencode($domain);
60
61        return $this->queryVirusTotalEndpoint($endpoint, 'domain', $domain, (string) $apiKey, $timeout, $config);
62    }
63
64    public function checkUrl(string $url, ?string $apiKey, array $config = []): SecurityCheckResult
65    {
66        $isMock = !empty($config['mock_mode'])
67            || empty($apiKey)
68            || str_starts_with((string) $apiKey, 'demo_');
69
70        if ($isMock) {
71            return $this->mockProvider->checkUrl($url, $apiKey, $config);
72        }
73
74        $urlId = rtrim(strtr(base64_encode($url), '+/', '-_'), '=');
75        $timeout = (int) ($config['timeout_seconds'] ?? 3);
76        $endpoint = 'https://www.virustotal.com/api/v3/urls/' . $urlId;
77
78        return $this->queryVirusTotalEndpoint($endpoint, 'url', $url, (string) $apiKey, $timeout, $config);
79    }
80
81    /**
82     * Helper to query VirusTotal v3 and parse analysis statistics.
83     *
84     * @param string               $endpoint Target VirusTotal endpoint.
85     * @param string               $type     Check type ('ip', 'domain', 'url').
86     * @param string               $target   Original target value.
87     * @param string               $apiKey   VirusTotal x-apikey.
88     * @param int                  $timeout  Timeout in seconds.
89     * @param array<string, mixed> $config   Custom options.
90     * @return SecurityCheckResult
91     */
92    private function queryVirusTotalEndpoint(
93        string $endpoint,
94        string $type,
95        string $target,
96        string $apiKey,
97        int $timeout,
98        array $config
99    ): SecurityCheckResult {
100        try {
101            $response = $this->httpClient->request(
102                url: $endpoint,
103                method: 'GET',
104                headers: [
105                    'x-apikey' => $apiKey,
106                    'Accept'   => 'application/json',
107                ],
108                timeout: $timeout
109            );
110
111            if ($response['status'] !== 200) {
112                return new SecurityCheckResult(
113                    type: $type,
114                    target: $target,
115                    isSafe: true,
116                    status: 'unknown',
117                    summary: "VirusTotal: Service unavailable (HTTP status: {$response['status']})",
118                    provider: 'VirusTotal',
119                    details: ['http_status' => $response['status']],
120                    cachedTtl: 300
121                );
122            }
123
124            /** @var array{data?: array{attributes?: array<string, mixed>}} $json */
125            $json = json_decode($response['body'], true);
126            $attributes = $json['data']['attributes'] ?? [];
127            $stats = (array) ($attributes['last_analysis_stats'] ?? []);
128
129            $malicious = (int) ($stats['malicious'] ?? 0);
130            $suspicious = (int) ($stats['suspicious'] ?? 0);
131            $harmless = (int) ($stats['harmless'] ?? 0);
132            $threshold = (int) ($config['max_malicious_engines_threshold'] ?? 1);
133
134            $isSafe = $malicious < $threshold && $suspicious === 0;
135            $status = match (true) {
136                $isSafe => 'safe',
137                $malicious >= 3 => 'dangerous',
138                default => 'suspicious',
139            };
140            $summary = $isSafe
141                ? "VirusTotal: Verified (Malicious engines: {$malicious}, Harmless: {$harmless})"
142                : "VirusTotal: Threat detected by {$malicious} antivirus engines";
143
144            return new SecurityCheckResult(
145                type: $type,
146                target: $target,
147                isSafe: $isSafe,
148                status: $status,
149                summary: $summary,
150                provider: 'VirusTotal',
151                details: [
152                    'malicious'  => $malicious,
153                    'suspicious' => $suspicious,
154                    'harmless'   => $harmless,
155                    'threshold'  => $threshold,
156                ],
157                cachedTtl: 86400
158            );
159        } catch (Throwable $e) {
160            return new SecurityCheckResult(
161                type: $type,
162                target: $target,
163                isSafe: true,
164                status: 'unknown',
165                summary: "VirusTotal: Connection error ({$e->getMessage()})",
166                provider: 'VirusTotal',
167                details: ['error' => $e->getMessage()],
168                cachedTtl: 300
169            );
170        }
171    }
172}