query('SELECT rep, sha256 FROM plugin_virenscan_files LIMIT 1'); } catch (Throwable $e) { try { plugin_run_migrations(get_db(), VIRENSCAN); } catch (Throwable $e2) { error_log('virenscan schema: ' . $e2->getMessage()); } } } function virenscan_config(): array { $key = ''; $enc = (string) plugin_setting(VIRENSCAN, 'vt_key', ''); if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } } return [ 'analysis' => plugin_setting(VIRENSCAN, 'analysis', '1') === '1', 'block_macros' => plugin_setting(VIRENSCAN, 'block_macros', '1') === '1', 'block_encrypted' => plugin_setting(VIRENSCAN, 'block_encrypted', '1') === '1', 'block_unscannable' => plugin_setting(VIRENSCAN, 'block_unscannable', '0') === '1', 'vt_key' => $key, 'vt_threshold' => max(1, min(30, (int) plugin_setting(VIRENSCAN, 'vt_threshold', '3'))), ] + virenscan_config_base(); } function virenscan_config_base(): array { return [ 'mode' => in_array(plugin_setting(VIRENSCAN, 'mode', 'auto'), ['auto', 'clamdscan', 'clamscan', 'builtin'], true) ? plugin_setting(VIRENSCAN, 'mode', 'auto') : 'auto', 'binary' => trim((string) plugin_setting(VIRENSCAN, 'binary', '')), 'timeout' => max(5, min(300, (int) plugin_setting(VIRENSCAN, 'timeout', '60'))), 'max_mb' => max(1, min(500, (int) plugin_setting(VIRENSCAN, 'max_mb', '25'))), 'block_on_error' => plugin_setting(VIRENSCAN, 'block_on_error', '0') === '1', 'notify' => plugin_setting(VIRENSCAN, 'notify', '1') === '1', ]; } function virenscan_uploads_dir(): string { return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/'); } function virenscan_quarantine_dir(): string { $dir = virenscan_uploads_dir() . '/quarantaene'; if (!is_dir($dir)) { @mkdir($dir, 0750, true); @file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n"); @file_put_contents($dir . '/index.html', ''); } return $dir; } /** Die Standard-Testzeichenfolge (EICAR), in Teilen geschrieben, damit Virenscanner diese Quelldatei nicht selbst melden. */ function virenscan_eicar(): string { return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$' . 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!' . '$H+H*'; } /** * Führt ein Programm mit Zeitlimit aus (ohne Shell). Die Ausgabe geht in Temp-Dateien statt in Pipes, damit das Zeitlimit auch unter Windows greift * (dort lassen sich Pipes nicht nicht-blockierend lesen). @return array{0:int,1:string,2:string} Exit-Code, Ausgabe, Fehlerausgabe */ function virenscan_run(array $cmd, int $timeout): array { $outFile = tempnam(sys_get_temp_dir(), 'vso'); $errFile = tempnam(sys_get_temp_dir(), 'vse'); $proc = @proc_open($cmd, [1 => ['file', $outFile, 'w'], 2 => ['file', $errFile, 'w']], $pipes); if (!is_resource($proc)) { @unlink($outFile); @unlink($errFile); return [-1, '', 'Das Programm konnte nicht gestartet werden.']; } $end = microtime(true) + $timeout; $exit = null; $timedOut = false; while (true) { $status = proc_get_status($proc); if (!$status['running']) { $exit = (int) $status['exitcode']; break; } if (microtime(true) > $end) { proc_terminate($proc); $timedOut = true; break; } usleep(40000); } proc_close($proc); $out = (string) @file_get_contents($outFile, false, null, 0, 1048576); $err = (string) @file_get_contents($errFile, false, null, 0, 1048576); @unlink($outFile); @unlink($errFile); if ($timedOut) return [-1, $out, "Zeitüberschreitung nach {$timeout} Sekunden."]; return [$exit, $out, $err]; } /** Welches Prüfprogramm wird benutzt? @return array{mode:string,binary:string} mode: clamdscan, clamscan oder builtin */ function virenscan_resolve(): array { $cfg = virenscan_config(); if ($cfg['mode'] === 'builtin') return ['mode' => 'builtin', 'binary' => '']; if ($cfg['binary'] !== '') { $m = $cfg['mode'] === 'auto' ? (str_contains(strtolower(basename($cfg['binary'])), 'clamdscan') ? 'clamdscan' : 'clamscan') : $cfg['mode']; return ['mode' => $m, 'binary' => $cfg['binary']]; } $candidates = static fn(string $name) => ["/usr/bin/{$name}", "/usr/local/bin/{$name}", "/opt/clamav/bin/{$name}", "C:/Program Files/ClamAV/{$name}.exe"]; foreach ($cfg['mode'] === 'auto' ? ['clamdscan', 'clamscan'] : [$cfg['mode']] as $m) { foreach ($candidates($m) as $path) if (is_file($path) && is_executable($path)) return ['mode' => $m, 'binary' => $path]; } return ['mode' => 'builtin', 'binary' => '']; } /** Zustand des Prüfprogramms für die Einstellungsseite. @return array{mode:string,binary:string,version:?string,ok:bool,message:string} */ function virenscan_status(): array { $r = virenscan_resolve(); if ($r['mode'] === 'builtin') { return $r + ['version' => null, 'ok' => false, 'message' => 'ClamAV ist auf diesem Server nicht installiert. Es läuft die eingebaute Dateianalyse' . (virenscan_config()['vt_key'] !== '' ? ' mit Hash-Abfrage bei VirusTotal.' : '. Mit einem VirusTotal-Schlüssel (kostenlos) werden Dateien zusätzlich gegen bekannte Schadsoftware abgeglichen.')]; } [$code, $out, $err] = virenscan_run([$r['binary'], '--version'], 15); if ($code !== 0) return $r + ['version' => null, 'ok' => false, 'message' => 'Das Prüfprogramm antwortet nicht: ' . trim($err !== '' ? $err : $out)]; return $r + ['version' => trim(strtok($out, "\n")), 'ok' => true, 'message' => 'Bereit.']; } /** * Prüft eine Datei: zuerst ClamAV (falls auf dem Server vorhanden), dann die eingebaute Dateianalyse. * @return array{status:string,signature:?string,message:?string} status: clean, infected oder error */ function virenscan_scan_file(string $path, string $name = ''): array { if (!is_file($path)) return ['status' => 'error', 'signature' => null, 'message' => 'Datei nicht gefunden.']; $cfg = virenscan_config(); $r = virenscan_resolve(); $clam = null; if ($r['mode'] !== 'builtin') { $cmd = [$r['binary'], '--no-summary']; if ($r['mode'] === 'clamdscan') $cmd[] = '--fdpass'; $cmd[] = $path; [$code, $out, $err] = virenscan_run($cmd, $cfg['timeout']); $clam = virenscan_interpret($code, $out, $err); if ($clam['status'] === 'infected') return $clam; } // Eingebaute Analyse (läuft auch mit ClamAV zusätzlich: erkennt Dateitypen und Tricks, die ein Virenscanner durchlässt) $findings = $cfg['analysis'] ? virenscan_analyze($path, $name !== '' ? $name : basename($path)) : []; if (!$cfg['analysis']) { $head = (string) @file_get_contents($path, false, null, 0, 2 * 1024 * 1024); if (str_contains($head, virenscan_eicar())) $findings = [['eicar', 'Eicar-Test-Signature']]; } $reasons = virenscan_block_reasons($findings, $cfg); if ($reasons) { $sig = in_array('Eicar-Test-Signature', $reasons, true) ? 'Eicar-Test-Signature' : 'Dateianalyse: ' . implode('; ', $reasons); return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null]; } if ($clam !== null) return $clam; return ['status' => 'clean', 'signature' => null, 'message' => $cfg['analysis'] ? 'Dateianalyse' : 'Nur Testprüfung (Dateianalyse ausgeschaltet)']; } /** Wertet die Ausgabe von clamscan/clamdscan aus: Exit-Code 0 = sauber, 1 = Fund ("Datei: Name FOUND"), sonst Fehler. */ function virenscan_interpret(int $code, string $out, string $err): array { if ($code === 0) return ['status' => 'clean', 'signature' => null, 'message' => null]; if ($code === 1) { $sig = preg_match('/:\s*(.+?)\s+FOUND/i', $out, $m) ? $m[1] : 'unbekannt'; return ['status' => 'infected', 'signature' => mb_substr($sig, 0, 250), 'message' => null]; } return ['status' => 'error', 'signature' => null, 'message' => mb_substr(trim($err !== '' ? $err : $out) ?: 'Prüfung fehlgeschlagen (Code ' . $code . ').', 0, 250)]; } function virenscan_start_id(PDO $pdo): int { $v = plugin_setting(VIRENSCAN, 'start_id', null); if ($v === null) { // Erster Lauf: Bestand nicht nachträglich prüfen, nur neue Anhänge (über die Seite lässt sich der Bestand nachholen) $v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn(); plugin_set_setting(VIRENSCAN, 'start_id', $v); } return (int) $v; } /** Noch ungeprüfte Anhänge (optional nur eines Tickets). */ function virenscan_pending(PDO $pdo, int $limit = 25, ?int $ticketId = null): array { $sql = "SELECT a.id, a.original_name, a.stored_name, a.filesize, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE a.id > ? AND a.stored_name <> 'gesperrt' AND a.id NOT IN (SELECT attachment_id FROM plugin_virenscan_files)"; $params = [virenscan_start_id($pdo)]; if ($ticketId !== null) { $sql .= ' AND t.ticket_id = ?'; $params[] = $ticketId; } $stmt = $pdo->prepare($sql . ' ORDER BY a.id LIMIT ' . max(1, $limit)); $stmt->execute($params); return $stmt->fetchAll(); } function virenscan_record(PDO $pdo, array $att, string $status, ?string $signature, ?string $message, bool $quarantined = false): void { $pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)') ->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), $status, $signature, $message !== null ? mb_substr($message, 0, 250) : null, $quarantined ? 1 : 0, db_now()]); } /** Prüft einen Anhang und sperrt ihn, wenn er verseucht ist. @return string clean, infected, error, skipped oder blocked */ function virenscan_scan_attachment(PDO $pdo, array $att): string { $cfg = virenscan_config(); $path = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']); if (!is_file($path)) { virenscan_record($pdo, $att, 'error', null, 'Datei nicht gefunden.'); return 'error'; } if (filesize($path) > $cfg['max_mb'] * 1024 * 1024) { virenscan_record($pdo, $att, 'skipped', null, 'Größer als ' . $cfg['max_mb'] . ' MB, nicht geprüft.'); return 'skipped'; } $res = virenscan_scan_file($path, (string) $att['original_name']); if ($res['status'] === 'infected') { virenscan_record($pdo, $att, 'infected', $res['signature'], null); virenscan_quarantine($pdo, (int) $att['id'], (string) $res['signature']); return 'infected'; } if ($res['status'] === 'error') { if ($cfg['block_on_error']) { virenscan_record($pdo, $att, 'blocked', null, $res['message']); virenscan_quarantine($pdo, (int) $att['id'], 'Prüfung fehlgeschlagen'); return 'blocked'; } virenscan_record($pdo, $att, 'error', null, $res['message']); return 'error'; } virenscan_record($pdo, $att, 'clean', null, $res['message']); return 'clean'; } /** Verschiebt die Datei in die Quarantäne, benennt den Anhang um (nicht mehr herunterladbar) und vermerkt es im Ticket. */ function virenscan_quarantine(PDO $pdo, int $attachmentId, string $reason): bool { $stmt = $pdo->prepare('SELECT a.*, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE a.id = ?'); $stmt->execute([$attachmentId]); $att = $stmt->fetch(); if (!$att || $att['stored_name'] === 'gesperrt') return false; $from = virenscan_uploads_dir() . '/' . basename((string) $att['stored_name']); $to = virenscan_quarantine_dir() . '/' . basename((string) $att['stored_name']); if (is_file($from) && !@rename($from, $to)) { if (!@copy($from, $to) || !@unlink($from)) return false; } $pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $att['original_name'], 0, 255), $attachmentId]); $pdo->prepare('UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = ?')->execute([$attachmentId]); $ticketId = (int) $att['ticket_id']; $text = 'Der Anhang „' . $att['original_name'] . '“ wurde vom Virenscan gesperrt (' . $reason . '). Er liegt in der Quarantäne und lässt sich unter Virenscan prüfen oder freigeben.'; $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticketId, $text, db_now()]); log_activity($pdo, $ticketId, null, 'Anhang vom Virenscan gesperrt: ' . $att['original_name'] . ' (' . $reason . ')'); if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht'); if (virenscan_config()['notify']) virenscan_notify($pdo, $ticketId, (string) $att['original_name'], $reason); return true; } /** Mail an die Adresse des Administrators (Einstellung der Installation). */ function virenscan_notify(PDO $pdo, int $ticketId, string $name, string $reason): void { $to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? '')); if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return; $t = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?'); $t->execute([$ticketId]); $number = (string) $t->fetchColumn(); try { send_mail($to, "Virenscan: Anhang in Ticket #{$number} gesperrt", '

