open($zipPath, ZipArchive::CREATE); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) { $zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1))); } $zip->close(); $r = plugin_install_zip($pdo, $zipPath); $check($r['ok'] && $r['slug'] === 'virenscan', 'Plugin lässt sich installieren: ' . json_encode($r)); $check(!plugin_set_active($pdo, 'virenscan', true)['ok'], 'Aktivierung ohne Bestätigung der Berechtigungen wird abgelehnt'); $act = plugin_set_active($pdo, 'virenscan', true, ['hooks.tickets', 'hooks.cron', 'ui.menu', 'db.migrations']); $check($act['ok'], 'Plugin lässt sich aktivieren: ' . json_encode($act)); $state = &plugin_state(); $state['booted'] = false; plugins_boot(); $check(isset(plugin_state()['loaded']['virenscan']), 'Plugin wird geladen'); $check(count(plugin_admin_menu_items(['id' => 1, 'role' => 'admin'])) === 1, 'Menüpunkt steht im Adminportal'); $check(count(plugin_menu_items(['id' => 1, 'role' => 'admin'])) === 0, 'Menüpunkt steht nicht im Hauptmenü'); $check(count(plugin_admin_menu_items(['id' => 2, 'role' => 'agent'])) === 0 && count(plugin_menu_items(['id' => 2, 'role' => 'agent'])) === 0, 'Mitarbeiter sehen den Menüpunkt nicht'); $check((int) $pdo->query('SELECT COUNT(*) FROM plugin_virenscan_files')->fetchColumn() === 0, 'Tabelle wurde angelegt'); // Testprüfung erzwingen (ClamAV muss für den Test nicht installiert sein) plugin_set_setting('virenscan', 'mode', 'builtin'); $st = virenscan_status(); $check($st['mode'] === 'builtin' && $st['ok'] === false && str_contains($st['message'], 'ClamAV'), 'ohne ClamAV: Hinweis, dass nur die Testprüfung läuft'); // ── Daten ────────────────────────────────────────────────────────────────── $uploads = virenscan_uploads_dir(); $created = []; $now = db_now(); $dept = (int) $pdo->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); $prio = (int) $pdo->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); $pdo->prepare("INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES ('VS-1','Test','K','k@example.test',?,?, 'open','web',?,?)")->execute([$dept, $prio, $now, $now]); $ticketId = (int) $pdo->lastInsertId(); $pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'customer', 'Anhang anbei', 0, ?)")->execute([$ticketId, $now]); $threadId = (int) $pdo->lastInsertId(); $attach = function (string $name, string $content) use ($pdo, $threadId, $uploads, &$created, $now): int { $stored = 'vstest-' . bin2hex(random_bytes(4)) . '.bin'; file_put_contents($uploads . '/' . $stored, $content); $created[] = $stored; $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')->execute([$threadId, $name, $stored, strlen($content), $now]); return (int) $pdo->lastInsertId(); }; // Erster Lauf legt die Startmarke fest: schon vorhandene Anhänge werden nicht nachgeprüft $old = $attach('alt.bin', virenscan_eicar()); $check(virenscan_tick($pdo) === 0, 'Bestand vor dem Einschalten wird nicht geprüft'); $check(virenscan_start_id($pdo) === $old, 'Startmarke steht auf dem letzten vorhandenen Anhang'); // ── Prüfung und Quarantäne ───────────────────────────────────────────────── $clean = $attach('rechnung.pdf', "%PDF-1.4 harmlos\n"); $bad = $attach('lebenslauf.pdf.exe', 'MZ' . str_repeat('x', 50) . virenscan_eicar()); $n = virenscan_scan_ticket($pdo, $ticketId); $check($n === 2, 'Beide neuen Anhänge werden geprüft (' . $n . ')'); $rows = $pdo->query('SELECT attachment_id, status, quarantined, signature FROM plugin_virenscan_files ORDER BY attachment_id')->fetchAll(PDO::FETCH_UNIQUE | PDO::FETCH_ASSOC); $check(($rows[$clean]['status'] ?? '') === 'clean' && (int) $rows[$clean]['quarantined'] === 0, 'saubere Datei bleibt unverändert'); $check(($rows[$bad]['status'] ?? '') === 'infected' && (int) $rows[$bad]['quarantined'] === 1 && ($rows[$bad]['signature'] ?? '') === 'Eicar-Test-Signature', 'verseuchte Datei wird erkannt und gesperrt'); $badRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch(); $check($badRow['stored_name'] === 'gesperrt' && str_starts_with($badRow['original_name'], '[GESPERRT] '), 'Anhang ist umbenannt und nicht mehr herunterladbar'); $cleanRow = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$clean}")->fetch(); $check($cleanRow['original_name'] === 'rechnung.pdf' && is_file($uploads . '/' . $cleanRow['stored_name']), 'saubere Datei liegt unverändert da'); $badStored = $created[array_search(true, array_map(fn($c) => !is_file($uploads . '/' . $c), $created), true)] ?? ''; $check($badStored !== '' && is_file($uploads . '/quarantaene/' . $badStored), 'verseuchte Datei liegt in der Quarantäne'); $check(!is_file($uploads . '/' . $badStored), 'verseuchte Datei ist aus dem Upload-Ordner verschwunden'); $check(is_file($uploads . '/quarantaene/.htaccess'), 'Quarantäne-Ordner ist vor Web-Zugriff geschützt'); $note = $pdo->query("SELECT COUNT(*) FROM ticket_threads WHERE ticket_id = {$ticketId} AND author_type = 'system' AND is_internal_note = 1 AND message LIKE '%Virenscan gesperrt%'")->fetchColumn(); $check((int) $note === 1, 'Ticket bekommt eine interne Notiz'); $act = $pdo->query("SELECT COUNT(*) FROM activity_log WHERE ticket_id = {$ticketId} AND action LIKE 'Anhang vom Virenscan gesperrt%'")->fetchColumn(); $check((int) $act === 1, 'Aktivitätsverlauf nennt die Sperre'); $tag = $pdo->query("SELECT COUNT(*) FROM ticket_tags tt JOIN tags g ON g.id = tt.tag_id WHERE tt.ticket_id = {$ticketId} AND g.name = 'Virus-Verdacht'")->fetchColumn(); $check((int) $tag === 1, 'Ticket trägt den Tag „Virus-Verdacht“'); $check(virenscan_scan_ticket($pdo, $ticketId) === 0, 'nichts wird doppelt geprüft'); // ── Freigeben und Löschen ────────────────────────────────────────────────── $q = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad}")->fetchColumn(); $check(virenscan_release($pdo, $q), 'Freigeben klappt'); $after = $pdo->query("SELECT * FROM ticket_attachments WHERE id = {$bad}")->fetch(); $check($after['original_name'] === 'lebenslauf.pdf.exe' && $after['stored_name'] === $badStored && is_file($uploads . '/' . $badStored), 'freigegebene Datei ist wieder da, Name zurückgesetzt'); $check(virenscan_stats($pdo)['released'] === 1 && virenscan_stats($pdo)['quarantined'] === 0, 'Zähler zeigen „freigegeben“'); $check(!virenscan_release($pdo, $q), 'zweites Freigeben wird abgelehnt'); $bad2 = $attach('zweite.exe', virenscan_eicar()); virenscan_tick($pdo); $q2 = (int) $pdo->query("SELECT id FROM plugin_virenscan_files WHERE attachment_id = {$bad2}")->fetchColumn(); $stored2 = $pdo->query("SELECT stored_name FROM plugin_virenscan_files WHERE id = {$q2}")->fetchColumn(); $check(is_file($uploads . '/quarantaene/' . $stored2), 'Hintergrundlauf findet und sperrt weitere Dateien'); $check(virenscan_delete($pdo, $q2) && !is_file($uploads . '/quarantaene/' . $stored2), 'Löschen entfernt die Datei endgültig'); $check(str_starts_with((string) $pdo->query("SELECT original_name FROM ticket_attachments WHERE id = {$bad2}")->fetchColumn(), '[GELÖSCHT] '), 'Anhang im Ticket ist als gelöscht vermerkt'); // ── Sonderfälle ──────────────────────────────────────────────────────────── $missing = $attach('weg.txt', 'x'); @unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing}")->fetchColumn()); virenscan_tick($pdo); $check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing}")->fetchColumn() === 'error', 'fehlende Datei: Fehler, nichts gesperrt'); plugin_set_setting('virenscan', 'max_mb', '1'); $big = $attach('gross.bin', str_repeat('A', 1200000)); virenscan_tick($pdo); $check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$big}")->fetchColumn() === 'skipped', 'zu große Datei wird übersprungen'); plugin_set_setting('virenscan', 'block_on_error', '1'); $missing2 = $attach('weg2.txt', 'x'); @unlink($uploads . '/' . $pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$missing2}")->fetchColumn()); virenscan_tick($pdo); $check($pdo->query("SELECT status FROM plugin_virenscan_files WHERE attachment_id = {$missing2}")->fetchColumn() === 'error', 'fehlende Datei wird auch bei „bei Fehler sperren“ nur als Fehler vermerkt (nichts zum Sperren)'); // ── ClamAV-Ausgabe und Programmaufruf ────────────────────────────────────── $check(virenscan_interpret(0, '/tmp/a: OK', '')['status'] === 'clean', 'ClamAV Exit 0 = sauber'); $inf = virenscan_interpret(1, "/tmp/a.exe: Win.Test.EICAR_HDB-1 FOUND\n", ''); $check($inf['status'] === 'infected' && $inf['signature'] === 'Win.Test.EICAR_HDB-1', 'ClamAV Exit 1 = Fund mit Signaturname'); $errR = virenscan_interpret(2, '', 'ERROR: Can\'t connect to clamd'); $check($errR['status'] === 'error' && str_contains((string) $errR['message'], 'clamd'), 'ClamAV Exit 2 = Fehler mit Meldung'); [$code, $out] = virenscan_run([PHP_BINARY, '-r', 'echo "hallo"; exit(3);'], 10); $check($code === 3 && $out === 'hallo', 'Programmaufruf liefert Exit-Code und Ausgabe'); $t0 = microtime(true); [$code2, , $e2] = virenscan_run([PHP_BINARY, '-r', 'sleep(6);'], 1); $check($code2 === -1 && str_contains($e2, 'Zeitüberschreitung') && microtime(true) - $t0 < 4, 'Zeitlimit beendet hängende Prüfprogramme'); // ── Auslösung über die Ticket-Ereignisse ─────────────────────────────────── plugin_set_setting('virenscan', 'max_mb', '25'); $viaHook = $attach('per-hook.exe', virenscan_eicar()); webhook_dispatch($pdo, 'ticket.replied', $ticketId, ['author' => 'customer', 'internal' => false]); $check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook}")->fetchColumn() == 1, 'Ereignis „Antwort“ löst die Prüfung aus'); $viaHook2 = $attach('neu.exe', virenscan_eicar()); webhook_dispatch($pdo, 'ticket.created', $ticketId); $check($pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$viaHook2}")->fetchColumn() == 1, 'Ereignis „Ticket erstellt“ löst die Prüfung aus'); $check(is_string(virenscan_eicar()) && strlen(virenscan_eicar()) === 68, 'EICAR-Zeichenfolge ist die Standardzeichenfolge (68 Zeichen)'); // ── Eingebaute Dateianalyse ──────────────────────────────────────────────── $tmpDir = sys_get_temp_dir() . '/vs-an-' . bin2hex(random_bytes(3)); mkdir($tmpDir); $mk = function (string $name, string $content) use ($tmpDir): string { $p = $tmpDir . '/' . bin2hex(random_bytes(3)); file_put_contents($p, $content); return $p; }; $zipOf = function (array $entries, ?string $password = null) use ($tmpDir): string { $p = $tmpDir . '/' . bin2hex(random_bytes(3)) . '.zip'; $z = new ZipArchive(); $z->open($p, ZipArchive::CREATE); foreach ($entries as $n => $c) { $z->addFromString($n, $c); if ($password !== null) { $z->setEncryptionName($n, ZipArchive::EM_AES_256, $password); } } $z->close(); return $p; }; $verdict = function (string $path, string $name) use ($pdo): array { $r = virenscan_scan_file($path, $name); return [$r['status'], (string) $r['signature']]; }; plugin_set_setting('virenscan', 'mode', 'builtin'); plugin_set_setting('virenscan', 'analysis', '1'); foreach (['block_macros' => '1', 'block_encrypted' => '1', 'block_unscannable' => '0'] as $k => $v) plugin_set_setting('virenscan', $k, $v); $clean = fn(string $path, string $name) => $verdict($path, $name)[0] === 'clean'; $infected = fn(string $path, string $name) => $verdict($path, $name)[0] === 'infected'; $check($clean($mk('a', 'Hallo, anbei die Rechnung.'), 'rechnung.txt'), 'Textdatei ist sauber'); $check($clean($mk('a', "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF"), 'rechnung.pdf'), 'normales PDF ist sauber'); $check($clean($mk('a', "\x89PNG\r\n\x1a\n" . str_repeat('x', 100)), 'foto.png'), 'Bild ist sauber'); $check($clean($zipOf(['bericht.docx' => 'PK', 'notiz.txt' => 'hallo']), 'daten.zip'), 'harmloses Archiv ist sauber'); $check($infected($mk('a', 'echo hi'), 'start.bat'), '.bat wird gesperrt'); $check($infected($mk('a', 'x'), 'Rechnung.pdf.exe'), 'doppelte Endung mit .exe wird gesperrt'); $check($infected($mk('a', 'x'), 'Rechnung.exe.pdf'), 'versteckte .exe vor der Endung wird erkannt'); $check($infected($mk('a', 'x'), "Rechnung\u{202E}fdp.exe"), 'umgekehrte Schreibrichtung im Namen wird erkannt'); $check($infected($mk('a', "MZ\x90\x00\x03" . str_repeat("\0", 60)), 'foto.jpg'), 'Programm als Bild getarnt wird erkannt'); $check($infected($mk('a', "\x7fELF\x02\x01"), 'dokument.pdf'), 'Linux-Programm als PDF getarnt wird erkannt'); $check($infected($zipOf(['readme.txt' => 'hi', 'setup.exe' => 'x']), 'update.zip'), 'ZIP mit .exe wird gesperrt'); $check($infected($zipOf(['bilder/foto.jpg' => "MZ\x90\x00" . str_repeat('x', 30)]), 'fotos.zip'), 'ZIP mit getarntem Programm wird gesperrt'); $check($infected($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP wird gesperrt'); plugin_set_setting('virenscan', 'block_encrypted', '0'); $check($clean($zipOf(['a.txt' => 'geheim'], 'pw123'), 'rechnung.zip'), 'passwortgeschütztes ZIP lässt sich erlauben'); plugin_set_setting('virenscan', 'block_encrypted', '1'); $check($infected($zipOf(['word/vbaProject.bin' => 'x', '[Content_Types].xml' => '']), 'angebot.docx'), 'Office-Datei mit vbaProject.bin wird gesperrt'); $check($infected($mk('a', 'x'), 'angebot.docm'), 'makrofähige Endung wird gesperrt'); $check($infected($mk('a', "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" . str_repeat("\0", 100) . '_VBA_PROJECT' . str_repeat("\0", 50)), 'tabelle.xls'), 'altes Office-Format mit VBA wird gesperrt'); plugin_set_setting('virenscan', 'block_macros', '0'); $check($clean($mk('a', 'x'), 'angebot.docm'), 'Makros lassen sich erlauben'); plugin_set_setting('virenscan', 'block_macros', '1'); $check($infected($mk('a', "%PDF-1.7\n<< /OpenAction << /S /JavaScript /JS (app.alert(1)) >> >>"), 'info.pdf'), 'PDF mit automatischem JavaScript wird gesperrt'); $check($infected($mk('a', "%PDF-1.7\n<< /S /Launch /F (cmd.exe) >>"), 'info.pdf'), 'PDF mit /Launch wird gesperrt'); $check($infected($mk('a', ''), 'zahlung.html'), 'HTML mit Skript wird gesperrt'); $check($clean($mk('a', 'Hallo'), 'seite.html'), 'HTML ohne Skript ist sauber'); $check($infected($mk('a', ''), 'logo.svg'), 'SVG mit Skript wird gesperrt'); $check($infected($mk('a', '{\\rtf1 {\\object {\\*\\objdata 0105}}}'), 'brief.rtf'), 'RTF mit eingebettetem Objekt wird gesperrt'); $check($clean($mk('a', 'Rar!'), 'archiv.rar'), 'RAR ist standardmäßig erlaubt'); plugin_set_setting('virenscan', 'block_unscannable', '1'); $check($infected($mk('a', 'Rar!'), 'archiv.rar'), 'RAR lässt sich sperren'); plugin_set_setting('virenscan', 'block_unscannable', '0'); $check($verdict($mk('a', virenscan_eicar()), 'test.txt')[1] === 'Eicar-Test-Signature', 'EICAR wird mit Namen erkannt'); plugin_set_setting('virenscan', 'analysis', '0'); $check($clean($mk('a', 'x'), 'start.bat') && $infected($mk('a', virenscan_eicar()), 'test.txt'), 'ohne Dateianalyse bleibt nur die Testdatei'); plugin_set_setting('virenscan', 'analysis', '1'); // ── Hash-Abfrage (VirusTotal), HTTP ersetzt ──────────────────────────────── $vtCalls = []; $GLOBALS['virenscan_http'] = function (string $url, array $headers) use (&$vtCalls, &$vtAnswer): array { $vtCalls[] = ['url' => $url, 'headers' => $headers]; return $vtAnswer; }; $sha = str_repeat('a', 64); $vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 12, 'suspicious' => 0], 'popular_threat_classification' => ['suggested_threat_label' => 'trojan.agent']]]])]; $r1 = virenscan_vt_lookup($sha, 'k', 3); $check($r1['result'] === 'bad' && str_contains((string) $r1['detail'], '12 Scanner') && str_contains((string) $r1['detail'], 'trojan.agent'), 'VirusTotal: bekannte Schadsoftware wird erkannt'); $check(str_ends_with($vtCalls[0]['url'], '/files/' . $sha) && in_array('x-apikey: k', $vtCalls[0]['headers'], true), 'Abfrage enthält nur den Hash und den Schlüssel'); $vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 1]]]])]; $check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'clean', 'unter der Schwelle gilt als sauber'); $vtAnswer = ['status' => 404, 'body' => '']; $check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'unknown', '404 = Datei unbekannt'); $vtAnswer = ['status' => 429, 'body' => '']; $check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'limit', '429 = Abfragegrenze'); $vtAnswer = ['status' => 401, 'body' => '']; $check(virenscan_vt_lookup($sha, 'k', 3)['result'] === 'key', '401 = Schlüssel ungültig'); // Hintergrundlauf: sauberer Anhang wird nachträglich gesperrt, wenn VirusTotal ihn kennt plugin_set_setting('virenscan', 'vt_key', encrypt_secret(str_repeat('b', 64))); $known = $attach('rechnung.pdf', "%PDF-1.4\nBekannte Schadsoftware " . bin2hex(random_bytes(4))); $vtAnswer = ['status' => 200, 'body' => json_encode(['data' => ['attributes' => ['last_analysis_stats' => ['malicious' => 20]]]])]; $vtCalls = []; virenscan_tick($pdo); $row = $pdo->query("SELECT status, quarantined, rep, sha256 FROM plugin_virenscan_files WHERE attachment_id = {$known}")->fetch(); $check($row && $row['status'] === 'infected' && (int) $row['quarantined'] === 1 && $row['rep'] === 'bad' && strlen((string) $row['sha256']) === 64, 'Hintergrundlauf sperrt eine bei VirusTotal bekannte Datei: ' . json_encode($row)); $check($pdo->query("SELECT stored_name FROM ticket_attachments WHERE id = {$known}")->fetchColumn() === 'gesperrt', 'Anhang ist im Ticket gesperrt'); $unknownAtt = $attach('neu.pdf', "%PDF-1.4\nUnbekannt " . bin2hex(random_bytes(4))); $vtAnswer = ['status' => 404, 'body' => '']; virenscan_tick($pdo); $check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() === 'unknown' && $pdo->query("SELECT quarantined FROM plugin_virenscan_files WHERE attachment_id = {$unknownAtt}")->fetchColumn() == 0, 'unbekannte Datei bleibt freigegeben und ist als geprüft vermerkt'); $before = count($vtCalls); virenscan_tick($pdo); $check(count($vtCalls) === $before, 'bereits abgefragte Dateien werden nicht noch einmal abgefragt'); $lim = $attach('limit.pdf', "%PDF-1.4\nLimit " . bin2hex(random_bytes(4))); $vtAnswer = ['status' => 429, 'body' => '']; virenscan_tick($pdo); $check($pdo->query("SELECT rep FROM plugin_virenscan_files WHERE attachment_id = {$lim}")->fetchColumn() === null, 'bei Abfragegrenze bleibt die Datei für den nächsten Lauf offen'); $vtAnswer = ['status' => 401, 'body' => '']; virenscan_tick($pdo); $check(str_contains((string) plugin_setting('virenscan', 'vt_error', ''), 'abgelehnt'), 'ungültiger Schlüssel wird auf der Seite gemeldet'); plugin_set_setting('virenscan', 'vt_key', ''); $vtCalls = []; virenscan_reputation_tick($pdo); $check($vtCalls === [], 'ohne Schlüssel wird nichts abgefragt'); unset($GLOBALS['virenscan_http']); foreach (glob($tmpDir . '/*') ?: [] as $f) @unlink($f); @rmdir($tmpDir); // Selbstheilung: fehlen nach einem Update die Spalten (ältere DeskHub-Dateien), holt das Plugin die Änderung selbst nach $pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN rep'); $pdo->exec('ALTER TABLE plugin_virenscan_files DROP COLUMN sha256'); $pdo->exec("DELETE FROM schema_migrations WHERE migration LIKE 'plugin_virenscan_002%'"); virenscan_ensure_schema(true); $vsCols = array_column($pdo->query('PRAGMA table_info(plugin_virenscan_files)')->fetchAll(), 'name'); $check(in_array('rep', $vsCols, true) && in_array('sha256', $vsCols, true), 'Selbstheilung: die fehlenden Spalten werden nachgeholt'); // ── Reiter der Seite: Übersicht, Quarantäne, Geprüft, Einstellungen ─────── $pagePath = $pluginDir . '/virenscan/pages/virenscan.php'; $adminStaff = ['id' => 1, 'role' => 'admin', 'name' => 'Chef']; $render = function (string $tab, array $get = [], ?array $who = null) use ($pdo, $adminStaff, $pagePath): string { $_GET = ($tab === '' ? [] : ['tab' => $tab]) + $get; $_SERVER['REQUEST_METHOD'] = 'GET'; $staff = $who ?? $adminStaff; ob_start(); include $pagePath; return (string) ob_get_clean(); }; $pdo->exec('DELETE FROM plugin_virenscan_files'); $ins = $pdo->prepare('INSERT INTO plugin_virenscan_files (attachment_id, ticket_id, original_name, stored_name, status, signature, message, quarantined, scanned_at) VALUES (?,?,?,?,?,?,?,?,?)'); for ($i = 1; $i <= 120; $i++) $ins->execute([9000 + $i, null, "datei-{$i}.pdf", "s{$i}", 'clean', null, 'Dateianalyse', 0, date('Y-m-d H:i:s', time() - $i * 60)]); $ins->execute([9500, null, 'Rechnung.pdf.exe', 'q1', 'infected', 'Dateianalyse: Gefährlicher Dateityp (.exe)', null, 1, db_now()]); $ins->execute([9501, null, 'fehler.zip', 'q2', 'error', null, 'Zeitüberschreitung', 0, db_now()]); $home = $render(''); $check(str_contains($home, '

