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.'); } $unix = config('services.malware.socket'); if ($unix !== null && $unix !== '') { if (! is_string($unix) || ! str_starts_with($unix, '/') || str_contains($unix, "\0") || strlen($unix) > 107) { throw new RuntimeException('The ClamD Unix socket is invalid.'); } $endpoint = 'unix://'.$unix; } else { $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.'); } $endpoint = "tcp://{$host}:{$port}"; } $timeout = max(1, (int) config('services.malware.timeout_seconds', 120)); $socket = @stream_socket_client($endpoint, $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, ]; } }