Der Anhang ' . e($name) . ' in Ticket #' . e($number) . ' wurde gesperrt (' . e($reason) . ').

Er liegt in der Quarantäne. Prüfen, freigeben oder löschen kannst du ihn unter Virenscan im Mitarbeiterbereich.

'); } catch (Throwable $e) { // Mailfehler dürfen das Sperren nie verhindern } } /** Gibt eine gesperrte Datei wieder frei (zum Beispiel bei Fehlalarm). */ function virenscan_release(PDO $pdo, int $rowId): bool { $stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1'); $stmt->execute([$rowId]); $row = $stmt->fetch(); if (!$row) return false; $from = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']); $to = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']); if (is_file($from) && !@rename($from, $to)) return false; $pdo->prepare('UPDATE ticket_attachments SET stored_name = ?, original_name = ? WHERE id = ?')->execute([$row['stored_name'], $row['original_name'], (int) $row['attachment_id']]); $pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'released', message = ? WHERE id = ?")->execute(['Freigegeben am ' . db_now(), $rowId]); if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'Gesperrter Anhang freigegeben: ' . $row['original_name']); return true; } /** Löscht eine gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */ function virenscan_delete(PDO $pdo, int $rowId): bool { $stmt = $pdo->prepare('SELECT * FROM plugin_virenscan_files WHERE id = ? AND quarantined = 1'); $stmt->execute([$rowId]); $row = $stmt->fetch(); if (!$row) return false; $file = virenscan_quarantine_dir() . '/' . basename((string) $row['stored_name']); if (is_file($file)) @unlink($file); $pdo->prepare("UPDATE ticket_attachments SET original_name = ? WHERE id = ?")->execute([mb_substr('[GELÖSCHT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]); $pdo->prepare("UPDATE plugin_virenscan_files SET quarantined = 0, status = 'deleted', message = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), $rowId]); return true; } /** Prüft die noch ungeprüften Anhänge eines Tickets (nach neuer Nachricht oder neuem Ticket). */ function virenscan_scan_ticket(PDO $pdo, int $ticketId): int { $n = 0; foreach (virenscan_pending($pdo, 10, $ticketId) as $att) { virenscan_scan_attachment($pdo, $att); $n++; } return $n; } /** Hintergrundlauf (etwa alle 5 Minuten): ungeprüfte Anhänge nachholen, zum Beispiel aus E-Mails. */ function virenscan_tick(PDO $pdo): int { $n = 0; foreach (virenscan_pending($pdo, 25) as $att) { virenscan_scan_attachment($pdo, $att); $n++; } $n += virenscan_reputation_tick($pdo); if ($n > 0) plugin_set_setting(VIRENSCAN, 'last_run', db_now()); return $n; } /** Zahlen für die Seite: Anzahl je Status. @return array */ function virenscan_stats(PDO $pdo): array { $out = ['clean' => 0, 'infected' => 0, 'error' => 0, 'skipped' => 0, 'blocked' => 0, 'released' => 0, 'deleted' => 0, 'quarantined' => 0]; foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virenscan_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n']; $out['quarantined'] = (int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files WHERE quarantined = 1')->fetchColumn(); return $out; } // ── Hash-Abfrage bei VirusTotal ──────────────────────────────────────────── // Es wird nur der SHA-256-Wert der Datei gesendet, nie die Datei selbst. Kostenloser Schlüssel: 4 Abfragen pro Minute, 500 pro Tag. /** HTTP-Aufruf (für Tests austauschbar über $GLOBALS['virenscan_http']). @return array{status:int,body:string} */ function virenscan_http(string $url, array $headers): array { if (isset($GLOBALS['virenscan_http']) && is_callable($GLOBALS['virenscan_http'])) return ($GLOBALS['virenscan_http'])($url, $headers); $ctx = stream_context_create(['http' => ['method' => 'GET', 'header' => implode("\r\n", $headers), 'timeout' => 8, 'ignore_errors' => true]]); $res = @file_get_contents($url, false, $ctx); $status = 0; foreach ($http_response_header ?? [] as $h) if (preg_match('#^HTTP/\S+\s+(\d+)#', $h, $m)) $status = (int) $m[1]; return ['status' => $status, 'body' => $res === false ? '' : (string) $res]; } /** @return array{result:string,detail:?string} result: bad, clean, unknown, limit, key oder error */ function virenscan_vt_lookup(string $sha256, string $key, int $threshold): array { $r = virenscan_http('https://www.virustotal.com/api/v3/files/' . $sha256, ['x-apikey: ' . $key, 'Accept: application/json']); if ($r['status'] === 404) return ['result' => 'unknown', 'detail' => null]; if ($r['status'] === 429) return ['result' => 'limit', 'detail' => null]; if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.']; if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').']; $j = json_decode($r['body'], true); $stats = $j['data']['attributes']['last_analysis_stats'] ?? null; if (!is_array($stats)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.']; $bad = (int) ($stats['malicious'] ?? 0); if ($bad >= $threshold) { $label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? ''); return ['result' => 'bad', 'detail' => 'VirusTotal: ' . $bad . ' Scanner melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')]; } return ['result' => 'clean', 'detail' => null]; } /** * Hintergrundlauf der Hash-Abfrage: prüft saubere Dateien nachträglich gegen VirusTotal, höchstens 4 pro Lauf und 450 pro Tag. * Findet VirusTotal Schadsoftware, wird die Datei gesperrt. @return int Zahl der abgefragten Dateien */ function virenscan_reputation_tick(PDO $pdo): int { $cfg = virenscan_config(); if ($cfg['vt_key'] === '') return 0; $today = date('Y-m-d'); if (plugin_setting(VIRENSCAN, 'vt_day', '') !== $today) { plugin_set_setting(VIRENSCAN, 'vt_day', $today); plugin_set_setting(VIRENSCAN, 'vt_count', '0'); } $used = (int) plugin_setting(VIRENSCAN, 'vt_count', '0'); $rows = $pdo->query("SELECT * FROM plugin_virenscan_files WHERE status = 'clean' AND rep IS NULL AND quarantined = 0 ORDER BY id LIMIT 4")->fetchAll(); $done = 0; foreach ($rows as $row) { if ($used >= 450) break; $path = virenscan_uploads_dir() . '/' . basename((string) $row['stored_name']); if (!is_file($path) || filesize($path) > $cfg['max_mb'] * 1024 * 1024) { $pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'skipped' WHERE id = ?")->execute([$row['id']]); continue; } $hash = hash_file('sha256', $path); $res = virenscan_vt_lookup($hash, $cfg['vt_key'], $cfg['vt_threshold']); if ($res['result'] === 'limit') break; if ($res['result'] === 'key' || $res['result'] === 'error') { plugin_set_setting(VIRENSCAN, 'vt_error', db_now() . ': ' . $res['detail']); break; } plugin_set_setting(VIRENSCAN, 'vt_error', ''); $used++; $done++; plugin_set_setting(VIRENSCAN, 'vt_count', (string) $used); if ($res['result'] === 'bad') { $pdo->prepare("UPDATE plugin_virenscan_files SET rep = 'bad', sha256 = ?, status = 'infected', signature = ? WHERE id = ?")->execute([$hash, mb_substr((string) $res['detail'], 0, 250), $row['id']]); virenscan_quarantine($pdo, (int) $row['attachment_id'], (string) $res['detail']); } else { $pdo->prepare('UPDATE plugin_virenscan_files SET rep = ?, sha256 = ? WHERE id = ?')->execute([$res['result'], $hash, $row['id']]); } } return $done; } /** Adresse eines Reiters der Plugin-Seite. */ function virenscan_tab_url(string $tab, array $extra = []): string { $q = ['p' => VIRENSCAN, 'page' => 'virenscan', 'tab' => $tab] + array_filter($extra, fn($v) => $v !== '' && $v !== null && $v !== 1); return '/staff/plugin.php?' . http_build_query($q); } /** Reiterleiste. @param array $tabs Schlüssel => [Beschriftung, Anzahl (0 = keine Anzeige)] */ function virenscan_tabs(string $active, array $tabs): string { $html = '
'; foreach ($tabs as $key => [$label, $count]) { $on = $key === $active; $html .= '' . htmlspecialchars($label) . ($count > 0 ? ' ' . (int) $count . '' : '') . ''; } return $html . '
'; } /** Deinstallation mit „Daten löschen“: Quarantäne-Dateien bleiben liegen, nur die Tabelle verschwindet. */ plugin_add_hook('plugin.uninstall.' . VIRENSCAN, function () { try { get_db()->exec('DROP TABLE IF EXISTS plugin_virenscan_files'); } catch (Throwable $e) { } });