Zustand

') && str_contains($home, 'Prüfung testen (EICAR-Testdatei)') && !str_contains($home, 'Geprüfte Dateien') && !str_contains($home, 'Dateianalyse (braucht nichts auf dem Server)'), 'Standard-Reiter „Übersicht“: Zustand und Testknöpfe, aber weder Liste noch Einstellungen'); $check(str_contains($home, 'tab=quarantaene') && str_contains($home, 'tab=geprueft') && str_contains($home, 'tab=einstellungen') && str_contains($home, 'tab=uebersicht'), 'Reiterleiste mit allen vier Reitern'); $check(preg_match('#Quarantäne ]*>1#', $home) === 1, 'der Reiter „Quarantäne“ zeigt die Anzahl (1)'); $check(str_contains($home, '1 Datei(en) in Quarantäne prüfen'), 'die Übersicht verweist auf die Quarantäne'); $quar = $render('quarantaene'); $check(str_contains($quar, 'Rechnung.pdf.exe') && str_contains($quar, 'Freigeben') && str_contains($quar, 'Löschen') && !str_contains($quar, 'datei-1.pdf') && !str_contains($quar, '

Zustand

'), 'Reiter „Quarantäne“: nur gesperrte Dateien mit Freigeben und Löschen'); $pdo->exec('UPDATE plugin_virenscan_files SET quarantined = 0'); $check(str_contains($render('quarantaene'), 'Keine gesperrten Dateien'), 'leere Quarantäne zeigt einen ruhigen Hinweis'); $pdo->exec("UPDATE plugin_virenscan_files SET quarantined = 1 WHERE attachment_id = 9500"); $p1 = $render('geprueft'); $check(str_contains($p1, 'Geprüfte Dateien') && substr_count($p1, 'datei-') === 48 && substr_count($p1, '') === 51 && str_contains($p1, 'Seite 1 von 3') && str_contains($p1, 'Ältere →') && !str_contains($p1, '← Neuere'), 'Reiter „Geprüft“: 50 Einträge pro Seite, Seite 1 von 3'); $p3 = $render('geprueft', ['seite' => '3']); $check(str_contains($p3, 'Seite 3 von 3') && str_contains($p3, '← Neuere') && !str_contains($p3, 'Ältere →'), 'letzte Seite hat nur „Neuere“'); $check(str_contains($render('geprueft', ['seite' => '99']), 'Seite 3 von 3'), 'zu große Seitenzahl wird auf die letzte begrenzt'); $inf = $render('geprueft', ['status' => 'infected']); $check(str_contains($inf, 'Rechnung.pdf.exe') && !str_contains($inf, 'datei-1.pdf') && !str_contains($inf, 'fehler.zip') && str_contains($inf, 'mit Ergebnis „Verseucht“'), 'Filter nach Ergebnis (Verseucht)'); $check(str_contains($render('geprueft', ['status' => 'unsinn']), '(122)'), 'unbekannter Filter wird ignoriert (alle 122)'); $set = $render('einstellungen'); $check(str_contains($set, 'Dateianalyse (braucht nichts auf dem Server)') && str_contains($set, 'Hash-Abfrage bei VirusTotal') && str_contains($set, 'name="action" value="save"') && !str_contains($set, 'Geprüfte Dateien'), 'Reiter „Einstellungen“: nur das Formular'); $check(str_contains($render('gibt-es-nicht'), '

