421 lines
25 KiB
PHP
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
|
|
}
|
|
});
|