Files
2026-11-05 21:23:54 +00:00

421 lines
25 KiB
PHP

<?php
declare(strict_types=1);
// VirusTotal-Anhangsprüfung: jeder Anhang wird zurückgehalten, bis VirusTotal ihn geprüft hat. Alle Funktionen beginnen mit vt_.
const VT = 'virustotal';
const VT_API = 'https://www.virustotal.com/api/v3';
const VT_DAY_LIMIT = 480; // der kostenlose Schlüssel erlaubt 500 Abfragen pro Tag
const VT_UPLOAD_MAX = 33554432; // 32 MB: größere Dateien nimmt der einfache Upload von VirusTotal nicht an
const VT_EICAR_SHA256 = '275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f';
/** Selbstheilung: Fehlt die Tabelle nach einem Update, wird sie nachgeholt, statt dass das Plugin abgeschaltet wird. */
function vt_ensure_schema(bool $force = false): void
{
static $done = false;
if ($done && !$force) return;
$done = true;
try {
get_db()->query('SELECT sha256, analysis_id FROM plugin_virustotal_files LIMIT 1');
} catch (Throwable $e) {
try {
plugin_run_migrations(get_db(), VT);
} catch (Throwable $e2) {
error_log('virustotal schema: ' . $e2->getMessage());
}
}
}
function vt_config(): array
{
$key = '';
$enc = (string) plugin_setting(VT, 'key_enc', '');
if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } }
$unknown = (string) plugin_setting(VT, 'unknown', 'hold');
return [
'key' => $key,
'threshold' => max(1, min(30, (int) plugin_setting(VT, 'threshold', '3'))),
'unknown' => in_array($unknown, ['hold', 'allow'], true) ? $unknown : 'hold',
'upload' => plugin_setting(VT, 'upload', '0') === '1',
'notify' => plugin_setting(VT, 'notify', '1') === '1',
'on_error' => plugin_setting(VT, 'on_error', 'hold') === 'allow' ? 'allow' : 'hold',
];
}
function vt_uploads_dir(): string
{
return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/');
}
/** Ordner für zurückgehaltene und gesperrte Dateien (nicht von außen erreichbar). */
function vt_hold_dir(): string
{
$dir = vt_uploads_dir() . '/vt-pruefung';
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;
}
/**
* HTTP-Aufruf zu VirusTotal (für Tests austauschbar über $GLOBALS['virustotal_http']). Nur Adressen der VirusTotal-Schnittstelle.
* @param array{file?:string,name?:string} $opts bei POST: Datei für den Upload
* @return array{status:int,body:string}
*/
function vt_http(string $method, string $path, string $key, array $opts = []): array
{
$url = VT_API . '/' . ltrim($path, '/');
if (isset($GLOBALS['virustotal_http']) && is_callable($GLOBALS['virustotal_http'])) return ($GLOBALS['virustotal_http'])($method, $url, $key, $opts);
if (!function_exists('curl_init')) return ['status' => 0, 'body' => ''];
$body = '';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $method === 'POST' ? 120 : 12, CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['x-apikey: ' . $key, 'Accept: application/json', 'User-Agent: DeskHub-VirusTotal'],
CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
$body .= $data;
return strlen($body) > 1048576 ? 0 : strlen($data);
},
]);
if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => new CURLFile((string) ($opts['file'] ?? ''), 'application/octet-stream', (string) ($opts['name'] ?? 'datei.bin'))]);
}
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return ['status' => $status, 'body' => $body];
}
/** Wertet Scan-Zahlen aus. @return array{verdict:string,malicious:int,engines:int} verdict: bad, clean oder none (noch keine Analyse) */
function vt_verdict(array $stats, int $threshold): array
{
$malicious = (int) ($stats['malicious'] ?? 0);
$engines = 0;
foreach (['malicious', 'suspicious', 'undetected', 'harmless'] as $k) $engines += (int) ($stats[$k] ?? 0);
if ($engines === 0) return ['verdict' => 'none', 'malicious' => 0, 'engines' => 0];
return ['verdict' => $malicious >= $threshold ? 'bad' : 'clean', 'malicious' => $malicious, 'engines' => $engines];
}
/** Fragt den Fingerabdruck einer Datei ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, unknown, none, limit, key, error */
function vt_lookup(string $sha256, string $key, int $threshold): array
{
$r = vt_http('GET', 'files/' . $sha256, $key);
$base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
if ($r['status'] === 404) return ['result' => 'unknown'] + $base;
if ($r['status'] === 429) return ['result' => 'limit'] + $base;
if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
$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.'] + $base;
$v = vt_verdict($stats, $threshold);
$label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? '');
$detail = $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')
: ($v['verdict'] === 'clean' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden etwas' : null);
return ['result' => $v['verdict'], 'detail' => $detail, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
}
/** Lädt eine Datei zur Analyse hoch. @return array{ok:bool,id:?string,detail:?string} */
function vt_upload(string $path, string $name, string $key): array
{
$r = vt_http('POST', 'files', $key, ['file' => $path, 'name' => $name]);
if ($r['status'] === 429) return ['ok' => false, 'id' => null, 'detail' => 'limit'];
if ($r['status'] === 401 || $r['status'] === 403) return ['ok' => false, 'id' => null, 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'];
$j = json_decode($r['body'], true);
$id = (string) ($j['data']['id'] ?? '');
if ($r['status'] !== 200 || $id === '') return ['ok' => false, 'id' => null, 'detail' => 'Upload zu VirusTotal fehlgeschlagen (Status ' . $r['status'] . ').'];
return ['ok' => true, 'id' => mb_substr($id, 0, 250), 'detail' => null];
}
/** Fragt eine Analyse nach einem Upload ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, wait, none, limit, key, error */
function vt_analysis(string $id, string $key, int $threshold): array
{
$r = vt_http('GET', 'analyses/' . rawurlencode($id), $key);
$base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
if ($r['status'] === 429) return ['result' => 'limit'] + $base;
if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
$j = json_decode($r['body'], true);
$attr = $j['data']['attributes'] ?? null;
if (!is_array($attr)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base;
if (($attr['status'] ?? '') !== 'completed') return ['result' => 'wait'] + $base;
$v = vt_verdict((array) ($attr['stats'] ?? []), $threshold);
return ['result' => $v['verdict'], 'detail' => $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' : null, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
}
// ── Anhänge zurückhalten ───────────────────────────────────────────────────
/** Ab welchem Anhang wird geprüft? Bestand vor dem Einschalten bleibt unberührt. */
function vt_start_id(PDO $pdo): int
{
$v = plugin_setting(VT, 'start_id', null);
if ($v === null) {
$v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn();
plugin_set_setting(VT, 'start_id', $v);
}
return (int) $v;
}
/** Anhänge ohne Eintrag (noch nicht zurückgehalten), optional nur eines Tickets. */
function vt_unseen(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_virustotal_files)";
$params = [vt_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();
}
/** Hält einen Anhang zurück (Datei in den Prüfordner, Anhang nicht mehr herunterladbar) und legt den Eintrag an. @return string pending oder error */
function vt_hold(PDO $pdo, array $att): string
{
$now = db_now();
$from = vt_uploads_dir() . '/' . basename((string) $att['stored_name']);
$insert = fn(string $status, ?string $sha, ?string $detail) => $pdo->prepare('INSERT INTO plugin_virustotal_files (attachment_id, ticket_id, original_name, stored_name, filesize, sha256, status, detail, tries, next_try, created_at) VALUES (?,?,?,?,?,?,?,?,0,?,?)')
->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), (int) ($att['filesize'] ?? 0), $sha, $status, $detail, $now, $now]);
if (!is_file($from)) { $insert('error', null, 'Datei nicht gefunden.'); return 'error'; }
$sha = hash_file('sha256', $from);
$to = vt_hold_dir() . '/' . basename((string) $att['stored_name']);
if (!@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) {
// Ohne Zurückhalten gäbe es keine Sperre: ehrlich vermerken statt so zu tun
$insert('error', $sha, 'Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).');
return 'error';
}
$pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[IN PRÜFUNG] ' . $att['original_name'], 0, 255), (int) $att['id']]);
$insert('pending', $sha, null);
return 'pending';
}
/** Hält die neuen Anhänge eines Tickets zurück und vermerkt es einmal im Ticket. @return int Anzahl */
function vt_hold_ticket(PDO $pdo, int $ticketId): int
{
$n = 0;
$names = [];
foreach (vt_unseen($pdo, 25, $ticketId) as $att) {
if (vt_hold($pdo, $att) === 'pending') { $n++; $names[] = (string) $att['original_name']; }
}
if ($n > 0) {
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
->execute([$ticketId, e('Anhang' . ($n > 1 ? 'e' : '') . ' wird bei VirusTotal geprüft und ist bis dahin gesperrt: ' . implode(', ', array_map(fn($x) => '„' . $x . '“', array_slice($names, 0, 5))) . ($n > 5 ? ' …' : '')), db_now()]);
log_activity($pdo, $ticketId, null, 'VirusTotal: ' . $n . ' Anhang/Anhänge zur Prüfung zurückgehalten');
}
return $n;
}
function vt_row(PDO $pdo, int $id): ?array
{
$s = $pdo->prepare('SELECT * FROM plugin_virustotal_files WHERE id = ?');
$s->execute([$id]);
return $s->fetch() ?: null;
}
/** Gibt eine zurückgehaltene Datei frei (sauber, bewusst freigegeben oder zugelassen). */
function vt_release(PDO $pdo, array $row, string $status, string $detail): bool
{
$from = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
$to = vt_uploads_dir() . '/' . basename((string) $row['stored_name']);
if (is_file($from) && !@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) 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_virustotal_files SET status = ?, detail = ?, checked_at = ? WHERE id = ?')->execute([$status, mb_substr($detail, 0, 250), db_now(), (int) $row['id']]);
if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang freigegeben: ' . $row['original_name'] . ' (' . $detail . ')');
return true;
}
/** Sperrt eine Datei dauerhaft (bleibt im Prüfordner), vermerkt es im Ticket und benachrichtigt den Administrator. */
function vt_block(PDO $pdo, array $row, string $detail, int $malicious = 0, int $engines = 0): void
{
$pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]);
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'bad', detail = ?, malicious = ?, engines = ?, checked_at = ? WHERE id = ?")->execute([mb_substr($detail, 0, 250), $malicious, $engines, db_now(), (int) $row['id']]);
$ticketId = (int) ($row['ticket_id'] ?? 0);
if ($ticketId > 0) {
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
->execute([$ticketId, e('Der Anhang „' . $row['original_name'] . '“ wurde von VirusTotal als gefährlich eingestuft (' . $detail . ') und bleibt gesperrt. Er liegt in der Quarantäne und lässt sich unter VirusTotal prüfen oder löschen.'), db_now()]);
log_activity($pdo, $ticketId, null, 'VirusTotal: Anhang gesperrt: ' . $row['original_name'] . ' (' . $detail . ')');
if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht');
}
if (vt_config()['notify']) vt_notify($pdo, $ticketId, (string) $row['original_name'], $detail);
}
function vt_notify(PDO $pdo, int $ticketId, string $name, string $detail): 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, 'VirusTotal: Anhang in Ticket #' . $number . ' gesperrt', '<p>Der Anhang <strong>' . e($name) . '</strong> in Ticket <strong>#' . e($number) . '</strong> wurde gesperrt (' . e($detail) . ').</p><p>Er liegt in der Quarantäne. Prüfen oder löschen kannst du ihn unter <strong>VirusTotal</strong> im Mitarbeiterbereich.</p>');
} catch (Throwable $e) {
// Mailfehler dürfen das Sperren nie verhindern
}
}
/** Löscht eine zurückgehaltene oder gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */
function vt_delete(PDO $pdo, int $rowId): bool
{
$row = vt_row($pdo, $rowId);
if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
$file = vt_hold_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_virustotal_files SET status = 'deleted', detail = ?, checked_at = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), db_now(), $rowId]);
return true;
}
/** Von Hand freigeben (zum Beispiel bei Fehlalarm oder unbekannter Datei). */
function vt_release_manual(PDO $pdo, int $rowId): bool
{
$row = vt_row($pdo, $rowId);
if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
return vt_release($pdo, $row, 'released', 'Von Hand freigegeben am ' . db_now());
}
/** Noch einmal prüfen lassen (zum Beispiel nach einem Fehler). */
function vt_recheck(PDO $pdo, int $rowId): bool
{
$row = vt_row($pdo, $rowId);
if (!$row || !in_array($row['status'], ['unknown', 'error'], true)) return false;
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'pending', tries = 0, next_try = ?, detail = NULL, analysis_id = NULL WHERE id = ?")->execute([db_now(), $rowId]);
return true;
}
// ── Hintergrundlauf ────────────────────────────────────────────────────────
function vt_day_used(): int
{
$today = date('Y-m-d');
if (plugin_setting(VT, 'day', '') !== $today) { plugin_set_setting(VT, 'day', $today); plugin_set_setting(VT, 'day_count', '0'); }
return (int) plugin_setting(VT, 'day_count', '0');
}
function vt_day_add(int $n = 1): void
{
plugin_set_setting(VT, 'day_count', (string) (vt_day_used() + $n));
}
/**
* Entscheidung nach einer Abfrage. @return bool false = Lauf beenden (Tageslimit, Minutenlimit oder Schlüsselproblem)
*/
function vt_apply(PDO $pdo, array $row, array $res, array $cfg): bool
{
$soon = fn(int $sec) => date('Y-m-d H:i:s', time() + $sec);
switch ($res['result']) {
case 'limit':
$pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
return false;
case 'key':
plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
return false;
case 'error':
plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
$tries = (int) $row['tries'] + 1;
if ($tries >= 6) {
if ($cfg['on_error'] === 'allow') vt_release($pdo, $row, 'released', 'VirusTotal nicht erreichbar, freigegeben (Einstellung)');
else $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, tries = ?, checked_at = ? WHERE id = ?")->execute([mb_substr((string) $res['detail'], 0, 250), $tries, db_now(), $row['id']]);
} else {
$pdo->prepare('UPDATE plugin_virustotal_files SET tries = ?, next_try = ?, detail = ? WHERE id = ?')->execute([$tries, $soon(min(3600, 60 * (2 ** $tries))), mb_substr((string) $res['detail'], 0, 250), $row['id']]);
}
return true;
case 'bad':
plugin_set_setting(VT, 'last_error', '');
vt_block($pdo, $row, (string) $res['detail'], (int) $res['malicious'], (int) $res['engines']);
return true;
case 'clean':
plugin_set_setting(VT, 'last_error', '');
vt_release($pdo, $row, 'clean', (string) ($res['detail'] ?? 'VirusTotal: keine Funde'));
$pdo->prepare('UPDATE plugin_virustotal_files SET malicious = ?, engines = ? WHERE id = ?')->execute([(int) $res['malicious'], (int) $res['engines'], $row['id']]);
return true;
case 'wait':
if (time() - (int) strtotime((string) $row['created_at']) > 3600) {
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, checked_at = ? WHERE id = ?")->execute(['Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.', db_now(), $row['id']]);
} else {
$pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
}
return true;
default: // unknown oder none: VirusTotal kennt die Datei (noch) nicht
$path = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
$size = is_file($path) ? (int) filesize($path) : 0;
if ($cfg['upload'] && $size > 0 && $size <= VT_UPLOAD_MAX && $row['status'] === 'pending') {
$up = vt_upload($path, (string) $row['original_name'], $cfg['key']);
vt_day_add();
if (!$up['ok']) {
if ($up['detail'] === 'limit') { $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); return false; }
return vt_apply($pdo, $row, ['result' => 'error', 'detail' => $up['detail'], 'malicious' => 0, 'engines' => 0], $cfg);
}
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'analyzing', analysis_id = ?, next_try = ?, detail = 'Zur Analyse bei VirusTotal hochgeladen' WHERE id = ?")->execute([$up['id'], $soon(60), $row['id']]);
return true;
}
if ($cfg['unknown'] === 'allow') {
vt_release($pdo, $row, 'released', 'Bei VirusTotal unbekannt, freigegeben (Einstellung)');
return true;
}
$why = $cfg['upload'] && $size > VT_UPLOAD_MAX ? 'Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).' : 'Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.';
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'unknown', detail = ?, checked_at = ? WHERE id = ?")->execute([$why, db_now(), $row['id']]);
if ((int) $row['ticket_id'] > 0) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang bleibt gesperrt (unbekannt): ' . $row['original_name']);
return true;
}
}
/**
* Hintergrundlauf (jede Minute oder alle 5 Minuten): zurückgehaltene Anhänge abfragen, höchstens 4 pro Lauf (Grenze des kostenlosen
* Schlüssels) und 480 pro Tag. @return int Zahl der Abfragen
*/
function vt_tick(PDO $pdo): int
{
$cfg = vt_config();
if ($cfg['key'] === '') return 0;
// Zwei Auslöser (jede Minute und alle 5 Minuten) sollen sich nicht überlappen
if (time() - (int) plugin_setting(VT, 'tick_at', '0') < 25) return 0;
plugin_set_setting(VT, 'tick_at', (string) time());
// Anhänge, die kein Ereignis gemeldet hat (zum Beispiel aus E-Mails), nachträglich zurückhalten
foreach (vt_unseen($pdo, 25) as $att) vt_hold($pdo, $att);
$rows = $pdo->prepare("SELECT * FROM plugin_virustotal_files WHERE status IN ('pending','analyzing') AND next_try <= ? ORDER BY id LIMIT 4");
$rows->execute([db_now()]);
$done = 0;
foreach ($rows->fetchAll() as $row) {
if (vt_day_used() >= VT_DAY_LIMIT) { plugin_set_setting(VT, 'last_error', db_now() . ': Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.'); break; }
$res = $row['status'] === 'analyzing' && $row['analysis_id']
? vt_analysis((string) $row['analysis_id'], $cfg['key'], $cfg['threshold'])
: vt_lookup((string) $row['sha256'], $cfg['key'], $cfg['threshold']);
vt_day_add();
$done++;
$pdo->prepare('UPDATE plugin_virustotal_files SET checked_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
if (!vt_apply($pdo, $row, $res, $cfg)) break;
}
if ($done > 0) plugin_set_setting(VT, 'last_run', db_now());
return $done;
}
/** Zahlen für die Seite. @return array<string,int> */
function vt_stats(PDO $pdo): array
{
$out = ['pending' => 0, 'analyzing' => 0, 'clean' => 0, 'bad' => 0, 'unknown' => 0, 'error' => 0, 'released' => 0, 'deleted' => 0];
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virustotal_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n'];
return $out;
}
/** Prüft, ob der Schlüssel funktioniert (Abfrage des Fingerabdrucks der Standard-Testdatei EICAR). @return array{ok:bool,message:string} */
function vt_test_key(string $key, int $threshold): array
{
if ($key === '') return ['ok' => false, 'message' => 'Bitte zuerst einen Schlüssel eintragen und speichern.'];
$r = vt_lookup(VT_EICAR_SHA256, $key, $threshold);
if ($r['result'] === 'bad') return ['ok' => true, 'message' => 'Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt (' . $r['malicious'] . ' von ' . $r['engines'] . ' Scannern).'];
if ($r['result'] === 'limit') return ['ok' => false, 'message' => 'Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.'];
if ($r['result'] === 'clean' || $r['result'] === 'unknown' || $r['result'] === 'none') return ['ok' => true, 'message' => 'Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet (' . $r['result'] . ').'];
return ['ok' => false, 'message' => (string) ($r['detail'] ?? 'Die Abfrage ist fehlgeschlagen.')];
}
plugin_add_hook('plugin.uninstall.' . VT, function () {
try {
get_db()->exec('DROP TABLE IF EXISTS plugin_virustotal_files');
} catch (Throwable $e) {
// Die Dateien im Ordner uploads/vt-pruefung bleiben liegen
}
});