Zustand

'), 'unbekannter Reiter fällt auf die Übersicht zurück'); $check(str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'nur für Administratoren') && !str_contains($render('einstellungen', [], ['id' => 2, 'role' => 'agent', 'name' => 'Agent']), 'name="action" value="save"'), 'Mitarbeiter ohne Adminrecht sehen weder Reiter noch Formular'); $check(str_contains(virenscan_tab_url('geprueft', ['status' => 'clean', 'seite' => 2]), 'tab=geprueft&status=clean&seite=2') && !str_contains(virenscan_tab_url('geprueft', ['seite' => 1]), 'seite'), 'Adressen der Reiter: Seite 1 und leere Filter werden weggelassen'); // ── Aufräumen ────────────────────────────────────────────────────────────── foreach ($created as $c) { @unlink($uploads . '/' . $c); @unlink($uploads . '/quarantaene/' . $c); } foreach (['.htaccess', 'index.html'] as $x) @unlink($uploads . '/quarantaene/' . $x); @rmdir($uploads . '/quarantaene'); foreach (scandir($pluginDir) ?: [] as $entry) { if ($entry === '.' || $entry === '..') continue; is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry); } @rmdir($pluginDir); @unlink($zipPath); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo 'Virenscan plugin checks passed.' . "\n";