332 lines
27 KiB
PHP
332 lines
27 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prüft das Plugin "Anhänge-Virenscan": Installation, Prüfung über die eingebaute Testprüfung (EICAR), Sperren und Quarantäne,
|
|
* Freigeben und Löschen, Fehler- und Größenbehandlung, Auswertung der ClamAV-Ausgabe, Zeitlimit des Prüfprogramms, Auslösung über die Ticket-Ereignisse.
|
|
*/
|
|
|
|
$pluginDir = sys_get_temp_dir() . '/deskhub-vs-' . bin2hex(random_bytes(4));
|
|
mkdir($pluginDir, 0755, true);
|
|
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
|
|
putenv('DESKHUB_TESTING=1');
|
|
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
|
|
putenv('DESKHUB_SQLITE_PATH=:memory:');
|
|
require_once dirname(__DIR__) . '/includes/bootstrap.php';
|
|
|
|
$failures = [];
|
|
$check = function (bool $ok, string $what) use (&$failures): void {
|
|
if (!$ok) $failures[] = $what;
|
|
};
|
|
$pdo = get_db();
|
|
|
|
require_once __DIR__ . '/fixtures/paths.php';
|
|
$src = plugin_source_dir_or_skip('virenscan', 'plugin_virenscan');
|
|
$zipPath = sys_get_temp_dir() . '/vs-' . bin2hex(random_bytes(3)) . '.zip';
|
|
$zip = new ZipArchive();
|
|
$zip->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' => '<x/>']), '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', '<html><script>location="http://x"</script></html>'), 'zahlung.html'), 'HTML mit Skript wird gesperrt');
|
|
$check($clean($mk('a', '<html><body>Hallo</body></html>'), 'seite.html'), 'HTML ohne Skript ist sauber');
|
|
$check($infected($mk('a', '<svg onload="alert(1)"></svg>'), '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, '<h3>Zustand</h3>') && 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 <span[^>]*>1</span>#', $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, '<h3>Zustand</h3>'), '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, '<tr>') === 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'), '<h3>Zustand</h3>'), '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";
|