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' => '