426 lines
24 KiB
PHP
426 lines
24 KiB
PHP
<?php
|
|
// Virenscan-Plugin: Prüfung, Quarantäne und Verwaltung. Alle Funktionen beginnen mit virenscan_.
|
|
|
|
const VIRENSCAN = 'virenscan';
|
|
|
|
require_once __DIR__ . '/analyse.php';
|
|
|
|
/** Einstellungen mit Standardwerten. */
|
|
/**
|
|
* Selbstheilung: Fehlt nach einem Update eine Datenbank-Änderung des Plugins (ältere DeskHub-Dateien spielen sie nicht von selbst ein),
|
|
* wird sie hier nachgeholt, statt dass das Plugin mit Datenbankfehlern abgeschaltet wird.
|
|
*/
|
|
function virenscan_ensure_schema(bool $force = false): void
|
|
{
|
|
static $done = false;
|
|
if ($done && !$force) return;
|
|
$done = true;
|
|
try {
|
|
get_db()->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", '<p>Der Anhang <strong>' . e($name) . '</strong> in Ticket <strong>#' . e($number) . '</strong> wurde gesperrt (' . e($reason) . ').</p><p>Er liegt in der Quarantäne. Prüfen, freigeben oder löschen kannst du ihn unter <strong>Virenscan</strong> im Mitarbeiterbereich.</p>');
|
|
} 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<string,int> */
|
|
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<string,array{0:string,1:int}> $tabs Schlüssel => [Beschriftung, Anzahl (0 = keine Anzeige)] */
|
|
function virenscan_tabs(string $active, array $tabs): string
|
|
{
|
|
$html = '<div class="plugin-tabs" style="display:flex;gap:4px;flex-wrap:wrap;border-bottom:1px solid var(--border,#e3e9ee);margin:0 0 18px">';
|
|
foreach ($tabs as $key => [$label, $count]) {
|
|
$on = $key === $active;
|
|
$html .= '<a href="' . htmlspecialchars(virenscan_tab_url($key), ENT_QUOTES) . '" style="padding:8px 16px;border-radius:8px 8px 0 0;text-decoration:none;font-size:.9rem;'
|
|
. ($on ? 'background:var(--card-bg,#fff);border:1px solid var(--border,#e3e9ee);border-bottom-color:var(--card-bg,#fff);font-weight:600;color:inherit;position:relative;top:1px' : 'color:var(--muted,#64748b);border:1px solid transparent') . '">'
|
|
. htmlspecialchars($label)
|
|
. ($count > 0 ? ' <span style="background:#dc2626;color:#fff;border-radius:20px;padding:1px 8px;font-size:.72rem;font-weight:700">' . (int) $count . '</span>' : '')
|
|
. '</a>';
|
|
}
|
|
return $html . '</div>';
|
|
}
|
|
|
|
/** 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) {
|
|
}
|
|
});
|