235 lines
9.3 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|