temporaryFile && is_file($this->temporaryFile)) { unlink($this->temporaryFile); } parent::tearDown(); } public function test_real_clamd_scans_a_harmless_document_and_honours_the_file_limit(): void { config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]); $scanner = app(MalwareScanner::class); $health = $scanner->health(); if (! $health['available']) { $this->markTestSkipped('A local ClamD with current signatures is required for this integration test.'); } $root = storage_path('app/private'); $directory = $root.'/malware-integration-tests'; if (! is_dir($directory)) { mkdir($directory, 0700, true); } $this->temporaryFile = $directory.'/'.Str::uuid().'.txt'; file_put_contents($this->temporaryFile, 'Harmless InvestMoz malware scanner integration test document.'); $result = $scanner->scan($this->temporaryFile, filesize($this->temporaryFile), $root); $this->assertSame('clean', $result['result']); $this->assertSame($health['engine_version'], $result['engine_version']); $this->assertNotNull($result['signatures_at']); config(['services.malware.max_file_bytes' => 1]); $limited = $scanner->scan($this->temporaryFile, filesize($this->temporaryFile), $root); $this->assertSame('inconclusive', $limited['result']); $this->assertSame('file_scan_limit_exceeded', $limited['reason_code']); } public function test_unavailable_clamd_is_indeterminate_and_never_an_approval(): void { config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 1]); $result = app(MalwareScanner::class)->scan(__FILE__, filesize(__FILE__), dirname(__DIR__, 2)); $this->assertSame('inconclusive', $result['result']); $this->assertSame('scanner_unavailable', $result['reason_code']); } public function test_clamd_response_timeout_is_inconclusive(): void { $root = storage_path('app/private'); $directory = $root.'/malware-integration-tests'; if (! is_dir($directory)) { mkdir($directory, 0700, true); } $this->temporaryFile = $directory.'/'.Str::uuid().'.txt'; file_put_contents($this->temporaryFile, 'Harmless timeout fixture.'); $fixture = __DIR__.'/../Fixtures/ClamDStallServer.php'; $process = proc_open([PHP_BINARY, $fixture], [0=>['pipe','r'], 1=>['pipe','w'], 2=>['pipe','w']], $pipes); $this->assertIsResource($process); fclose($pipes[0]); $address = trim((string) fgets($pipes[1])); $port = (int) substr($address, strrpos($address, ':') + 1); config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => $port, 'services.malware.timeout_seconds' => 1]); try { $result = app(MalwareScanner::class)->scan($this->temporaryFile, filesize($this->temporaryFile), $root); } finally { proc_terminate($process); fclose($pipes[1]); fclose($pipes[2]); proc_close($process); } $this->assertSame('inconclusive', $result['result']); $this->assertSame('scanner_timeout', $result['reason_code']); } public function test_real_clamd_keeps_a_password_protected_archive_inconclusive(): void { $this->requireRealClamd(); if (! class_exists(\ZipArchive::class) || ! defined('ZipArchive::EM_AES_256')) { $this->markTestSkipped('PHP ZipArchive with AES support is required to create the harmless encrypted fixture.'); } $root = storage_path('app/private'); $directory = $root.'/malware-integration-tests'; if (! is_dir($directory)) { mkdir($directory, 0700, true); } $this->temporaryFile = $directory.'/'.Str::uuid().'.zip'; $archive = new \ZipArchive; $this->assertSame(true, $archive->open($this->temporaryFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)); $this->assertTrue($archive->addFromString('readme.txt', 'Harmless encrypted archive fixture.')); $this->assertTrue($archive->setPassword('local-test-password')); $this->assertTrue($archive->setEncryptionName('readme.txt', \ZipArchive::EM_AES_256)); $archive->close(); $result = app(MalwareScanner::class)->scan($this->temporaryFile, filesize($this->temporaryFile), $root); $this->assertSame('inconclusive', $result['result']); $this->assertSame('encrypted_or_password_protected', $result['reason_code']); } public function test_zip_beyond_the_decompression_limit_is_inconclusive(): void { $this->requireRealClamd(); if (! class_exists(\ZipArchive::class)) { $this->markTestSkipped('PHP ZipArchive is required to create the harmless compressed fixture.'); } $root = storage_path('app/private'); $directory = $root.'/malware-integration-tests'; if (! is_dir($directory)) { mkdir($directory, 0700, true); } $this->temporaryFile = $directory.'/'.Str::uuid().'.zip'; $archive = new \ZipArchive; $this->assertSame(true, $archive->open($this->temporaryFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)); $this->assertTrue($archive->addFromString('expanded.txt', str_repeat('A', 110 * 1024 * 1024))); $archive->close(); $result = app(MalwareScanner::class)->scan($this->temporaryFile, filesize($this->temporaryFile), $root); $this->assertSame('inconclusive', $result['result']); $this->assertSame('archive_decompression_limit', $result['reason_code']); } private function requireRealClamd(): void { config(['services.malware.scanner' => 'clamd', 'services.malware.host' => '127.0.0.1', 'services.malware.port' => 3310]); if (! app(MalwareScanner::class)->health()['available']) { $this->markTestSkipped('A local ClamD with current signatures is required for this integration test.'); } } }