Files
Investidor-V2/app/Services/Malware/ClamDScanner.php
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

235 lines
9.3 KiB
PHP

<?php
namespace App\Services\Malware;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
use Throwable;
final class ClamDScanner implements MalwareScanner
{
private string $engineVersion = '';
private ?DateTimeImmutable $signaturesAt = null;
public function health(): array
{
try {
$pong = $this->command('PING');
if ($pong !== 'PONG') {
return $this->unavailable('clamd_protocol_error');
}
$version = $this->command('VERSION');
if (! preg_match('/^ClamAV\s+([^\/\s]+)\/(\d+)\/(.+)$/', $version, $matches)) {
return $this->unavailable('signature_version_unavailable');
}
$dateText = preg_replace('/\s+/', ' ', trim($matches[3]));
$date = DateTimeImmutable::createFromFormat('D M j H:i:s Y', (string) $dateText, new DateTimeZone('UTC'));
if (! $date || $date->getTimestamp() > time() + 3600) {
return $this->unavailable('signature_date_invalid');
}
$ageHours = max(0, (time() - $date->getTimestamp()) / 3600);
$this->engineVersion = $matches[1];
$this->signaturesAt = $date;
if ($ageHours > (float) config('services.malware.max_signature_age_hours', 48)) {
return [
'available' => false, 'engine' => 'ClamAV', 'engine_version' => $this->engineVersion,
'signatures_at' => $date, 'signature_age_hours' => $ageHours, 'reason_code' => 'signatures_stale',
];
}
return [
'available' => true, 'engine' => 'ClamAV', 'engine_version' => $this->engineVersion,
'signatures_at' => $date, 'signature_age_hours' => $ageHours, 'reason_code' => null,
];
} catch (Throwable $exception) {
$reason = str_contains(strtolower($exception->getMessage()), 'timed out')
? 'scanner_timeout'
: 'scanner_unavailable';
return $this->unavailable($reason);
}
}
public function scan(string $path, int $fileSizeBytes, string $storageRoot): array
{
$startedAt = hrtime(true);
$health = $this->health();
if (! $health['available']) {
return $this->result('inconclusive', $health['reason_code'], $startedAt, null);
}
$root = realpath($storageRoot);
$resolved = realpath($path);
if (! $root || ! $resolved || ! is_file($resolved)
|| ! str_starts_with(strtolower($resolved), strtolower($root.DIRECTORY_SEPARATOR))) {
return $this->result('inconclusive', 'private_file_unavailable', $startedAt, null);
}
$actualSize = filesize($resolved);
if (! is_int($actualSize) || $actualSize !== $fileSizeBytes) {
return $this->result('inconclusive', 'file_size_changed', $startedAt, null);
}
if ($actualSize > (int) config('services.malware.max_file_bytes', 20 * 1024 * 1024)
|| $actualSize > (int) config('services.malware.max_stream_bytes', 25 * 1024 * 1024)) {
return $this->result('inconclusive', 'file_scan_limit_exceeded', $startedAt, null);
}
$archiveFailure = app(ArchiveLimitInspector::class)->inspect($resolved);
if ($archiveFailure !== null) {
return $this->result('inconclusive', $archiveFailure, $startedAt, null);
}
$socket = null;
$file = fopen($resolved, 'rb');
if (! is_resource($file)) {
return $this->result('inconclusive', 'private_file_unavailable', $startedAt, null);
}
try {
$socket = $this->connect();
$this->writeAll($socket, "zINSTREAM\0");
$sent = 0;
while (! feof($file)) {
$chunk = fread($file, 65536);
if ($chunk === false) {
throw new RuntimeException('The private file could not be streamed to clamd.');
}
if ($chunk === '') {
break;
}
$sent += strlen($chunk);
if ($sent > $actualSize || $sent > (int) config('services.malware.max_stream_bytes', 25 * 1024 * 1024)) {
return $this->result('inconclusive', 'stream_limit_exceeded', $startedAt, null);
}
$this->writeAll($socket, pack('N', strlen($chunk)).$chunk);
}
if ($sent !== $actualSize) {
return $this->result('inconclusive', 'file_size_changed', $startedAt, null);
}
$this->writeAll($socket, pack('N', 0));
$reply = $this->readReply($socket);
if (str_ends_with($reply, ': OK')) {
return $this->result('clean', null, $startedAt, null);
}
if (preg_match('/:\s(.+) FOUND$/', $reply, $matches)) {
$signature = $matches[1];
if (preg_match('/encrypted|password|limits?\.exceeded|oversized/i', $signature)) {
$reason = preg_match('/encrypted|password/i', $signature)
? 'encrypted_or_password_protected'
: 'scanner_decompression_limit';
return $this->result('inconclusive', $reason, $startedAt, $signature);
}
return $this->result('infected', 'threat_detected', $startedAt, $signature);
}
if (stripos($reply, 'size limit exceeded') !== false || stripos($reply, 'limit exceeded') !== false) {
return $this->result('inconclusive', 'scanner_decompression_limit', $startedAt, $reply);
}
throw new RuntimeException('ClamD returned an unrecognized scan response.');
} catch (Throwable $exception) {
$reason = str_contains(strtolower($exception->getMessage()), 'timed out')
? 'scanner_timeout'
: 'scanner_unavailable';
return $this->result('inconclusive', $reason, $startedAt, null);
} finally {
fclose($file);
if (is_resource($socket)) {
fclose($socket);
}
}
}
private function command(string $command): string
{
$socket = $this->connect();
try {
$this->writeAll($socket, 'z'.$command."\0");
return $this->readReply($socket);
} finally {
fclose($socket);
}
}
/** @return resource */
private function connect()
{
if (config('services.malware.scanner') !== 'clamd') {
throw new RuntimeException('No production scanner is configured.');
}
$host = (string) config('services.malware.host', '127.0.0.1');
$port = (int) config('services.malware.port', 3310);
if ($host === '' || in_array($host, ['0.0.0.0', '::'], true) || $port < 1 || $port > 65535) {
throw new RuntimeException('The ClamD endpoint is invalid.');
}
$timeout = max(1, (int) config('services.malware.timeout_seconds', 120));
$socket = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, min(10, $timeout));
if (! is_resource($socket)) {
throw new RuntimeException('ClamD is unavailable.');
}
stream_set_timeout($socket, $timeout);
return $socket;
}
/** @param resource $socket */
private function writeAll($socket, string $data): void
{
$offset = 0;
$length = strlen($data);
while ($offset < $length) {
$written = fwrite($socket, substr($data, $offset, 1024 * 1024));
if ($written === false || $written === 0) {
throw new RuntimeException('ClamD connection stopped accepting scan data.');
}
$offset += $written;
$meta = stream_get_meta_data($socket);
if ($meta['timed_out']) {
throw new RuntimeException('ClamD scan timed out while sending data.');
}
}
}
/** @param resource $socket */
private function readReply($socket): string
{
$reply = stream_get_line($socket, 65536, "\0");
$meta = stream_get_meta_data($socket);
if ($reply === false || $meta['timed_out']) {
throw new RuntimeException($meta['timed_out'] ? 'ClamD scan timed out.' : 'ClamD returned no scan result.');
}
return trim($reply);
}
/** @return array{available:bool,engine:string,engine_version:?string,signatures_at:?DateTimeImmutable,signature_age_hours:?float,reason_code:?string} */
private function unavailable(string $reason): array
{
return ['available' => false, 'engine' => 'ClamAV', 'engine_version' => null, 'signatures_at' => null,
'signature_age_hours' => null, 'reason_code' => $reason];
}
/** @return array{result:string,reason_code:?string,engine:string,engine_version:string,signatures_at:?DateTimeImmutable,duration_ms:int,detail:?string} */
private function result(string $result, ?string $reason, int $startedAt, ?string $detail): array
{
return [
'result' => $result, 'reason_code' => $reason, 'engine' => 'ClamAV',
'engine_version' => $this->engineVersion, 'signatures_at' => $this->signaturesAt,
'duration_ms' => (int) ((hrtime(true) - $startedAt) / 1_000_000), 'detail' => $detail,
];
}
}