Upload via GUI (65 Dateien)

This commit is contained in:
2026-11-05 21:23:54 +00:00
parent 95faa40e98
commit 84448ad45e
15 changed files with 943 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS plugin_vertretung_absences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
staff_id INTEGER NOT NULL,
substitute_id INTEGER NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'planned',
created_by INTEGER NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_status ON plugin_vertretung_absences (status, start_date);
CREATE TABLE IF NOT EXISTS plugin_vertretung_moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
absence_id INTEGER NOT NULL,
ticket_id INTEGER NOT NULL,
from_staff_id INTEGER NOT NULL,
to_staff_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
returned_at TEXT NULL
);
CREATE INDEX IF NOT EXISTS idx_plugin_vertretung_moves_absence ON plugin_vertretung_moves (absence_id, returned_at)
@@ -0,0 +1,93 @@
<?php
// Seite des Vertretungs-Plugins. Verfügbar: $staff, $pdo.
$isAdmin = ($staff['role'] ?? '') === 'admin';
$message = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$action = $_POST['action'] ?? '';
if ($action === 'create') {
$staffId = $isAdmin ? (int) ($_POST['staff_id'] ?? 0) : (int) $staff['id'];
$r = vertretung_create($pdo, $staff, $staffId, (int) ($_POST['substitute_id'] ?? 0), (string) ($_POST['start_date'] ?? ''), (string) ($_POST['end_date'] ?? ''), (string) ($_POST['note'] ?? ''));
if ($r['ok']) $message = 'Die Abwesenheit ist eingetragen.'; else $error = $r['error'];
} elseif ($action === 'cancel') {
if (vertretung_cancel($pdo, $staff, (int) ($_POST['id'] ?? 0))) $message = 'Die Abwesenheit wurde beendet, offene Tickets sind wieder bei der ursprünglichen Person.';
else $error = 'Diese Abwesenheit kann nicht beendet werden.';
}
}
$people = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$labels = vertretung_status_labels();
$names = [];
foreach ($people as $p) $names[(int) $p['id']] = $p['name'];
$name = fn($id) => e($names[(int) $id] ?? vertretung_name($pdo, (int) $id));
$current = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id AND m.returned_at IS NULL) AS holding FROM plugin_vertretung_absences a WHERE a.status IN ('planned','active') ORDER BY a.start_date, a.id")->fetchAll();
$past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m WHERE m.absence_id = a.id) AS moved FROM plugin_vertretung_absences a WHERE a.status NOT IN ('planned','active') ORDER BY a.id DESC LIMIT 20")->fetchAll();
?>
<h1>Abwesenheit und Vertretung</h1>
<?php if ($message): ?><div class="flash success"><?= e($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="flash error"><?= e($error) ?></div><?php endif; ?>
<form method="post" class="card" style="max-width:900px;margin-bottom:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="create">
<h3>Abwesenheit eintragen</h3>
<p class="muted" style="font-size:.85rem">Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).</p>
<?php if (!function_exists('staff_substitution_department_ids')): ?><div class="flash warning">Dieser Server ist älter als 1.0.55: Die Vertretung bekommt die Tickets, sieht sie aber nur, wenn sie auch Zugang zur Abteilung hat. Bitte den Server aktualisieren.</div><?php endif; ?>
<div style="display:flex;gap:14px;flex-wrap:wrap">
<?php if ($isAdmin): ?>
<div><label>Wer ist abwesend?</label><select name="staff_id"><?php foreach ($people as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) $p['id'] === (int) $staff['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
<?php endif; ?>
<div><label>Vertretung</label><select name="substitute_id" required><option value="">– wählen –</option><?php foreach ($people as $p): if ((int) $p['id'] === (int) $staff['id'] && !$isAdmin) continue; ?><option value="<?= (int) $p['id'] ?>"><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
<div><label>Von</label><input type="date" name="start_date" value="<?= e(vertretung_today()) ?>" required></div>
<div><label>Bis (einschließlich)</label><input type="date" name="end_date" required></div>
</div>
<label style="margin-top:8px">Notiz (optional)</label><input type="text" name="note" maxlength="250" placeholder="zum Beispiel Sommerurlaub">
<div class="btn-row"><button type="submit" class="btn">Eintragen</button></div>
</form>
<div class="card" style="margin-bottom:16px">
<h3>Laufend und geplant</h3>
<?php if (!$current): ?><p class="muted">Keine Abwesenheiten eingetragen.</p><?php else: ?>
<table style="width:100%">
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Stand</th><th>Tickets</th><th>Die Vertretung sieht</th><th></th></tr></thead>
<tbody>
<?php foreach ($current as $a):
$dq = $pdo->prepare('SELECT d.name FROM departments d WHERE d.id = (SELECT department_id FROM staff WHERE id = ?) OR d.id IN (SELECT department_id FROM staff_department WHERE staff_id = ?) ORDER BY d.name');
$dq->execute([(int) $a['staff_id'], (int) $a['staff_id']]);
$deptNames = $dq->fetchAll(PDO::FETCH_COLUMN);
$absentAdmin = ($pdo->query('SELECT role FROM staff WHERE id = ' . (int) $a['staff_id'])->fetchColumn() === 'admin');
?>
<tr>
<td><?= $name($a['staff_id']) ?><?php if ($a['note']): ?><div class="muted" style="font-size:.76rem"><?= e((string) $a['note']) ?></div><?php endif; ?></td>
<td><?= $name($a['substitute_id']) ?></td>
<td><?= e(vertretung_date((string) $a['start_date'])) ?> – <?= e(vertretung_date((string) $a['end_date'])) ?></td>
<td><?= e($labels[$a['status']] ?? $a['status']) ?></td>
<td><?= $a['status'] === 'active' ? (int) $a['holding'] . ' bei der Vertretung' : '–' ?></td>
<td style="font-size:.82rem"><?= $absentAdmin ? 'Abteilungen der übernommenen Tickets' : ($deptNames ? e(implode(', ', $deptNames)) : '–') ?></td>
<td>
<?php if (vertretung_can_manage($staff, (int) $a['staff_id'])): ?>
<form method="post" style="display:inline" onsubmit="return confirm('Abwesenheit beenden? Offene Tickets gehen zurück.')"><?= csrf_field() ?><input type="hidden" name="action" value="cancel"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>"><button class="btn secondary" type="submit"><?= $a['status'] === 'active' ? 'Jetzt beenden' : 'Löschen' ?></button></form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php if ($past): ?>
<div class="card">
<h3>Frühere Abwesenheiten</h3>
<table style="width:100%">
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Ergebnis</th><th>Weitergegeben</th></tr></thead>
<tbody>
<?php foreach ($past as $a): ?>
<tr><td><?= $name($a['staff_id']) ?></td><td><?= $name($a['substitute_id']) ?></td><td><?= e(vertretung_date((string) $a['start_date'])) ?> – <?= e(vertretung_date((string) $a['end_date'])) ?></td><td><?= e($labels[$a['status']] ?? $a['status']) ?></td><td><?= (int) $a['moved'] ?> Tickets</td></tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
+9
View File
@@ -0,0 +1,9 @@
{
"slug": "vertretung",
"name": "Urlaub und Vertretung",
"version": "1.1.0",
"description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Die Vertretung sieht während der Abwesenheit auch die Abteilungen der abwesenden Person, selbst ohne eigenen Zugang. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf (Server ab 1.0.55).",
"author": "DeskHub",
"requires": "1.0.55",
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
}
+16
View File
@@ -0,0 +1,16 @@
<?php
// Urlaub und Vertretung: Zeiträume für Abwesenheiten, Umverteilung und Rückgabe von Tickets.
require_once __DIR__ . '/lib.php';
plugin_add_hook('cron.run', function () {
vertretung_tick(get_db());
});
// Ticket ist (von Regel, Hand oder API) einer abwesenden Person zugewiesen → an die Vertretung weitergeben
foreach (['ticket.created', 'ticket.assigned'] as $hook) {
plugin_add_hook($hook, function (array $payload) {
if (!empty($payload['ticketId'])) vertretung_redirect(get_db(), (int) $payload['ticketId']);
});
}
// Jeder Mitarbeiter pflegt seine eigene Abwesenheit, Administratoren alle
plugin_register_menu('Abwesenheit', 'vertretung');
Binary file not shown.
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
# VirusTotal-Anhangsprüfung für DeskHub
Jeder Anhang eines Tickets wird **zurückgehalten, bis VirusTotal ihn geprüft hat**. Erst danach ist er für Mitarbeiter herunterladbar.
Der Unterschied zum Plugin **Anhänge-Virenscan**: Der Virenscan prüft sofort lokal und fragt VirusTotal (optional) erst **nachträglich**; die Datei ist in der Zwischenzeit schon verfügbar. Dieses Plugin sperrt zuerst und gibt erst nach der Antwort von VirusTotal frei. Beide laufen nebeneinander (der Virenscan prüft eine Datei erst, wenn sie hier freigegeben ist).
## Ablauf
1. Neue Anhänge (Ticket anlegen, Antwort, E-Mail-Abruf) werden sofort **zurückgehalten**: Die Datei wandert in den Ordner `uploads/vt-pruefung/` (nicht von außen erreichbar), der Anhang heißt `[IN PRÜFUNG] …` und lässt sich nicht herunterladen. Im Ticket steht ein interner Hinweis.
2. Der Hintergrunddienst fragt bei VirusTotal den **SHA-256-Fingerabdruck** der Datei ab (nicht die Datei).
3. Ergebnis:
- **Bekannt und sauber** (weniger als *Schwelle* Scanner melden etwas, Standard 3): freigegeben, alles wie vorher.
- **Schadsoftware** (mindestens *Schwelle* Scanner): bleibt gesperrt (`[GESPERRT] …`), Tag „Virus-Verdacht“, Hinweis im Ticket, Mail an den Administrator.
- **Unbekannt:** Standard ist, die Datei **zurückzuhalten**, bis ein Administrator sie prüft und freigibt oder löscht. Einstellbar: freigeben, oder (nur wenn ausdrücklich eingeschaltet) **zur Analyse hochladen**.
- **VirusTotal nicht erreichbar:** mehrere Versuche mit wachsendem Abstand, danach zurückhalten (oder freigeben, einstellbar).
## Einrichtung
1. Plugin hochladen und aktivieren (Berechtigungen `hooks.tickets`, `hooks.cron`, `ui.menu`, `db.migrations`).
2. Auf virustotal.com ein (kostenloses) Konto anlegen und im Profil den **API Key** kopieren (64 Zeichen).
3. Im Adminportal unter **VirusTotal** den Schlüssel eintragen, speichern und **Schlüssel testen**.
**Ohne Schlüssel werden keine Anhänge zurückgehalten.** Anhänge, die vor dem Einschalten da waren, bleiben unberührt.
## Datenschutz (wichtig)
- Standard: **Keine Datei verlässt den Server.** Es wird nur der Fingerabdruck abgefragt.
- „Unbekannte Dateien hochladen“ (aus, bis zu 32 MB) sendet die **Datei** an VirusTotal. Dort wird sie gespeichert und steht Sicherheitsfirmen und zahlenden Kunden zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten (Ausweise, Bilder, Dokumente). Nur einschalten, wenn das für eure Nutzer in Ordnung ist und in der Datenschutzerklärung steht.
## Limits und Verhalten
- Kostenloser Schlüssel: **4 Abfragen pro Minute, 500 pro Tag.** Das Plugin hält sich daran (4 pro Lauf, höchstens 480 pro Tag). Bei vielen Anhängen entsteht eine Warteschlange; ist das Tageslimit erreicht, stehen die Dateien bis zum nächsten Tag an. Ein bezahlter Schlüssel hebt die Grenzen auf; die Zahl im Plugin bleibt konservativ.
- Die Abfragen laufen im Hintergrunddienst von DeskHub (jede Minute, wenn der echte Cronjob eingerichtet ist, sonst alle 5 Minuten bzw. bei Seitenaufrufen). **Mit Cronjob** (Einstellungen → Cron-Jobs → „Jetzt installieren“) sind Anhänge nach etwa einer Minute frei.
- Bis zum Ergebnis erscheint der Anhang im Ticket als `[IN PRÜFUNG]` und lässt sich nicht herunterladen (auch nicht vom Kunden, falls er seine Anhänge sieht).
- Der **Virenscan-Schutz bleibt wichtig**: VirusTotal kennt neue, gezielte Schadsoftware oft nicht. Unbekannt heißt nicht sauber; darum ist „zurückhalten“ der Standard.
## Grenzen
- Geprüft werden Dateien bis 32 MB per Upload; die Abfrage des Fingerabdrucks funktioniert für jede Größe.
- Es wird nur der Fingerabdruck bewertet (kein Entpacken von Archiven, kein Lesen des Inhalts); dafür gibt es den Virenscan.
- Bereits freigegebene Dateien werden nicht erneut geprüft.
## Tests
`tests/plugin_virustotal.php` im DeskHub-Projekt (VirusTotal wird dort durch eine Funktion ersetzt; es gibt keine Verbindung zu VirusTotal).
+100
View File
@@ -0,0 +1,100 @@
{
"_info": "English translations of the VirusTotal plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/<code>.json, for example lang/fr.json.",
"VirusTotal-Anhangsprüfung": "VirusTotal attachment check",
"Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.": "Holds back every ticket attachment until VirusTotal has checked it: first only the fingerprint (SHA-256) is looked up; the file itself does not leave the server. If the file is clean it is released; if several scanners report malware it stays blocked (quarantine, note in the ticket, email to the administrator). Unknown files are held back for manual review, released, or (only if explicitly switched on) uploaded to VirusTotal for analysis. Also works alongside the “Attachment virus scan” plugin. For administrators only.",
"VirusTotal": "VirusTotal",
"Zustand": "Status",
"Noch kein Schlüssel.": "No key yet.",
"Ohne VirusTotal-Schlüssel werden Anhänge": "Without a VirusTotal key, attachments are",
"nicht": "not",
"zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.": "held back and not checked. A free key is available after signing in at virustotal.com, in your profile under “API Key”.",
"✓ Aktiv.": "✓ Active.",
"Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.": "New attachments are blocked until VirusTotal has checked them.",
"warten ·": "waiting ·",
"gesperrt ·": "blocked ·",
"unbekannt ·": "unknown ·",
"mit Fehler ·": "with error ·",
"freigegeben": "released",
"Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: {}.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: {}.",
"Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: noch nicht.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: not yet.",
"Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den": "Procedure: every new attachment is held back immediately (not downloadable). The plugin then asks VirusTotal only for the",
"Fingerabdruck (SHA-256)": "fingerprint (SHA-256)",
"der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens {#} Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.": "of the file. If the file is known and clean it is released; if at least {} scanners report malware it stays blocked. Attachments that existed before the plugin was switched on are left untouched.",
"Einstellungen": "Settings",
"VirusTotal-Schlüssel (API Key)": "VirusTotal key (API key)",
"64 Zeichen": "64 characters",
"Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)": "Remove key (attachments are then no longer checked)",
"Sperren ab so vielen Scannern, die Schadsoftware melden": "Block from this many scanners that report malware",
"Dateien, die VirusTotal nicht kennt": "Files that VirusTotal does not know",
"Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)": "Hold back until an administrator reviews and releases them (safe)",
"Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)": "Release (convenient, but VirusTotal often does not know new malware yet)",
"Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).": "Upload unknown files to VirusTotal for analysis (up to 32 MB).",
"Achtung Datenschutz:": "Privacy warning:",
"Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt": "Uploaded files are stored at VirusTotal and are available to security companies and paying customers of VirusTotal. Ticket attachments can contain personal content. When switched off,",
"keine Datei": "no file",
"den Server, nur der Fingerabdruck wird abgefragt.": "leaves the server; only the fingerprint is looked up.",
"Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist": "If VirusTotal cannot be reached after several attempts",
"Zurückhalten (sicher)": "Hold back (safe)",
"Mail an den Administrator, wenn ein Anhang gesperrt wird": "Email the administrator when an attachment is blocked",
"Speichern": "Save",
"Schlüssel testen": "Test key",
"Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.": "Looks up the fingerprint of the standard test file (EICAR) at VirusTotal. Nothing is uploaded. Save first.",
"Zurückgehaltene und gesperrte Anhänge": "Held-back and blocked attachments",
"Nichts zurückgehalten.": "Nothing held back.",
"Zuletzt geprüft": "Recently checked",
"Noch nichts geprüft.": "Nothing checked yet.",
"Datei": "File",
"Ticket": "Ticket",
"Größe": "Size",
"Hinweis": "Note",
"Ergebnis": "Result",
"Wann": "When",
"Freigeben": "Release",
"Erneut prüfen": "Check again",
"Löschen": "Delete",
"Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.": "Release this file? Staff will be able to download it.",
"Diese Datei endgültig löschen?": "Delete this file permanently?",
"Wartet auf VirusTotal": "Waiting for VirusTotal",
"Wird bei VirusTotal analysiert": "Being analysed at VirusTotal",
"Unbekannt (zurückgehalten)": "Unknown (held back)",
"Fehler (zurückgehalten)": "Error (held back)",
"Gesperrt (Schadsoftware)": "Blocked (malware)",
"Sauber, freigegeben": "Clean, released",
"Von Hand freigegeben": "Released by hand",
"Gelöscht": "Deleted",
"Gespeichert.": "Saved.",
"Die Datei ist freigegeben.": "The file has been released.",
"Die Datei ließ sich nicht freigeben.": "The file could not be released.",
"Die Datei ist gelöscht.": "The file has been deleted.",
"Die Datei ließ sich nicht löschen.": "The file could not be deleted.",
"Die Datei wird beim nächsten Lauf noch einmal geprüft.": "The file will be checked again on the next run.",
"Das ging nicht.": "That did not work.",
"Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).": "The VirusTotal key consists of 64 characters (0–9, a–f).",
"Bitte zuerst einen Schlüssel eintragen und speichern.": "Please enter a key and save it first.",
"Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt ({} von {} Scannern).": "The key works: VirusTotal recognised the standard test file (EICAR) as dangerous ({} of {} scanners).",
"Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.": "The VirusTotal query limit has just been reached. Please try again in a minute.",
"Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet ({}).": "The key is accepted, but the test file was rated unexpectedly ({}).",
"Die Abfrage ist fehlgeschlagen.": "The query failed.",
"Keine Berechtigung.": "No permission.",
"VirusTotal: {} von {} Scannern melden Schadsoftware": "VirusTotal: {} of {} scanners report malware",
"VirusTotal: {} von {} Scannern melden etwas": "VirusTotal: {} of {} scanners report something",
"VirusTotal: keine Funde": "VirusTotal: no findings",
"Der VirusTotal-Schlüssel wird abgelehnt.": "The VirusTotal key is rejected.",
"VirusTotal nicht erreichbar (Status {}).": "VirusTotal not reachable (status {}).",
"Unerwartete Antwort von VirusTotal.": "Unexpected response from VirusTotal.",
"Upload zu VirusTotal fehlgeschlagen (Status {}).": "Upload to VirusTotal failed (status {}).",
"Zur Analyse bei VirusTotal hochgeladen": "Uploaded to VirusTotal for analysis",
"Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.": "The analysis at VirusTotal was not finished in time.",
"Bei VirusTotal unbekannt, freigegeben (Einstellung)": "Unknown at VirusTotal, released (setting)",
"Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).": "Unknown at VirusTotal and too large for the upload (over 32 MB).",
"Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.": "Unknown at VirusTotal: please check it yourself and release or delete it.",
"VirusTotal nicht erreichbar, freigegeben (Einstellung)": "VirusTotal not reachable, released (setting)",
"Von Hand freigegeben am {}": "Released by hand on {}",
"Gelöscht am {}": "Deleted on {}",
"Datei nicht gefunden.": "File not found.",
"Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).": "The file could not be held back (check write permissions in the uploads folder).",
"{}: Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.": "{}: The daily limit of the VirusTotal key has been reached; it continues tomorrow.",
"{}: Der VirusTotal-Schlüssel wird abgelehnt.": "{}: The VirusTotal key is rejected.",
"{}: VirusTotal nicht erreichbar (Status {}).": "{}: VirusTotal not reachable (status {}).",
"{}: Unerwartete Antwort von VirusTotal.": "{}: Unexpected response from VirusTotal."
}
+420
View File
@@ -0,0 +1,420 @@
<?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
}
});
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS plugin_virustotal_files (
id INT AUTO_INCREMENT PRIMARY KEY,
attachment_id INT NOT NULL,
ticket_id INT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
filesize BIGINT NOT NULL DEFAULT 0,
sha256 VARCHAR(64) NULL,
status VARCHAR(20) NOT NULL,
detail VARCHAR(255) NULL,
analysis_id VARCHAR(255) NULL,
malicious INT NULL,
engines INT NULL,
tries INT NOT NULL DEFAULT 0,
next_try DATETIME NOT NULL,
created_at DATETIME NOT NULL,
checked_at DATETIME NULL,
UNIQUE KEY idx_plugin_virustotal_attachment (attachment_id),
KEY idx_plugin_virustotal_status (status, next_try)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS plugin_virustotal_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
attachment_id INTEGER NOT NULL,
ticket_id INTEGER NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
filesize INTEGER NOT NULL DEFAULT 0,
sha256 VARCHAR(64) NULL,
status VARCHAR(20) NOT NULL,
detail VARCHAR(255) NULL,
analysis_id VARCHAR(255) NULL,
malicious INTEGER NULL,
engines INTEGER NULL,
tries INTEGER NOT NULL DEFAULT 0,
next_try DATETIME NOT NULL,
created_at DATETIME NOT NULL,
checked_at DATETIME NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virustotal_attachment ON plugin_virustotal_files (attachment_id);
CREATE INDEX IF NOT EXISTS idx_plugin_virustotal_status ON plugin_virustotal_files (status, next_try)
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
if (($staff['role'] ?? '') !== 'admin') {
http_response_code(403);
echo '<p>Keine Berechtigung.</p>';
return;
}
$pdo = get_db();
vt_ensure_schema();
$message = '';
$messageType = 'success';
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
verify_csrf();
$action = (string) ($_POST['action'] ?? '');
$id = (int) ($_POST['id'] ?? 0);
if ($action === 'save') {
$keyIn = trim((string) ($_POST['key'] ?? ''));
if ($keyIn !== '' && !preg_match('/^[a-f0-9]{64}$/i', $keyIn)) {
$message = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).';
$messageType = 'error';
} else {
if ($keyIn !== '') plugin_set_setting(VT, 'key_enc', encrypt_secret($keyIn));
if (!empty($_POST['clear_key'])) plugin_set_setting(VT, 'key_enc', '');
plugin_set_setting(VT, 'threshold', (string) max(1, min(30, (int) ($_POST['threshold'] ?? 3))));
plugin_set_setting(VT, 'unknown', ($_POST['unknown'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
plugin_set_setting(VT, 'on_error', ($_POST['on_error'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
plugin_set_setting(VT, 'upload', !empty($_POST['upload']) ? '1' : '0');
plugin_set_setting(VT, 'notify', !empty($_POST['notify']) ? '1' : '0');
audit_log((int) $staff['id'], 'VirusTotal: Einstellungen geändert' . (!empty($_POST['upload']) ? ' (Upload unbekannter Dateien an)' : ''));
$message = 'Gespeichert.';
}
} elseif ($action === 'test') {
$t = vt_test_key(vt_config()['key'], vt_config()['threshold']);
$message = $t['message'];
$messageType = $t['ok'] ? 'success' : 'error';
} elseif ($action === 'release' && $id > 0) {
$ok = vt_release_manual($pdo, $id);
if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' von Hand freigegeben');
$message = $ok ? 'Die Datei ist freigegeben.' : 'Die Datei ließ sich nicht freigeben.';
$messageType = $ok ? 'success' : 'error';
} elseif ($action === 'delete' && $id > 0) {
$ok = vt_delete($pdo, $id);
if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' gelöscht');
$message = $ok ? 'Die Datei ist gelöscht.' : 'Die Datei ließ sich nicht löschen.';
$messageType = $ok ? 'success' : 'error';
} elseif ($action === 'recheck' && $id > 0) {
$ok = vt_recheck($pdo, $id);
$message = $ok ? 'Die Datei wird beim nächsten Lauf noch einmal geprüft.' : 'Das ging nicht.';
$messageType = $ok ? 'success' : 'error';
}
}
$cfg = vt_config();
$stats = vt_stats($pdo);
$used = vt_day_used();
$lastError = (string) plugin_setting(VT, 'last_error', '');
$lastRun = (string) plugin_setting(VT, 'last_run', '');
$held = $pdo->query("SELECT f.*, t.ticket_number FROM plugin_virustotal_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.status IN ('pending','analyzing','unknown','error','bad') ORDER BY (CASE f.status WHEN 'bad' THEN 0 WHEN 'unknown' THEN 1 WHEN 'error' THEN 2 ELSE 3 END), f.id DESC LIMIT 100")->fetchAll();
$recent = $pdo->query("SELECT * FROM plugin_virustotal_files WHERE status IN ('clean','released','deleted') ORDER BY id DESC LIMIT 30")->fetchAll();
$labels = ['pending' => ['Wartet auf VirusTotal', '#b45309'], 'analyzing' => ['Wird bei VirusTotal analysiert', '#b45309'], 'unknown' => ['Unbekannt (zurückgehalten)', '#b45309'], 'error' => ['Fehler (zurückgehalten)', '#b91c1c'],
'bad' => ['Gesperrt (Schadsoftware)', '#b91c1c'], 'clean' => ['Sauber, freigegeben', '#15803d'], 'released' => ['Von Hand freigegeben', '#15803d'], 'deleted' => ['Gelöscht', '#64748b']];
$mb = fn($b) => $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int) round($b / 1024)) . ' KB';
?>
<h1>VirusTotal</h1>
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
<section class="card" style="max-width:960px">
<h3>Zustand</h3>
<?php if ($cfg['key'] === ''): ?>
<p><strong style="color:#b45309">Noch kein Schlüssel.</strong> Ohne VirusTotal-Schlüssel werden Anhänge <strong>nicht</strong> zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.</p>
<?php else: ?>
<p><strong style="color:#157a45">✓ Aktiv.</strong> Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.</p>
<?php endif; ?>
<p style="font-size:.9rem">
<strong><?= (int) ($stats['pending'] + $stats['analyzing']) ?></strong> warten ·
<strong style="color:#b91c1c"><?= (int) $stats['bad'] ?></strong> gesperrt ·
<strong style="color:#b45309"><?= (int) $stats['unknown'] ?></strong> unbekannt ·
<strong><?= (int) $stats['error'] ?></strong> mit Fehler ·
<strong><?= (int) ($stats['clean'] + $stats['released']) ?></strong> freigegeben
</p>
<p class="muted" style="font-size:.85rem">Abfragen heute: <?= (int) $used ?> von <?= (int) VT_DAY_LIMIT ?> (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: <?= $lastRun !== '' ? e(format_dt($lastRun)) : 'noch nicht' ?>.</p>
<?php if ($lastError !== ''): ?><p style="color:#b91c1c;font-size:.88rem"><?= e($lastError) ?></p><?php endif; ?>
<p class="muted" style="font-size:.85rem">Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den <strong>Fingerabdruck (SHA-256)</strong> der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens <?= (int) $cfg['threshold'] ?> Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.</p>
</section>
<form method="post" class="card" style="max-width:960px;margin-top:16px">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<h3>Einstellungen</h3>
<label for="key">VirusTotal-Schlüssel (API Key)</label>
<input type="password" id="key" name="key" value="" placeholder="<?= $cfg['key'] !== '' ? '(unverändert)' : '64 Zeichen' ?>" autocomplete="new-password">
<?php if ($cfg['key'] !== ''): ?><label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-top:6px"><input type="checkbox" name="clear_key" value="1" style="width:auto"> Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)</label><?php endif; ?>
<label for="threshold">Sperren ab so vielen Scannern, die Schadsoftware melden</label>
<input type="number" id="threshold" name="threshold" min="1" max="30" value="<?= (int) $cfg['threshold'] ?>" style="max-width:120px">
<label for="unknown">Dateien, die VirusTotal nicht kennt</label>
<select id="unknown" name="unknown" style="max-width:520px">
<option value="hold" <?= $cfg['unknown'] === 'hold' ? 'selected' : '' ?>>Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)</option>
<option value="allow" <?= $cfg['unknown'] === 'allow' ? 'selected' : '' ?>>Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)</option>
</select>
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:12px"><input type="checkbox" name="upload" value="1" style="width:auto;margin-top:3px" <?= $cfg['upload'] ? 'checked' : '' ?>> <span><strong>Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).</strong> <span style="color:#b91c1c">Achtung Datenschutz:</span> Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt <strong>keine Datei</strong> den Server, nur der Fingerabdruck wird abgefragt.</span></label>
<label for="on_error" style="margin-top:12px">Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist</label>
<select id="on_error" name="on_error" style="max-width:520px">
<option value="hold" <?= $cfg['on_error'] === 'hold' ? 'selected' : '' ?>>Zurückhalten (sicher)</option>
<option value="allow" <?= $cfg['on_error'] === 'allow' ? 'selected' : '' ?>>Freigeben</option>
</select>
<label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-top:12px"><input type="checkbox" name="notify" value="1" style="width:auto" <?= $cfg['notify'] ? 'checked' : '' ?>> Mail an den Administrator, wenn ein Anhang gesperrt wird</label>
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
</form>
<form method="post" class="card" style="max-width:960px;margin-top:16px">
<?= csrf_field() ?><input type="hidden" name="action" value="test">
<h3>Schlüssel testen</h3>
<p class="muted" style="font-size:.88rem">Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.</p>
<button type="submit" class="btn secondary">Schlüssel testen</button>
</form>
<section class="card" style="margin-top:16px">
<h3>Zurückgehaltene und gesperrte Anhänge</h3>
<?php if (!$held): ?>
<p class="muted">Nichts zurückgehalten.</p>
<?php else: ?>
<div style="overflow-x:auto"><table style="width:100%">
<thead><tr><th>Datei</th><th>Ticket</th><th>Größe</th><th>Zustand</th><th>Hinweis</th><th></th></tr></thead>
<tbody>
<?php foreach ($held as $r): [$label, $color] = $labels[$r['status']] ?? [$r['status'], '#64748b']; ?>
<tr>
<td><?= e($r['original_name']) ?><div class="muted" style="font-size:.72rem;word-break:break-all"><?= e((string) $r['sha256']) ?></div></td>
<td><?php if (!empty($r['ticket_number'])): ?><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $r['ticket_number'])) ?>">#<?= e((string) $r['ticket_number']) ?></a><?php endif; ?></td>
<td><?= e($mb((int) $r['filesize'])) ?></td>
<td><strong style="color:<?= e($color) ?>"><?= e($label) ?></strong></td>
<td style="max-width:300px;font-size:.82rem"><?= e((string) $r['detail']) ?></td>
<td style="white-space:nowrap">
<?php foreach ([['release', 'Freigeben', 'Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.'], ['recheck', 'Erneut prüfen', ''], ['delete', 'Löschen', 'Diese Datei endgültig löschen?']] as [$act, $text, $ask]):
if ($act === 'recheck' && !in_array($r['status'], ['unknown', 'error'], true)) continue; ?>
<form method="post" style="display:inline"<?= $ask !== '' ? ' data-confirm="' . e($ask) . '" onsubmit="return confirm(this.dataset.confirm)"' : '' ?>><?= csrf_field() ?><input type="hidden" name="action" value="<?= e($act) ?>"><input type="hidden" name="id" value="<?= (int) $r['id'] ?>"><button class="btn secondary" style="padding:3px 10px;font-size:.8rem" type="submit"><?= e($text) ?></button></form>
<?php endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</section>
<section class="card" style="margin-top:16px">
<h3>Zuletzt geprüft</h3>
<?php if (!$recent): ?>
<p class="muted">Noch nichts geprüft.</p>
<?php else: ?>
<div style="overflow-x:auto"><table style="width:100%">
<thead><tr><th>Datei</th><th>Zustand</th><th>Ergebnis</th><th>Wann</th></tr></thead>
<tbody>
<?php foreach ($recent as $r): [$label, $color] = $labels[$r['status']] ?? [$r['status'], '#64748b']; ?>
<tr><td><?= e($r['original_name']) ?></td><td><strong style="color:<?= e($color) ?>"><?= e($label) ?></strong></td><td class="muted" style="font-size:.82rem"><?= e((string) $r['detail']) ?></td><td><?= e(format_dt((string) ($r['checked_at'] ?: $r['created_at']))) ?></td></tr>
<?php endforeach; ?>
</tbody>
</table></div>
<?php endif; ?>
</section>
+10
View File
@@ -0,0 +1,10 @@
{
"slug": "virustotal",
"name": "VirusTotal-Anhangsprüfung",
"version": "1.0.0",
"description": "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.",
"author": "DeskHub",
"entry": "plugin.php",
"requires": "1.0.0",
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
// VirusTotal-Anhangsprüfung: hält Anhänge neuer Tickets und Antworten zurück, bis VirusTotal sie geprüft hat.
require_once __DIR__ . '/lib.php';
vt_ensure_schema();
try {
vt_start_id(get_db()); // Startmarke beim ersten Laden setzen: was vorher schon da war, bleibt unberührt (und der erste neue Anhang wird sicher erfasst)
} catch (Throwable $e) {
error_log('virustotal start: ' . $e->getMessage());
}
foreach (['ticket.created', 'ticket.replied'] as $hook) {
plugin_add_hook($hook, function (array $payload) {
// Nur zurückhalten, wenn ein Schlüssel eingetragen ist: ohne Schlüssel gäbe es nie ein Ergebnis und die Anhänge blieben für immer gesperrt
if (!empty($payload['ticketId']) && vt_config()['key'] !== '') vt_hold_ticket(get_db(), (int) $payload['ticketId']);
});
}
// Abfragen: jede Minute (wenn der Hintergrunddienst es kann), sonst alle 5 Minuten
plugin_add_hook('cron.minute', function () {
vt_tick(get_db());
});
plugin_add_hook('cron.run', function () {
vt_tick(get_db());
});
plugin_register_menu('VirusTotal', 'virustotal', null, 'admin');