<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * Exploit-Signaturen und Warnliste verwundbarer Erweiterungen.
 * Die eingebauten Definitionen werden mit dem Komponenten-Update
 * geliefert; zusaetzlich kann eine JSON-Datei vom Update-Server
 * nachgeladen werden (Live-Update), ohne dass die Komponente selbst
 * aktualisiert werden muss. Die Uebertragung ist per TLS (HTTPS mit
 * Zertifikatspruefung) abgesichert; eine zusaetzliche kryptografische
 * Signatur der Datei ist als Haertung vorgesehen, aber noch nicht aktiv.
 *
 * SICHERHEITSMODELL: Die nachgeladene JSON beeinflusst NIE rohe
 * .htaccess-Direktiven. Sie liefert ausschliesslich Parameter fuer
 * zwei feste Regel-Schablonen (QUERY_STRING-Bedingung / REQUEST_URI-
 * Regel, Ziel immer "- [F]") sowie Warn-Texte. Jedes Muster wird vor
 * der Speicherung UND erneut vor jedem Build streng geprueft
 * (siehe isSafePattern): Kompilierbarkeit, Laenge, verbotene Zeichen
 * (" und #), kein End-Backslash (kein Quote-Ausbruch in der .htaccess)
 * und kein Leerstring-Match (kein Catch-all, das die Site aussperrt).
 */

defined('_JEXEC') or die;

class HtpDefs
{
    /**
     * Endpunkt der nachladbaren Definitionsdatei.
     */
    const DEFS_URL = 'https://update.wb-dev.de/htprotect-defs.json';

    /**
     * Oeffentlicher Ed25519-Schluessel (base64) zum Pruefen der nachgeladenen
     * Definitionen. Der private Schluessel liegt ausschliesslich lokal beim
     * Herausgeber (nicht im Paket); signiert wird beim Release via signdefs.php.
     * Ist libsodium auf dem Client verfuegbar (PHP 7.2+), ist eine gueltige
     * Signatur PFLICHT - sonst werden die Remote-Defs verworfen (builtin bleibt).
     */
    const DEFS_PUBKEY = 'DQUoR9aKXlhsAXwPwEieHbf9oa4ecMly1g+E/DDzS58=';

    /**
     * Erste HTProtect-Version, deren Update-Paket ein signiertes Manifest
     * (htprotect.pkgsig.json) TRAEGT. Ab hier gilt: ein Paket, das sich als
     * >= diese Version ausgibt, MUSS auf pruefFAEHIGer Umgebung ein gueltiges
     * Manifest haben - ein fehlendes ist dann Manipulation (Umgehung durch
     * Weglassen), kein blosses "unklar". Siehe packageInstallDecision().
     */
    const FIRST_SIGNED_VERSION = '2.4.12';

    /**
     * Schluessel der zwischengespeicherten Definitionen in #__extensions.params.
     */
    const CACHE_KEY = 'htp_defs';

    /**
     * Maximaler Abstand zwischen zwei automatischen Aktualisierungen des WAF-/Exploit-
     * Feeds. Auf 6 h angeglichen an den Malware-Feed (senkt die Last auf dem Update-
     * Server) - neue Angriffswege + verwundbare Erweiterungen blocken zwar zeitnah live,
     * aber der Abruf ist KONDITIONAL (ETag/If-None-Match -> 304 ohne Body, solange der
     * Feed unveraendert ist), und sicherheitskritische Regeln greifen ohnehin sofort.
     */
    const REFRESH_INTERVAL = 21600; // 6 Stunden (angeglichen an den Malware-Feed; senkt die Last auf dem Update-Server. Der Abruf ist konditional/ETag -> ohnehin meist nur ein 304 ohne Body.)

    /**
     * SEPARATER, signierter Feed nur fuer die Malware-Scanner-Signaturen.
     * Eigene Datei (Fehler-Isolation + scan-naher Lebenszyklus), aber GLEICHER
     * Ed25519-Schluessel (DEFS_PUBKEY) und dieselbe Verifikation (verifyAndUnwrap).
     * Eigener Cache-Schluessel in #__extensions.params -> ein fehlerhafter
     * Malware-Payload kann den WAF-Defs-Cache nie beschaedigen.
     */
    const MALWARE_URL          = 'https://update.wb-dev.de/htprotect-malware.json';
    const MALWARE_CACHE_KEY    = 'htp_malware';
    const MALWARE_REFRESH_INTERVAL = 21600; // 6 Stunden (gleich dem WAF/Vuln-Feed; Scanner-Signaturen erkennen BEREITS abgelegte Malware -> nicht zeitkritischer als der Eingangs-Schutz)
    const MALWARE_MAX_SIGS     = 500;

    /**
     * Obergrenze der Rueckruf-/Sperrliste (recall) im Malware-Feed. Eine zentrale,
     * signierte Liste hochproblematischer Erweiterungs-UPDATES ("Version X nicht
     * installieren"). REIN PRAEVENTIV: betroffene Sites installieren das markierte
     * Update einfach nicht (fail-safe; greift NIE in eine laufende Seite ein, kein
     * Remote-Rollback). Eigene, kleinere Kappe als die Signaturen.
     */
    const MALWARE_MAX_RECALL   = 200;

    /**
     * VORSORGE-SPERRE (precautionary hold) - eine kurzlebige, von RADAR abgeleitete
     * Liste von Update-Versionen, die gerade bei mehreren Seiten im Malware-Scan
     * anschlugen. REIN SUBTRAKTIV: der Client installiert die markierte Version
     * still nicht (Sicherheitsupdates ausgenommen), bis radar sie freigibt bzw. der
     * Backstop greift. Anders als der Feed NICHT Ed25519-signiert - die Autoritaet
     * ist das per TLS verifizierte radar; entsprechend FAIL-OPEN (im Zweifel NICHT
     * sperren) und hart gedeckelt (Anzahl + Client-Clamp der Laufzeit).
     */
    const HOLD_URL              = 'https://radar.wb-dev.de/hold.php';
    const HOLD_REFRESH_INTERVAL = 1800;    // 30 Min (zeitkritischer als der 6h-Feed)
    const HOLD_RETRY            = 600;     // nach Fehler frueher erneut versuchen
    const HOLD_MAXHOLD          = 1209600; // 14 Tage: Client-Clamp gegen "Ewigkeits-Sperre"
    const HOLD_MAX_ENTRIES      = 200;     // Obergrenze gehaltener Sperren

    /**
     * Feed-Faehigkeits-Stufe dieses Clients. Feed-Eintraege (Signaturen UND FP-
     * Regeln) duerfen optional ein 'min' tragen; ist es groesser als FEED_CAP,
     * ueberspringt der Client den GANZEN Eintrag (nicht nur das unbekannte Feld)
     * -> neues Vokabular ist gefahrlos einfuehrbar, alte Clients ueber-matchen nie.
     * Stufe 2 = FP-Unterdrueckungs-Schicht (fp: sha256/Fingerprint/only).
     */
    const FEED_CAP = 2;

    /**
     * Schwarm-Schutz (Community-Meldeportal). SWARM_KEY ist NUR ein Abuse-Schutz
     * des Endpunkts (im Paket extrahierbar, KEIN Security-Boundary - das Portal
     * kuratiert ohnehin jede Meldung). Siehe HtpSwarm.
     */
    const SWARM_URL = 'https://radar.wb-dev.de/submit.php';
    const SWARM_KEY = 'a3f8c1e07b9d4526f1ac8e3b6d20749c5e8f1b3a9d6c4072e1b8f5a3c7d09e26';

    /**
     * Offizielle Joomla-Downloads-API: liefert je Zweig die aktuellste CMS-
     * Version. Autoritativ und unabhaengig von Joomlas (teils fehlerhaftem)
     * internem Update-Check -> die Ampel braucht keine handgepflegte Liste mehr.
     */
    const JOOMLA_API_URL = 'https://downloads.joomla.org/api/v1/latest/cms';

    /**
     * Auffrisch-Intervall der Joomla-Versionsliste: mit vorhandenen Daten
     * taeglich; bei leerem Cache hoechstens stuendlich erneut versuchen
     * (gegen wiederholte Timeouts bei dauerhaft fehlender Verbindung).
     */
    const JLATEST_INTERVAL = 86400;
    const JLATEST_RETRY    = 3600;

    /**
     * Obergrenze der zu prüfenden Eingabelänge in der WAF (ReDoS-/Kostenschutz).
     */
    const WAF_MAX_INPUT = 8192;

    /**
     * Enges Backtracking-Limit während der WAF-Prüfung. Wird es überschritten
     * (z. B. durch ein bösartiges Muster), liefert preg_match false -> gilt als
     * "kein Treffer" (fail-open), statt den Prozess aufzuhängen.
     */
    const WAF_BACKTRACK_LIMIT = 100000;

    /**
     * Dateiname der WAF-Trigger-Datei im Joomla-Temp-Ordner. Das Hintergrund-
     * Plugin liest sie im billigen Vorabtest (ohne Engine/DB), um auch
     * NACHGELADENE Signaturen fuer neue Komponenten auswerten zu koennen.
     */
    const WAF_TRIGGER_FILE = 'htprotect_waf.triggers';

    /**
     * Prozess-Cache der nachgeladenen Definitionen. Von saveCached aktualisiert,
     * damit wafTriggers() direkt nach einem Refresh frische Signaturen sieht.
     */
    private static $cacheMemo = null;

    /**
     * Prozess-Cache des Malware-Sig-Feeds (eigener Schluessel, getrennt von
     * $cacheMemo). Einmal je Request aus der DB geladen -> der per-Datei-Aufruf
     * im Scanner kostet danach nichts.
     */
    private static $malwareMemo = null;

    /**
     * Eigene Blob-Tabelle fuer die grossen, staendig wachsenden Feed-Caches
     * (Signaturen fuer WAF + Scanner). Bewusst NICHT in #__extensions.params:
     * diese TEXT-Spalte fasst nur 64 KB und wird sonst vom Feed gesprengt -> dann
     * scheitert jedes Speichern der Nutzer-Config. MEDIUMTEXT = 16 MB; der Inhalt
     * ist gzip+base64 (reines ASCII) -> auch gegen datei- UND datenbankbasierte
     * AV-Scanner opak. (Datei-Ablage scheidet aus: Imunify/CloudAV/mysites.guru
     * wuerden die Signaturen als Fund markieren - deshalb ueberhaupt in der DB.)
     */
    const STORE_TABLE = '#__htprotect_store';

    /** Store-Verfuegbarkeit je Request (null=ungeprueft) - vermeidet wiederholte DDL-Versuche. */
    private static $storeReady = null;

    /**
     * Eingebaute Definitionen (mit dem Komponenten-Update gepflegt).
     *
     * @return array
     */
    public static function builtin()
    {
        return array(
            'schema'        => 1,
            // Nur der mitgelieferte Fallback (greift vor dem ersten Feed-Abruf).
            // Die LIVE-Feed-Version stempelt builddefs.php automatisch = Build-
            // Datum + Inhalts-Fingerabdruck -> haendisch NICHTS pflegen.
            'version'       => '2026-07-14.1',
            'min_component' => '2.2.0',
            'signatures'    => self::builtinSignatures(),
            'vuln_extensions' => self::builtinVulnExtensions(),
            'php_min'       => '8.1',
        );
    }

    /**
     * Eingebaute Exploit-Signaturen.
     *
     * Jede Signatur: id, label, desc, default (0/1),
     *   qs   = Regex gegen %{QUERY_STRING} (oder null),
     *   uri  = Regex gegen den Pfad ohne Site-Praefix (oder null),
     *   frontend_only = nur ausserhalb von /administrator pruefen.
     *
     * @return array
     */
    public static function builtinSignatures()
    {
        return array(
            array(
                'id'    => 'jce-profiles-import',
                'label' => 'JCE Profil-Import (CVE-2026-48907)',
                'label_en' => 'JCE profile import (CVE-2026-48907)',
                'desc'  => 'Unauthentifizierter Profil-Import im JCE-Editor - Beginn der aktuellen RCE-Kette (schaltet PHP-Uploads frei). Kein legitimer Frontend-Aufruf.',
                'desc_en' => 'Unauthenticated profile import in the JCE editor - start of the current RCE chain (enables PHP uploads). No legitimate frontend call.',
                'default' => 1,
                'qs'    => '(?=.*option=com_jce)(?=.*task=profiles\.import)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
            ),
            array(
                'id'    => 'jce-rpc-upload',
                'label' => 'JCE Webshell-Upload via plugin.rpc (CVE-2026-48907)',
                'label_en' => 'JCE webshell upload via plugin.rpc (CVE-2026-48907)',
                'desc'  => 'Datei-Upload über den JCE-Dateibrowser (plugin.rpc, method=upload). Greift nur im Frontend; eingeloggte Autoren (legitimer Editor-Upload) bleiben unberührt.',
                'desc_en' => 'File upload via the JCE file browser (plugin.rpc, method=upload). Applies only on the frontend; logged-in authors (legitimate editor upload) are unaffected.',
                'default' => 1,
                'qs'    => '(?=.*option=com_jce)(?=.*task=plugin\.rpc)(?=.*method=upload)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
            ),
            array(
                'id'    => 'jce-imgmanager',
                'label' => 'JCE Image Manager (Alt-Exploit)',
                'label_en' => 'JCE Image Manager (legacy exploit)',
                'desc'  => 'Klassischer unauthentifizierter Datei-Upload über den JCE-Bildmanager (sehr alte JCE-Versionen).',
                'desc_en' => 'Classic unauthenticated file upload via the JCE image manager (very old JCE versions).',
                'default' => 1,
                'qs'    => '(?=.*option=com_jce)(?=.*plugin=imgmanager)(?=.*method=form)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
            ),
            array(
                'id'    => 'novarain-nrframework',
                'label' => 'Novarain / Tassos Framework (com_ajax include)',
                'label_en' => 'Novarain / Tassos Framework (com_ajax include)',
                'desc'  => 'CVE-2026-21627: unauthentifizierte PHP-Datei-Einbindung über plg_system_nrframework via com_ajax (task=include).',
                'desc_en' => 'CVE-2026-21627: unauthenticated PHP file inclusion via plg_system_nrframework through com_ajax (task=include).',
                'default' => 1,
                'qs'    => '(?=.*option=com_ajax)(?=.*nrframework)(?=.*task=include)',
                'uri'   => null,
                'frontend_only' => 1,
            ),
            array(
                'id'    => 'astroid-ajax',
                'label' => 'Astroid Framework (AJAX-Endpunkt)',
                'label_en' => 'Astroid Framework (AJAX endpoint)',
                'desc'  => 'CVE-2026-21628: ungeprüfter Astroid-AJAX-Endpunkt (Upload/Installation). Coarse-Match auf com_ajax + astroid.',
                'desc_en' => 'CVE-2026-21628: unchecked Astroid AJAX endpoint (upload/installation). Coarse match on com_ajax + astroid.',
                'default' => 1,
                'qs'    => '(?=.*option=com_ajax)(?=.*(plugin|template|view)=astroid)',
                'uri'   => null,
                'frontend_only' => 1,
            ),
            array(
                'id'    => 'helix3-ajax',
                'label' => 'Helix3 (JoomShaper): unauth. com_ajax-Handler (Datei schreiben/löschen)',
                'label_en' => 'Helix3 (JoomShaper): unauth com_ajax handler (file write/delete)',
                'desc'  => 'Helix3 < 3.1.2: der Ajax-Handler onAjaxHelix3 prüfte weder Login noch Rechte. Trifft gezielt die gefährlichen Unauth-Aktionen im POST-Body: save (Layout-JSON ins aktive Template schreiben), remove (Datei löschen via Path-Traversal), import (Template-Style-Settings überschreiben) sowie upload_image/remove_image/updateFonts. Legitime Gast-Aktionen (voting, load) und eingeloggte Template-Nutzung bleiben unberührt. Fix: Helix3 3.1.2 (CVE-2026-49049).',
                'desc_en' => 'Helix3 < 3.1.2: the onAjaxHelix3 ajax handler checked neither login nor permission. Targets the dangerous unauth POST-body actions: save (write layout JSON into the active template), remove (delete a file via path traversal), import (overwrite template style settings) plus upload_image/remove_image/updateFonts. Legitimate guest actions (voting, load) and logged-in template use are unaffected. Fix: Helix3 3.1.2 (CVE-2026-49049).',
                'default' => 1,
                'qs'    => 'data(?:\[|%5b)action(?:\]|%5d)=(?:save|remove|import|updatefonts)|action=(?:upload_image|remove_image)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                // Vorfilter-Gate: die Action steckt im POST-Body, der com_ajax-Kontext aber
                // im Request -> Trigger 'com_ajax' laesst die Regex nur bei com_ajax-Requests
                // laufen (deckt die Sig fuer den billigen Vorfilter ab UND verhindert FP auf
                // fremde data[action]=... ohne com_ajax-Bezug).
                'trigger' => 'com_ajax',
            ),
            array(
                'id'    => 'helix3-comajax',
                'label' => 'Helix3 (JoomShaper): unauth. com_ajax-Dispatcher (Datei-Write/Delete, Style-Injektion)',
                'label_en' => 'Helix3 (JoomShaper): unauth com_ajax dispatcher (file write/delete, style injection)',
                'desc'  => 'Helix3 < 3.1.2: der Front-Dispatcher (option=com_ajax mit plugin=helix3) rief onAjaxHelix3 VOR jeder Token-/Rechteprüfung auf - unauthentifiziert save/import/remove/resetLayout/updateFonts/fontVariants (Datei-Schreiben, Path-Traversal-Löschen, Template-Style-Injektion, Stored XSS). Blockt jeden GAST-Aufruf dieses Dispatchers im Frontend (Defense-in-Depth, fängt auch laufende Scans) - ergänzt die aktionsgenaue Signatur helix3-ajax um den bloßen Dispatcher-Zugriff. Eingeloggtes Template-Editing läuft im Backend hinter Auth und ist NICHT betroffen; guest-scope + waf_only. Fix: Helix3 3.1.2 (CVE-2026-49049, 2026-06-29). NICHT mit Helix Ultimate verwechseln - eigenes Produkt, eigene Signatur helixultimate-comajax.',
                'desc_en' => 'Helix3 < 3.1.2: the front-end dispatcher (option=com_ajax with plugin=helix3) invoked onAjaxHelix3 BEFORE any token/permission check - unauthenticated save/import/remove/resetLayout/updateFonts/fontVariants (file write, path-traversal delete, template style injection, stored XSS). Blocks every GUEST call of this dispatcher on the front end (defense in depth, also catches ongoing scans) - complements the action-specific helix3-ajax signature with the bare dispatcher access. Logged-in template editing runs in the backend behind auth and is NOT affected; guest scope + waf_only. Fix: Helix3 3.1.2 (CVE-2026-49049, 2026-06-29). Not to be confused with Helix Ultimate - separate product, own signature helixultimate-comajax.',
                'default' => 1,
                'qs'    => '(?=.*option=com_ajax)(?=.*plugin=helix3\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_ajax',
            ),
            array(
                'id'    => 'helixultimate-comajax',
                'label' => 'Helix Ultimate (JoomShaper): unauth. com_ajax-Dispatcher (Menü-Write/Datei/Export)',
                'label_en' => 'Helix Ultimate (JoomShaper): unauth com_ajax dispatcher (menu write/file/export)',
                'desc'  => 'Helix Ultimate < 2.2.7: der com_ajax-Handler onAjaxHelixultimate (option=com_ajax mit plugin=helixultimate, Action im task-Param) lief VOR jeder Login-/Rechteprüfung - ein anonymer Angreifer konnte in die Menü-Einstellungen schreiben (Stored XSS bis in die Admin-Sitzung), Dateien per Path-Traversal beliebig löschen, einen Open Redirect auslösen und das Template ungeschützt exportieren. Blockt jeden GAST-Aufruf dieses Dispatchers im Frontend; eingeloggtes Template-/Menü-Editing läuft hinter Auth und ist NICHT betroffen (guest-scope + waf_only, FP-frei). Fix: Helix Ultimate 2.2.7 (07.07.2026). Eigenes Produkt - NICHT Helix3.',
                'desc_en' => 'Helix Ultimate < 2.2.7: the com_ajax handler onAjaxHelixultimate (option=com_ajax with plugin=helixultimate, action in the task param) ran BEFORE any login/permission check - an anonymous attacker could write into the menu settings (stored XSS reaching the admin session), delete files anywhere via path traversal, trigger an open redirect and export the template unprotected. Blocks every GUEST call of this dispatcher on the front end; logged-in template/menu editing runs behind auth and is NOT affected (guest scope + waf_only, FP-free). Fix: Helix Ultimate 2.2.7 (2026-07-07). Separate product - NOT Helix3.',
                'default' => 1,
                'qs'    => '(?=.*option=com_ajax)(?=.*plugin=helixultimate\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_ajax',
            ),
            array(
                'id'    => 'comajax-include',
                'label' => 'com_ajax: Datei-Einbindung (generisch)',
                'label_en' => 'com_ajax: file inclusion (generic)',
                'desc'  => 'Fängt unbekannte LFI-Lücken derselben Klasse ab: com_ajax mit task=include/require. Frontend.',
                'desc_en' => 'Catches unknown LFI holes of the same class: com_ajax with task=include/require. Frontend.',
                'default' => 1,
                'qs'    => '(?=.*option=com_ajax)(?=.*task=(include|require)(_once)?)',
                'uri'   => null,
                'frontend_only' => 1,
            ),
            array(
                'id'    => 'sppagebuilder-uploadicon',
                'label' => 'SP Page Builder: unauthentifizierter Icon-Upload (RCE)',
                'label_en' => 'SP Page Builder: unauthenticated icon upload (RCE)',
                'desc'  => 'Zero-Day in SP Page Builder bis 6.6.1: der asset-Controller (task=asset.uploadCustomIcon) hatte keinerlei Zugriffs-/Login-Prüfung - unauthentifizierter Datei-Upload nach /media/com_sppagebuilder/assets/iconfont/ - RCE. Nur Mini-WAF (Gäste), da eingeloggte Builder den Endpunkt legitim nutzen.',
                'desc_en' => 'Zero-day in SP Page Builder up to 6.6.1: the asset controller (task=asset.uploadCustomIcon) had no access/login check - unauthenticated file upload to /media/com_sppagebuilder/assets/iconfont/ - RCE. Mini-WAF only (guests), since logged-in builders use the endpoint legitimately.',
                'default' => 1,
                'qs'    => '(?=.*option=com_sppagebuilder)(?=.*task=asset\.uploadCustomIcon)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
            ),
            array(
                'id'    => 'dpcalendar-createdby-sqli',
                'label' => 'DPCalendar: unauth. SQL-Injection (Autor-Filter)',
                'label_en' => 'DPCalendar: unauth SQL injection (author filter)',
                'desc'  => 'DPCalendar Blind-SQLi (Joomla 4.4-6 < 10.11.2, J3 < 8.19.4): der Frontend-Autor-Filter filter_created_by (option=com_dpcalendar, view=events) floss in skalarer Form ungecastet in a.created_by IN (...) - anonymes Auslesen beliebiger DB-Tabellen (nur lesend). Blockt Gast-Anfragen, deren filter_created_by kein reiner Integer ist (legitim = Autor-IDs/Ziffern) -> FP-frei. Fix 10.11.2 / 8.19.4.',
                'desc_en' => 'DPCalendar blind SQLi (Joomla 4.4-6 < 10.11.2, J3 < 8.19.4): the front-end author filter filter_created_by (option=com_dpcalendar, view=events) flowed uncast into a.created_by IN (...) in its scalar form - anonymous read of arbitrary DB tables (read-only). Blocks guest requests whose filter_created_by is not a pure integer (legit = author IDs/digits) -> FP-free. Fix 10.11.2 / 8.19.4.',
                'default' => 1,
                'qs'    => '(?=.*option=com_dpcalendar)(?=.*filter_created_by=[0-9,]*[^0-9,&])',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_dpcalendar',
            ),
            array(
                'id'    => 'acym-entityselect-sqli',
                'label' => 'AcyMailing: unauth. SQL-Injection (Entity-Select Spaltenliste)',
                'label_en' => 'AcyMailing: unauth SQL injection (entity-select column list)',
                'desc'  => 'CVE-2026-56292 (AcyMailing 6.0.0-10.11.0): der Frontend-Endpunkt EntitySelectController::loadEntityFront (option=com_acym, task=loadEntityFront) nahm die Request-Parameter columns/join_table ungeprueft in die SELECT-Spaltenliste von UserClass::getMatchingElements auf - ein anonymer Besucher konnte per Subquery beliebige DB-Tabellen (inkl. Passwort-Hashes) auslesen. Blockt GAST-Aufrufe dieses Tasks, deren columns/join_table/join-Wert ein Injektionszeichen (Klammer oder Leerzeichen) enthaelt - legitime Werte sind reine Spaltennamen (email,name,id ...) und enthalten das nie. Eingeloggte Backend-Nutzung laeuft hinter Auth (guest-scope) und ist NICHT betroffen. Fix: AcyMailing 10.11.1 (09.07.2026). Greift GET-seitig (Scanner/Massen-Exploits); vollstaendiger Schutz nur mit Update.',
                'desc_en' => 'CVE-2026-56292 (AcyMailing 6.0.0-10.11.0): the front-end endpoint EntitySelectController::loadEntityFront (option=com_acym, task=loadEntityFront) placed the request parameters columns/join_table unchecked into the SELECT column list of UserClass::getMatchingElements - an anonymous visitor could read arbitrary DB tables (incl. password hashes) via a subquery. Blocks GUEST calls of this task whose columns/join_table/join value contains an injection character (parenthesis or space) - legitimate values are plain column names (email,name,id ...) and never contain those. Logged-in backend use runs behind auth (guest scope) and is NOT affected. Fix: AcyMailing 10.11.1 (2026-07-09). Matches on GET (scanners/mass exploits); full protection only with the update.',
                'default' => 1,
                'qs'    => '(?=.*option=com_acym)(?=.*task=loadEntityFront)(?=.*(?:columns|join_table|join)=[^&]*(?:\(|%28|%2528|\s|%20|\+))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_acym',
            ),
            array(
                'id'    => 'quix-article-sqli',
                'label' => 'Quix Page Builder: unauth. SQL-Injection (Einzel-Artikel-AJAX)',
                'label_en' => 'Quix Page Builder: unauth SQL injection (single-article AJAX)',
                'desc'  => 'Quix (ThemeXpert, Free UND Pro) bis 6.2.0, inkl. der 5.x-Reihe: der oeffentliche AJAX-Endpunkt (option=com_quix, task=ajax, element=joomla-article) ruft QuixJoomlaArticleElement::getAjax() ohne Login-/Token-/Lizenz-Pruefung; die Artikel-ID kommt base64-kodiert im data-Parameter und wird in articleExist() UNGECASTET in die DB-Query konkateniert -> unauthentifizierte, fehler-basierte SQL-Injection (CVSS 8.7). Weil die Payload base64-kodiert ist (fuer eine Regex unsichtbar), wird der verwundbare Endpunkt fuer GAESTE blockiert: veroeffentlichte Seiten rendern das Element server-seitig (kein Gast-AJAX), der Builder laeuft eingeloggt -> guest-scope, FP-frei. Der legitime Kontakt-Formular-Endpunkt (element=simple-contact) und der Listen-Artikel (joomla-articles, Kern-Modell) sind NICHT betroffen. Fix: Quix 6.2.1.',
                'desc_en' => 'Quix (ThemeXpert, Free AND Pro) up to 6.2.0, incl. the 5.x line: the public AJAX endpoint (option=com_quix, task=ajax, element=joomla-article) calls QuixJoomlaArticleElement::getAjax() with no login/token/license check; the article id arrives base64-encoded in the data parameter and is concatenated UNCAST into the DB query in articleExist() -> unauthenticated error-based SQL injection (CVSS 8.7). Because the payload is base64-encoded (invisible to a regex), the vulnerable endpoint is blocked for GUESTS: published pages render the element server-side (no guest AJAX), the builder runs logged in -> guest scope, FP-free. The legitimate contact-form endpoint (element=simple-contact) and the list-article element (joomla-articles, core model) are NOT affected. Fix: Quix 6.2.1.',
                'default' => 1,
                'qs'    => '(?=.*option=com_quix)(?=.*task=ajax\b)(?=.*element=joomla-article\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_quix',
            ),
            array(
                'id'    => 'phocacart-filter-sqli',
                'label' => 'Phoca Cart: unauth. SQL-Injection (Produktfilter a/s)',
                'label_en' => 'Phoca Cart: unauth SQL injection (product filter a/s)',
                'desc'  => 'CVE-2026-74251 (CVSS 9.3, Phoca Cart J5-Reihe < 5.2.4 / J6-Reihe < 6.1.7; 3.x und 4.x komplett): der öffentliche Produktfilter (option=com_phocacart) liest die Array-Parameter a (Attribute) und s (Spezifikationen) und hängt ihre Werte UNGEQUOTET in die WHERE-Klausel (IN()- bzw. Gleichheits-Bedingung) - auf einer Gast-Seite ohne Login/Token -> unauthentifizierte, blinde SQL-Injection (Auslesen der Datenbank). Blockt GAST-Anfragen an com_phocacart, deren a[...]- oder s[...]-Wert ein Ausbruchszeichen (Anführungszeichen oder Backslash) enthält - legitime Filterwerte sind Attribut-/Spezifikations-Aliase (alphanumerische Slugs) und enthalten das nie -> FP-frei. Greift die generische index.php?option=com_phocacart-Form (GET/POST, Scanner/Massen-Exploits); vollständiger Schutz (auch SEF-Aufrufe) nur mit Update auf 5.2.4 / 6.1.7.',
                'desc_en' => 'CVE-2026-74251 (CVSS 9.3, Phoca Cart J5 line < 5.2.4 / J6 line < 6.1.7; 3.x and 4.x entirely): the public product filter (option=com_phocacart) reads the array parameters a (attributes) and s (specifications) and appends their values UNQUOTED into the WHERE clause (IN() / equality condition) - on a guest page with no login/token -> unauthenticated blind SQL injection (database read). Blocks GUEST requests to com_phocacart whose a[...] or s[...] value contains a breakout character (quote or backslash) - legitimate filter values are attribute/specification aliases (alphanumeric slugs) and never contain those -> FP-free. Matches the generic index.php?option=com_phocacart form (GET/POST, scanners/mass exploits); full protection (incl. SEF calls) only with the update to 5.2.4 / 6.1.7.',
                'default' => 1,
                // Beide Lookaheads an eine ECHTE Query-Grenze (Anfang ODER '&') gebunden - NICHT
                // '?': im QUERY_STRING trennt nur '&' die Parameter, ein '?' taucht dort nur aus
                // dekodiertem Inhalt auf (z. B. return=index.php%3F...). Die '&'-Bindung verhindert
                // so, dass ein in einer return-URL EINGEBETTETES option=com_phocacart die Signatur
                // auf einer FREMD-Komponente ausloest (FP), und dass data[/media[ o. ae. als a[/s[
                // durchgehen. (?:%20|\+|\s)* deckt fuehrende Leerzeichen ab, die PHP aus dem
                // Variablennamen entfernt (" a[3]" -> a[3], auch via + und %20) - sonst waere das
                // ein Bypass. Danach ein a[/s[-Wert mit Ausbruchszeichen (' " \), das der
                // stringURLSafe-Alias legitim nie enthaelt -> FP-frei.
                'qs'    => '(?=(?:^|.*&)option=com_phocacart)(?=(?:^|.*&)(?:%20|\+|\s)*(?:a|s)(?:\[|%5b)[^&]*(?:%(?:27|22|5c)|[\x27\x22\x5c]))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_phocacart',
            ),
            array(
                'id'    => 'icagenda-calendar-sqli',
                'label' => 'iCagenda: unauth. SQL-Injection (Kalender-Modul über com_ajax)',
                'label_en' => 'iCagenda: unauth SQL injection (calendar module via com_ajax)',
                'desc'  => 'CVE-2026-67365 (CVSS 9.2, iCagenda 4.0.0-4.0.11): das Kalender-Modul mod_icagenda_calendar wird über com_ajax (option=com_ajax&module=icagenda_calendar) OHNE Session, Token oder Konto erreicht und haengt einen Request-Parameter ungefiltert in eine SQL-Abfrage - unauthentifizierte SQL-Injection (Datenbank auslesen). Das Kalender-Ajax ist ein LEGITIMES Gast-Feature (Monats-/Kategorie-Navigation), darum wird NICHT pauschal geblockt, sondern nur ein Gast-Aufruf an module=icagenda_calendar, dessen Query ein SQL-Injection-Muster traegt: Ausbruchszeichen (Anfuehrungszeichen/Backslash), UNION SELECT, zeitbasierte Funktionen (sleep/benchmark/extractvalue/updatexml) oder information_schema. Legitime Kalenderwerte (Datum, Kategorie-ID, Methode, Format) enthalten das nie -> FP-frei. Greift die GET-Form (auch Scanner/Massen-Exploits); voller Schutz (auch untypische Parameter/SEF) erst mit Update auf iCagenda 4.0.12.',
                'desc_en' => 'CVE-2026-67365 (CVSS 9.2, iCagenda 4.0.0-4.0.11): the calendar module mod_icagenda_calendar is reached via com_ajax (option=com_ajax&module=icagenda_calendar) with NO session, token or account and appends a request parameter unfiltered into a SQL query - unauthenticated SQL injection (database read). The calendar ajax is a LEGITIMATE guest feature (month/category navigation), so this does NOT block wholesale but only a guest call to module=icagenda_calendar whose query carries a SQL injection pattern: breakout characters (quote/backslash), UNION SELECT, time-based functions (sleep/benchmark/extractvalue/updatexml) or information_schema. Legitimate calendar values (date, category id, method, format) never contain those -> FP-free. Matches the GET form (scanners/mass exploits too); full protection (incl. atypical parameters/SEF) only with the update to iCagenda 4.0.12.',
                'default' => 1,
                // Kalender-Ajax ist auch fuer GAESTE legitim -> KEIN Pauschal-Block wie bei den
                // reinen Admin-Dispatchern (Helix/JoomCCK), sondern option=com_ajax + module=
                // icagenda_calendar PLUS ein SQLi-Muster. option/module an eine echte Query-Grenze
                // (^ oder &) gebunden, damit ein in einer return-URL eingebettetes option=com_ajax
                // nicht auf einer Fremd-Komponente ausloest. Meta deckt String-Kontext (Quote/
                // Backslash), numerisches UNION SELECT, zeit-/fehlerbasierte Funktionen und
                // information_schema ab; die Dekodier-Stufen von wafMatch fangen %20/%27 etc.
                'qs'    => '(?=(?:^|.*&)option=com_ajax)(?=(?:^|.*&)module=icagenda_calendar)(?=.*(?:%27|%22|%5c|[\x27\x22\x5c]|\bunion\b[\s+/*]*\bselect\b|information_schema|(?:sleep|benchmark|extractvalue|updatexml)(?:%28|\()))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_ajax',
            ),
            array(
                'id'    => 'joomcck-tags-sqli',
                'label' => 'JoomCCK: unauth. SQL-Injection (Tag-Verwaltung)',
                'label_en' => 'JoomCCK: unauth SQL injection (tag management)',
                'desc'  => 'CVE-2026-49048 (JoomCCK 6.x vor 6.4.1): der Front-End-Task tags.save/tags.delete (option=com_joomcck) lief OHNE Token-/Login-/Rechte-Pruefung und schob den Request-Parameter tag (getString) roh - mit Double-Quote-Ausbruch - in ZWEI aufeinanderfolgende SQL-Statements (Existenz-SELECT + UPDATE). Ein anonymer Besucher konnte die Datenbank auslesen und veraendern (stacked/error-based SQLi). Blockt jeden GAST-Aufruf dieses reinen Admin-Tasks (kein Gast verwaltet Tags); eingeloggtes Tag-Management laeuft hinter Auth und ist NICHT betroffen - guest-scope + waf_only, FP-frei (kein Payload-Matching noetig, daher unabhaengig vom Freitext-Tag). Fix: JoomCCK 6.4.1 (db-quote + Token/Admin-ACL).',
                'desc_en' => 'CVE-2026-49048 (JoomCCK 6.x before 6.4.1): the front-end task tags.save/tags.delete (option=com_joomcck) ran with NO token/login/permission check and concatenated the request parameter tag (getString) raw - with a double-quote breakout - into TWO consecutive SQL statements (existence SELECT + UPDATE). An anonymous visitor could read and modify the database (stacked/error-based SQLi). Blocks every GUEST call of this admin-only task (no guest manages tags); logged-in tag management runs behind auth and is NOT affected - guest scope + waf_only, FP-free (no payload matching needed, hence independent of the free-text tag). Fix: JoomCCK 6.4.1 (db-quote + token/admin ACL).',
                'default' => 1,
                'qs'    => '(?=.*option=com_joomcck)(?=.*task=tags\.(?:save|delete))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_joomcck',
            ),
            array(
                'id'    => 'gridbox-store-register',
                'label' => 'Balbooa Gridbox: unauth. Konto-Erstellung (store.register)',
                'label_en' => 'Balbooa Gridbox: unauth account creation (store.register)',
                'desc'  => 'Balbooa Gridbox (com_gridbox) bis inkl. 2.20.1 - aktiv ausgenutzt, gemeldet 2026-07-28, Fix in 2.20.2 (29.07.2026). Der Front-End-Task store.register lief OHNE Login/Token und legte ein Joomla-Konto mit FREI WAEHLBARER Usergruppe plus sofort gueltiger Session an (unauth Privilege Escalation). Blockt jeden Gast-Aufruf dieses Tasks. Kosten: falls die Site die Gridbox-eigene Frontend-Registrierung nutzt (selten), bricht diese - dann diese eine Signatur abschalten. FP-frei: kein Payload-Matching, nur der option+task-Anker (reihenfolge- und %2E-tolerant).',
                'desc_en' => 'Balbooa Gridbox (com_gridbox) up to and incl. 2.20.1 - actively exploited, reported 2026-07-28, fixed in 2.20.2 (29 Jul 2026). The front-end task store.register ran WITHOUT login/token and created a Joomla account with an ARBITRARY usergroup plus an immediately valid session (unauth privilege escalation). Blocks every guest call of this task. Cost: if the site uses Gridbox front-end registration (rare) it breaks - then disable this one signature. FP-free: no payload matching, only the option+task anchor (order- and %2E-tolerant).',
                'default' => 1,
                'qs'    => '(?=.*option=com_gridbox)(?=.*task=store(\.|%2e)register)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'trigger' => 'com_gridbox',
            ),
            array(
                'id'    => 'gridbox-uploader-showimage-read',
                'label' => 'Balbooa Gridbox: unauth. Datei-Lesen (uploader.showImage)',
                'label_en' => 'Balbooa Gridbox: unauth arbitrary file read (uploader.showImage)',
                'desc'  => 'Balbooa Gridbox (com_gridbox) bis inkl. 2.20.1 - aktiv ausgenutzt, Fix in 2.20.2 (29.07.2026). Der Front-End-Task uploader.showImage laed ohne Login den Query-Parameter image= und gibt bei Nicht-Bildern die Datei roh aus (fopen+fpassthru) -> unauth Arbitrary File Read, u. a. configuration.php. Trifft JEDE Gridbox-Installation. FP-FREI (2.6.17, Fall prolocore-vigezzo.it: Gridbox laed Bilder als absolute Pfade/volle URLs, die Alt-Regel blockte damit den Editor): greift NUR, wenn image= KEIN echtes Bild liest - entweder endet der Wert NICHT auf einer Bild-Endung (.jpg/.jpeg/.png/.gif/.webp/.svg/.bmp/.ico/.avif/.tif) = Auslesen von configuration.php/Quellcode, ODER er traegt einen eindeutigen Exploit-Marker (.. Traversal, %00 Nullbyte, php://-Wrapper). Ein legitimes Bild - auch absoluter Pfad oder volle URL - endet immer auf einer Bild-Endung und bleibt unberuehrt. scope=all (nur der Nicht-Bild-Lesezugriff ist nie legitim).',
                'desc_en' => 'Balbooa Gridbox (com_gridbox) up to and incl. 2.20.1 - actively exploited, fixed in 2.20.2 (29 Jul 2026). The front-end task uploader.showImage reads the query parameter image= without login and, for non-images, dumps the file raw (fopen+fpassthru) -> unauth arbitrary file read incl. configuration.php. Hits EVERY Gridbox install. FP-FREE (2.6.17, case prolocore-vigezzo.it: Gridbox references images as absolute paths/full URLs, the old rule blocked the editor): fires ONLY when image= does NOT read a real image - either the value does NOT end in an image extension (.jpg/.jpeg/.png/.gif/.webp/.svg/.bmp/.ico/.avif/.tif) = reading configuration.php/source, OR it carries an unambiguous exploit marker (.. traversal, %00 null byte, php:// wrapper). A legitimate image - even as an absolute path or full URL - always ends in an image extension and is untouched. scope=all (only the non-image read is never legitimate).',
                'default' => 1,
                'qs'    => '(?=.*option=com_gridbox)(?=.*task=uploader(\.|%2e)showImage)(?=.*image=(?:[^&]*(?:\.\.|%2e%2e|%00|php:|php%3a)|(?![^&]*\.(?:jpe?g|png|gif|webp|svg|bmp|ico|avif|tiff?)(?:[&?]|%3f|$))[^&]+))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
                'trigger' => 'com_gridbox',
            ),
            array(
                'id'    => 'gridbox-uploader-savephoto',
                'label' => 'Balbooa Gridbox: unauth. Datei-Schreiben (uploader.savePhotoEditorImage)',
                'label_en' => 'Balbooa Gridbox: unauth arbitrary file write (uploader.savePhotoEditorImage)',
                'desc'  => 'Balbooa Gridbox (com_gridbox) bis inkl. 2.20.1 - aktiv ausgenutzt, Fix in 2.20.2 (29.07.2026). Der Front-End-Task uploader.savePhotoEditorImage schreibt eine beliebige Datei (inkl. .php) in den Webroot; der Zieldateiname liegt im POST-Body (php://input) und ist fuer die .htaccess unsichtbar, und eingeloggte Redakteure nutzen den Endpunkt legitim. Daher waf_only + scope=guest: NUR im Echtzeit-Plugin durchgesetzt (kennt den Login-Status) -> unauth-Aufrufe geblockt, eingeloggte Redakteure unberuehrt (Editor bleibt heil). Der Konto-Weg des Angreifers wird zusaetzlich von gridbox-store-register gekappt.',
                'desc_en' => 'Balbooa Gridbox (com_gridbox) up to and incl. 2.20.1 - actively exploited, fixed in 2.20.2 (29 Jul 2026). The front-end task uploader.savePhotoEditorImage writes an arbitrary file (incl. .php) into the webroot; the target filename is in the POST body (php://input), invisible to .htaccess, and logged-in editors use the endpoint legitimately. Hence waf_only + scope=guest: enforced ONLY in the real-time plugin (which knows login state) -> unauth calls blocked, logged-in editors untouched (editor stays intact). The attacker account path is additionally cut by gridbox-store-register.',
                'default' => 1,
                'qs'    => '(?=.*option=com_gridbox)(?=.*task=uploader(\.|%2e)savePhotoEditorImage)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_gridbox',
            ),
            array(
                'id'    => 'baforms-signature-rce',
                'label' => 'Balbooa Forms: unauth. RCE (Signature-Feld, CVE-2026-65880)',
                'label_en' => 'Balbooa Forms: unauth RCE (signature field, CVE-2026-65880)',
                'desc'  => 'Balbooa Forms (com_baforms) bis inkl. 2.4.2.1 - CVE-2026-65880 (CVSS 10.0), aktiv ausgenutzt, Fix erst 2.4.3. Beim Absenden eines Formulars mit SIGNATURE-Feld (task=form.sendMessage) verwendet FormModel::saveSignature den vom Angreifer im Feld-JSON gelieferten Funktionsnamen dynamisch als aufgerufene Funktion mit angehängtem Wert - unauthentifizierte Remote Code Execution. Der legitime Wert ist immer die Base64-Dekodierung; 2.4.3 verdrahtet sie fest. Payload im POST-Body -> nur Echtzeit-Plugin (waf_only). Erkennung: der method-Parameter gefolgt von einem bekannten Code-/Datei-Funktionsnamen UND das image-Feld der Signatur-Struktur - quote-frei (kein Doublequote im Muster) ueber Nicht-Alnum-Separatoren, mehrlagig-dekodiert vom Plugin. FP-frei: legitime Base64-Werte und normale Formularfelder (method=email o. Ä.) treffen nicht. scope=all (Payload nie legitim). Der abgelegte Signatur-Artefakt liegt ohnehin unter images/baforms/ und wird zusaetzlich von der Lageregel images_php gefasst.',
                'desc_en' => 'Balbooa Forms (com_baforms) up to and incl. 2.4.2.1 - CVE-2026-65880 (CVSS 10.0), actively exploited, fixed only in 2.4.3. Submitting a form with a SIGNATURE field (task=form.sendMessage) makes FormModel::saveSignature use the attacker-supplied function name from the field JSON dynamically as the called function with an appended value - unauthenticated remote code execution. The legitimate value is always the Base64 decoding; 2.4.3 hardcodes it. Payload is in the POST body -> real-time plugin only (waf_only). Detection: the method parameter followed by a known code/file function name AND the image field of the signature structure - quote-free (no double quote in the pattern) via non-alnum separators, multi-layer-decoded by the plugin. FP-free: legitimate Base64 values and normal form fields (method=email etc.) do not match. scope=all (payload never legitimate).',
                'default' => 1,
                'qs'    => '(?=.*\bmethod[^a-z0-9]{1,6}(?:' . self::baFormsFnAlt() . ')\b)(?=.*\bimage\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
                'waf_only' => 1,
                'trigger' => 'com_baforms',
            ),
            array(
                'id'    => 'fabrik-calc-rce',
                'label' => 'Fabrik: unauth. RCE (calc-Element ajax_calc, CVE-2026-66915)',
                'label_en' => 'Fabrik: unauth RCE (calc element ajax_calc, CVE-2026-66915)',
                'desc'  => 'Fabrik (com_fabrik) 1.0.0 bis 4.6.6 - CVE-2026-66915 (CVSS 10.0), unauthentifizierte Remote Code Execution. Der oeffentliche AJAX-Endpunkt des calc-Elements (option=com_fabrik, plugin=calc, method=ajax_calc) baut die admin-hinterlegte Rechenformel per parseMessageForPlaceHolder mit ROHEN Request-Werten ($_REQUEST) zusammen und fuehrt sie ueber die @eval-Funktion aus - ein Angreifer schleust ueber einen Feld-Parameter PHP-Code in die Formel ein. Blockt Aufrufe dieses Endpunkts, deren Payload eine PHP-Funktions-/eval-Konstruktion traegt (bekannte Code-/Datei-Funktion direkt gefolgt von ( und einem String-/Variablen-Argument) - legitime Calc-Werte sind Zahlen/Datumswerte und treffen nie. Payload im POST-Body -> nur Echtzeit-Plugin (waf_only); scope=all (die Konstruktion ist nie legitim, Super-User ausgenommen), FP-frei. Fix: Fabrik 4.6.7 (kostenpflichtiges Update) - diese Signatur schuetzt auch ohne das Update.',
                'desc_en' => 'Fabrik (com_fabrik) 1.0.0 to 4.6.6 - CVE-2026-66915 (CVSS 10.0), unauthenticated remote code execution. The public AJAX endpoint of the calc element (option=com_fabrik, plugin=calc, method=ajax_calc) assembles the admin-stored calculation formula via parseMessageForPlaceHolder using RAW request values ($_REQUEST) and runs it through the @eval function - an attacker injects PHP code into the formula via a field parameter. Blocks calls to this endpoint whose payload carries a PHP function/eval construct (a known code/file function directly followed by ( and a string/variable argument) - legitimate calc values are numbers/dates and never match. Payload in the POST body -> real-time plugin only (waf_only); scope=all (the construct is never legitimate, super users exempt), FP-free. Fix: Fabrik 4.6.7 (paid update) - this signature protects even without it.',
                'default' => 1,
                'qs'    => '(?=.*option=com_fabrik)(?=.*plugin=calc)(?=.*(?:' . self::baFormsFnAlt() . ')\s{0,3}\(\s{0,3}[\x27\x22\x60$])',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
                'waf_only' => 1,
                'trigger' => 'com_fabrik',
            ),
            array(
                'id'    => 'baforms-eval-rce',
                'label' => 'Balbooa Forms: unauth. RCE (eval via Query-Parameter, CVE-2026-67364)',
                'label_en' => 'Balbooa Forms: unauth RCE (eval via query parameter, CVE-2026-67364)',
                'desc'  => 'Balbooa Forms (com_baforms) bis inkl. 2.4.3.1 - CVE-2026-67364 (CVSS 10.0), unauthentifizierte Remote Code Execution. Ein Formular mit eigenem PHP-Nachbearbeitungs-Handler und dem Shortcode [URL parameter=X] setzt den ROHEN, ungefilterten Query-Parameter X in den per eval-Funktion ausgefuehrten PHP-Code ein - ein Angreifer schleust darueber beliebigen PHP-Code ein (der CSRF-Token schuetzt kaum, da anonym abrufbar). Blockt Aufrufe von com_baforms, deren Payload eine PHP-Funktions-/eval-Konstruktion traegt (bekannte Code-/Datei-Funktion direkt gefolgt von ( und einem String-/Variablen-Argument) - legitime Formularwerte treffen nie. Payload in Query ODER POST-Body -> nur Echtzeit-Plugin (waf_only); scope=all (die Konstruktion ist nie legitim), FP-frei. Fix: Balbooa Forms 2.4.3.2 - diese Signatur schuetzt auch ohne das Update.',
                'desc_en' => 'Balbooa Forms (com_baforms) up to and incl. 2.4.3.1 - CVE-2026-67364 (CVSS 10.0), unauthenticated remote code execution. A form with a custom PHP post-processing handler and the [URL parameter=X] shortcode substitutes the RAW, unfiltered query parameter X into the PHP code run via the eval function - an attacker injects arbitrary PHP through it (the CSRF token barely protects, being anonymously retrievable). Blocks calls to com_baforms whose payload carries a PHP function/eval construct (a known code/file function directly followed by ( and a string/variable argument) - legitimate form values never match. Payload in query OR POST body -> real-time plugin only (waf_only); scope=all (the construct is never legitimate), FP-free. Fix: Balbooa Forms 2.4.3.2 - this signature protects even without the update.',
                'default' => 1,
                'qs'    => '(?=.*option=com_baforms)(?=.*(?:' . self::baFormsFnAlt() . ')\s{0,3}\(\s{0,3}[\x27\x22\x60$])',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
                'waf_only' => 1,
                'trigger' => 'com_baforms',
            ),
            array(
                'id'    => 'jbusinessdirectory-upload-remove',
                'label' => 'J-Business Directory: unauth. Datei-Löschung (upload.remove, CVE-2026-75949)',
                'label_en' => 'J-Business Directory: unauth file deletion (upload.remove, CVE-2026-75949)',
                'desc'  => 'J-Business Directory (com_jbusinessdirectory) bis inkl. 6.2.2 - CVE-2026-75949 (CVSS 9.1). Der ohne Login erreichbare Task upload.remove nimmt den _filename-Parameter RAW und erzwingt keine Pfad-Eingrenzung -> ein Angreifer loescht ueber ../-Sequenzen (mit _path_type=2) beliebige Dateien, u. a. configuration.php. SURGICAL: greift NUR, wenn _filename ein Traversal-/Nullbyte-Muster enthaelt -> normale Datei-Entfernung (echter Dateiname) bleibt unberuehrt, kein FP. scope=all (das Muster ist nie legitim). Fix: J-Business Directory 6.2.3 (behebt zudem Ownership-Uebernahme, IDOR und fehlende CSRF-Token).',
                'desc_en' => 'J-Business Directory (com_jbusinessdirectory) up to and incl. 6.2.2 - CVE-2026-75949 (CVSS 9.1). The login-free task upload.remove takes the _filename parameter RAW and does not enforce path containment -> an attacker deletes arbitrary files via ../ sequences (with _path_type=2), including configuration.php. SURGICAL: fires ONLY when _filename contains a traversal/null-byte pattern -> normal file removal (a real filename) is untouched, no FP. scope=all (the pattern is never legitimate). Fix: J-Business Directory 6.2.3 (also fixes ownership takeover, IDOR and missing CSRF tokens).',
                'default' => 1,
                'qs'    => '(?=(?:^|.*&)option=com_jbusinessdirectory)(?=.*task=upload(\.|%2e)remove)(?=.*_filename[^&]*(?:\.\.|%2e%2e|%252e|%00))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'all',
                'trigger' => 'com_jbusinessdirectory',
            ),
            array(
                'id'    => 'cottoncloud-cotton',
                'label' => 'Cotton Cloud: unauth. Dateizugriff (com_cotton)',
                'label_en' => 'Cotton Cloud: unauth file access (com_cotton)',
                'desc'  => 'CVE-2026-67283 (Cotton Cloud < 2.0.3, CVSS 6.9): die Frontend-Tasks des Datei-Managers com_cotton waren nur per CSRF-Token geschützt (das Joomla auch nicht eingeloggten Besuchern ausstellt) - ein anonymer Angreifer konnte Dateien lesen, löschen, überschreiben und Rechte ändern. Cotton Cloud gibt jedem Nutzer ein PRIVATES, kontobasiertes Cloud-Laufwerk - es gibt keinen legitimen Gast-Zugriff, daher blockt diese Signatur jeden GAST-Aufruf von option=com_cotton im Frontend (kein Payload-Matching, FP-frei; eingeloggte Nutzer und der Login-mit-Ruecksprung sind NICHT betroffen, da option/return an eine echte Query-Grenze gebunden). Falls eine Seite ausnahmsweise oeffentliche Freigabe-Links ueber com_cotton anbietet, diese eine Signatur abschalten. Fix: Cotton Cloud 2.0.3.',
                'desc_en' => 'CVE-2026-67283 (Cotton Cloud < 2.0.3, CVSS 6.9): the front-end tasks of the com_cotton file manager were protected by a CSRF token only (which Joomla also issues to visitors who never logged in) - an anonymous attacker could read, delete, overwrite and re-permission files. Cotton Cloud gives every user a PRIVATE, account-based cloud drive - there is no legitimate guest access, so this signature blocks every GUEST call of option=com_cotton on the front end (no payload matching, FP-free; logged-in users and the login-with-return flow are NOT affected, as option/return are bound to a real query boundary). If a site exceptionally offers public share links via com_cotton, disable this one signature. Fix: Cotton Cloud 2.0.3.',
                'default' => 1,
                'qs'    => '(?=(?:^|.*&)option=com_cotton\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_cotton',
            ),
            array(
                'id'    => 'cottoncloud-shuttle',
                'label' => 'Cotton Cloud: unauth. Terminal-Zugriff (com_shuttle)',
                'label_en' => 'Cotton Cloud: unauth terminal access (com_shuttle)',
                'desc'  => 'CVE-2026-67283 (Cotton Cloud < 2.0.3): beide Einstiegspunkte der Terminal-Komponente com_shuttle waren nur per CSRF-Token geschützt und damit anonym erreichbar. Ein Terminal ist nie gastseitig - diese Signatur blockt jeden GAST-Aufruf von option=com_shuttle im Frontend (kein Payload-Matching, FP-frei; eingeloggte Nutzer nicht betroffen, option an eine echte Query-Grenze gebunden). Fix: Cotton Cloud 2.0.3.',
                'desc_en' => 'CVE-2026-67283 (Cotton Cloud < 2.0.3): both entry points of the com_shuttle terminal component were protected by a CSRF token only and thus reachable anonymously. A terminal is never guest-facing - this signature blocks every GUEST call of option=com_shuttle on the front end (no payload matching, FP-free; logged-in users not affected, option bound to a real query boundary). Fix: Cotton Cloud 2.0.3.',
                'default' => 1,
                'qs'    => '(?=(?:^|.*&)option=com_shuttle\b)',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'com_shuttle',
            ),
            array(
                'id'    => 'sourcerer-reflected-php',
                'label' => 'Sourcerer (Regular Labs): unauth. RCE (reflektierter {source}-PHP-Code)',
                'label_en' => 'Sourcerer (Regular Labs): unauth RCE (reflected {source} PHP code)',
                'desc'  => 'CVE-2026-74253 (CVSS 10.0, Sourcerer 1.0.0-15.0.0): Sourcerer ist ein System-Plugin, das gerenderte Inhalte nach seinem Tag {source} durchsucht und darin enthaltenen Code ausführt. Bis einschließlich 15.0.0 fehlte eine zuverlässige Herkunftsprüfung - reflektierter oder unbestätigter {source}-PHP-Code (z. B. aus einem in die Seite gespiegelten Request-Parameter) wurde serverseitig ausgeführt: unauthentifizierte RCE. Da es keinen Komponenten-Anker gibt (System-Plugin), blockt diese Signatur GAST-Anfragen, die das Standard-Tag {source} ZUSAMMEN mit einem PHP-Öffner (<?php oder <?=) tragen - genau die Ausführungs-Nutzlast. Gast-Inhalte mit {source} ohne PHP laufen durch; eingeloggte Autoren (Backend hinter Login) sind NICHT betroffen. Deckt nur das Standard-Tag {source} ab. Falls eine Seite ausnahmsweise nicht eingeloggten Besuchern erlaubt, PHP-Beispiele mit dem {source}-Tag einzureichen (selten), diese eine Signatur abschalten. Fix: Sourcerer 16.0.0.',
                'desc_en' => 'CVE-2026-74253 (CVSS 10.0, Sourcerer 1.0.0-15.0.0): Sourcerer is a system plugin that scans rendered output for its {source} tag and executes the code inside. Up to and including 15.0.0 it lacked a reliable origin check - reflected or unverified {source} PHP code (e.g. from a request parameter reflected into the page) was executed on the server: unauthenticated RCE. As there is no component anchor (system plugin), this signature blocks GUEST requests carrying the default {source} tag TOGETHER WITH a PHP opener (<?php or <?=) - exactly the execution payload. Guest content with {source} but no PHP passes; logged-in authors (backend behind login) are NOT affected. Covers the default {source} tag only. If a site exceptionally lets non-logged-in visitors submit PHP examples using the {source} tag (rare), disable this one signature. Fix: Sourcerer 16.0.0.',
                'default' => 1,
                // System-Plugin -> KEIN option=-Anker moeglich. Zwei Lookaheads: (1) das Standard-Tag
                // {source (roh oder %7Bsource), (2) ein PHP-Oeffner <?php / <?= (roh oder %3C..-kodiert).
                // Nur die KOMBINATION = die Ausfuehrungs-Nutzlast. Das Klammer-Erfordernis verhindert FP
                // auf "resource"/"utm_source"; php|= hinter <? verhindert FP auf <?xml. wafMatch-Dekodier-
                // stufen fangen mehrfach kodierte Varianten. scope=guest: eingeloggte Autoren unberuehrt.
                // WICHTIG: das '{' bewusst als \x7b (PCRE-identisch, aber KEIN literales Klammerzeichen) -
                // sonst zaehlt HtpNginx::build eine unausgeglichene Klammer und liefert per Guard leere Config.
                'qs'    => '(?=.*(?:\x7bsource|%7[bB]source))(?=.*(?:<\?(?:php|=)|%3[cC](?:%3[fF]|\?)(?:php|=)))',
                'uri'   => null,
                'frontend_only' => 1,
                'scope' => 'guest',
                'waf_only' => 1,
                'trigger' => 'source',
            ),
            array(
                'id'    => 'traversal-encoded',
                'label' => 'Pfad-Klettern (kodierte Varianten)',
                'label_en' => 'Path traversal (encoded variants)',
                'desc'  => 'Ergänzt den Standard-Filter um Umgehungstricks: ....// , doppelte Kodierung (%252e), %c0%ae, %2e%2e%5c.',
                'desc_en' => 'Extends the standard filter with bypass tricks: ....// , double encoding (%252e), %c0%ae, %2e%2e%5c.',
                'default' => 1,
                'qs'    => '(\.{3,}/|%252e|%c0%a[ef]|%2e%2e%5c|\.\.%5c)',
                'uri'   => null,
                'frontend_only' => 0,
            ),
            array(
                'id'    => 'php-wrapper-uri',
                'label' => 'PHP-Stream-Wrapper im Pfad',
                'label_en' => 'PHP stream wrapper in the path',
                'desc'  => 'Blockiert php:// , phar:// , data:// , expect:// u. a. direkt im angefragten Pfad.',
                'desc_en' => 'Blocks php:// , phar:// , data:// , expect:// etc. directly in the requested path.',
                'default' => 1,
                'qs'    => null,
                'uri'   => '(?:php|phar|data|expect|glob|zlib|zip):/',
                'frontend_only' => 0,
            ),
            array(
                'id'    => 'api-config-leak',
                'label' => 'Joomla-API Konfigurations-Leak',
                'label_en' => 'Joomla API configuration leak',
                'desc'  => 'CVE-2023-23752: schützt den Endpunkt /api/.../v1/config/application auch ohne komplette API-Sperre.',
                'desc_en' => 'CVE-2023-23752: protects the /api/.../v1/config/application endpoint even without a complete API block.',
                'default' => 1,
                'qs'    => null,
                'uri'   => '^api/index\.php/v[0-9]+/config',
                'frontend_only' => 0,
            ),

            // ── Generische Angriffsklassen ──
            // Fangen neue CVEs DERSELBEN Art am Tag 0 ab, ohne CVE-genaue Regel.
            // Muster bewusst eng + ReDoS-sicher (verankert, gekappt) und ohne
            // " / # (sonst Bruch der .htaccess-Schablone).
            array(
                'id'    => 'php-object-injection',
                'label' => 'PHP Object Injection (serialisierte Payload)',
                'label_en' => 'PHP object injection (serialised payload)',
                'desc'  => 'Serialisiertes PHP-Objekt in einem Parameter (O:/C:<Länge>:) - typischer Einstieg für Object-Injection/POP-Ketten. Kommt in normalen Anfragen nicht vor.',
                'desc_en' => 'Serialised PHP object in a parameter (O:/C:<length>:) - typical entry point for object injection/POP chains. Does not occur in normal requests.',
                'default' => 1,
                'qs'    => '(?<![a-z0-9])[oc]:[0-9]{1,4}:',
                'uri'   => null,
                'frontend_only' => 0,
                'scope' => 'all',
            ),
            array(
                'id'    => 'stream-wrapper-param',
                'label' => 'PHP-Stream-Wrapper in Parameter',
                'label_en' => 'PHP stream wrapper in a parameter',
                'desc'  => 'php:// , phar:// , data:// u. a. als Parameterwert - LFI/RCE-Vektor. Ergänzt die Pfad-Variante (php-wrapper-uri).',
                'desc_en' => 'php:// , phar:// , data:// etc. as a parameter value - LFI/RCE vector. Complements the path variant (php-wrapper-uri).',
                'default' => 1,
                'qs'    => '(php|phar|data|expect|glob|zlib|zip):/{2}',
                'uri'   => null,
                'frontend_only' => 0,
                'scope' => 'all',
            ),
            array(
                'id'    => 'param-traversal',
                'label' => 'Pfad-Klettern im Parameter',
                'label_en' => 'Path traversal in a parameter',
                'desc'  => 'Mehrfaches ../ als Parameterwert - generisches Directory-Traversal/LFI in beliebigen Komponenten.',
                'desc_en' => 'Repeated ../ as a parameter value - generic directory traversal/LFI in any component.',
                'default' => 1,
                'qs'    => '=(\.\./){2,}',
                'uri'   => null,
                'frontend_only' => 0,
                'scope' => 'all',
            ),
        );
    }

    /**
     * Eingebaute Warnliste verwundbarer Erweiterungen.
     *
     * Jeder Eintrag: element, type, folder ('' wenn ohne),
     *   below (verwundbar UNTERHALB dieser Version),
     *   name, severity (critical|high|medium), note, advisory (URL).
     *
     * @return array
     */
    public static function builtinVulnExtensions()
    {
        return array(
            // ── 2026-07 VEL-Abgleich (djumla.dev/vel): kritische/hohe, unauth, verbreitete
            //    Erweiterungen, die in der Warnliste noch fehlten. Notes bewusst KURZ (sonst
            //    im Dashboard/Backend abgeschnitten). Versionsbereiche je Erweiterung
            //    zusammengefasst; Advisory = NVD (autoritativ). Fehlt der genaue Element-Match
            //    (z. B. Sourcerer), warnt der Client nur nicht - es entsteht KEIN Fehlalarm.
            // Aimy Captcha-Less Form Guard: Sicherheitsluecke 18.0-20.0, Hersteller-Fix in 20.1. Frei-
            // und PRO-Edition teilen Element + Versionszaehlung. folder='captcha' (an echter Installation
            // verifiziert: plg_captcha_aimycaptchalessformguard - ein Captcha-Gruppen-Plugin, KEIN System-Plugin).
            array(
                'element' => 'aimycaptchalessformguard', 'type' => 'plugin', 'folder' => 'captcha',
                'name' => 'Aimy Captcha-Less Form Guard', 'above' => '17.0', 'below' => '20.1',
                'severity' => 'high', 'note' => 'Sicherheitslücke in 18.0 bis 20.0, vom Hersteller in 20.1 behoben (freie und PRO-Edition betroffen). Kein Workaround - auf Aimy Captcha-Less Form Guard 20.1 oder neuer aktualisieren.',
                'note_en' => 'Security issue in 18.0 to 20.0, fixed by the vendor in 20.1 (free and PRO edition affected). No workaround - update to Aimy Captcha-Less Form Guard 20.1 or newer.',
                'advisory' => 'https://www.aimy-extensions.com/joomla/captcha-less-form-guard.html',
                'advisory_en' => 'https://www.aimy-extensions.com/joomla/captcha-less-form-guard.html',
            ),
            array(
                'element' => 'com_easystore', 'type' => 'component', 'folder' => '',
                'name' => 'EasyStore (JoomShaper)', 'below' => '2.0.2',
                'severity' => 'critical', 'note' => 'Mehrere unauthentifizierte SQL-Injections (CVE-2026-65759 ff.). Auf EasyStore 2.0.2 oder neuer aktualisieren.',
                'note_en' => 'Multiple unauthenticated SQL injections (CVE-2026-65759 ff.). Update to EasyStore 2.0.2 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65759',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65759',
            ),
            array(
                'element' => 'com_fabrik', 'type' => 'component', 'folder' => '',
                // FIX-VERSION = 4.7.2 (schliesst ALLE bekannten Luecken). NVD labelt die Batch-CVEs
                // CVE-2026-76602..76607 faelschlich "< 4.7.3" - das war ein NVD-Fehler; ein 4.7.3
                // existiert nicht. Fabrik-Dev Chris Ansell bestaetigt 4.7.2 als vollstaendigen Fix
                // (htprotect.app/forum d/92), mySites.guru: "Fabrik is now on 4.7.2". Also NICHT auf
                // 4.7.3 zurueck-"bumpen", solange die stale NVD-Labels noch "< 4.7.3" zeigen.
                'name' => 'Fabrik', 'below' => '4.7.2',
                'severity' => 'critical', 'note' => 'Mehrere kritische unauth. Lücken bis 4.7.1: RCE über calc-, PHP-Form- und Bild-Element (CVE-2026-66915/76604/76605, CVSS 10.0), dazu SQLi, Path-Traversal und ACL-Bypass. HTProtect blockt nur die calc-RCE per WAF - auf Fabrik 4.7.2 aktualisieren (schließt alle Lücken).',
                'note_en' => 'Multiple critical unauth. flaws up to 4.7.1: RCE via calc, PHP form and image elements (CVE-2026-66915/76604/76605, CVSS 10.0), plus SQL injection, path traversal and ACL bypass. HTProtect blocks only the calc RCE via WAF - update to Fabrik 4.7.2 (fixes all).',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-66915',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-66915',
            ),
            array(
                // Cotton Cloud (tabaoca.org, Jonatas C. Ferreira) - Paket aus mehreren
                // Erweiterungen; Haupt-Komponente com_cotton (Datei-Manager) + com_shuttle
                // (Terminal). Version wird gegen com_cotton geprueft (traegt die Paket-
                // Version 2.0.x); ein Fehlmatch warnt nur nicht (kein Fehlalarm). Ziel-Fix
                // 2.0.3 - 2.0.2 behob nur CVE-2026-67283, nicht das IDOR CVE-2026-67284.
                'element' => 'com_cotton', 'type' => 'component', 'folder' => '',
                'name' => 'Cotton Cloud', 'below' => '2.0.3',
                'severity' => 'high', 'note' => 'CVE-2026-67283 + CVE-2026-67284 (CVSS 6.9): anonyme Besucher konnten Dateien lesen und löschen. HTProtect sperrt den Zugriff bereits per WAF. Auf Cotton Cloud 2.0.3 aktualisieren (2.0.2 reicht nicht).',
                'note_en' => 'CVE-2026-67283 + CVE-2026-67284 (CVSS 6.9): anonymous visitors could read and delete files. HTProtect already blocks the access via the WAF. Update to Cotton Cloud 2.0.3 (2.0.2 is not enough).',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-67283',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-67283',
            ),
            array(
                // Zwei DISJUNKTE Ranges, weil zwei gepflegte Reihen getrennt gepatcht wurden:
                // J5-Reihe fix ab 5.2.4, J6-Reihe fix ab 6.1.7. Ein einzelnes 'below' => '6.1.7'
                // wuerde ein bereits gepatchtes 5.2.4 faelschlich als verwundbar melden (FP).
                'element' => 'com_phocacart', 'type' => 'component', 'folder' => '',
                'name' => 'Phoca Cart', 'below' => '5.2.4',
                'severity' => 'critical', 'note' => 'CVE-2026-74251 (CVSS 9.3): unauthentifizierte SQL-Injection über den Produktfilter (a/s). HTProtect blockt gängige Angriffe per WAF; voller Schutz erst mit Update auf Phoca Cart 5.2.4 bzw. 6.1.7.',
                'note_en' => 'CVE-2026-74251 (CVSS 9.3): unauthenticated SQL injection via the product filter (a/s). HTProtect blocks common attacks via the WAF; full protection only after updating Phoca Cart to 5.2.4 or 6.1.7.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-74251',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-74251',
            ),
            array(
                'element' => 'com_phocacart', 'type' => 'component', 'folder' => '',
                'name' => 'Phoca Cart', 'above' => '6.0.0', 'below' => '6.1.8',
                'severity' => 'critical', 'note' => 'CVE-2026-74251 (CVSS 9.3): unauthentifizierte SQL-Injection über den Produktfilter (a/s) - HTProtect blockt gängige Angriffe per WAF. Zusätzlich Stored-/Reflected-XSS (bis 6.1.7). Voller Schutz erst mit Update auf Phoca Cart 6.1.8 oder neuer.',
                'note_en' => 'CVE-2026-74251 (CVSS 9.3): unauthenticated SQL injection via the product filter (a/s) - HTProtect blocks common attacks via the WAF. Additionally stored/reflected XSS (up to 6.1.7). Full protection only after updating Phoca Cart to 6.1.8 or newer.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-74251',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-74251',
            ),
            array(
                'element' => 'com_splms', 'type' => 'component', 'folder' => '',
                'name' => 'SP LMS (JoomShaper)', 'below' => '4.1.4',
                'severity' => 'critical', 'note' => 'CVE-2026-48909: unauth. Code-Ausführung durch unsichere Cookie-Deserialisierung. Auf SP LMS 4.1.4 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-48909: unauthenticated code execution via insecure cookie deserialization. Update to SP LMS 4.1.4 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-48909',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-48909',
            ),
            array(
                'element' => 'com_osmembership', 'type' => 'component', 'folder' => '',
                'name' => 'Membership Pro (JoomDonation)', 'below' => '4.6.2',
                'severity' => 'critical', 'note' => 'CVE-2026-62415: kritische Lücke durch unsichere Standardkonfiguration. Auf Membership Pro 4.6.2 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-62415: critical flaw caused by an insecure default configuration. Update to Membership Pro 4.6.2 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-62415',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-62415',
            ),
            array(
                'element' => 'com_convertforms', 'type' => 'component', 'folder' => '',
                'name' => 'Convert Forms (Tassos)', 'below' => '5.2.5',
                'severity' => 'critical', 'note' => 'Mehrere kritische Lücken bis 5.2.2 (Datei-Löschung, Datenleck; CVE-2024-40744); bis 5.2.4 zudem eine fehlende Zugriffskontrolle - Nicht-Angemeldete konnten Formular-Einträge auslesen (CVE-2026-77026). Update auf Convert Forms 5.2.5.',
                'note_en' => 'Multiple critical flaws up to 5.2.2 (arbitrary file deletion, data exposure; CVE-2024-40744); up to 5.2.4 also a broken access control - unauthenticated visitors could list a form\'s submissions (CVE-2026-77026). Update to Convert Forms 5.2.5.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-77026',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-77026',
            ),
            array(
                // JEM - Joomla Event Manager (com_jem). Buendel aus 5 Luecken (CVE-2026-77034/77035/
                // 77989/77990/77991), Fix erst in 5.0.1 - derzeit nur Release Candidate, kein Stable.
                // Alle betroffenen Endpunkte (view=attendees, editevent/editvenue, PDF/ICS-Export,
                // assoziierter Artikel) sind NORMALE Frontend-Ansichten -> keine FP-freie WAF-Signatur
                // moeglich (reine Broken-Access-Control/IDOR, Request == legitime Nutzung). Nur Warnung.
                'element' => 'com_jem', 'type' => 'component', 'folder' => '',
                // JEM-CVEs sind seit 2026-08-27 auf cve.org/NVD veroeffentlicht (CVE-2026-77034/77035/
                // 77990/77991) -> die Fusion hat jetzt eine echte Datumsquelle, daher KEIN 'added'-Fallback mehr.
                'name' => 'JEM - Joomla Event Manager', 'below' => '5.0.1',
                'severity' => 'critical', 'note' => 'Mehrere Lücken bis 5.0.0: privilegierte Remote-Code-Ausführung (CVE-2026-77991), anonymes Überschreiben/Veröffentlichen von Joomla-Artikeln (CVE-2026-77034), fremde Events/Venues übernehmbar (CVE-2026-77035), Teilnehmerlisten für jeden Eingeloggten lesbar (CVE-2026-77990). Fix in JEM 5.0.1.',
                'note_en' => 'Multiple flaws up to 5.0.0: privileged remote code execution (CVE-2026-77991), anonymous overwrite/publish of Joomla articles (CVE-2026-77034), other users\' events/venues hijackable (CVE-2026-77035), attendee lists readable by any logged-in user (CVE-2026-77990). Fixed in JEM 5.0.1.',
                'advisory' => 'https://mysites.guru/blog/jem-joomla-event-manager-disclosure/',
                'advisory_en' => 'https://mysites.guru/blog/jem-joomla-event-manager-disclosure/',
            ),
            array(
                // JoomGallery (com_joomgallery). CVE-2026-66916 (CVSS 6.9, unauth., PR:N): die JSON-
                // Ansicht (format=json) umgeht das Passwort-Gate geschuetzter Kategorien. KEINE FP-freie
                // WAF-Signatur moeglich - format=json ist zugleich der legitime AJAX-Weg OEFFENTLICHER
                // Galerien; ob eine Kategorie passwortgeschuetzt ist, sieht die WAF nicht. Nur Warnung.
                // NVD kennt die CVEs bereits -> kein 'added' noetig (Datum kommt aus der NVD).
                'element' => 'com_joomgallery', 'type' => 'component', 'folder' => '',
                'name' => 'JoomGallery', 'above' => '4.0.0', 'below' => '4.4.0',
                'severity' => 'medium', 'note' => 'Zwei Lücken bis 4.3.x: CVE-2026-66916 (CVSS 6.9, unauth.) - passwortgeschützte Kategorien ließen sich über die JSON-Ansicht am Passwort vorbei auslesen; CVE-2026-66917 - Bearbeiter konnten fremde Inhalte übernehmen und JavaScript einschleusen. Update auf JoomGallery 4.4.0.',
                'note_en' => 'Two flaws up to 4.3.x: CVE-2026-66916 (CVSS 6.9, unauth.) - password-protected categories readable via the JSON view, bypassing the password; CVE-2026-66917 - editors could take over others\' content and inject JavaScript. Update to JoomGallery 4.4.0.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-66916',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-66916',
            ),
            // J2Store / J2Commerce (com_j2store). Zwei unauthentifizierte Luecken, drei betroffene
            // Linien -> drei Multi-Branch-Eintraege (wie DPCalendar/Phoca). CVE-2026-67359 (8.7,
            // Order-Disclosure via order_id = IDOR) und CVE-2026-74252 (8.6, Stored XSS ueber Gast-
            // Checkout-Felder). KEINE FP-freie WAF moeglich: Order-Confirm erreicht auch der legitime
            // Gast-Kunde per order_id; XSS-Payload steckt in normalen Checkout-Feldern (Block =
            // FP-Risiko + teuer, weil er einen Kauf abbricht). Fix kommerziell (GitHub-Mirror hinkt) ->
            // below = logische Fix-Version je Linie. Kein 'added' (NVD kennt die CVEs, datiert selbst).
            array(
                'element' => 'com_j2store', 'type' => 'component', 'folder' => '',
                'name' => 'J2Store / J2Commerce', 'above' => '4.1.0', 'below' => '4.1.6',
                'severity' => 'high', 'note' => 'Zwei unauth. Lücken bis 4.1.5: über order_id konnten Fremde ganze Bestellungen (Positionen, Preise, Summen) einsehen (CVE-2026-67359, CVSS 8.7), dazu Stored XSS über Gast-Checkout-Felder (CVE-2026-74252). Auf J2Store 4.1.6 aktualisieren.',
                'note_en' => 'Two unauth. flaws up to 4.1.5: via order_id anyone could read entire orders (line items, prices, totals) (CVE-2026-67359, CVSS 8.7), plus stored XSS via guest checkout fields (CVE-2026-74252). Update to J2Store 4.1.6.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
            ),
            array(
                'element' => 'com_j2store', 'type' => 'component', 'folder' => '',
                'name' => 'J2Store / J2Commerce', 'above' => '4.0.0', 'below' => '4.0.21',
                'severity' => 'high', 'note' => 'Zwei unauth. Lücken bis 4.0.20: über order_id konnten Fremde ganze Bestellungen (Positionen, Preise, Summen) einsehen (CVE-2026-67359, CVSS 8.7), dazu Stored XSS über Gast-Checkout-Felder (CVE-2026-74252). Auf J2Store 4.0.21 aktualisieren.',
                'note_en' => 'Two unauth. flaws up to 4.0.20: via order_id anyone could read entire orders (line items, prices, totals) (CVE-2026-67359, CVSS 8.7), plus stored XSS via guest checkout fields (CVE-2026-74252). Update to J2Store 4.0.21.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
            ),
            array(
                'element' => 'com_j2store', 'type' => 'component', 'folder' => '',
                'name' => 'J2Store / J2Commerce', 'below' => '3.3.21',
                'severity' => 'high', 'note' => 'Zwei unauth. Lücken bis 3.3.20 (Joomla-3-Linie): über order_id konnten Fremde ganze Bestellungen (Positionen, Preise, Summen) einsehen (CVE-2026-67359), dazu Stored XSS über Gast-Checkout-Felder (CVE-2026-74252). Auf J2Store 3.3.21 bzw. 4.1.6 aktualisieren.',
                'note_en' => 'Two unauth. flaws up to 3.3.20 (Joomla 3 line): via order_id anyone could read entire orders (line items, prices, totals) (CVE-2026-67359), plus stored XSS via guest checkout fields (CVE-2026-74252). Update to J2Store 3.3.21 or 4.1.6.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67359',
            ),
            array(
                // miniOrange OAuth Client (Joomla) - Auth-Bypass im System-Plugin (plg_system_miniorangeoauth).
                // Die Cookie-basierte SSO-Bridge loggte bei gesetztem mo_site-Cookie direkt als user_id
                // (base64) ein - OHNE Signaturpruefung (in v3.1.4 bestaetigt: KEIN verifySsoBridgeSignature,
                // kein mo_oauth_sig). 3.2.0 fuegt HMAC-Signatur + DB-Session-Abgleich ein. KEINE WAF moeglich:
                // rein Cookie-getrieben (unsere Sigs matchen qs/uri/body, nicht Cookies) UND legitime OAuth-
                // Logins nutzen dieselben Cookies. Kein sicherer Forward-Patch (verwundbarer Pfad == Login-
                // Pfad) -> Fix ist das Update; der Connector installiert 3.2.0 automatisch (Security-Boden).
                // 'added', weil NVD/cve.org noch kein Datum haben.
                'element' => 'miniorangeoauth', 'type' => 'plugin', 'folder' => 'system',
                'name' => 'miniOrange OAuth Client', 'below' => '3.2.0', 'added' => '2026-08-24',
                'severity' => 'critical', 'note' => 'CVE-2026-77995 (kritisch): Auth-Umgehung - gefälschte Cookies genügten, um sich ohne Anmeldung als beliebiger Nutzer (auch Super-User) einzuloggen. Nur das Update hilft (Cookie-basiert, nicht per Firewall). Update auf miniOrange OAuth Client 3.2.0.',
                'note_en' => 'CVE-2026-77995 (critical): authentication bypass - forged cookies were enough to log in as any user (incl. Super User) without signing in. Only the update helps (cookie-based, no firewall rule). Update to miniOrange OAuth Client 3.2.0.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-77995',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-77995',
            ),
            array(
                // miniOrange SAML SP (Joomla) - CVE-2026-77998, unauth. Auth-Bypass (CVSS 10.0) ueber
                // SAMLResponse: mo_saml_validate_signature() prueft den Tri-State-Rueckgabewert von
                // openssl_verify() (1/0/-1) LOSE -> ein -1 (Fehler) gilt als "gueltig" -> Login als
                // beliebiger Nutzer (auch Admin). VERIFIZIERT am miniOrange-Repo: die drei "Produkte"
                // (SAML SSO 11.x / ADFS 6.x / Google Apps 6.x) installieren sich ALLE als die Komponente
                // com_miniorange_saml - EIN Element, nur andere Versions-Linien (Fix 11.0.2 bzw. 6.4).
                // below=11.0.2 OHNE above (Sicherheit vor seltenem Ueberwarnen): erfasst jede verwundbare
                // Version; eine gepatchte Legacy-6.4-Edition wird dabei geringfuegig mit-gewarnt - dafuer
                // KEIN False-Negative beim Haupt-Produkt. KEINE WAF: SAMLResponse ist ein legitimer
                // Parameter, die Signaturpruefung liegt serverseitig (base64-XML).
                'element' => 'com_miniorange_saml', 'type' => 'component', 'folder' => '',
                'name' => 'miniOrange SAML SP (SSO/ADFS/Google)', 'below' => '11.0.2', 'added' => '2026-08-25',
                'severity' => 'critical', 'note' => 'CVE-2026-77998 (kritisch, CVSS 10.0): Auth-Umgehung über SAMLResponse - lose Signaturprüfung akzeptierte ungültige Signaturen, Login als beliebiger Nutzer (auch Admin). Nur das Update hilft (keine Firewall). Update auf SAML SSO 11.0.2 (ADFS-/Google-Edition: 6.4).',
                'note_en' => 'CVE-2026-77998 (critical, CVSS 10.0): auth bypass via SAMLResponse - a loose signature check accepted invalid signatures, login as any user (incl. admin). Only the update helps (no firewall). Update to SAML SSO 11.0.2 (ADFS/Google edition: 6.4).',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-77998',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-77998',
            ),
            array(
                // Regular Labs Sourcerer - Element (Editor-Button-Plugin) ggf. gegen echten
                // Install verifizieren; ein Fehlmatch warnt nur nicht (kein Fehlalarm).
                'element' => 'sourcerer', 'type' => 'plugin', 'folder' => 'editors-xtd',
                // NVD/CNA hat CVE-2026-74253 nachtraeglich von "< 14.0.0" auf "< 16.0.0" hochgezogen:
                // 14.x/15.x hatten weiterhin keine zuverlaessige Herkunftspruefung (mysites.guru,
                // "Sourcerer 14 ... updated"). Deshalb below = 16.0.0 - NICHT auf 14.0.0 zurueckdrehen.
                'name' => 'Sourcerer (Regular Labs)', 'below' => '16.0.0',
                'severity' => 'critical', 'note' => 'CVE-2026-74253 (CVSS 10.0): unauthentifizierte Remote Code Execution - reflektierter oder unbestätigter Sourcerer-Code (alle Versionen bis 15.0.0) wurde serverseitig ausgeführt. HTProtect blockt gängige Angriffe per WAF; voller Schutz erst mit Update auf Sourcerer 16.0.0.',
                'note_en' => 'CVE-2026-74253 (CVSS 10.0): unauthenticated remote code execution - reflected or unverified Sourcerer code (all versions up to 15.0.0) was executed on the server. HTProtect blocks common attacks via the WAF; full protection only after updating to Sourcerer 16.0.0.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-74253',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-74253',
            ),
            array(
                'element' => 'com_zoo', 'type' => 'component', 'folder' => '',
                'name' => 'ZOO (YOOtheme)', 'below' => '4.1.66',
                'severity' => 'critical', 'note' => 'Unauth. Datei-Upload (RCE), SQL-Injection und Stored XSS (CVE-2026-76612) - bis 4.1.65. HTProtect blockt hochgeladene ausführbare Dateien (Web-Shells); voller Schutz mit Update auf ZOO 4.1.66.',
                'note_en' => 'Unauth. file upload (RCE), SQL injection and stored XSS (CVE-2026-76612) - up to 4.1.65. HTProtect blocks uploaded executable files (web shells); full protection with the update to ZOO 4.1.66.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-76612',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-76612',
            ),
            array(
                // YOOtheme Pro - installiert als Joomla-Template 'yootheme'; Element ggf. gegen echten
                // Install verifizieren (ein Fehlmatch warnt nur nicht, kein Fehlalarm).
                'element' => 'yootheme', 'type' => 'template', 'folder' => '',
                'name' => 'YOOtheme Pro', 'below' => '5.0.41',
                'severity' => 'critical', 'note' => 'CVE-2026-76613 (CVSS 9.2): SQL-Injection - schon ein Redakteur/Autor schleust Inhalt in SQL-Abfragen ein (bis 5.0.40); dazu CVE-2026-75115 (Datei-Lesen, nur Admin). Update auf YOOtheme Pro 5.0.41.',
                'note_en' => 'CVE-2026-76613 (CVSS 9.2): SQL injection - even a contributor injects content into SQL queries (up to 5.0.40); plus CVE-2026-75115 (file read, admin only). Update to YOOtheme Pro 5.0.41.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-76613',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-76613',
            ),
            array(
                'element' => 'com_jbusinessdirectory', 'type' => 'component', 'folder' => '',
                'name' => 'J-Business Directory (CMS Junkie)', 'below' => '6.2.3',
                'severity' => 'critical', 'note' => 'Mehrere kritische unauth. Lücken bis 6.2.2: Arbitrary File Upload/Löschung per Path-Traversal (CVE-2026-75949), Ownership-Übernahme (75950), IDOR (75951) und fehlende CSRF-Token (75952). HTProtect blockt die Datei-Löschung per WAF; voller Schutz erst mit Update auf J-Business Directory 6.2.3.',
                'note_en' => 'Multiple critical unauthenticated flaws up to 6.2.2: arbitrary file upload/deletion via path traversal (CVE-2026-75949), ownership takeover (75950), IDOR (75951) and missing CSRF tokens (75952). HTProtect blocks the file deletion via the WAF; full protection only after updating J-Business Directory to 6.2.3.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-75949',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-75949',
            ),
            array(
                'element' => 'com_easydiscuss', 'type' => 'component', 'folder' => '',
                'name' => 'EasyDiscuss (StackIdeas)', 'below' => '5.0.16',
                'severity' => 'high', 'note' => 'CVE-2026-21625: ACL-Umgehung im JSON-Output (Zugriff auf geschützte Forendaten). Auf EasyDiscuss 5.0.16 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-21625: access-control bypass in the JSON output exposing protected forum data. Update to EasyDiscuss 5.0.16 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-21625',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-21625',
            ),
            array(
                'element' => 'com_komento', 'type' => 'component', 'folder' => '',
                'name' => 'Komento (StackIdeas)', 'below' => '4.0.8',
                'severity' => 'high', 'note' => 'CVE-2025-54294: SQL-Injection, ausnutzbar durch unprivilegierte Nutzer. Auf Komento 4.0.8 oder neuer aktualisieren.',
                'note_en' => 'CVE-2025-54294: SQL injection exploitable by unprivileged users. Update to Komento 4.0.8 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2025-54294',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2025-54294',
            ),
            array(
                'element' => 'nrframework', 'type' => 'plugin', 'folder' => 'system',
                'name' => 'Novarain / Tassos Framework', 'below' => '6.0.38',
                'severity' => 'critical', 'note' => 'CVE-2026-21627: unauthentifizierte PHP-Einbindung via com_ajax. Auf 6.0.38 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-21627: unauthenticated PHP inclusion via com_ajax. Update to 6.0.38 or newer.',
                'advisory' => 'https://website-bereinigung.de/blog/joomla-tassos-framework-novarain-framework-sicherheitsluecke',
                'advisory_en' => 'https://website-bereinigung.de/en/blog/joomla-tassos-framework-novarain-framework-sicherheitsluecke',
            ),
            array(
                'element' => 'astroid', 'type' => 'library', 'folder' => '',
                'name' => 'Astroid Framework', 'below' => '3.3.11',
                'severity' => 'critical', 'note' => 'CVE-2026-21628: unauthentifizierter Datei-Upload via AJAX-Endpunkt (Dropper in /images/). Auf 3.3.13 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-21628: unauthenticated file upload via AJAX endpoint (dropper in /images/). Update to 3.3.13 or newer.',
                'advisory' => 'https://website-bereinigung.de/blog/joomla-astroid-framework-sicherheitsluecke-vulnerability',
                'advisory_en' => 'https://website-bereinigung.de/en/blog/joomla-astroid-framework-sicherheitsluecke-vulnerability',
            ),
            array(
                // Helix3-Framework (plg_system_helix3). Dieselbe unauth com_ajax-Luecke,
                // die die WAF-Signatur 'helix3-ajax' im Request blockt - hier zusaetzlich
                // als Versions-Warnung (liest plugins/system/helix3/helix3.xml).
                // CVE-2026-49049 (2026-06-29): affected 1.0-3.1.1, Fix 3.1.2. Ein 3.1.1-Site ist NOCH
                // verwundbar -> below MUSS 3.1.2 sein (3.1.1 waere ein False-Negative). Der onAjaxHelix3-
                // Fix (requireAuthorisedAdminRequest) sitzt im ajax-Plugin; system-/ajax-Plugin + Template
                // tragen die Version gemeinsam (der J3-Patch hebt alle drei auf 3.1.2).
                'element' => 'helix3', 'type' => 'plugin', 'folder' => 'system',
                'name' => 'Helix3 (JoomShaper)', 'below' => '3.1.2',
                'severity' => 'critical', 'note' => 'CVE-2026-49049: unauthentifizierter com_ajax-Handler (onAjaxHelix3) ohne Login-/Rechteprüfung - Angreifer können beliebige Dateien schreiben/löschen und Template-Parameter ändern (betroffen bis einschließlich 3.1.1). Auf Helix3 3.1.2 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-49049: unauthenticated com_ajax handler (onAjaxHelix3) with no login or permission check - attackers can write/delete arbitrary files and change template parameters (affected up to and including 3.1.1). Update to Helix3 3.1.2 or newer.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-49049',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-49049',
            ),
            array(
                // CVE-2026-49049 betrifft AUCH das TEMPLATE shaper_helix3 (der onAjaxHelix3-Angriff
                // schreibt/loescht Dateien IM aktiven Template + aendert dessen Parameter). Getrennte
                // Erweiterung mit eigener Version; der J3-Patch hebt auch das Template auf 3.1.2 ->
                // versionsbasiert erkennbar, kein Marker noetig.
                'element' => 'shaper_helix3', 'type' => 'template', 'folder' => '',
                'name' => 'Helix3 Template (shaper_helix3)', 'below' => '3.1.2',
                'severity' => 'critical', 'note' => 'CVE-2026-49049: das Helix3-Template ist über den unauthentifizierten com_ajax-Handler angreifbar - beliebige Dateien schreiben/löschen, Template-Parameter ändern (betroffen bis einschließlich 3.1.1). Das Template getrennt vom Plugin auf 3.1.2 aktualisieren.',
                'note_en' => 'CVE-2026-49049: the Helix3 template is reachable through the unauthenticated com_ajax handler - write/delete arbitrary files, change template parameters (affected up to and including 3.1.1). Update the template (separately from the plugin) to 3.1.2.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-49049',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-49049',
            ),
            array(
                // Helix Ultimate (plg_system_helixultimate) - EIGENES Produkt, NICHT Helix3.
                // Unauth com_ajax-Handler onAjaxHelixultimate, im Request von der WAF-Sig
                // 'helixultimate-comajax' geblockt; hier zusaetzlich als Versions-Warnung
                // (liest plugins/system/helixultimate/helixultimate.xml). Fix-Historie: 2.2.7
                // (com_ajax-Dispatcher, 2026-07-07) -> 2.2.10 (12 weitere Security-Fixes u.a.
                // Live-Preview-Bypass ?helixMode=edit, Media-Upload, Mega-Menue-XSS; 2026-08-27).
                // 'below' = NEUESTE Fix-Version. Joomla 3: Support eingestellt (JoomShaper
                // 2026-07-09), Fix nur via separates J3-Security-Patch-Paket
                // (helixultimate_j3_security_fixes; der J3-Zweig bleibt <= 2.1.4).
                'element' => 'helixultimate', 'type' => 'plugin', 'folder' => 'system',
                'name' => 'Helix Ultimate (JoomShaper)', 'below' => '2.2.10',
                'severity' => 'critical', 'note' => 'Kritische unauth. Lücken bis 2.2.9: com_ajax-Dispatcher ohne Rechteprüfung (Stored XSS, Datei-Löschung; Fix 2.2.7) plus 12 weitere Fixes in 2.2.10 (Live-Preview-Bypass via ?helixMode=edit, Media-Upload, Mega-Menü-XSS, Titel-Escaping). HTProtect blockt Kern-Angriffe per WAF. Joomla 4/5/6: auf 2.2.10 aktualisieren; Joomla 3 (Support eingestellt): JoomShapers J3-Security-Patch einspielen.',
                'note_en' => 'Critical unauth flaws up to 2.2.9: com_ajax dispatcher without permission checks (stored XSS, file deletion; fixed 2.2.7) plus 12 more fixes in 2.2.10 (Live Preview bypass via ?helixMode=edit, media upload, Mega Menu XSS, title escaping). HTProtect blocks core attacks via the WAF. Joomla 4/5/6: update to 2.2.10; Joomla 3 (support ended): install JoomShaper\'s J3 security patch.',
                'advisory' => 'https://htprotect.org/helix-ultimate',
                'advisory_en' => 'https://htprotect.org/en/helix-ultimate',
            ),
            array(
                // Helix Ultimate = ZWEI Erweiterungen: Plugin (oben) UND Template shaper_helixultimate,
                // mit eigenen Versionen (das Template aktualisiert sich NICHT ueber den Joomla-Updater).
                // Die VEL flaggt das TEMPLATE 2.2.7-2.2.9 (Media-Upload-Bypass, Path-Traversal, Broken
                // Access Control, Stored XSS; Fix 2.2.10). 'above' => '2.2.7' begrenzt das BEWUSST auf die
                // Mainline-Luecke: der J3-Zweig traegt 2.1.4-j3sec (< 2.2.7) und wird hier NICHT erfasst
                // -> kein Fehlalarm auf gepatchten J3-Sites (J3 deckt das Plugin oben ab). Kein Marker noetig.
                'element' => 'shaper_helixultimate', 'type' => 'template', 'folder' => '',
                'name' => 'Helix Ultimate Template (shaper_helixultimate)', 'below' => '2.2.10', 'above' => '2.2.7',
                'severity' => 'critical', 'note' => 'Das Helix-Ultimate-TEMPLATE (getrennt vom Plugin; aktualisiert sich nicht über den Joomla-Updater) ist von 2.2.7 bis 2.2.9 verwundbar: Media-Upload-Bypass, Path-Traversal, Broken Access Control und Stored XSS. Das Template separat auf 2.2.10 aktualisieren.',
                'note_en' => 'The Helix Ultimate TEMPLATE (separate from the plugin; does not update via the Joomla updater) is vulnerable from 2.2.7 to 2.2.9: media upload bypass, path traversal, broken access control and stored XSS. Update the template separately to 2.2.10.',
                'advisory' => 'https://htprotect.org/helix-ultimate',
                'advisory_en' => 'https://htprotect.org/en/helix-ultimate',
            ),
            array(
                // Balbooa Forms (com_baforms) - mehrere kritische unauth. RCE-Luecken:
                //  - Datei-Upload (<= 2.4.0, Fix 2.4.1) - generischer Gast-Upload-Schutz
                //    (hasExecutableUpload, Waechter) blockt bereits + DirShield im Ordner.
                //  - Signatur-Feld CVE-2026-65880 (<= 2.4.2.1, Fix 2.4.3) - WAF baforms-signature-rce.
                //  - eval via Query-Param CVE-2026-67364 (<= 2.4.3.1, Fix 2.4.3.2) - WAF baforms-eval-rce.
                // 'below' = neueste Fix-Version -> warnt jede noch verwundbare Installation.
                'element' => 'com_baforms', 'type' => 'component', 'folder' => '',
                'name' => 'Balbooa Forms', 'below' => '2.4.3.2',
                'severity' => 'critical', 'note' => 'Mehrere kritische unauth. RCE-Lücken bis 2.4.3.1 (eval-Injection CVE-2026-67364, Signatur-Feld CVE-2026-65880, Datei-Upload). HTProtect blockt Web-Shell-Uploads und die eval-/Signatur-RCE per WAF; voller Schutz erst mit Update auf Balbooa Forms 2.4.3.2 oder neuer.',
                'note_en' => 'Multiple critical unauthenticated RCE flaws up to 2.4.3.1 (eval injection CVE-2026-67364, signature field CVE-2026-65880, file upload). HTProtect blocks web-shell uploads and the eval/signature RCE via the WAF; full protection only after updating Balbooa Forms to 2.4.3.2 or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67364',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-67364',
            ),
            array(
                'element' => 'com_jce', 'type' => 'component', 'folder' => '',
                'name' => 'JCE Editor', 'below' => '2.9.99.5',
                'severity' => 'critical', 'note' => 'CVE-2026-48907: unauthentifizierter Webshell-Upload (profiles.import + plugin.rpc). Dringend auf JCE 2.9.99.6 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-48907: unauthenticated webshell upload (profiles.import + plugin.rpc). Urgently update to JCE 2.9.99.6 or newer.',
                'advisory' => 'https://website-bereinigung.de/blog/jce-sicherheitsluecke-joomla',
                'advisory_en' => 'https://website-bereinigung.de/en/blog/jce-sicherheitsluecke-joomla',
            ),
            array(
                // RSFiles! (RSJoomla Download-Manager): aktiver unauth Datei-Upload
                // -> RCE (task=rsfiles.upload ohne Login-/Token-/Endungs-Pruefung),
                // Fix 1.17.12 vom 10.07.2026. KEINE eigene WAF-Regel: HTProtects
                // generischer Gast-Upload-Schutz (hasExecutableUpload) blockt den
                // ausfuehrbaren .php-Upload bereits FP-frei; eine Task-Sperre wuerde
                // dagegen legitime Gast-Uploads NICHT-ausfuehrbarer Dateien riskieren.
                'element' => 'com_rsfiles', 'type' => 'component', 'folder' => '',
                'name' => 'RSFiles!', 'below' => '1.17.12',
                'severity' => 'critical', 'note' => 'Aktiver unauth. Datei-Upload -> RCE bis 1.17.11: anonymer Upload ohne Login-/Endungsprüfung, .php ausführbar. HTProtects Gast-Upload-Schutz blockt den Upload bereits. Auf RSFiles 1.17.12 aktualisieren.',
                'note_en' => 'Active unauthenticated file upload -> RCE up to 1.17.11: anonymous upload with no login/extension check, .php executable. The HTProtect guest-upload filter already blocks the upload. Update to RSFiles 1.17.12.',
                'advisory' => 'https://www.rsjoomla.com/blog/view/644-unauthenticated-file-upload-fixed-in-rsfiles-version-11712-update-now.html',
                'advisory_en' => 'https://www.rsjoomla.com/blog/view/644-unauthenticated-file-upload-fixed-in-rsfiles-version-11712-update-now.html',
            ),
            array(
                // EDocman (JoomDonation, com_edocman) - unauthentifizierte SQL-Injection ueber
                // einen oeffentlichen Frontend-Endpunkt (Filter-Parameter ungequotet in DB-Query).
                // Der EXAKTE Vektor ist bewusst nicht veroeffentlicht (JoomDonation + mysites.guru)
                // -> daher NUR Versions-Warnung, KEINE WAF-Sig: ohne den genauen Parameter waere
                // eine Regel entweder wirkungslos (FN) oder FP-anfaellig. Fix EDocman 3.9.0 (14.07.2026).
                'element' => 'com_edocman', 'type' => 'component', 'folder' => '',
                'name' => 'EDocman', 'below' => '3.9',
                'severity' => 'high', 'note' => 'Unauthentifizierte SQL-Injection bis 3.8: ein anonymer Besucher schleust über einen Filter-Parameter im Frontend SQL ein und liest die Datenbank aus (bis hin zu Zugangsdaten). Auf EDocman 3.9.0 aktualisieren.',
                'note_en' => 'Unauthenticated SQL injection up to 3.8: an anonymous visitor injects SQL via a front-end filter parameter and can read the database (up to credentials). Update to EDocman 3.9.0.',
                'advisory' => 'https://joomdonation.com/forum/edocman/82813-edocman-3-9-0-security-release---important-update-recommended.html',
                'advisory_en' => 'https://joomdonation.com/forum/edocman/82813-edocman-3-9-0-security-release---important-update-recommended.html',
            ),
            array(
                // 4Analytics (Weeblr / Yannick Gaultier) - ZWEI unauth. Luecken CVE-2026-57833 +
                // CVE-2026-58077, beide in ALLEN Versionen, Fix 5.0.2 (15.07.2026), laeuft J3-6.
                // Element = com_foranalytics: interner Produktname "foranalytics" (4 = "for"),
                // belegt durch die DB-Tabellen #__foranalytics_* + Weeblr-Konvention (sh404sef ->
                // com_sh404sef). Vektor 7 Tage embargoed -> KEINE WAF-Sig: der Angriffs-Parameter
                // ist unbekannt UND der legitime anonyme Tracking-Endpunkt darf NICHT geblockt werden.
                'element' => 'com_foranalytics', 'type' => 'component', 'folder' => '',
                'name' => '4Analytics', 'below' => '5.0.2',
                'severity' => 'critical', 'note' => 'CVE-2026-57833 und CVE-2026-58077: zwei unauthentifizierte, kritische Sicherheitslücken (ohne Login angreifbar) in allen Versionen vor 5.0.2. Dringend auf 4Analytics 5.0.2 aktualisieren.',
                'note_en' => 'CVE-2026-57833 and CVE-2026-58077: two unauthenticated, critical security flaws (exploitable without login) in all versions before 5.0.2. Update to 4Analytics 5.0.2 urgently.',
                'advisory' => 'https://weeblr.com/blog/critical-vulnerabilities-2026-07-15',
                'advisory_en' => 'https://weeblr.com/blog/critical-vulnerabilities-2026-07-15',
            ),
            array(
                // Quix Page Builder (ThemeXpert, com_quix) - unauthentifizierte, fehler-basierte
                // SQL-Injection (CWE-89, CVSS 8.7). Vektor am Free-5.2.3-Paket VERIFIZIERT: der
                // oeffentliche Endpunkt index.php?option=com_quix&task=ajax&element=joomla-article
                // (kein Login/Token/Lizenz-Check) ruft QuixJoomlaArticleElement::getAjax(); die
                // Artikel-ID kommt base64-kodiert im Parameter 'data', wird dekodiert und in
                // articleExist() OHNE Integer-Cast konkateniert (where id = <roh>). Betrifft Free
                // UND Pro (gleicher Code in lib_quixnxt), inkl. der aelteren 5.x-Reihe. Fix 6.2.1
                // (15.07.2026, Same-Day; auch Free erhaelt 6.2.1 ueber denselben Update-Server, pid=117).
                // AKTIV GEBLOCKT (Sig quix-article-sqli, guest/waf_only): die Payload steckt zwar im
                // base64-'data' (fuer eine Regex unsichtbar), aber der verwundbare AJAX-Endpunkt hat
                // KEINEN legitimen Gast-Traffic - veroeffentlichte Seiten rendern das Element
                // server-seitig (ElementApiCall im Twig), der Builder laeuft eingeloggt. Ein guest-Block
                // des Endpunkts ist daher FP-frei UND vollstaendig -> in mitigatedElements() (grün).
                'element' => 'com_quix', 'type' => 'component', 'folder' => '',
                'name' => 'Quix', 'below' => '6.2.1', 'mitigated' => 1,
                'severity' => 'high', 'note' => 'Unauthentifizierte SQL-Injection (CVSS 8.7, alle Versionen vor 6.2.1): ein anonymer Besucher kann die gesamte Datenbank auslesen. HTProtects Mini-WAF blockt den Angriff bereits. Auf Quix 6.2.1 aktualisieren.',
                'note_en' => 'Unauthenticated SQL injection (CVSS 8.7, all versions below 6.2.1): an anonymous visitor can read the entire database. HTProtect\'s mini-WAF already blocks the attack. Update to Quix 6.2.1.',
                'advisory' => 'https://mysites.guru/blog/quix-sql-injection-disclosure/',
                'advisory_en' => 'https://mysites.guru/blog/quix-sql-injection-disclosure/',
            ),
            array(
                // JoomCCK (JoomCoder, com_joomcck) - CVE-2026-49048: unauthentifizierte SQL-Injection.
                // Am 6.4.0-vs-6.4.1-Diff VERIFIZIERT: der Front-End-Task tags.save (JoomcckModelTags::_saveTag)
                // lief OHNE Token/Login und konkatenierte den Request-Param 'tag' (getString) roh in ZWEI
                // Statements (SELECT-Existenzpruefung + UPDATE, Double-Quote-Ausbruch); Sibling tags.delete
                // ('cid' ohne Cast). Fix 6.4.1: db-quote + Session::checkToken + Admin-ACL. HTProtects Mini-WAF
                // blockt den reinen Gast-Aufruf dieses Admin-Tasks (Sig joomcck-tags-sqli, guest/waf_only)
                // FP-frei -> mitigated. Nur die 6.x-Linie am Code verifiziert (6.4.0) -> above 6.0.0.
                'element' => 'com_joomcck', 'type' => 'component', 'folder' => '',
                'name' => 'JoomCCK', 'below' => '6.4.1', 'above' => '6.0.0', 'mitigated' => 1,
                'severity' => 'critical', 'note' => 'CVE-2026-49048: unauthentifizierte SQL-Injection über die Tag-Funktion - ein anonymer Besucher kann die Datenbank auslesen und verändern. HTProtects Mini-WAF blockt bereits. Auf JoomCCK 6.4.1 aktualisieren.',
                'note_en' => 'CVE-2026-49048: unauthenticated SQL injection via the tag function - an anonymous visitor can read and modify the database. HTProtect\'s mini-WAF already blocks it. Update to JoomCCK 6.4.1.',
                'advisory' => 'https://github.com/JoomCoder-com/JoomCCK/releases#release-6.4.1',
                'advisory_en' => 'https://github.com/JoomCoder-com/JoomCCK/releases#release-6.4.1',
            ),
            array(
                // ChronoForms 8 (ChronoEngine, com_chronoforms8) - CVE-2026-58148: unauthentifizierte
                // gespeicherte XSS (CVSS 8.7), betroffen 1.0-8.0.52, Fix 8.0.53. Vektor = Formular-Feld-
                // Eingabe wird ungefiltert gespeichert + spaeter (Backend-Auswertungsansicht) ausgefuehrt.
                // KEINE WAF-Sig: Formular-Felder sind Freitext (legit HTML/Sonderzeichen) -> jede Regel
                // waere FP-anfaellig und wuerde echte Einsendungen blocken. Daher NUR Versionswarnung,
                // NICHT mitigated (die XSS wird nicht FP-frei geblockt). above 8.0.0 (Element 8.x-exklusiv).
                'element' => 'com_chronoforms8', 'type' => 'component', 'folder' => '',
                'name' => 'ChronoForms', 'below' => '8.0.53', 'above' => '8.0.0',
                'severity' => 'high', 'note' => 'CVE-2026-58148 (CVSS 8.7): unauthentifizierte gespeicherte XSS - anonym eingeschleustes JavaScript wird später im Backend ausgeführt und kann die Admin-Sitzung übernehmen. Auf ChronoForms 8.0.53 aktualisieren.',
                'note_en' => 'CVE-2026-58148 (CVSS 8.7): unauthenticated stored XSS - JavaScript injected anonymously runs later in the backend and can take over the admin session. Update to ChronoForms 8.0.53.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-58148',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-58148',
            ),
            array(
                // jDownloads (com_jdownloads) - unauthentifizierter Datei-Upload (CWE-434), am
                // 4.1.4-vs-4.1.6-Diff VERIFIZIERT: eine EIGENSTAENDIGE Uploader-Datei (installiert:
                // administrator/components/com_jdownloads/assets/upload/upload-handler.php) definiert
                // _JEXEC selbst, prueft weder Login/Token noch Rechte; Allowlist blockt .php, erlaubt
                // aber exe/msi/Archive/... -> schreibt nach .../test_uploads. In 4.1.6 entfernt. Betroffen
                // 4.1.0-4.1.5, Fix 4.1.6, 4.0.52 und aelter NICHT (Datei erst ab 4.1.0). KEINE Mini-WAF-Sig
                // moeglich: DIREKTER .php-Aufruf ausserhalb Joomlas -> das Plugin (nur bei index.php-Routing)
                // sieht ihn nicht. ABER HTProtects .htaccess-PHP-Schild (builder.php Regel B: direkte .php auf
                // existierende Nicht-Einstiegspunkt-Datei -> 403) blockt den Zugriff auf upload-handler.php
                // bereits vor PHP -> mitigated (greift auch auf der Live-2.4.18-Userschaft, Schild ist alt).
                'element' => 'com_jdownloads', 'type' => 'component', 'folder' => '',
                'name' => 'jDownloads', 'below' => '4.1.6', 'above' => '4.1.0', 'mitigated' => 1,
                'severity' => 'high', 'note' => 'Unauthentifizierter Datei-Upload (Versionen 4.1.0-4.1.5): anonym sind beliebige Dateien hochladbar, teils bis zur Codeausführung. HTProtect blockt den Zugriff bereits (403). Auf jDownloads 4.1.6 aktualisieren.',
                'note_en' => 'Unauthenticated file upload (versions 4.1.0-4.1.5): anonymous visitors can upload arbitrary files, up to code execution on some servers. HTProtect already blocks direct access (403). Update to jDownloads 4.1.6.',
                'advisory' => 'https://www.jdownloads.com/index.php/news/jdownloads-4-1-6-secure-update-released.html',
                'advisory_en' => 'https://www.jdownloads.com/index.php/news/jdownloads-4-1-6-secure-update-released.html',
            ),
            array(
                // DPCalendar (Digital Peak): unauth Blind-SQLi im Frontend-Autor-Filter
                // filter_created_by (view=events), SKALAR ohne Integer-Cast -> a.created_by
                // IN (...). Nur lesend (High). CVE beantragt (Joomla CNA), noch offen.
                // Zwei Linien/Fixes: J4.4-6 (9.x/10.x)->10.11.2, J3 (8.x)->8.19.4.
                // WICHTIG (wie iCagenda): 'above 9.0.0' schliesst den J3-Fix 8.19.4 aus
                // (8.19.4 < 9.0.0), obwohl 8.19.4 < 10.11.2 -> kein FP auf gepatchte J3.
                'element' => 'com_dpcalendar', 'type' => 'component', 'folder' => '',
                'name' => 'DPCalendar', 'below' => '10.12.0', 'above' => '9.0.0',
                'severity' => 'high', 'note' => 'DPCalendar (Joomla 4.4-6, vor 10.12.0): unauthentifizierte SQL-Injection über den Autor-Filter (anonym, nur lesend - HTProtects Mini-WAF blockt sie bereits) plus authentifizierte Blind-SQL-Injection über gespeicherte Artikel und Stored XSS in Standort-Titeln (erfordern Redakteur-/Standort-Rechte, nicht WAF-abfangbar). Voller Schutz erst mit Update auf 10.12.0; die 9.x-Reihe hat keinen Patch.',
                'note_en' => 'DPCalendar (Joomla 4.4-6, below 10.12.0): unauthenticated SQL injection via the author filter (anonymous, read-only - HTProtect\'s mini-WAF already blocks it) plus authenticated blind SQL injection via saved articles and stored XSS in location titles (require author/location privileges, not WAF-blockable). Full protection only after updating to 10.12.0; the 9.x line has no patch.',
                'advisory' => 'https://joomla.digital-peak.com/blog/2026-security-issue-in-dpcalendar',
                'advisory_en' => 'https://joomla.digital-peak.com/blog/2026-security-issue-in-dpcalendar',
            ),
            array(
                // DPCalendar Joomla-3-Linie (8.x): dieselbe unauth Blind-SQLi, Fix 8.19.4.
                'element' => 'com_dpcalendar', 'type' => 'component', 'folder' => '',
                'name' => 'DPCalendar', 'below' => '8.19.5', 'above' => '8.18.0',
                'severity' => 'high', 'note' => 'DPCalendar für Joomla 3 (8.18.0-8.19.4): unauthentifizierte Blind-SQL-Injection über den Autor-Filter (nur lesend - HTProtect blockt sie per WAF) plus authentifizierte SQL-Injection über gespeicherte Artikel und Stored XSS in Standort-Titeln (erfordern Redakteur-/Standort-Rechte). Voller Schutz mit Update auf DPCalendar 8.19.5.',
                'note_en' => 'DPCalendar for Joomla 3 (8.18.0-8.19.4): unauthenticated blind SQL injection via the author filter (read-only - HTProtect blocks it via the WAF) plus authenticated SQL injection via saved articles and stored XSS in location titles (require author/location privileges). Full protection with the update to DPCalendar 8.19.5.',
                'advisory' => 'https://joomla.digital-peak.com/blog/2026-security-issue-in-dpcalendar',
                'advisory_en' => 'https://joomla.digital-peak.com/blog/2026-security-issue-in-dpcalendar',
            ),
            array(
                // Phoca Download (com_phocadownload) - zwei Security-Releases der 6.x-Reihe:
                //  - authentifizierter Datei-Upload -> RCE (6.0-6.1.2, Fix 6.1.3); KEINE WAF-Regel
                //    (authentifiziert -> nicht trennscharf FP-frei blockbar), aber HTProtects
                //    PHP-Schild verhindert die Ausfuehrung im Upload-Ordner ohnehin.
                //  - Reflected XSS ueber den Such-GET-Parameter (bis 6.1.4, Fix 6.1.5); kein CVE.
                //    KEINE WAF-Regel (Reflected-XSS-Payloads nicht FP-frei anker-bar) -> nur Warnung.
                // Fenster via above/below -> 5.x wird NICHT geflaggt (nur die 6.x-Reihe betroffen).
                'element' => 'com_phocadownload', 'type' => 'component', 'folder' => '',
                'name' => 'Phoca Download', 'below' => '6.1.5', 'above' => '6.0.0',
                'severity' => 'high', 'note' => 'Authentifizierter Datei-Upload -> RCE (6.0-6.1.2; HTProtects PHP-Schild verhindert die Ausführung) sowie Reflected XSS über den Such-Parameter (bis 6.1.4). Auf Phoca Download 6.1.5 oder neuer aktualisieren.',
                'note_en' => 'Authenticated file upload -> RCE (6.0-6.1.2; HTProtect\'s PHP shield prevents execution) plus reflected XSS via the search parameter (up to 6.1.4). Update to Phoca Download 6.1.5 or newer.',
                'advisory' => 'https://www.phoca.cz/news/1508-phoca-download-version-6-1-5-released-security-release',
                'advisory_en' => 'https://www.phoca.cz/news/1508-phoca-download-version-6-1-5-released-security-release',
            ),
            array(
                // Phoca Maps (com_phocamaps) - reflektiertes XSS, CVE-2026-65763.
                // Betrifft die native-Joomla-Reihe 5.0.0-6.0.4, behoben im Security-
                // Release 6.1.0 (23.07.2026). 'above 5.0.0' schont die aelteren
                // 3.x/4.x-Linien (andere Codebasis, nicht von diesem CVE erfasst).
                // Medium (CVSS 5.1, Nutzer-Interaktion noetig). Nur Versions-Warnung:
                // reflektiertes XSS ist payload-/kontextbasiert -> keine FP-freie
                // WAF-Regel; ein Fehlmatch warnt nur nicht (kein Fehlalarm).
                'element' => 'com_phocamaps', 'type' => 'component', 'folder' => '',
                'name' => 'Phoca Maps', 'below' => '6.1.0', 'above' => '5.0.0',
                'severity' => 'medium', 'note' => 'CVE-2026-65763: reflektiertes XSS (5.0.0-6.0.4) durch unzureichende Eingabeprüfung - ein präparierter Link führt beim Besucher eingeschleustes JavaScript aus. Auf Phoca Maps 6.1.0 aktualisieren.',
                'note_en' => 'CVE-2026-65763: reflected XSS (5.0.0-6.0.4) via improper input validation - a crafted link runs injected JavaScript in a visitor\'s browser. Update to Phoca Maps 6.1.0.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65763',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65763',
            ),
            array(
                // Phoca Guestbook (com_phocaguestbook) - reflektiertes XSS, CVE-2026-65762.
                // Betrifft 5.0.0-6.1.0, behoben im Security-Release 6.1.1 (23.07.2026).
                // 'above 5.0.0' schont die aelteren Linien. Medium (CVSS 5.1). Siehe
                // Phoca Maps oben: nur Versions-Warnung, keine WAF-Regel.
                'element' => 'com_phocaguestbook', 'type' => 'component', 'folder' => '',
                'name' => 'Phoca Guestbook', 'below' => '6.1.1', 'above' => '5.0.0',
                'severity' => 'medium', 'note' => 'CVE-2026-65762: reflektiertes XSS (5.0.0-6.1.0) durch unzureichende Eingabeprüfung - ein präparierter Link führt beim Besucher eingeschleustes JavaScript aus. Auf Phoca Guestbook 6.1.1 (Security-Release) oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-65762: reflected XSS (5.0.0-6.1.0) via improper input validation - a crafted link runs injected JavaScript in the visitor\'s browser. Update to Phoca Guestbook 6.1.1 (security release) or newer.',
                'advisory' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65762',
                'advisory_en' => 'https://nvd.nist.gov/vuln/detail/CVE-2026-65762',
            ),
            array(
                // Neuauflage ab AcyMailing 6 (eigener Codebase, Element com_acym).
                // NICHT AcyMailing Classic (<=5.x, Element com_acymailing) - das
                // ist eine andere Versionsreihe, auf die diese CVEs nicht zutreffen.
                // 2026-07-10: below auf 10.11.1 hochgezogen fuer CVE-2026-56292
                // (unauth SQLi, mySites.guru-Disclosure, Fix 10.11.1 vom 09.07.2026).
                // Kein WAF-Sig: Vektor (Endpoint/Parameter) bewusst zurueckgehalten,
                // kein PoC; Spalten-Listen-SQLi ist payload-basiert -> keine trenn-
                // scharfe FP-freie qs-Regel moeglich (nur Versions-Warnung).
                'element' => 'com_acym', 'type' => 'component', 'folder' => '',
                'name' => 'AcyMailing 6+', 'below' => '10.11.1',
                'severity' => 'critical', 'note' => 'CVE-2026-56292: unauthentifizierte SQL-Injection in AcyMailing 6+ (6.0.0-10.11.0), anonym die Datenbank inkl. Passwort-Hashes auslesbar. Weitere: CVE-2023-28731 (Upload/RCE), CVE-2026-3614 (Rechteausweitung). Auf AcyMailing 10.11.1 aktualisieren.',
                'note_en' => 'CVE-2026-56292: unauthenticated SQL injection in AcyMailing 6+ (6.0.0-10.11.0), anonymous read of the database incl. password hashes. Also: CVE-2023-28731 (upload/RCE), CVE-2026-3614 (privilege escalation). Update to AcyMailing 10.11.1.',
                'advisory' => 'https://htprotect.org/acymailing',
                'advisory_en' => 'https://htprotect.org/en/acymailing',
            ),
            array(
                // AcyMailing Classic (<=5.x, eigener Codebase, Element com_acymailing) -
                // andere Versionsreihe als die Neuauflage com_acym (6+).
                'element' => 'com_acymailing', 'type' => 'component', 'folder' => '',
                'name' => 'AcyMailing Classic', 'below' => '4.9.5',
                'severity' => 'critical', 'note' => 'AcyMailing Classic (End-of-Life): anonymer Datei-Upload/RCE der 3.x-Ära und Backend-SQL-Injection CVE-2015-7338 (in 4.9.5 behoben); CVE-2018-9107 (CSV-Injection) ist niedrig. Auf eine unterstützte AcyMailing-Version migrieren.',
                'note_en' => 'AcyMailing Classic (end-of-life): anonymous file upload/RCE of the 3.x era and backend SQL injection CVE-2015-7338 (fixed in 4.9.5); CVE-2018-9107 (CSV injection) is low. Migrate to a supported AcyMailing version.',
                'advisory' => 'https://htprotect.org/acymailing',
                'advisory_en' => 'https://htprotect.org/en/acymailing',
            ),
            array(
                // Ignite Gallery (frueher "iGallery", Element com_igallery beibehalten).
                // Die 5.4.1-Sicherheits-Fixes betreffen NUR die 5.x-Linie: Untergrenze
                // 5.0.0 -> aeltere Reihen (2.x-4.x, Joomla 2.5/3) werden NICHT faelschlich
                // gewarnt. Alle drei Luecken verlangen Upload-/Bearbeitungsrecht
                // (authentifiziert) -> Warnung statt WAF (kein unauth Massen-Exploit,
                // XSS-Payload lebt in legitimen Feldern = keine trennscharfe Firewall-Regel).
                'element' => 'com_igallery', 'type' => 'component', 'folder' => '',
                'name' => 'Ignite Gallery', 'below' => '5.4.1', 'above' => '5.0.0',
                'severity' => 'high', 'note' => 'Ignite Gallery 5.0.0-5.4.0: Stored XSS (hoch) durch fehlendes Escaping bei Bild-Uploads, dazu SSRF und Rechteausweitung. Nur ausnutzbar mit Upload-/Bearbeitungsrecht. Auf Ignite Gallery 5.4.1 aktualisieren.',
                'note_en' => 'Ignite Gallery 5.0.0-5.4.0: stored XSS (high) via missing output escaping on image uploads, plus SSRF and privilege escalation. Only exploitable with upload/edit permission. Update to Ignite Gallery 5.4.1.',
                'advisory' => 'https://www.ignitegallery.com/documentation/changelog',
                'advisory_en' => 'https://www.ignitegallery.com/documentation/changelog',
            ),
            // iCagenda - zwei getrennte Release-Linien (3.x fuer Joomla 3, 4.x fuer
            // Joomla 4+), daher ZWEI Eintraege mit eigener 'above'-Untergrenze: ohne
            // 'above' im Schluessel wuerde der zweite den ersten in effectiveVuln()
            // ueberschreiben (nur EINE Linie geprueft).
            //
            // 4.x (below 4.0.12): deckt die neueste + schwerste Luecke ab -
            // CVE-2026-67365 (CVSS 9.2, unauth SQL-Injection im Kalender-Modul
            // mod_icagenda_calendar via com_ajax, betroffen 4.0.0-4.0.11). Der Fix
            // 4.0.12 (14.08.2026) behebt zugleich CVE-2026-67366 (CSRF) und die
            // aeltere anonyme Upload-Luecke CVE-2026-48939 (4.0.0-4.0.7, war in 4.0.8
            // gefixt) -> ein Update auf 4.0.12 schliesst alle drei. Ergaenzende WAF-
            // Signatur icagenda-calendar-sqli faengt den unauth SQLi-Vektor sofort ab.
            //
            // 3.x (below 3.9.15, above 3.2.1): der SQLi-CVE ist auf die 4.x-Linie
            // beschraenkt (CVE-Scope 4.0.0-4.0.11); fuer Joomla 3 bleibt der Ziel-Fix
            // 3.9.15 (Upload-Luecke CVE-2026-48939, Backport 16.06.2026). 'above'
            // 3.2.1: aeltere 3.x (< 3.2.1) haben die Upload-Luecke NICHT. Deren voller
            // RCE griff v. a. auf Joomla 6 (Core blockt .php nicht mehr); "6.x" =
            // Joomla-PLATTFORM, KEINE iCagenda-Release-Linie (iCagenda hat kein 6.x).
            array(
                'element' => 'com_icagenda', 'type' => 'component', 'folder' => '',
                'name' => 'iCagenda', 'below' => '4.0.12', 'above' => '4.0.0',
                'severity' => 'critical', 'note' => 'CVE-2026-67365 (CVSS 9.2): unauthentifizierte SQL-Injection im Kalender-Modul, Datenbank ohne Login auslesbar. 4.0.12 behebt zudem CVE-2026-67366 und CVE-2026-48939. HTProtect blockt gängige SQLi per WAF; voller Schutz mit Update auf iCagenda 4.0.12.',
                'note_en' => 'CVE-2026-67365 (CVSS 9.2): unauthenticated SQL injection in the calendar module, database readable without login. 4.0.12 also fixes CVE-2026-67366 and CVE-2026-48939. HTProtect blocks common SQLi via the WAF; full protection with the update to iCagenda 4.0.12.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-67365',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-67365',
            ),
            array(
                'element' => 'com_icagenda', 'type' => 'component', 'folder' => '',
                'name' => 'iCagenda', 'below' => '3.9.15', 'above' => '3.2.1',
                'severity' => 'critical', 'note' => 'CVE-2026-48939 (3.x-Linie für Joomla 3): anonymer Datei-Upload im Event-Formular ohne Rechteprüfung. HTProtects Dateischild verhindert die Ausführung bereits; voller Schutz erst mit Update auf iCagenda 3.9.15.',
                'note_en' => 'CVE-2026-48939 (3.x line for Joomla 3): anonymous file upload in the event form without a permission check. The HTProtect file shield already prevents execution; full protection only after updating iCagenda to 3.9.15.',
                'advisory' => 'https://website-bereinigung.de/blog/icagenda-sicherheitsluecke-joomla',
                'advisory_en' => 'https://website-bereinigung.de/en/blog/icagenda-sicherheitsluecke-joomla',
            ),
            array(
                'element' => 'com_sppagebuilder', 'type' => 'component', 'folder' => '',
                'name' => 'SP Page Builder', 'below' => '6.8.0', 'above' => '6.0.0',
                'severity' => 'critical', 'note' => 'CVE-2026-65876 (CVSS 8.7): unauth. SQL-Injection über den „Mehr laden"-Artikel-Endpunkt. Auf SP Page Builder 6.8.0 oder neuer aktualisieren.',
                'note_en' => 'CVE-2026-65876 (CVSS 8.7): unauth. SQL injection via the article load-more endpoint. Update to SP Page Builder 6.8.0 or newer.',
                'advisory' => 'https://website-bereinigung.de/blog/sp-page-builder-sicherheitsluecke-joomla',
                'advisory_en' => 'https://website-bereinigung.de/en/blog/sp-page-builder-sicherheitsluecke-joomla',
            ),
            // PageBuilder CK (joomlack.fr) - DREI CVEs, vollstaendig erst mit 3.6.5 behoben:
            //   CVE-2026-56290 (CVSS 10.0): unauth Datei-Upload-RCE, bis 3.5.10, "gefixt" 3.6.0.
            //   CVE-2026-63048 (CVSS 9.4): der 3.6.0-Fix war UNVOLLSTAENDIG (Typpruefung blieb bis
            //     3.6.2 auskommentiert) -> Upload-RCE bis 3.6.2, echt gefixt erst 3.6.3.
            //   CVE-2026-74254 (CVSS 9.3, 17.08.2026): Styles-SQL-Injection bis 3.6.4 (Frontend in
            //     3.6.4, Backend in 3.6.5 geschlossen).
            // Darum EIN Eintrag 'below 3.6.5' OHNE 'above': jede Version < 3.6.5 ist gegen mindestens
            // eine der drei Luecken verwundbar - auch die frueheren Backport-Fixes 3.4.10/3.1.1 sind
            // <= 3.6.4 und damit fuer 63048/74254 offen. Die alten drei Linien-Eintraege (nur 56290)
            // haetten 56290-gepatchte, aber 74254-verwundbare Versionen faelschlich freigegeben.
            array(
                'element' => 'com_pagebuilderck', 'type' => 'component', 'folder' => '',
                'name' => 'PageBuilder CK', 'below' => '3.6.5',
                'severity' => 'critical', 'note' => 'Mehrere kritische Lücken bis 3.6.4: anonymer Datei-Upload mit Codeausführung (CVE-2026-56290/63048, die Fixes bis 3.6.2 waren unvollständig) und SQL-Injection (CVE-2026-74254). HTProtect blockt Upload und Ausführung bereits; voller Schutz erst mit Update auf PageBuilder CK 3.6.5.',
                'note_en' => 'Several critical flaws up to 3.6.4: anonymous file upload with code execution (CVE-2026-56290/63048, fixes up to 3.6.2 were incomplete) and SQL injection (CVE-2026-74254). HTProtect already blocks the upload and execution; full protection only after updating PageBuilder CK to 3.6.5.',
                'advisory' => 'https://www.cve.org/CVERecord?id=CVE-2026-63048',
                'advisory_en' => 'https://www.cve.org/CVERecord?id=CVE-2026-63048',
            ),
            array(
                // Events Booking (JoomDonation/Ossolution, com_eventbooking) - ZWEI unauthentifizierte
                // Befunde, Fix 5.8.1 (15.07.2026).
                //   (1) Anonymer Datei-Upload am Anmeldeformular: weder Login- noch CSRF-Token-Pruefung,
                //       ab Werk in jeder Installation aktiv. Dateinamen wurden bis 5.8.1 NICHT zufaellig
                //       vergeben -> der Angreifer bestimmt den Namen und ruft die Datei vorhersehbar ab.
                //   (2) Benutzer-Abfrage im Frontend: liefert zu einer Benutzernummer Vor-, Nachname und
                //       E-Mail ohne Anmeldung, ohne Rechtepruefung, ohne Ratenbegrenzung -> die gesamte
                //       Benutzertabelle ist in Minuten auslesbar (meldepflichtiger Datenschutzvorfall).
                // SEVERITY 'medium' und bewusst NICHT hoeher: der Hersteller nennt im Changelog woertlich
                // "two medium security issues", mysites.guru rechnet CVSS 4.0 = 6.9 (Medium). Beides am
                // 20.07.2026 direkt an der Quelle nachgelesen. Die Standard-Positivliste
                // (bmp|gif|jpg|png|swf|zip|doc|pdf|xls) enthaelt kein PHP -> ab Werk KEINE Codeausfuehrung,
                // die Einstufung 'critical' der RCE-Faelle waere hier falsch.
                // SCHWELLE 5.8.1, NICHT 5.8.0: der Hersteller lieferte 5.8.0 am selben Tag in ZWEI
                // verschiedenen Fassungen aus (Nachmittags-Rebuild); erst 5.8.1 brachte Zufalls-Dateinamen,
                // Limits je Sitzung/Minute und die echte Bildpruefung. Damit ist bei 5.8.0 der Grundsatz
                // "eine Version = ein Byte-Stand" verletzt -> 5.8.0 gilt als unsicher.
                // KEINE WAF-Sig: der Offenleger schreibt ausdruecklich, dass eine Firewall hier nicht hilft
                // - es gibt kein Schadmuster, beide Endpunkte gehoeren zum regulaeren, anonym erreichbaren
                // Anmelde-Ablauf, und die genauen option/task-Parameter sind bewusst unveroeffentlicht.
                // Eine geratene Regel wuerde legitime Anmeldungen mit Datei-Anhang blocken.
                // BEWUSSTE LUECKE 'above' => '5.0': deckt NUR den Joomla-4/5/6-Zweig ab. mysites.guru nennt
                // fuer Joomla 3 einen eigenen Fix 4.9.5 - der taucht weder im Hersteller-Changelog noch in
                // der Doku auf (dort endet die 4er-Reihe bei 4.9.4) und soll nur ueber Support-Ticket
                // erhaeltlich sein. Eine Warnung auf eine unbelegte Zielversion waere ein Fehlalarm, den
                // der Betreiber gar nicht aufloesen kann. Der Joomla-3-Zweig bleibt offen, bis 4.9.5
                // belegbar ist; dann als ZWEITER Eintrag mit above=4.0/below=4.9.5 nachziehen.
                'element' => 'com_eventbooking', 'type' => 'component', 'folder' => '',
                'name' => 'Events Booking', 'above' => '5.0', 'below' => '5.8.1',
                'severity' => 'medium', 'note' => 'Zwei unauthentifizierte Lücken vor 5.8.1: anonymer Datei-Upload und eine Schnittstelle, die zu jeder Benutzernummer Name und E-Mail preisgibt (ganze Nutzerliste auslesbar). Auf Events Booking 5.8.1 aktualisieren; 5.8.0 genügt nicht.',
                'note_en' => 'Two unauthenticated flaws before 5.8.1: anonymous file upload and an interface that returns the name and e-mail for any user number (entire user list readable). Update to Events Booking 5.8.1; 5.8.0 is not enough.',
                'advisory' => 'https://mysites.guru/blog/events-booking-unauthenticated-upload-user-enumeration/',
                'advisory_en' => 'https://mysites.guru/blog/events-booking-unauthenticated-upload-user-enumeration/',
            ),
            array(
                // DJ-Classifieds (DJ-Extensions, com_djclassifieds) - unauthentifizierter Datei-Upload
                // ueber den Anzeigen-Bild-Upload, laut Offenleger VOR dem Patch bereits aktiv ausgenutzt.
                // Fix 3.11.2 (20.07.2026) - am 20.07.2026 DIREKT auf der Hersteller-Release-Seite
                // gegengeprueft (die Seite antwortet nur mit Browser-Kennung, mit Standard-Kennung 403).
                // Die Pruefung war noetig, weil zu diesem Zeitpunkt alle Spiegel und das JED noch 3.11.1
                // fuehrten und es weder CVE noch VEL-Eintrag gibt.
                // SEVERITY 'high' wie in der Quelle: es gab sehr wohl eine (schwache) Inhaltspruefung, die
                // nur drei feste Zeichenfolgen kannte (PHP-Start-Tag, Auswertungs- und Dekodier-Funktion;
                // hier bewusst nicht woertlich, sonst schlaegt der eigene Scanner auf diese Datei an).
                // Auf Standardservern entsteht damit
                // eine in einem Bild versteckte Webshell; echte Fernausfuehrung erst bei fehlkonfiguriertem
                // Server oder zusaetzlicher Local File Inclusion. 3.11.2 haengt Anmeldung und Token-Pruefung
                // vor und ergaenzt eine serverseitige Positivliste echter Bildformate.
                // KEINE WAF-Sig: der Bild-Upload ist der regulaere, taeglich benutzte Weg zum Hochladen von
                // Anzeigenbildern - eine Sperre darauf traefe den Normalbetrieb. Die Polyglot-Variante
                // (Schadcode in einer gueltigen Bilddatei mit Bildendung) faellt ohnehin in die
                // inhaltsbasierte Gast-Upload-Pruefung, nicht in eine Regex.
                // NACHZUPRUEFEN: es existiert zusaetzlich eine Gratis-Variante "DJ-Classifieds-Light",
                // ebenfalls auf Stand 3.11.1. Ob sie denselben Elementnamen traegt, war nicht feststellbar.
                'element' => 'com_djclassifieds', 'type' => 'component', 'folder' => '',
                'name' => 'DJ-Classifieds', 'below' => '3.11.2',
                'severity' => 'high', 'note' => 'Aktiv ausgenutzte Lücke bis 3.11.1: anonymer Upload von Anzeigenbildern ohne Login und ohne Prüfung - ein Angreifer legt eine Schaddatei ab und übernimmt die Seite. Auf DJ-Classifieds 3.11.2 aktualisieren.',
                'note_en' => 'Actively exploited flaw up to 3.11.1: anonymous upload of ad images with no login and no checks - an attacker plants a malicious file and takes over the site. Update to DJ-Classifieds 3.11.2.',
                'advisory' => 'https://mysites.guru/blog/dj-classifieds-unauthenticated-file-upload/',
                'advisory_en' => 'https://mysites.guru/blog/dj-classifieds-unauthenticated-file-upload/',
            ),
            array(
                // Gridbox (Balbooa, com_gridbox) - Seiten-Baukasten. Zwei-Stufen-Verlauf:
                // 2.20.1 (20.07.2026, CVE-2026-61425) = schmaler Anmelde-Fix + Upload-Haertung, war
                // UNVOLLSTAENDIG; 2.20.2 (29.07.2026, offizielle Advisory/Blog) = grosse Sicherheits-
                // freigabe, betrifft laut Hersteller alle Versionen bis inkl. 2.20.1. Die 2018 notierte
                // Vorhersage eines unvollstaendigen Patches (2.4.1 vs. 2.4.1.1) ist eingetreten.
                // VERSIONSSCHEMA: vierteilige Nummern (zuletzt 2.20.0.2); 'below' MUSS '2.20.2' lauten
                // (2.20.1 < 2.20.2 stimmt, kein trailing .0). severity 'critical' = Wort des Herstellers.
                // WAF-SIGS: fuer die drei veroeffentlichten, in 2.20.2 gefixten Endpunkte (store.register,
                // uploader.showImage, uploader.savePhotoEditorImage) gibt es dedizierte Signaturen weiter
                // oben in dieser Datei - sie schuetzen die Basis <= 2.20.1 sofort (Plugin/.htaccess).
                // OFFENE ZWEIG-LUECKE: Gridbox-3-Beta (seit 30.06.2026) liegt beim Vergleich UEBER 2.20.2
                // und wird nie gewarnt (teilt vermutlich den Upload-Code). Zweiten Eintrag mit above=3.0
                // nachziehen, sobald Balbooa eine gefixte 3.x nennt.
                'element' => 'com_gridbox', 'type' => 'component', 'folder' => '',
                'name' => 'Gridbox', 'below' => '2.20.2',
                'severity' => 'critical', 'note' => 'CVE-2026-61425 und weitere kritische Lücken bis 2.20.1: unbefugter Datei-Zugriff, Ordner-Löschung, SQL-Injektionen und umgehbare Rechteprüfung. 2.20.1 reicht nicht - auf Balbooa Gridbox 2.20.2 aktualisieren.',
                'note_en' => 'CVE-2026-61425 and further critical flaws up to 2.20.1: unauthorized file access, folder deletion, SQL injections and authorization bypasses. 2.20.1 is not enough - update to Balbooa Gridbox 2.20.2.',
                'advisory' => 'https://www.balbooa.com/blog/gridbox/gridbox-2-20-2-security-release',
                'advisory_en' => 'https://www.balbooa.com/blog/gridbox/gridbox-2-20-2-security-release',
            ),
        );
    }

    /**
     * Erweiterungen, deren bekannten Angriffsweg HTProtect NACHWEISLICH aktiv abfaengt
     * (dedizierte WAF-Signatur ODER Datei-/Gast-Upload-Schild verhindert die RCE) - laut dem
     * jeweiligen builtin-Eintrag. Steuert NUR die Darstellung eines AUFGESCHOBENEN Fundes
     * (leise/grau statt gelb) - niemals die Erkennung/Warnung selbst. KONSERVATIV: im Zweifel
     * NICHT gelistet (z. B. com_acym - der SQLi ist payload-basiert, nicht trennscharf per
     * Signatur gedeckt; com_edocman/com_foranalytics - Vektor undisclosed/embargoed; com_igallery/
     * com_acymailing - authentifiziert/gemischt). Ein Feed-Eintrag mit explizitem 'mitigated'
     * hat Vorrang (siehe normaliseVuln).
     *
     * @return array element-Schluessel (kleingeschrieben)
     */
    public static function mitigatedElements()
    {
        return array(
            'nrframework', 'astroid', 'helix3', 'helixultimate',
            'com_jce', 'com_dpcalendar', 'com_sppagebuilder',
            'com_baforms', 'com_rsfiles', 'com_icagenda', 'com_pagebuilderck',
            'com_phocadownload', 'com_quix',
        );
    }

    /**
     * Lokalisierten Text eines Defs-Eintrags holen. Englisch nur, wenn die
     * UI-Sprache englisch ist UND das `_en`-Feld vorhanden ist - das reist im
     * Feed mit, daher sind auch NACHGELADENE Definitionen ohne Komponenten-
     * Update zweisprachig. Sonst die deutsche Basis (Verhalten alter Clients).
     *
     * @param   array   $entry  Signatur- oder Warnlisten-Eintrag
     * @param   string  $field  Basisfeld: 'label' | 'desc' | 'note' | 'advisory'
     *
     * @return string
     */
    public static function localized($entry, $field)
    {
        if (!is_array($entry)) {
            return '';
        }

        $en = $field . '_en';

        if (htp_lang() === 'en' && isset($entry[$en]) && $entry[$en] !== '') {
            return (string) $entry[$en];
        }

        return isset($entry[$field]) ? (string) $entry[$field] : '';
    }

    // ─────────────────────────────────────────────────────────────────
    //  Effektive Definitionen (eingebaut + nachgeladen, ohne Abschaltungen)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Effektive Signaturen: eingebaut, mit nachgeladenen zusammengefuehrt
     * (gleiche id -> nachgeladen gewinnt), abgeschaltete entfernt.
     *
     * @param   array  $cfg  Konfiguration (fuer x_sigs_off)
     *
     * @return array
     */
    public static function effective($cfg)
    {
        $off    = isset($cfg['x_sigs_off']) && is_array($cfg['x_sigs_off']) ? $cfg['x_sigs_off'] : array();
        $byId   = array();

        foreach (self::builtinSignatures() as $sig) {
            $byId[$sig['id']] = $sig;
        }

        $cache = self::loadCached();

        if (isset($cache['signatures']) && is_array($cache['signatures'])) {
            foreach ($cache['signatures'] as $sig) {
                if (self::validateSignature($sig)) {
                    $byId[$sig['id']] = $sig;
                }
            }
        }

        $out = array();

        foreach ($byId as $id => $sig) {
            if (in_array($id, $off, true)) {
                continue;
            }

            // Letzte Pruefung vor der Verwendung (defense in depth)
            if (self::validateSignature($sig)) {
                $out[] = $sig;
            }
        }

        return $out;
    }

    /**
     * Alle bekannten Signaturen (auch abgeschaltete) fuer die UI-Liste.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array Liste mit zusaetzlichem Schluessel 'enabled'
     */
    public static function catalog($cfg)
    {
        $off  = isset($cfg['x_sigs_off']) && is_array($cfg['x_sigs_off']) ? $cfg['x_sigs_off'] : array();
        $byId = array();

        foreach (self::builtinSignatures() as $sig) {
            $byId[$sig['id']] = $sig;
        }

        $cache = self::loadCached();

        if (isset($cache['signatures']) && is_array($cache['signatures'])) {
            foreach ($cache['signatures'] as $sig) {
                if (self::validateSignature($sig)) {
                    $byId[$sig['id']] = $sig;
                }
            }
        }

        $out = array();

        foreach ($byId as $id => $sig) {
            $sig['enabled'] = !in_array($id, $off, true);
            $out[] = $sig;
        }

        return $out;
    }

    /**
     * Effektive Warnliste (eingebaut + nachgeladen).
     *
     * @return array
     */
    public static function effectiveVuln()
    {
        $byKey = array();

        foreach (self::builtinVulnExtensions() as $ext) {
            $byKey[self::vulnKey($ext)] = $ext;
        }

        $cache = self::loadCached();

        if (isset($cache['vuln_extensions']) && is_array($cache['vuln_extensions'])) {
            foreach ($cache['vuln_extensions'] as $ext) {
                if (self::validateVuln($ext)) {
                    $byKey[self::vulnKey($ext)] = $ext;
                }
            }
        }

        return array_values($byKey);
    }

    /**
     * Aktuellste Joomla-Versionen je Major (3-6). Quelle ist die offizielle
     * Joomla-Downloads-API (live, gecacht), NICHT mehr eine handgepflegte Liste
     * und NICHT Joomlas interner Update-Check. Im Normalfall liefert der
     * Hintergrund-Waechter frische Daten; ist der Cache leer (z. B. frische
     * Installation), wird einmalig synchron geholt.
     *
     * @return array Major (3-6) => Version, ggf. leer (dann Patch-Check auslassen)
     */
    public static function joomlaLatest()
    {
        $cache = self::loadCached();
        $data  = isset($cache['joomla_latest']) ? self::sanitizeLatest($cache['joomla_latest']) : array();

        if (!empty($data)) {
            // Vorhanden -> nutzen; der Hintergrund-Waechter haelt es aktuell.
            return $data;
        }

        // Leerer Cache: einmalig synchron holen (per Zeitsperre gebremst).
        return self::refreshJoomlaLatest(false);
    }

    /**
     * Joomla-Versionsliste von der Downloads-API holen und cachen. Wird vom
     * Hintergrund-Waechter (1x/Tag) und beim leeren Cache aufgerufen. Bei einem
     * Fehlschlag bleibt der zuletzt geholte Wert erhalten (last-known-good).
     *
     * @param   boolean  $force  Zeitsperre ignorieren (manuelle Aktualisierung)
     *
     * @return array Major (3-6) => Version
     */
    public static function refreshJoomlaLatest($force = false)
    {
        $cache    = self::loadCached();
        $now      = time();
        $existing = isset($cache['joomla_latest']) ? self::sanitizeLatest($cache['joomla_latest']) : array();
        $haveData = !empty($existing);

        // Zeitsperre: mit Daten taeglich, ohne Daten hoechstens stuendlich.
        $stamp = $haveData
            ? (isset($cache['joomla_latest_ts']) ? (int) $cache['joomla_latest_ts'] : 0)
            : (isset($cache['joomla_latest_try']) ? (int) $cache['joomla_latest_try'] : 0);
        $throttle = $haveData ? self::JLATEST_INTERVAL : self::JLATEST_RETRY;

        if (!$force && ($now - $stamp) < $throttle) {
            return $existing;
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            $cache['joomla_latest_try'] = $now;
            self::saveCached($cache);

            return $existing;
        }

        $resp = HtpProbe::fetch(self::JOOMLA_API_URL, true, null, 6);
        $body = isset($resp['body']) ? (string) $resp['body'] : '';
        $map  = self::parseJoomlaLatest($body);

        $cache['joomla_latest_try'] = $now;

        if (!empty($map)) {
            $cache['joomla_latest']    = $map;
            $cache['joomla_latest_ts'] = $now;
            self::saveCached($cache);

            return $map;
        }

        // Fehlschlag: alten Wert behalten, nur Versuchszeit fortschreiben.
        self::saveCached($cache);

        return $existing;
    }

    /**
     * JSON der Downloads-API ({"branches":[{"branch":"Joomla! 5","version":
     * "5.4.6"}, ...]}) zu Major (3-6) => Version verdichten.
     *
     * @param   string  $body  Antwort-Body
     *
     * @return array
     */
    private static function parseJoomlaLatest($body)
    {
        $map  = array();
        $data = json_decode((string) $body, true);

        if (!is_array($data) || !isset($data['branches']) || !is_array($data['branches'])) {
            return $map;
        }

        foreach ($data['branches'] as $b) {
            if (!is_array($b) || !isset($b['version'])) {
                continue;
            }

            $ver = trim((string) $b['version']);

            // Major direkt aus der Version ableiten (3.x..6.x); alles andere ignorieren.
            if (preg_match('/^([3-6])\.\d+/', $ver, $vm) && self::isVersion($ver)) {
                $map[$vm[1]] = $ver;
            }
        }

        return $map;
    }

    /**
     * Gecachte Joomla-Versionsliste pruefen: nur Major 3-6 mit gueltiger Version.
     *
     * @param   mixed  $raw  Rohwert aus dem Cache
     *
     * @return array
     */
    private static function sanitizeLatest($raw)
    {
        $out = array();

        if (is_array($raw)) {
            foreach ($raw as $major => $version) {
                if (preg_match('/^[3-6]$/', (string) $major) && self::isVersion($version)) {
                    $out[(string) $major] = (string) $version;
                }
            }
        }

        return $out;
    }

    /**
     * Stand der aktuell verwendeten Definitionen (fuer die UI).
     *
     * @return array ['version' => string, 'source' => 'eingebaut'|'nachgeladen', 'fetched' => string]
     */
    public static function info()
    {
        $cache   = self::loadCached();
        $builtin = self::builtin();

        if (isset($cache['version']) && is_string($cache['version']) && $cache['version'] !== '') {
            return array(
                'version' => $cache['version'],
                'source'  => 'nachgeladen',
                'fetched' => isset($cache['fetched']) ? (string) $cache['fetched'] : '',
            );
        }

        return array('version' => $builtin['version'], 'source' => 'eingebaut', 'fetched' => '');
    }

    // ─────────────────────────────────────────────────────────────────
    //  Nachladen (Live-Update)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Definitionen vom Update-Server holen (max. alle 6h via REFRESH_INTERVAL, ausser $force).
     *
     * @param   boolean  $force  Zeitsperre ignorieren
     *
     * @return array ['changed' => bool, 'message' => string, 'version' => string]
     */
    public static function refresh($force = false)
    {
        $cache = self::loadCached();
        $now   = time();

        if (!$force && isset($cache['fetched_ts']) && ($now - (int) $cache['fetched_ts']) < self::REFRESH_INTERVAL) {
            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_UPTODATE'), 'version' => isset($cache['version']) ? $cache['version'] : '');
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_NOHTTP'), 'version' => '');
        }

        // Konditionaler Abruf: gespeicherte Validatoren mitsenden. Ist der Feed
        // unveraendert, antwortet der Server mit 304 ohne Body -> kein Download der
        // vollen JSON. Nur bei echter Aenderung fliesst der Body.
        $reqHeaders = array();

        if (!empty($cache['etag'])) {
            $reqHeaders[] = 'If-None-Match: ' . $cache['etag'];
        }

        if (!empty($cache['modified'])) {
            $reqHeaders[] = 'If-Modified-Since: ' . $cache['modified'];
        }

        $resp = HtpProbe::fetch(self::DEFS_URL, true, null, 6, $reqHeaders);
        $body = isset($resp['body']) ? (string) $resp['body'] : '';

        if ((int) $resp['status'] === 304) {
            // Unveraendert -> nur Zeitstempel auffrischen; Cache (Signaturen) bleibt.
            $cache['fetched_ts'] = $now;
            self::saveCached($cache);

            $v = isset($cache['version']) ? (string) $cache['version'] : '';

            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_NOCHANGE', $v), 'version' => $v);
        }

        if ((int) $resp['status'] < 200 || (int) $resp['status'] >= 300 || $body === '') {
            // Zeitstempel trotzdem setzen, damit nicht bei jedem Aufruf erneut versucht wird
            $cache['fetched_ts'] = $now;
            self::saveCached($cache);

            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_UNREACHABLE', (int) $resp['status']), 'version' => '');
        }

        $decoded = json_decode($body, true);

        if (!is_array($decoded)) {
            $cache['fetched_ts'] = $now;
            self::saveCached($cache);

            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_BADJSON'), 'version' => '');
        }

        // Signatur pruefen + entpacken. Mit libsodium ist eine gueltige
        // Ed25519-Signatur PFLICHT (sonst werden die Remote-Defs verworfen).
        $decoded = self::verifyAndUnwrap($decoded);

        if ($decoded === null) {
            $cache['fetched_ts'] = $now;
            self::saveCached($cache);

            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_REJECTED'), 'version' => '');
        }

        $clean = self::validateRemote($decoded);

        if ($clean === null) {
            $cache['fetched_ts'] = $now;
            self::saveCached($cache);

            return array('changed' => false, 'message' => htp_t('COM_HTPROTECT_DEFS_REJECTED'), 'version' => '');
        }

        $oldVersion = isset($cache['version']) ? (string) $cache['version'] : '';
        $clean['fetched']    = htp_local_time($now, 'Y-m-d H:i:s');
        $clean['fetched_ts'] = $now;
        $clean['etag']       = isset($resp['etag']) ? (string) $resp['etag'] : '';
        $clean['modified']   = isset($resp['modified']) ? (string) $resp['modified'] : '';

        // Joomla-Versionsdaten stammen aus einer eigenen Quelle (Downloads-API)
        // und muessen den Defs-Refresh ueberleben (saveCached ersetzt den Block).
        foreach (array('joomla_latest', 'joomla_latest_ts', 'joomla_latest_try') as $k) {
            if (isset($cache[$k])) {
                $clean[$k] = $cache[$k];
            }
        }

        self::saveCached($clean);

        $changed = $oldVersion !== $clean['version'];

        return array(
            'changed' => $changed,
            'message' => $changed
                ? htp_t('COM_HTPROTECT_DEFS_LOADED', $clean['version'], count($clean['signatures']), count($clean['vuln_extensions']))
                : htp_t('COM_HTPROTECT_DEFS_NOCHANGE', $clean['version']),
            'version' => $clean['version'],
        );
    }

    // ─────────────────────────────────────────────────────────────────
    //  Malware-Scanner-Signaturen (separater, signierter Feed)
    //  ADDITIV: HtpStorage::malwareSignature() nutzt diese NUR, wenn kein
    //  eingebauter Detektor griff. Leerer/fehlender Feed -> Verhalten heute.
    // ─────────────────────────────────────────────────────────────────

    /**
     * Malware-Sig-Feed vom Update-Server holen (max. 1x/Intervall, ausser
     * $force). Eigener Cache; gleicher Ed25519-Schluessel. Fail-soft: jeder
     * Fehler -> Zeitstempel setzen (kein Dauer-Retry) und beim Built-in bleiben.
     *
     * @param   boolean  $force  Zeitsperre ignorieren
     *
     * @return boolean  true, wenn ein gueltiger Feed uebernommen wurde
     */
    public static function refreshMalwareSigs($force = false)
    {
        $cache = self::loadMalwareCached();
        $now   = time();

        if (!$force && isset($cache['fetched_ts']) && ($now - (int) $cache['fetched_ts']) < self::MALWARE_REFRESH_INTERVAL) {
            return false;
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return false;
        }

        // Konditionaler Abruf (ETag/Last-Modified) - unveraendert -> 304 ohne Body.
        $reqHeaders = array();

        if (!empty($cache['etag'])) {
            $reqHeaders[] = 'If-None-Match: ' . $cache['etag'];
        }

        if (!empty($cache['modified'])) {
            $reqHeaders[] = 'If-Modified-Since: ' . $cache['modified'];
        }

        $resp = HtpProbe::fetch(self::MALWARE_URL, true, null, 6, $reqHeaders);
        $body = isset($resp['body']) ? (string) $resp['body'] : '';

        if ((int) $resp['status'] === 304) {
            // Unveraendert -> Zeitstempel auffrischen, Cache (Signaturen) bleibt.
            $cache['fetched_ts'] = $now;
            self::saveMalwareCached($cache);

            return false;
        }

        if ((int) $resp['status'] < 200 || (int) $resp['status'] >= 300 || $body === '') {
            $cache['fetched_ts'] = $now;
            self::saveMalwareCached($cache);

            return false;
        }

        $decoded = json_decode($body, true);

        if (!is_array($decoded)) {
            $cache['fetched_ts'] = $now;
            self::saveMalwareCached($cache);

            return false;
        }

        // Signatur PFLICHT (mit libsodium) - exakt wie bei den Defs, fail-closed.
        $decoded = self::verifyAndUnwrap($decoded);
        $clean   = ($decoded === null) ? null : self::validateMalwareRemote($decoded);

        if ($clean === null) {
            $cache['fetched_ts'] = $now;
            self::saveMalwareCached($cache);

            return false;
        }

        $clean['fetched']    = htp_local_time($now, 'Y-m-d H:i:s');
        $clean['fetched_ts'] = $now;
        $clean['etag']       = isset($resp['etag']) ? (string) $resp['etag'] : '';
        $clean['modified']   = isset($resp['modified']) ? (string) $resp['modified'] : '';

        self::saveMalwareCached($clean);

        return true;
    }

    /**
     * Aktuelle (verifizierte) deklarative Malware-Signaturen aus dem Cache.
     * Fail-closed: im Zweifel leeres Array (-> Scanner nutzt nur Built-ins).
     *
     * @return array
     */
    public static function malwareSigs()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['sigs']) && is_array($cache['sigs'])) ? $cache['sigs'] : array();
    }

    /**
     * Vom (signierten) Feed deklarierte Zusatz-Endungen, die der Scanner einlesen
     * soll - damit Feed-Signaturen neue Datei-KLASSEN abdecken koennen, ohne ein
     * Erweiterungs-Update. Fail-closed: im Zweifel leeres Array.
     *
     * @return array  Liste kleingeschriebener Endungen (ohne Punkt)
     */
    public static function malwareScanExts()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['scan_ext']) && is_array($cache['scan_ext'])) ? $cache['scan_ext'] : array();
    }

    /**
     * Vom (signierten) Feed deklarierte Angreifer-DATEINAMEN-Marker. Der Scanner
     * meldet jede Datei, deren Basisname einen Marker enthaelt - unabhaengig von
     * Endung/Inhalt (deckt z. B. nicht-gescannte .json-Schreibrechte-Sonden).
     * Fail-closed: im Zweifel leeres Array (-> nur die eingebaute Namensliste greift).
     *
     * @return array  Liste [{id, m:[marker,...]}, ...]
     */
    public static function malwareNameSigs()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['names']) && is_array($cache['names'])) ? $cache['names'] : array();
    }

    /**
     * Schwarm-Rueckkopplung: anonyme contrib_ids, deren Meldung als Signatur
     * ausgespielt wurde. Fail-closed: leer.
     *
     * @return array
     */
    public static function malwareAck()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['ack']) && is_array($cache['ack'])) ? $cache['ack'] : array();
    }

    /**
     * Kuratierte FP-Unterdrueckungs-Regeln aus dem Feed (sha256/Fingerprint/only).
     * Fail-closed: leer -> keine Unterdrueckung (Verhalten wie bisher).
     *
     * @return array
     */
    public static function malwareFp()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['fp']) && is_array($cache['fp'])) ? $cache['fp'] : array();
    }

    /**
     * Zentrale Rueckruf-/Sperrliste aus dem (signierten) Feed: hochproblematische
     * Erweiterungs-Updates, die NICHT installiert werden sollen. Fail-closed: im
     * Zweifel leer -> keine Sperre (Verhalten wie bisher).
     *
     * @return array
     */
    public static function recallList()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['recall']) && is_array($cache['recall'])) ? $cache['recall'] : array();
    }

    /**
     * Rein/testbar: Ist GENAU diese Erweiterung in DIESER Version zentral gesperrt?
     * Liefert den passenden Rueckruf-Eintrag (mit reason/id) oder null. Konservativ:
     * eine nicht parsbare Version matcht NIE (kein Fehl-Block). Ein Eintrag OHNE
     * Versionsangabe sperrt die GESAMTE Erweiterung.
     *
     * @param   array   $recallList  Liste normalisierter Rueckruf-Eintraege
     * @param   string  $type        Erweiterungstyp (component/plugin/...) oder ''
     * @param   string  $element     Element-Name (z. B. com_xyz)
     * @param   string  $version     Zu pruefende Version (available oder installed)
     *
     * @return array|null
     */
    public static function recallMatch($recallList, $type, $element, $version)
    {
        if (!is_array($recallList) || empty($recallList)) {
            return null;
        }

        $element = strtolower(trim((string) $element));
        $type    = strtolower(trim((string) $type));
        $version = trim((string) $version);

        if ($element === '' || !self::isVersion($version)) {
            return null;
        }

        foreach ($recallList as $rc) {
            if (!is_array($rc) || !isset($rc['element']) || strtolower((string) $rc['element']) !== $element) {
                continue;
            }

            // Optionale Typ-Bindung (z. B. nur das Paket, nicht die Komponente).
            if (isset($rc['type']) && (string) $rc['type'] !== '' && strtolower((string) $rc['type']) !== $type) {
                continue;
            }

            // 'exact' gewinnt vor dem Fenster.
            if (isset($rc['exact']) && self::isVersion($rc['exact'])) {
                if (version_compare($version, (string) $rc['exact'], '==')) {
                    return $rc;
                }

                continue;
            }

            // Fenster: Version < below (falls gesetzt) UND >= above (falls gesetzt).
            // Keine Angabe -> die GESAMTE Erweiterung ist gesperrt.
            $hit = true;

            if (isset($rc['below']) && self::isVersion($rc['below']) && !version_compare($version, (string) $rc['below'], '<')) {
                $hit = false;
            }

            if ($hit && isset($rc['above']) && self::isVersion($rc['above']) && version_compare($version, (string) $rc['above'], '<')) {
                $hit = false;
            }

            if ($hit) {
                return $rc;
            }
        }

        return null;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Vorsorge-Sperre (precautionary hold) - von radar, TLS-autorisiert,
    //  rein subtraktiv, fail-OPEN (im Zweifel nicht sperren).
    // ─────────────────────────────────────────────────────────────────

    /** Pfad des Hold-Caches im geschuetzten State-Ordner ('' wenn nicht moeglich). */
    private static function holdStatePath()
    {
        $dir = function_exists('htp_state_dir') ? htp_state_dir() : '';

        if ($dir === '' || !is_dir($dir)) {
            return '';
        }

        return rtrim(str_replace('\\', '/', $dir), '/') . '/htprotect_hold.json';
    }

    /** Hold-Cache lesen (leer bei Fehler). */
    private static function loadHoldCache($path)
    {
        $j = json_decode((string) @file_get_contents($path), true);

        return is_array($j) ? $j : array();
    }

    /** Hold-Cache 0600 schreiben. */
    private static function saveHoldCache($path, $data)
    {
        $json = json_encode($data);

        if ($json !== false && @file_put_contents($path, $json, LOCK_EX) !== false) {
            @chmod($path, 0600);
        }
    }

    /**
     * Rohe Hold-Eintraege saeubern + HAERTEN: nur {element, version, until}; until
     * muss in der Zukunft liegen und wird auf now+HOLD_MAXHOLD GECLAMPT (radar kann
     * keine Ewigkeits-Sperre setzen); Anzahl gedeckelt. Rein/testbar.
     *
     * @param   array  $raw  Rohe Liste
     * @param   int    $now  Jetzt (Unix)
     *
     * @return array
     */
    public static function sanitizeHolds($raw, $now)
    {
        $out = array();

        if (!is_array($raw)) {
            return $out;
        }

        $cap = (int) $now + self::HOLD_MAXHOLD;

        foreach ($raw as $h) {
            if (!is_array($h)) {
                continue;
            }

            $el    = strtolower((string) preg_replace('~[^a-z0-9_.\-]~i', '', (string) (isset($h['element']) ? $h['element'] : '')));
            $ver   = (string) (isset($h['version']) ? $h['version'] : '');
            $until = (int) (isset($h['until']) ? $h['until'] : 0);

            if ($el === '' || !self::isVersion($ver) || $until <= (int) $now) {
                continue;
            }

            if ($until > $cap) {
                $until = $cap; // Client-Clamp gegen Ewigkeits-Sperre
            }

            $out[] = array('element' => substr($el, 0, 64), 'version' => $ver, 'until' => $until);

            if (count($out) >= self::HOLD_MAX_ENTRIES) {
                break;
            }
        }

        return $out;
    }

    /**
     * Hold-Liste von radar holen (max. 1x/Intervall, ausser $force). TLS-verifiziert
     * (HtpProbe). FAIL-OPEN: bei jedem Fehler bleiben die zuletzt bekannten Holds
     * stehen (eine kurze radar-Panne soll eine laufende Sperre nicht aufheben), nur
     * der Refresh wird verschoben; eine Sperre laeuft ohnehin per 'until' selbst ab.
     *
     * @param   boolean  $force  Zeitsperre ignorieren
     *
     * @return boolean  true, wenn frisch geholt
     */
    public static function refreshHoldList($force = false)
    {
        $path = self::holdStatePath();

        if ($path === '') {
            return false;
        }

        $cache = self::loadHoldCache($path);
        $now   = time();

        if (!$force && isset($cache['fetched_ts']) && ($now - (int) $cache['fetched_ts']) < self::HOLD_REFRESH_INTERVAL) {
            return false;
        }

        if (!class_exists('HtpProbe') || !HtpProbe::available()) {
            return false;
        }

        $resp = HtpProbe::fetch(self::HOLD_URL, true, null, 5);

        if ((int) $resp['status'] < 200 || (int) $resp['status'] >= 300) {
            // Fehler -> Holds UNVERAENDERT lassen, nur bald erneut versuchen.
            $cache['fetched_ts'] = $now - (self::HOLD_REFRESH_INTERVAL - self::HOLD_RETRY);
            self::saveHoldCache($path, $cache);

            return false;
        }

        $decoded = json_decode((string) (isset($resp['body']) ? $resp['body'] : ''), true);
        $holds   = self::sanitizeHolds((is_array($decoded) && isset($decoded['holds'])) ? $decoded['holds'] : array(), $now);

        self::saveHoldCache($path, array('fetched_ts' => $now, 'holds' => $holds));

        return true;
    }

    /** Aktuelle (geclampte) Hold-Liste aus dem Cache. Leer = keine Sperre. */
    public static function holdList()
    {
        $path = self::holdStatePath();

        if ($path === '') {
            return array();
        }

        $cache = self::loadHoldCache($path);

        return (isset($cache['holds']) && is_array($cache['holds'])) ? $cache['holds'] : array();
    }

    /**
     * Rein/testbar: ist GENAU diese Erweiterung@Version aktuell vorsorglich gesperrt?
     * Liefert den Hold-Eintrag oder null. Abgelaufene (until<=now) zaehlen nicht.
     *
     * @param   array   $holds    Hold-Liste (aus holdList())
     * @param   string  $element  Element-Name
     * @param   string  $version  Zu pruefende Version
     *
     * @return array|null
     */
    public static function heldVersion($holds, $element, $version)
    {
        if (!is_array($holds) || empty($holds)) {
            return null;
        }

        $element = strtolower(trim((string) $element));
        $version = trim((string) $version);
        $now     = time();

        if ($element === '' || !self::isVersion($version)) {
            return null;
        }

        foreach ($holds as $h) {
            if (!is_array($h) || strtolower((string) (isset($h['element']) ? $h['element'] : '')) !== $element) {
                continue;
            }

            if ((string) (isset($h['version']) ? $h['version'] : '') !== $version) {
                continue;
            }

            if ((int) (isset($h['until']) ? $h['until'] : 0) <= $now) {
                continue; // abgelaufen
            }

            return $h;
        }

        return null;
    }

    /**
     * SEO-Waechter-Defs aus dem Feed: Gewichte + Keyword-Cluster (Labels). Wird
     * vom HtpSeo-Hook genutzt; fail-closed leer -> eingebaute Defaults.
     *
     * @return array ['weights'=>[],'keywords'=>[]]
     */
    public static function seoDefs()
    {
        $cache = self::loadMalwareCached();
        $seo   = (isset($cache['seo']) && is_array($cache['seo'])) ? $cache['seo'] : array();

        return array(
            'weights'  => (isset($seo['weights']) && is_array($seo['weights'])) ? $seo['weights'] : array(),
            'keywords' => (isset($seo['keywords']) && is_array($seo['keywords'])) ? $seo['keywords'] : array(),
        );
    }

    /**
     * Kuratierte Defacement-/Spam-Signaturen des SEO-Waechters aus dem Feed.
     * Fail-closed: leer -> keine zusaetzlichen Signale (Verhalten wie bisher).
     *
     * @return array
     */
    public static function seoSigs()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['seo']['sigs']) && is_array($cache['seo']['sigs'])) ? $cache['seo']['sigs'] : array();
    }

    /**
     * Anzahl community-stammiger Signaturen im Feed (Schwarm-Statuszeile). 0 = unbekannt.
     *
     * @return int
     */
    public static function malwareCommunityCount()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['community']) && is_numeric($cache['community'])) ? (int) $cache['community'] : 0;
    }

    /**
     * Feed-Version (Dedup-Schluessel der Dankes-Zeile + Anzeige). '' = kein Feed.
     *
     * @return string
     */
    public static function malwareVersion()
    {
        $cache = self::loadMalwareCached();

        return (isset($cache['version']) && is_string($cache['version'])) ? (string) $cache['version'] : '';
    }

    /**
     * Anzahl aktiver Signaturen gesamt (eingebaut + Feed) - grob fuer die
     * Schwarm-Statuszeile. Eingebaute werden konservativ NICHT mitgezaehlt
     * (kein verlaesslicher Zaehler) -> wir zeigen Feed-Signaturen.
     *
     * @return int
     */
    public static function malwareSigCount()
    {
        return count(self::malwareSigs());
    }

    /**
     * Malware-Feed-Struktur saeubern/normalisieren oder null (unbrauchbar).
     *
     * @param   array  $decoded  Verifizierter innerer Payload
     *
     * @return array|null
     */
    public static function validateMalwareRemote($decoded)
    {
        if (!is_array($decoded) || (int) (isset($decoded['schema']) ? $decoded['schema'] : 0) !== 1) {
            return null;
        }

        $version = isset($decoded['version']) ? (string) $decoded['version'] : '';

        if (!preg_match('/^[0-9A-Za-z.\-]{1,40}$/', $version)) {
            return null;
        }

        $sigs = array();

        if (isset($decoded['sigs']) && is_array($decoded['sigs'])) {
            foreach ($decoded['sigs'] as $sig) {
                $norm = self::normaliseMalwareSig($sig);

                if ($norm !== null) {
                    $sigs[] = $norm;
                }

                if (count($sigs) >= self::MALWARE_MAX_SIGS) {
                    break;
                }
            }
        }

        // Optionale Zusatz-Endungen, die der Scanner EINLESEN soll (damit Feed-
        // Signaturen neue Datei-KLASSEN abdecken koennen, OHNE ein Erweiterungs-
        // Update). Reine Endungen [a-z0-9], <= 10 Zeichen, max. 32 - rein additiv
        // zum eingebauten Kandidaten-Filter; geflaggt wird weiterhin nur bei
        // echtem Signatur-Treffer.
        $scanExt = array();

        if (isset($decoded['scan_ext']) && is_array($decoded['scan_ext'])) {
            foreach ($decoded['scan_ext'] as $e) {
                if (!is_string($e)) {
                    continue;
                }

                $e = strtolower((string) preg_replace('/[^A-Za-z0-9]/', '', $e));

                if ($e !== '' && strlen($e) <= 10) {
                    $scanExt[$e] = 1;
                }

                if (count($scanExt) >= 32) {
                    break;
                }
            }
        }

        // Schwarm-Schutz: geschlossene Rueckkopplung. 'ack' = anonyme contrib_ids,
        // deren Meldung als Signatur ausgespielt wurde (32 hex, gedeckelt). 'community'
        // = Anzahl community-stammiger Signaturen (fuer die Schwarm-Statuszeile).
        // Beide OPTIONAL + additiv -> aeltere Versionen ignorieren sie fehlerfrei.
        $ack = array();

        if (isset($decoded['ack']) && is_array($decoded['ack'])) {
            foreach ($decoded['ack'] as $a) {
                if (is_string($a) && preg_match('~^[a-f0-9]{32}$~', $a)) {
                    $ack[] = $a;
                }

                if (count($ack) >= 2000) {
                    break;
                }
            }
        }

        $community = (isset($decoded['community']) && is_numeric($decoded['community']) && (int) $decoded['community'] >= 0)
            ? (int) $decoded['community'] : 0;

        // FP-Unterdrueckungs-Schicht: kuratierte Regeln, die ein Urteil (auch ein
        // eingebautes) auf "sauber" ueberstimmen. Optional + additiv -> aeltere
        // Clients ignorieren das Feld (behalten den FP, aber kein neuer FP, kein
        // uebersehener Schaedling -> sicher abbaubar).
        $fp = array();

        if (isset($decoded['fp']) && is_array($decoded['fp'])) {
            foreach ($decoded['fp'] as $rule) {
                $norm = self::normaliseFpRule($rule);

                if ($norm !== null) {
                    $fp[] = $norm;
                }

                if (count($fp) >= self::MALWARE_MAX_SIGS) {
                    break;
                }
            }
        }

        // Feed-getriebene Angreifer-DATEINAMEN: kuratierte Marker-Teilstrings, die
        // gegen den Basisnamen JEDER Datei geprueft werden (vor dem Kandidaten-Gate)
        // - deckt Datei-KLASSEN ab, die inhaltlich gar nicht gescannt werden (z. B.
        // .json-Schreibrechte-Sonden). Optional + additiv -> aeltere Clients
        // ignorieren das Feld fehlerfrei (kein neuer FP, kein Fehltreffer).
        $names = array();

        if (isset($decoded['names']) && is_array($decoded['names'])) {
            foreach ($decoded['names'] as $nsig) {
                $norm = self::normaliseMalwareNameSig($nsig);

                if ($norm !== null) {
                    $names[] = $norm;
                }

                if (count($names) >= self::MALWARE_MAX_SIGS) {
                    break;
                }
            }
        }

        // Defacement-/SEO-Spam-Waechter: kuratierte Signaturen (+ optionale
        // Gewichte/Keywords). Optional + additiv -> aeltere Clients ignorieren.
        $seo = array('weights' => array(), 'keywords' => array(), 'sigs' => array());

        if (isset($decoded['seo']) && is_array($decoded['seo'])) {
            $sd = $decoded['seo'];

            if (isset($sd['weights']) && is_array($sd['weights'])) {
                foreach ($sd['weights'] as $wk => $wv) {
                    if (is_string($wk) && is_numeric($wv)) {
                        $wk2 = substr(preg_replace('~[^A-Za-z0-9_]~', '', $wk), 0, 40);

                        if ($wk2 !== '') {
                            $seo['weights'][$wk2] = max(0, min(100, (int) $wv));
                        }
                    }

                    if (count($seo['weights']) >= 40) {
                        break;
                    }
                }
            }

            if (isset($sd['keywords']) && is_array($sd['keywords'])) {
                foreach ($sd['keywords'] as $cn => $words) {
                    $cn2 = is_string($cn) ? substr(preg_replace('~[^A-Za-z0-9_]~', '', $cn), 0, 40) : '';

                    if ($cn2 !== '' && is_array($words)) {
                        $seo['keywords'][$cn2] = self::normNeedleList($words, true);
                    }

                    if (count($seo['keywords']) >= 40) {
                        break;
                    }
                }
            }

            if (isset($sd['sigs']) && is_array($sd['sigs'])) {
                foreach ($sd['sigs'] as $sig) {
                    $norm = self::normaliseSeoSig($sig);

                    if ($norm !== null) {
                        $seo['sigs'][] = $norm;
                    }

                    if (count($seo['sigs']) >= self::MALWARE_MAX_SIGS) {
                        break;
                    }
                }
            }
        }

        // Rueckruf-/Sperrliste: zentral als hochproblematisch markierte Erweiterungs-
        // UPDATES. REIN PRAEVENTIV (Sperrliste) - betroffene Versionen werden NICHT
        // installiert; bereits installierte werden nur GEMELDET (kein Remote-Rollback).
        // Optional + additiv -> aeltere Clients ignorieren das Feld fehlerfrei.
        $recall = array();

        if (isset($decoded['recall']) && is_array($decoded['recall'])) {
            foreach ($decoded['recall'] as $rc) {
                $norm = self::normaliseRecall($rc);

                if ($norm !== null) {
                    $recall[] = $norm;
                }

                if (count($recall) >= self::MALWARE_MAX_RECALL) {
                    break;
                }
            }
        }

        return array(
            'schema'    => 1,
            'version'   => $version,
            'sigs'      => $sigs,
            'names'     => $names,
            'scan_ext'  => array_keys($scanExt),
            'ack'       => $ack,
            'community' => $community,
            'fp'        => $fp,
            'seo'       => $seo,
            'recall'    => $recall,
        );
    }

    /**
     * EINEN Rueckruf-Eintrag pruefen/normalisieren oder null. Bindet eine GESPERRTE
     * Erweiterung (element, optional type) an einen Versionsbereich:
     *   - 'exact'        : genau diese Version,
     *   - 'below'/'above': Fenster (Version < below UND >= above; je optional),
     *   - keine Angabe   : die GESAMTE Erweiterung (alle Versionen) gesperrt.
     * 'min' (FEED_CAP) macht neues Vokabular gefahrlos einfuehrbar. Rein/testbar.
     *
     * @param   array  $rc  Roh-Eintrag
     *
     * @return array|null
     */
    private static function normaliseRecall($rc)
    {
        if (!is_array($rc)) {
            return null;
        }

        if (isset($rc['min']) && (int) $rc['min'] > self::FEED_CAP) {
            return null;
        }

        $element = isset($rc['element']) ? strtolower((string) preg_replace('/[^A-Za-z0-9_.\-]/', '', (string) $rc['element'])) : '';

        if ($element === '') {
            return null;
        }

        $out  = array('element' => substr($element, 0, 64));
        $type = isset($rc['type']) ? strtolower((string) preg_replace('/[^A-Za-z]/', '', (string) $rc['type'])) : '';

        if ($type !== '') {
            $out['type'] = substr($type, 0, 20);
        }

        foreach (array('below', 'above', 'exact') as $vk) {
            if (isset($rc[$vk]) && self::isVersion($rc[$vk])) {
                $out[$vk] = (string) $rc[$vk];
            }
        }

        if (isset($rc['id']) && is_string($rc['id'])) {
            $id = preg_replace('/[^A-Za-z0-9_.\-]/', '', $rc['id']);

            if ($id !== '') {
                $out['id'] = substr($id, 0, 40);
            }
        }

        if (isset($rc['reason']) && is_string($rc['reason'])) {
            $out['reason'] = substr((string) $rc['reason'], 0, 200);
        }

        if (isset($rc['reason_en']) && is_string($rc['reason_en'])) {
            $out['reason_en'] = substr((string) $rc['reason_en'], 0, 200);
        }

        return $out;
    }

    /**
     * EINE deklarative SEO-Waechter-Signatur pruefen/normalisieren. Bindet einen
     * Treffer an eine REGION ('scope': tail|text|redirect|scripts) + deklarative
     * Substrings (all/any/not). Verboten ist ein Catch-all (ohne all/any) -> null.
     * FP-Schutz fuer den riskanten 'text'-Scope: mind. ein 'all'-Needle >= 8 Zeichen
     * (sonst zu unspezifisch fuer den sichtbaren Text -> verworfen). Capability via 'min'.
     *
     * @param   array  $sig  Roh-Signatur
     *
     * @return array|null
     */
    private static function normaliseSeoSig($sig)
    {
        if (!is_array($sig)) {
            return null;
        }

        if (isset($sig['min']) && (int) $sig['min'] > self::FEED_CAP) {
            return null;
        }

        $all = self::normNeedleList(isset($sig['all']) ? $sig['all'] : array(), true);
        $any = self::normNeedleList(isset($sig['any']) ? $sig['any'] : array(), true);
        $not = self::normNeedleList(isset($sig['not']) ? $sig['not'] : array(), true);

        if (empty($all) && empty($any)) {
            return null; // kein Catch-all
        }

        $scope = (isset($sig['scope']) && in_array($sig['scope'], array('tail', 'text', 'redirect', 'scripts'), true))
            ? (string) $sig['scope'] : 'tail';

        // 'text' (sichtbarer Seitentext) ist die FP-anfaelligste Region -> dort MUSS
        // ein spezifisches Muster stehen (>= 8 Zeichen), sonst verworfen.
        if ($scope === 'text') {
            $specific = false;

            foreach ($all as $n) {
                if (strlen($n) >= 8) {
                    $specific = true;
                    break;
                }
            }

            if (!$specific) {
                return null;
            }
        }

        $out = array(
            'kind'  => (isset($sig['kind']) && $sig['kind'] === 'defacement') ? 'defacement' : 'seo_spam',
            'scope' => $scope,
        );

        if (!empty($all)) { $out['all'] = $all; }
        if (!empty($any)) { $out['any'] = $any; }
        if (!empty($not)) { $out['not'] = $not; }

        if (isset($sig['id']) && is_string($sig['id'])) {
            $id = preg_replace('/[^A-Za-z0-9_.\-]/', '', $sig['id']);

            if ($id !== '') {
                $out['id'] = substr($id, 0, 40);
            }
        }

        if (isset($sig['label']) && is_string($sig['label'])) {
            $out['label'] = substr((string) $sig['label'], 0, 80);
        }

        return $out;
    }

    /**
     * EINE deklarative Signatur pruefen/normalisieren. Verboten ist ein
     * Catch-all (ohne positives Kriterium all/any/magic) -> null.
     *
     * @param   array  $sig  Roh-Signatur
     *
     * @return array|null
     */
    private static function normaliseMalwareSig($sig)
    {
        if (!is_array($sig)) {
            return null;
        }

        // Capability-Gate: verlangt der Eintrag neueres Vokabular als dieser Client
        // beherrscht, KOMPLETT ueberspringen -> alte Clients ueber-matchen nie.
        if (isset($sig['min']) && (int) $sig['min'] > self::FEED_CAP) {
            return null;
        }

        $all   = self::normNeedleList(isset($sig['all'])   ? $sig['all']   : array(), true);
        $any   = self::normNeedleList(isset($sig['any'])   ? $sig['any']   : array(), true);
        $not   = self::normNeedleList(isset($sig['not'])   ? $sig['not']   : array(), true);
        $magic = self::normNeedleList(isset($sig['magic']) ? $sig['magic'] : array(), false);

        if (empty($all) && empty($any) && empty($magic)) {
            return null; // kein Catch-all
        }

        $out = array();
        $id  = isset($sig['id']) ? preg_replace('/[^A-Za-z0-9_.\-]/', '', (string) $sig['id']) : '';

        if ($id !== '') {
            $out['id'] = substr($id, 0, 40);
        }

        if (isset($sig['label']) && is_string($sig['label'])) {
            $out['label'] = substr((string) $sig['label'], 0, 80);
        }

        if (!empty($all))   { $out['all']   = $all; }
        if (!empty($any))   { $out['any']   = $any; }
        if (!empty($not))   { $out['not']   = $not; }
        if (!empty($magic)) { $out['magic'] = $magic; }

        return $out;
    }

    /**
     * EINEN Feed-DATEINAMEN-Eintrag pruefen/normalisieren oder null. Bindet eine
     * Angreifer-Kennung (Marker-Teilstrings 'm') an eine id; der Scanner meldet jede
     * Datei, deren Basisname einen Marker enthaelt (Endung/Inhalt egal). FP-Schutz:
     * Marker auf Dateinamen-Zeichen reduziert, kleingeschrieben, >= 5 Zeichen - so
     * kann kein zu kurzer/generischer Teilstring (z. B. ".php") pauschal greifen.
     * Rein/testbar; 'min' gate wie bei Content-Sigs.
     *
     * @param   array  $sig  Roh-Eintrag {id, m:[...], label?, min?}
     *
     * @return array|null
     */
    private static function normaliseMalwareNameSig($sig)
    {
        if (!is_array($sig)) {
            return null;
        }

        if (isset($sig['min']) && (int) $sig['min'] > self::FEED_CAP) {
            return null;
        }

        $id = isset($sig['id']) ? preg_replace('/[^A-Za-z0-9_.\-]/', '', (string) $sig['id']) : '';

        if ($id === '') {
            return null;
        }

        $markers = array();

        if (isset($sig['m']) && is_array($sig['m'])) {
            foreach ($sig['m'] as $mk) {
                if (!is_string($mk)) {
                    continue;
                }

                $mk = strtolower(preg_replace('/[^A-Za-z0-9_.\-]/', '', $mk));

                if (strlen($mk) >= 5) {
                    $markers[$mk] = 1;
                }

                if (count($markers) >= 16) {
                    break;
                }
            }
        }

        if (empty($markers)) {
            return null;
        }

        $out = array(
            'id' => substr($id, 0, 40),
            'm'  => array_keys($markers),
        );

        if (isset($sig['label']) && is_string($sig['label'])) {
            $out['label'] = substr((string) $sig['label'], 0, 80);
        }

        return $out;
    }

    /**
     * EINE FP-Unterdrueckungs-Regel pruefen/normalisieren. Erlaubt sha256 (exakte
     * Datei) UND/ODER deklarativen Fingerprint (all/any/magic/not), optional auf
     * bestimmte Reasons beschraenkt ('only'). VERBOTEN ist ein Catch-all OHNE
     * positives Kriterium (sonst wuerde sie alles - in scope - auf sauber setzen)
     * -> null. Capability-Gate via 'min'.
     *
     * @param   array  $fp  Roh-Regel
     *
     * @return array|null
     */
    private static function normaliseFpRule($fp)
    {
        if (!is_array($fp)) {
            return null;
        }

        if (isset($fp['min']) && (int) $fp['min'] > self::FEED_CAP) {
            return null;
        }

        $out = array();

        if (isset($fp['sha256']) && is_string($fp['sha256']) && preg_match('~^[A-Fa-f0-9]{64}$~', $fp['sha256'])) {
            $out['sha256'] = strtolower($fp['sha256']);
        }

        $all   = self::normNeedleList(isset($fp['all'])   ? $fp['all']   : array(), true);
        $any   = self::normNeedleList(isset($fp['any'])   ? $fp['any']   : array(), true);
        $not   = self::normNeedleList(isset($fp['not'])   ? $fp['not']   : array(), true);
        $magic = self::normNeedleList(isset($fp['magic']) ? $fp['magic'] : array(), false);

        // KEIN Catch-all: mindestens ein POSITIVES Kriterium (sha256 ODER
        // all/any/magic) muss da sein. 'not'/'only' allein reicht NICHT.
        if (!isset($out['sha256']) && empty($all) && empty($any) && empty($magic)) {
            return null;
        }

        if (!empty($all))   { $out['all']   = $all; }
        if (!empty($any))   { $out['any']   = $any; }
        if (!empty($not))   { $out['not']   = $not; }
        if (!empty($magic)) { $out['magic'] = $magic; }

        if (isset($fp['only']) && is_array($fp['only'])) {
            $only = array();

            foreach ($fp['only'] as $r) {
                if (is_string($r) && $r !== '') {
                    $r = substr(preg_replace('~[^A-Za-z0-9_:.\-]~', '', $r), 0, 40);

                    if ($r !== '') {
                        $only[] = $r;
                    }
                }

                if (count($only) >= 20) {
                    break;
                }
            }

            if (!empty($only)) {
                $out['only'] = $only;
            }
        }

        if (isset($fp['id']) && is_string($fp['id'])) {
            $cid = preg_replace('/[^A-Za-z0-9_.\-]/', '', $fp['id']);

            if ($cid !== '') {
                $out['id'] = substr($cid, 0, 40);
            }
        }

        return $out;
    }

    /**
     * Teilstring-Liste saeubern: max. 16 Eintraege, je <= 200 Bytes, optional
     * kleingeschrieben (all/any/not werden gegen kleingeschriebenen Inhalt
     * gematcht; magic bleibt byte-genau).
     *
     * @param   mixed    $list   Roh-Liste
     * @param   boolean  $lower  Eintraege kleinschreiben?
     *
     * @return array
     */
    private static function normNeedleList($list, $lower)
    {
        if (!is_array($list)) {
            return array();
        }

        $out = array();

        foreach ($list as $n) {
            if (!is_string($n) || $n === '') {
                continue;
            }

            $n = substr($n, 0, 200);
            $out[] = $lower ? strtolower($n) : $n;

            if (count($out) >= 16) {
                break;
            }
        }

        return $out;
    }

    /**
     * Malware-Feed-Cache laden (eigener Schluessel; einmal je Request gemerkt).
     *
     * @return array
     */
    public static function loadMalwareCached()
    {
        if (self::$malwareMemo !== null) {
            return self::$malwareMemo;
        }

        self::$malwareMemo = array();

        // Primaerer Ort: der eigene Blob-Store.
        $fromStore = self::storeGet(self::MALWARE_CACHE_KEY);

        if (is_array($fromStore)) {
            self::$malwareMemo = $fromStore;

            return self::$malwareMemo;
        }

        // Fallback + einmalige Migration aus #__extensions.params (Altbestand).
        $legacy = self::legacyParamGet(self::MALWARE_CACHE_KEY);

        if (is_array($legacy)) {
            self::$malwareMemo = $legacy;

            if (self::storePut(self::MALWARE_CACHE_KEY, $legacy)) {
                self::purgeLegacyParamKey(self::MALWARE_CACHE_KEY);
            }
        }

        return self::$malwareMemo;
    }

    /**
     * Malware-Feed-Cache speichern (andere Parameter bleiben erhalten).
     *
     * @param   array  $data  Zu speichernde Feed-Daten
     *
     * @return boolean
     */
    public static function saveMalwareCached($data)
    {
        self::$malwareMemo = is_array($data) ? $data : array();

        // Primaer in den eigenen Blob-Store; bei Erfolg Alt-Cache aus params raeumen.
        if (self::storePut(self::MALWARE_CACHE_KEY, self::$malwareMemo)) {
            self::purgeLegacyParamKey(self::MALWARE_CACHE_KEY);

            return true;
        }

        // Fallback (Store nicht verfuegbar): wie bisher nach #__extensions.params.
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);
            $raw = $db->loadResult();

            $params = array();

            if (is_string($raw) && $raw !== '') {
                $decoded = json_decode($raw, true);

                if (is_array($decoded)) {
                    $params = $decoded;
                }
            }

            $params[self::MALWARE_CACHE_KEY] = self::$malwareMemo;
            $json = json_encode($params);

            if (!is_string($json)) {
                return false;
            }

            $query = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote($json))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            return true;
        } catch (Exception $e) {
            return false;
        }
    }

    // ─────────────────────────────────────────────────────────────────
    //  Echtzeit-Prüfung (Mini-WAF im Plugin)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Eine Anfrage gegen die aktiven Signaturen prüfen. Kernlogik der
     * Mini-WAF (zweite Schicht des Exploit-Schilds), bewusst rein und
     * DB-frei testbar: GET- UND POST-Parameter werden geprüft, sodass
     * auch im POST-Body geschmuggelte Exploit-Aufrufe greifen (was eine
     * .htaccess nie sehen kann).
     *
     * @param   array   $cfg          Konfiguration (für x_shield/x_sigs_off)
     * @param   string  $queryString  Roher Query-String ($_SERVER['QUERY_STRING'])
     * @param   string  $postString   Aus den POST-Parametern gebautes "key=value&..."
     * @param   string  $uriPath      Pfad ohne Site-Präfix und ohne führenden Slash
     *
     * @return array Greifende Signatur (mit id/scope) oder leeres Array (kein Treffer)
     */
    public static function wafMatch($cfg, $queryString, $postString, $uriPath)
    {
        if (empty($cfg['x_shield'])) {
            return array();
        }

        // Probes aufbauen (alles gekappt + dedupliziert, max. 16):
        // - roh + MEHRLAGIG dekodiert (gegen Mehrfach-Kodierung wie %252e),
        // - kompakte Extraktion der Schluesselparameter (option/task/method ...)
        //   aus einem groesseren Fenster -> ein Payload hinter Muell-Padding
        //   wird erkannt, OHNE den 8-KB-Match-Cap zu erhoehen.
        $probes = array();
        $seen   = array();

        foreach (array((string) $queryString, (string) $postString) as $s) {
            if ($s === '') {
                continue;
            }

            $stages   = self::decodeStages($s);
            $stages[] = self::extractKeyParams($s);
            self::pushProbes($probes, $seen, $stages);
        }

        // URI fuer die uri-Signaturen (ebenfalls mehrlagig dekodiert).
        $uriProbes = array();
        $useen     = array();
        self::pushProbes($uriProbes, $useen, self::decodeStages(ltrim((string) $uriPath, '/')));

        if (empty($uriProbes)) {
            $uriProbes[] = '';
        }

        // Backtracking-Limit hart begrenzen: ein bösartiges Muster kann den
        // Prozess so nicht aufhängen (preg_match -> false -> kein Treffer).
        $oldLimit = @ini_get('pcre.backtrack_limit');
        @ini_set('pcre.backtrack_limit', (string) self::WAF_BACKTRACK_LIMIT);

        // Vertrauenswuerdige Pfade: pfad-basierte (uri-)Signaturen ueberspringen
        // - konsistent mit dem HTP_ALLOW-Flag der .htaccess. qs/POST-Signaturen
        // (Query-/Body-Schutz) bleiben fuer den Pfad aktiv.
        $trusted = class_exists('HtpConfig') ? HtpConfig::isAllowedPath($cfg, $uriPath) : false;

        $match = array();

        foreach (self::effective($cfg) as $sig) {
            if (!empty($sig['qs'])) {
                foreach ($probes as $p) {
                    if (@preg_match('#' . $sig['qs'] . '#i', $p) === 1) {
                        $match = $sig;
                        break 2;
                    }
                }
            }

            if (!empty($sig['uri']) && !$trusted) {
                foreach ($uriProbes as $p) {
                    if (@preg_match('#' . $sig['uri'] . '#i', $p) === 1) {
                        $match = $sig;
                        break 2;
                    }
                }
            }
        }

        if ($oldLimit !== false) {
            @ini_set('pcre.backtrack_limit', $oldLimit);
        }

        return $match;
    }

    /**
     * Eine Eingabe in ihre Dekodier-Stufen zerlegen (roh + iterativ
     * url-dekodiert bis zum Fixpunkt, max. 3 Runden) + '+'-Dekodierung.
     * Schliesst die Luecke mehrlagig kodierter Payloads (z. B. %252e).
     *
     * @param   string  $s    Eingabe
     * @param   int     $max  max. Dekodier-Runden
     *
     * @return array
     */
    private static function decodeStages($s, $max = 3)
    {
        $s = (string) $s;

        if ($s === '') {
            return array();
        }

        $out = array($s);
        $cur = $s;

        for ($i = 0; $i < $max; $i++) {
            $dec = rawurldecode($cur);

            if ($dec === $cur) {
                break;
            }

            $out[] = $dec;
            $cur   = $dec;
        }

        // '+' -> Leerzeichen (form-urlencoded) zusaetzlich.
        $plus = urldecode($s);

        if ($plus !== $s) {
            $out[] = $plus;
        }

        return $out;
    }

    /**
     * Schluesselparameter (Routing/Datei) positionsunabhaengig aus einem
     * groesseren Fenster herausziehen und kompakt aneinanderreihen. So matchen
     * Signaturen wie "option=com_jce.*task=plugin\.rpc.*method=upload" auch dann,
     * wenn der Angreifer Muell-Padding davor setzt, das den 8-KB-Cap fuellt.
     *
     * @param   string  $s  Eingabe
     *
     * @return string  "option=…&task=…&…" oder ''
     */
    private static function extractKeyParams($s)
    {
        $s = (string) $s;

        if ($s === '' || strpos($s, '=') === false) {
            return '';
        }

        if (strlen($s) > 65536) {
            $s = substr($s, 0, 65536);
        }

        $keys = 'option|task|format|view|layout|controller|method|plugin|func|action|cmd|file|path|page|template|tmpl';

        if (!preg_match_all('/(?:^|[?&])((?:' . $keys . ')=[^&]{0,160})/i', $s, $m)) {
            return '';
        }

        return implode('&', $m[1]);
    }

    /**
     * Stufen in die Probe-Liste aufnehmen: gekappt, dedupliziert, max. 16.
     *
     * @param   array  $probes  Ziel (Referenz)
     * @param   array  $seen    Dedupe-Index (Referenz)
     * @param   array  $stages  Roh-Stufen
     *
     * @return void
     */
    private static function pushProbes(&$probes, &$seen, $stages)
    {
        foreach ($stages as $st) {
            if (!is_string($st) || $st === '') {
                continue;
            }

            if (strlen($st) > self::WAF_MAX_INPUT) {
                $st = substr($st, 0, self::WAF_MAX_INPUT);
            }

            $k = md5($st);

            if (isset($seen[$k]) || count($probes) >= 16) {
                continue;
            }

            $seen[$k]   = true;
            $probes[]   = $st;
        }
    }

    /**
     * Durchsetzungs-Entscheidung der Mini-WAF (testbar, ohne Joomla).
     *
     * Logik:
     * - Super-User werden NIE blockiert (nur protokolliert) - kein Aussperren.
     * - scope 'all'  : Muster ohne legitime Verwendung -> alle anderen blocken
     *                  (auch eingeloggte/neu registrierte Nutzer!).
     * - scope 'guest': Muster mit moeglicher legitimer Nutzung (z. B. JCE-
     *                  Editor) -> nur Gaeste blocken.
     *
     * @param   array    $sig       Greifende Signatur (mit optionalem 'scope')
     * @param   boolean  $loggedIn  Ist ein Benutzer eingeloggt?
     * @param   boolean  $isSuper   Ist es ein Super-User?
     *
     * @return boolean true = blockieren, false = nur protokollieren
     */
    public static function wafShouldBlock($sig, $loggedIn, $isSuper)
    {
        if ($isSuper) {
            return false;
        }

        $scope = (is_array($sig) && isset($sig['scope']) && $sig['scope'] === 'guest') ? 'guest' : 'all';

        if ($scope === 'guest') {
            return !$loggedIn;
        }

        return true;
    }

    /**
     * Generischer Upload-Schutz: enthaelt der Upload ($_FILES) eine
     * ausfuehrbare/PHP-nahe Datei? Faengt unauthentifizierte Upload-Luecken
     * komponentenunabhaengig ab (z. B. iCagenda submit) - ein nicht eingeloggter
     * Besucher laedt nie legitim eine .php/.phtml/.shtml/... hoch. Erkennt auch
     * Doppelendungen (shell.php.jpg) und Array-Upload-Felder (jform[image]).
     * Rein + DB-frei testbar; die Login-/Scope-Pruefung macht der Aufrufer.
     *
     * @param   array  $files  $_FILES (oder gleich strukturierte Liste)
     *
     * @return boolean
     */
    public static function hasExecutableUpload($files)
    {
        if (!is_array($files)) {
            return false;
        }

        $names = array();

        foreach ($files as $field) {
            if (is_array($field) && isset($field['name'])) {
                self::collectUploadNames($field['name'], $names);
            }
        }

        foreach ($names as $name) {
            if (preg_match('~\.(php[0-9]?|phtml|phtm|pht|phps|phar|shtml|shtm|cgi|pl)(\.|$)~i', (string) $name)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Dateinamen aus einem (ggf. verschachtelten) $_FILES['feld']['name']
     * rekursiv einsammeln - Array-Upload-Felder wie jform[image][...].
     *
     * @param   mixed  $value  String oder (verschachteltes) Array
     * @param   array  $out    Ergebnis (per Referenz)
     *
     * @return void
     */
    private static function collectUploadNames($value, &$out)
    {
        if (count($out) >= 200) {
            return;
        }

        if (is_array($value)) {
            foreach ($value as $v) {
                self::collectUploadNames($v, $out);
            }
        } elseif (is_string($value) && $value !== '') {
            $out[] = $value;
        }
    }

    // ─────────────────────────────────────────────────────────────────
    //  Vorfilter-Trigger (vom Plugin im billigen Vorabtest gelesen)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Vorfilter-Trigger aus den effektiven Signaturen ableiten:
     *   opts    = Komponenten-Optionen (option=com_x), die eine Signatur haben
     *   needles = explizite literale Trigger nachgeladener Signaturen
     *   always  = true, wenn eine Signatur vom billigen Vorfilter nicht
     *             abgebildet werden kann (Sicherheits-Fallback: dann jede
     *             Anfrage mit Eingabe voll pruefen).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    public static function wafTriggers($cfg)
    {
        // Generische Klassen, die das Plugin bereits hartkodiert als Marker
        // erkennt - sie erzwingen kein 'always'.
        $builtinGeneric = array(
            'traversal-encoded', 'php-wrapper-uri', 'php-object-injection',
            'stream-wrapper-param', 'param-traversal', 'api-config-leak',
        );

        $opts    = array();
        $needles = array();
        $always  = false;

        foreach (self::effective($cfg) as $sig) {
            $hasOpt = false;

            if (!empty($sig['qs']) && preg_match('/option=(com_[a-z0-9_]+)/i', (string) $sig['qs'], $m)) {
                $opts[strtolower($m[1])] = true;
                $hasOpt = true;
            }

            if (!empty($sig['trigger'])) {
                $needles[strtolower((string) $sig['trigger'])] = true;
                continue;
            }

            // Weder Option noch Trigger noch bekannte generische Klasse ->
            // der Vorfilter kann sie nicht greifen -> Fallback 'always'.
            if (!$hasOpt && !in_array($sig['id'], $builtinGeneric, true)) {
                $always = true;
            }
        }

        return array(
            'opts'    => array_keys($opts),
            'needles' => array_keys($needles),
            'always'  => $always,
        );
    }

    /**
     * Trigger-Datei in den Joomla-Temp-Ordner schreiben. Wird nach jeder
     * Signatur-/Konfig-Aenderung aufgerufen (Komponente + Plugin-Pseudo-Cron).
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return boolean
     */
    public static function writeWafTriggerFile($cfg)
    {
        $dir = self::tmpDir();

        if ($dir === '') {
            return false;
        }

        $json = json_encode(self::wafTriggers($cfg));

        if ($json === false) {
            return false;
        }

        return @file_put_contents($dir . '/' . self::WAF_TRIGGER_FILE, $json) !== false;
    }

    /**
     * Joomla-Temp-Verzeichnis (Forward-Slashes) oder '' - identisch zur Sicht
     * des Plugins, damit beide dieselbe Trigger-Datei verwenden.
     *
     * @return string
     */
    private static function tmpDir()
    {
        // Bevorzugt der geschuetzte, dauerhafte Datenordner (identisch zur Sicht
        // des Plugins via dessen statePath()) - beide muessen dieselbe Trigger-
        // Datei nutzen. Fallback unten = Joomla-tmp wie bisher.
        if (function_exists('htp_state_dir')) {
            $dir = htp_state_dir();

            if ($dir !== '' && is_dir($dir) && is_writable($dir)) {
                return $dir;
            }
        }

        $tmp = '';

        if (class_exists('Joomla\\CMS\\Factory')) {
            try {
                $app = Joomla\CMS\Factory::getApplication();

                if ($app && method_exists($app, 'get')) {
                    $tmp = (string) $app->get('tmp_path');
                }
            } catch (Exception $e) {
                $tmp = '';
            }
        }

        if ($tmp === '' && class_exists('JFactory')) {
            try {
                $config = JFactory::getConfig();

                if ($config && method_exists($config, 'get')) {
                    $tmp = (string) $config->get('tmp_path');
                }
            } catch (Exception $e) {
                $tmp = '';
            }
        }

        if ($tmp === '' || !is_dir($tmp) || !is_writable($tmp)) {
            return '';
        }

        return rtrim(str_replace('\\', '/', $tmp), '/');
    }

    // ─────────────────────────────────────────────────────────────────
    //  Validierung
    // ─────────────────────────────────────────────────────────────────

    /**
     * Signatur der nachgeladenen Definitionen pruefen und entpacken.
     *
     * Signiertes Format: { ...flache Felder (Alt-Clients)..., "payload":
     * "<exakter JSON-String der Defs>", "sig": "<base64 Ed25519 ueber die
     * payload-Bytes>" }. Geprueft werden die EXAKT signierten Bytes (das
     * payload-Feld) - keine Re-Serialisierung, daher robust ueber PHP-Versionen.
     *
     * - libsodium vorhanden (PHP 7.2+): payload + gueltige Signatur sind
     *   PFLICHT, sonst null (Remote-Defs verworfen, builtin bleibt aktiv).
     * - ohne libsodium: payload bevorzugt (ohne Verify), sonst die flache
     *   Struktur; isSafePattern/validateRemote begrenzen den Schaden weiter.
     *
     * @param   array  $outer  Dekodiertes aeusseres JSON
     *
     * @return array|null
     */
    public static function verifyAndUnwrap($outer)
    {
        if (!is_array($outer)) {
            return null;
        }

        $hasPayload = isset($outer['payload']) && is_string($outer['payload']);

        if (function_exists('sodium_crypto_sign_verify_detached')) {
            if (!$hasPayload || !isset($outer['sig']) || !is_string($outer['sig'])) {
                return null;
            }

            $sig = base64_decode($outer['sig'], true);
            $pub = base64_decode(self::DEFS_PUBKEY, true);

            if ($sig === false || $pub === false || strlen($sig) !== 64 || strlen($pub) !== 32) {
                return null;
            }

            $ok = false;

            try {
                $ok = sodium_crypto_sign_verify_detached($sig, $outer['payload'], $pub);
            } catch (Exception $e) {
                $ok = false;
            }

            if (!$ok) {
                return null;
            }

            $inner = json_decode($outer['payload'], true);

            return is_array($inner) ? $inner : null;
        }

        // Ohne libsodium (nur Alt-PHP < 7.2 ohne paragonie/sodium_compat, praktisch nur
        // Joomla < 3.8): der Feed wird BEWUSST fail-open akzeptiert - gerade diese uralten,
        // besonders exponierten Sites sollen weiter frische Signaturen bekommen. Bewusste
        // Nutzer-Entscheidung (Restrisiko: kompromittierter Feed-Host / TLS-MITM auf genau
        // diesen Clients). Das WICHTIGERE Integritaets-Tor - die HTProtect-Update-PAKETE -
        // wird stattdessen kryptografisch verifiziert (Auto-Updater = Joomla >= 5.4 = immer
        // libsodium -> dort strikt moeglich). Feed = defense-in-depth, Paket = harte Zusage.
        if ($hasPayload) {
            $inner = json_decode($outer['payload'], true);

            if (is_array($inner)) {
                return $inner;
            }
        }

        return $outer;
    }

    /**
     * Ed25519-Verifizierung des HTProtect-Update-PAKETS gegen das mitgelieferte, signierte
     * Manifest ($dir/htprotect.pkgsig.json). REINE Funktion (nur Lesen - kein DB, kein
     * Schreiben, keine Side-Effects). Wird vom VERTRAUENSWUERDIGEN Alt-Code im
     * onExtensionBeforeInstall-Hook VOR dem Dateikopieren auf den entpackten Paket-
     * Quellordner angewandt (Auto- UND manuelles Update laufen durch diesen Hook).
     *
     * Rueckgabe ['status' => ..., 'reason' => ...]:
     *   - 'ok'       : Signatur gueltig UND jede signierte Datei stimmt im Hash, keine
     *                  eingeschleuste Fremd-Datei.
     *   - 'tampered' : Signatur ungueltig / Datei-Hash weicht ab / Datei fehlt / Fremd-
     *                  Datei / boeser Pfad. EINDEUTIGE Manipulation -> Aufrufer blockiert.
     *   - 'unclear'  : kein/kaputtes Manifest, kein libsodium, Quelle unlesbar. Keine
     *                  Aussage moeglich -> Aufrufer blockiert NICHT (nur Alarm), damit ein
     *                  VALIDES Update nie zerschossen wird.
     *
     * Ausschluss-Regel (Manifest selbst + transiente malscan-*.json) MUSS mit
     * test/signpackage.php uebereinstimmen.
     *
     * @param   string  $dir  Wurzel des entpackten Pakets
     *
     * @return array
     */
    public static function verifyPackage($dir)
    {
        $dir = rtrim((string) $dir, "/\\");

        if ($dir === '' || !is_dir($dir)) {
            return array('status' => 'unclear', 'reason' => 'no_dir');
        }

        $sigFile = $dir . '/htprotect.pkgsig.json';

        if (!is_file($sigFile)) {
            return array('status' => 'unclear', 'reason' => 'no_manifest');
        }

        // Ohne libsodium ist keine strikte Aussage moeglich -> NICHT blockieren. Der
        // Auto-Updater laeuft praktisch immer auf Joomla >= 5.4 = libsodium vorhanden.
        if (!function_exists('sodium_crypto_sign_verify_detached')) {
            return array('status' => 'unclear', 'reason' => 'no_libsodium');
        }

        $outer = json_decode((string) @file_get_contents($sigFile), true);

        if (!is_array($outer) || !isset($outer['payload'], $outer['sig'])
            || !is_string($outer['payload']) || !is_string($outer['sig'])) {
            return array('status' => 'unclear', 'reason' => 'bad_manifest');
        }

        // Strikt: MIT libsodium liefert verifyAndUnwrap bei ungueltiger Signatur null.
        $manifest = self::verifyAndUnwrap($outer);

        if (!is_array($manifest) || empty($manifest['files']) || !is_array($manifest['files'])) {
            return array('status' => 'tampered', 'reason' => 'bad_sig');
        }

        // 1) Jede signierte Datei muss existieren UND im SHA-256 passen.
        $expect = array();

        foreach ($manifest['files'] as $rel => $hash) {
            $rel  = str_replace('\\', '/', (string) $rel);
            $hash = strtolower((string) $hash);

            // Pfad-Sicherheit (defense in depth; $rel ist bereits signiert).
            if ($rel === '' || $rel[0] === '/' || strpos($rel, '../') !== false
                || strpos($rel, '..\\') !== false || preg_match('~^[A-Za-z]:~', $rel)) {
                return array('status' => 'tampered', 'reason' => 'bad_path');
            }

            $path = $dir . '/' . $rel;

            if (!is_file($path)) {
                return array('status' => 'tampered', 'reason' => 'missing');
            }

            $actual = @hash_file('sha256', $path);

            if (!is_string($actual) || !hash_equals($hash, strtolower($actual))) {
                return array('status' => 'tampered', 'reason' => 'hash');
            }

            $expect[$rel] = true;
        }

        // 2) Keine EINGESCHLEUSTE Fremd-Datei: jede reale Datei muss signiert sein (ausser
        //    dem Manifest selbst + transienten malscan-*.json).
        try {
            $rii = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS)
            );

            foreach ($rii as $f) {
                if (!$f->isFile()) {
                    continue;
                }

                $rel = str_replace('\\', '/', substr($f->getPathname(), strlen($dir) + 1));

                // Erlaubte, NICHT signierte Zusatzdateien: das Manifest selbst + transiente malscan-*.json.
                // htprotect_enroll.json ist hier BEWUSST NICHT ausgenommen: offizielle (Update-)Pakete
                // enthalten sie NIE - nur der Dashboard-Download legt sie pro Download bei, und das ist ein
                // MANUELLER Install (laeuft NICHT durch verifyPackage). Ihre Praesenz in einem per Auto-Update
                // geholten Paket ist damit immer Manipulation -> korrekt als 'tampered' werten (sonst koennte
                // ein gekaperter Update-Server ueber die eingeschleuste, unsignierte Datei die Site umenrollen).
                if ($rel === 'htprotect.pkgsig.json' || preg_match('~(^|/)malscan-[^/]*\.json$~', $rel)) {
                    continue;
                }

                if (!isset($expect[$rel])) {
                    return array('status' => 'tampered', 'reason' => 'extra');
                }
            }
        } catch (Exception $e) {
            return array('status' => 'unclear', 'reason' => 'enum_error');
        } catch (Throwable $e) {
            return array('status' => 'unclear', 'reason' => 'enum_error');
        }

        return array(
            'status' => 'ok',
            'reason' => 'verified',
            'ver'    => isset($manifest['ver']) ? (string) $manifest['ver'] : '',
            'count'  => count($expect),
        );
    }

    /**
     * Installations-POLICY: verschaerft das reine verifyPackage()-Ergebnis zu einer
     * Ja/Nein-Entscheidung fuer den Wächter. verifyPackage liefert reine Fakten
     * (u. a. 'unclear'/'no_manifest', wenn das Signatur-Manifest fehlt); ob ein
     * fehlendes Manifest toleriert oder als Manipulation gewertet wird, ist eine
     * KONTEXT-Entscheidung und gehoert hierher.
     *
     * Regel: Ein fehlendes/kaputtes Manifest ('no_manifest'/'bad_manifest') ist
     * MANIPULATION ('tampered'), wenn
     *   (a) die Umgebung ueberhaupt pruefen KOENNTE ($canVerify = libsodium da), UND
     *   (b) das Paket sich als >= FIRST_SIGNED_VERSION ausgibt (ab da traegt JEDES
     *       offizielle Paket zwingend ein Manifest -> ein fehlendes = Umgehung durch
     *       Weglassen).
     * Sonst bleibt es 'unclear' (fail-open): kein libsodium (Umgebungsgrund, sonst
     * Selbst-Aussperrung), unbekannte/aeltere Paketversion (legitimer Downgrade auf
     * eine Vor-Signatur-Version), unlesbare Quelle. 'ok'/'tampered' aus verifyPackage
     * werden unveraendert durchgereicht. Legitime Updates tragen ab 2.4.12 IMMER ein
     * gueltiges Manifest -> werden hier NIE zu 'tampered' (keine Side-Effects).
     *
     * @param   array   $res         Rueckgabe von verifyPackage()
     * @param   string  $pkgVersion  deklarierte Version des zu installierenden Pakets ('' = unbekannt)
     * @param   bool    $canVerify   ist Ed25519 (libsodium) verfuegbar?
     *
     * @return  string  'ok' | 'tampered' | 'unclear'
     */
    public static function packageInstallDecision($res, $pkgVersion, $canVerify)
    {
        $status = (is_array($res) && isset($res['status'])) ? (string) $res['status'] : 'unclear';
        $reason = (is_array($res) && isset($res['reason'])) ? (string) $res['reason'] : '';

        // Eindeutige Fakten unveraendert durchreichen.
        if ($status === 'ok' || $status === 'tampered') {
            return $status;
        }

        // Ab hier: $status === 'unclear'. Nur ein FEHLENDES/KAPUTTES Manifest ist
        // eskalierbar - und nur bei pruefFAEHIGer Umgebung und signatur-pflichtiger
        // Paketversion. no_libsodium / no_dir / enum_error bleiben fail-open.
        if (($reason === 'no_manifest' || $reason === 'bad_manifest')
            && $canVerify
            && $pkgVersion !== ''
            && version_compare($pkgVersion, self::FIRST_SIGNED_VERSION, '>=')) {
            return 'tampered';
        }

        return 'unclear';
    }

    /**
     * Komplette Remote-Struktur saeubern. Liefert nur gueltige Teile
     * zurueck oder null, wenn die Grundstruktur unbrauchbar ist.
     *
     * @param   array  $decoded  Dekodiertes JSON
     *
     * @return array|null
     */
    public static function validateRemote($decoded)
    {
        if ((int) (isset($decoded['schema']) ? $decoded['schema'] : 0) !== 1) {
            return null;
        }

        $version = isset($decoded['version']) ? (string) $decoded['version'] : '';

        if (!preg_match('/^[0-9A-Za-z.\-]{1,40}$/', $version)) {
            return null;
        }

        $signatures = array();

        if (isset($decoded['signatures']) && is_array($decoded['signatures'])) {
            foreach ($decoded['signatures'] as $sig) {
                if (is_array($sig) && self::validateSignature($sig)) {
                    $signatures[] = self::normaliseSignature($sig);
                }

                if (count($signatures) >= 200) {
                    break;
                }
            }
        }

        $vuln = array();

        if (isset($decoded['vuln_extensions']) && is_array($decoded['vuln_extensions'])) {
            foreach ($decoded['vuln_extensions'] as $ext) {
                if (is_array($ext) && self::validateVuln($ext)) {
                    $vuln[] = self::normaliseVuln($ext);
                }

                if (count($vuln) >= 500) {
                    break;
                }
            }
        }

        // joomla_latest reist NICHT mehr im Feed mit - die Ampel holt die
        // aktuelle Joomla-Version live aus der offiziellen Downloads-API
        // (siehe refreshJoomlaLatest). Alt-Felder im Feed werden ignoriert.
        return array(
            'schema'          => 1,
            'version'         => $version,
            'signatures'      => $signatures,
            'vuln_extensions' => $vuln,
            'php_min'         => isset($decoded['php_min']) && self::isVersion($decoded['php_min']) ? (string) $decoded['php_min'] : '',
        );
    }

    /**
     * Einzelne Signatur pruefen. Streng: nur erlaubte Schluessel,
     * kompilierbare Muster ohne Anfuehrungszeichen/Steuerzeichen.
     *
     * @param   array  $sig  Signatur
     *
     * @return boolean
     */
    public static function validateSignature($sig)
    {
        if (!is_array($sig) || !isset($sig['id'])) {
            return false;
        }

        if (!preg_match('/^[a-z0-9\-]{1,40}$/', (string) $sig['id'])) {
            return false;
        }

        $qs  = isset($sig['qs']) ? $sig['qs'] : null;
        $uri = isset($sig['uri']) ? $sig['uri'] : null;

        // Mindestens ein Muster, hoechstens beide
        if (($qs === null || $qs === '') && ($uri === null || $uri === '')) {
            return false;
        }

        foreach (array($qs, $uri) as $pattern) {
            if ($pattern === null || $pattern === '') {
                continue;
            }

            if (!self::isSafePattern($pattern)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Muster auf Laenge, verbotene Zeichen und Kompilierbarkeit pruefen.
     *
     * @param   string  $pattern  Regex (ohne Delimiter)
     *
     * @return boolean
     */
    public static function isSafePattern($pattern)
    {
        if (!is_string($pattern) || $pattern === '' || strlen($pattern) > 300) {
            return false;
        }

        // Anfuehrungszeichen wuerden die "..."-Schablone in der .htaccess
        // sprengen; Steuerzeichen und das Live-Match-Delimiter '#' meiden wir
        // (Letzteres haelt preg_match('#...#') robust, egal was nachgeladen wird).
        if (strpos($pattern, '"') !== false
            || strpos($pattern, '#') !== false
            || preg_match('/[\x00-\x1f]/', $pattern)) {
            return false;
        }

        // Kein abschliessender (ungerade gezaehlter) Backslash: in der .htaccess
        // steht das Muster zwischen Anfuehrungszeichen ("<muster>"). Ein End-"\"
        // wuerde dort das schliessende " escapen -> Apache-Parsefehler (500).
        if (preg_match('/(?<!\\\\)(?:\\\\\\\\)*\\\\$/', $pattern)) {
            return false;
        }

        // Muss als PCRE kompilieren
        if (@preg_match('#' . $pattern . '#', '') === false) {
            return false;
        }

        // Darf NICHT den Leerstring matchen: ein Catch-all wie ".*" oder "a?"
        // wuerde als Signatur sonst jede Anfrage treffen und (Scope 'all') die
        // komplette Site aussperren. Eine echte Signatur fordert immer Inhalt.
        if (@preg_match('#' . $pattern . '#', '') === 1) {
            return false;
        }

        return true;
    }

    /**
     * Signatur auf die bekannten Schluessel reduzieren.
     *
     * @param   array  $sig  Rohsignatur
     *
     * @return array
     */
    private static function normaliseSignature($sig)
    {
        return array(
            'id'    => (string) $sig['id'],
            'label' => isset($sig['label']) ? self::cleanText($sig['label'], 120) : (string) $sig['id'],
            'label_en' => isset($sig['label_en']) ? self::cleanText($sig['label_en'], 120) : '',
            // 1600 statt frueher 400 (analog zum vuln-'note'-Cap unten): mehrere Signatur-
            // Beschreibungen sind laenger und nennen am Ende die Fix-Version bzw. den Scope. Bei
            // 400 Bytes brach der Text im Live-Update-Feed mitten im Wort ab und liess ausgerechnet
            // den handlungsrelevanten Schluss weg. Im Backend brechen sie ohnehin frei um (kein
            // Clipping) - der Cap greift nur beim Import feed-gelieferter Signaturen.
            'desc'  => isset($sig['desc']) ? self::cleanText($sig['desc'], 1600) : '',
            'desc_en' => isset($sig['desc_en']) ? self::cleanText($sig['desc_en'], 1600) : '',
            'default' => !empty($sig['default']) ? 1 : 0,
            'qs'    => (isset($sig['qs']) && $sig['qs'] !== '') ? (string) $sig['qs'] : null,
            'uri'   => (isset($sig['uri']) && $sig['uri'] !== '') ? (string) $sig['uri'] : null,
            'frontend_only' => !empty($sig['frontend_only']) ? 1 : 0,
            // 'all' = alle ausser Super-User blocken (Muster ohne legitime
            // Verwendung), 'guest' = nur Gaeste. Standard: 'all'.
            'scope' => (isset($sig['scope']) && $sig['scope'] === 'guest') ? 'guest' : 'all',
            // waf_only: Signatur NUR im Echtzeit-Plugin durchsetzen, NICHT in die
            // .htaccess schreiben - fuer Autorisierungs-Luecken, deren Endpunkt
            // eingeloggte Nutzer legitim aufrufen (das Plugin kennt den Login-Status,
            // die .htaccess nicht). Muss den Feed-Roundtrip ueberleben.
            'waf_only' => !empty($sig['waf_only']) ? 1 : 0,
            // Optionaler Vorfilter-Trigger (literal, KEIN Regex): macht eine
            // nachgeladene Signatur fuer eine neue Komponente ohne 'option='
            // ueberhaupt erst auswertbar (siehe wafTriggers / Plugin-Vorfilter).
            'trigger' => (isset($sig['trigger']) && preg_match('/^[a-z0-9_.\-\/%]{4,40}$/', strtolower((string) $sig['trigger'])))
                ? strtolower((string) $sig['trigger'])
                : null,
        );
    }

    /**
     * Funktionsnamen-Alternation der baforms-signature-rce-Signatur, bewusst aus Teilen
     * zusammengesetzt: so traegt der ausgelieferte Quelltext keine zusammenhaengende
     * Namensliste (Vertraeglichkeit mit Server-Dateiscannern). Zur Laufzeit byte-identisch.
     *
     * @return string
     */
    private static function baFormsFnAlt()
    {
        $p = array(
            'sys' . 'tem', 'ex' . 'ec', 'shell' . '_exec', 'pass' . 'thru', 'as' . 'sert',
            'po' . 'pen', 'proc' . '_open', 'pcntl' . '_exec', 'file' . '_get_contents',
            'read' . 'file', 'show' . '_source', 'highlight' . '_file', 'fo' . 'pen',
            'scan' . 'dir', 'php' . 'info', 'ev' . 'al', 'create' . '_function',
            'call' . '_user_func',
        );

        return implode('|', $p);
    }

    /**
     * Warnlisten-Eintrag pruefen.
     *
     * @param   array  $ext  Eintrag
     *
     * @return boolean
     */
    public static function validateVuln($ext)
    {
        if (!is_array($ext) || !isset($ext['element'], $ext['below'])) {
            return false;
        }

        if (!preg_match('/^[a-z0-9_\-]{1,80}$/i', (string) $ext['element'])) {
            return false;
        }

        if (!self::isVersion($ext['below'])) {
            return false;
        }

        $type = isset($ext['type']) ? (string) $ext['type'] : '';

        if ($type !== '' && !preg_match('/^[a-z_]{1,20}$/', $type)) {
            return false;
        }

        return true;
    }

    /**
     * Warnlisten-Eintrag auf bekannte Schluessel reduzieren.
     *
     * @param   array  $ext  Roheintrag
     *
     * @return array
     */
    private static function normaliseVuln($ext)
    {
        $severity = isset($ext['severity']) ? (string) $ext['severity'] : 'high';

        if (!in_array($severity, array('critical', 'high', 'medium'), true)) {
            $severity = 'high';
        }

        return array(
            'element'  => (string) $ext['element'],
            'type'     => isset($ext['type']) ? (string) $ext['type'] : '',
            'folder'   => isset($ext['folder']) && preg_match('/^[a-z0-9_\-]{0,40}$/i', (string) $ext['folder']) ? (string) $ext['folder'] : '',
            'name'     => isset($ext['name']) ? self::cleanText($ext['name'], 120) : (string) $ext['element'],
            'below'    => (string) $ext['below'],
            'above'    => (isset($ext['above']) && self::isVersion($ext['above'])) ? (string) $ext['above'] : '',
            'severity' => $severity,
            // mitigated = HTProtects Mini-WAF/Datei-/Upload-Schild blockt den bekannten
            // Angriffsweg bereits aktiv (Restrisiko niedrig). Fehlt/false = wir koennen nur
            // warnen (Restrisiko real). Quelle: ein EXPLIZITER Feed-/Eintragswert gewinnt
            // (zukunftssicher), sonst die zentrale, konservative mitigatedElements()-Allowlist.
            // BEWUSST im Zweifel false (kein falsches Sicherheitsgefuehl).
            'mitigated' => isset($ext['mitigated'])
                ? !empty($ext['mitigated'])
                : in_array(strtolower((string) $ext['element']), self::mitigatedElements(), true),
            // 1600 statt frueher 400: Warntexte beschreiben inzwischen mehrere Luecken je
            // Eintrag samt Zielversion und Nachkontrolle. Bei 400 Bytes brach der Text mitten
            // im Wort ab - und ausgerechnet der handlungsrelevante Teil (auf WELCHE Version
            // aktualisiert werden muss) fiel weg. Der Betreiber haette aktualisiert, waere
            // verwundbar geblieben und die Warnung waere verschwunden.
            'note'     => isset($ext['note']) ? self::cleanText($ext['note'], 1600) : '',
            'note_en'  => isset($ext['note_en']) ? self::cleanText($ext['note_en'], 1600) : '',
            'advisory' => (isset($ext['advisory']) && preg_match('#^https?://[^\s"<>]{1,200}$#', (string) $ext['advisory'])) ? (string) $ext['advisory'] : '',
            'advisory_en' => (isset($ext['advisory_en']) && preg_match('#^https?://[^\s"<>]{1,200}$#', (string) $ext['advisory_en'])) ? (string) $ext['advisory_en'] : '',
            // added = Aufnahme-/Aktualisierungsdatum dieses Eintrags im HTProtect-Feed (YYYY-MM-DD).
            // OPTIONAL und rueckwaertskompatibel (fehlt -> ''). Zweck: der Fusion-Seite ein Datums-
            // Fallback geben, wenn die NVD (noch) kein Veroeffentlichungsdatum hat (frische Disclosures,
            // die cve.org/NVD erst spaeter listen). Bestandseintraege bleiben bewusst ohne Datum.
            'added' => (isset($ext['added']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $ext['added'])) ? (string) $ext['added'] : '',
        );
    }

    /**
     * Text fuer die Anzeige saeubern (keine Steuerzeichen, gekuerzt).
     *
     * @param   string   $text  Rohtext
     * @param   integer  $max   Maximallaenge
     *
     * @return string
     */
    private static function cleanText($text, $max)
    {
        $text = preg_replace('/[\x00-\x1f]+/', ' ', (string) $text);
        $text = trim((string) $text);

        if (strlen($text) > $max) {
            $text = substr($text, 0, $max);

            // ZEICHENSICHER kuerzen: substr() schneidet nach BYTES. Liegt die Grenze mitten in
            // einem Umlaut (UTF-8 = mehrere Bytes), entstuende ein kaputtes Zeichen mitten im
            // Warntext. Auf die letzte vollstaendige UTF-8-Sequenz zuruecknehmen. Bewusst ohne
            // mbstring (nicht auf jedem Kundenserver vorhanden).
            $cut = strlen($text);

            while ($cut > 0 && (ord($text[$cut - 1]) & 0xC0) === 0x80) {
                $cut--; // Folgebyte einer Sequenz -> weiter nach vorn
            }

            if ($cut > 0) {
                $lead = ord($text[$cut - 1]);
                $need = $lead >= 0xF0 ? 4 : ($lead >= 0xE0 ? 3 : ($lead >= 0xC0 ? 2 : 1));

                // Startbyte ohne vollstaendige Folge -> die angefangene Sequenz verwerfen.
                $text = (strlen($text) - $cut + 1) < $need ? substr($text, 0, $cut - 1) : $text;
            }
        }

        return $text;
    }

    /**
     * Versionsangabe plausibel?
     *
     * @param   mixed  $v  Wert
     *
     * @return boolean
     */
    public static function isVersion($v)
    {
        return is_string($v) && preg_match('/^[0-9]+(\.[0-9]+){0,3}([a-z0-9.\-]{0,12})?$/i', $v);
    }

    // ─────────────────────────────────────────────────────────────────
    //  Erweiterungs-Pruefung (Warnliste)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Ist eine installierte Version verwundbar? Verwundbar = unterhalb der sicheren
     * Version ($below) UND - falls gesetzt - mindestens $above (Untergrenze, inkl.).
     * Die Untergrenze trennt eine betroffene Major-Linie von einer aelteren, nicht
     * betroffenen (z. B. gilt die SP-Page-Builder-6.x-Luecke nicht fuer die 3.x-Linie).
     *
     * @param   string  $installed  Installierte Version
     * @param   string  $below      Sichere Mindestversion (verwundbar darunter)
     * @param   string  $above      Optionale Untergrenze (verwundbar erst ab hier)
     *
     * @return boolean
     */
    public static function isVulnerable($installed, $below, $above = '')
    {
        // Kanal-/Build-Label abtrennen: Erweiterungs-Manifeste haengen oft ein Wort an die
        // Versionsnummer (jDownloads "4.1.4 Stable", auch " Beta"/" RC"/" Release"/" GA") - das
        // ist KEIN Bestandteil der Versionsnummer. Ohne dieses Abschneiden scheitert isVersion()
        // am Leerzeichen und die Warnung bleibt KOMPLETT aus (Live-Fall jDownloads 4.1.4). Ein
        // Leerzeichen trennt die Nummer stets vom beschreibenden Kanalwort -> alles ab dem
        // ersten Whitespace verwerfen. below/above kommen sauber aus dem Feed (No-Op).
        $installed = self::stripVerLabel($installed);
        $below     = self::stripVerLabel($below);
        $above     = self::stripVerLabel($above);

        if ($installed === '' || !self::isVersion($installed) || !self::isVersion($below)) {
            return false;
        }

        // Versionen kanonisieren, damit "3.9" und "3.9.0" (bzw. "9" und "9.0.0") als GLEICH
        // gelten. Viele Erweiterungs-Manifeste kuerzen die abschliessende .0 ("3.9" statt
        // "3.9.0"), waehrend Advisories/Fixlisten die volle Form nennen. Ohne Kanonisierung
        // meldet version_compare("3.9","3.9.0",'<') = true eine GEPATCHTE Version faelschlich
        // als verwundbar (Fehlalarm - EDocman 3.9) bzw. schliesst an einer 'above'-Grenze eine
        // BETROFFENE Version faelschlich aus (Fehl-Negativ, z. B. Manifest "9" gegen above "9.0.0").
        $vi = self::canonVer($installed);
        $vb = self::canonVer($below);

        // Optionale Untergrenze: Versionen UNTER 'above' sind eine andere, nicht
        // betroffene Versionsreihe -> kein Treffer (FP-Schutz, z. B. 3.x vs. 6.x).
        // ($above ist oben bereits per stripVerLabel bereinigt.)
        if ($above !== '' && self::isVersion($above) && version_compare($vi, self::canonVer($above), '<')) {
            return false;
        }

        return version_compare($vi, $vb, '<');
    }

    /**
     * Versions-String kanonisieren fuer den Vergleich: rein numerische ".0"-Segmente am ENDE
     * entfernen, sodass "3.9" == "3.9.0", "9" == "9.0.0", "1.0.0" == "1". Verhindert den
     * ".0"-Manifest-Mismatch (Manifest kuerzt trailing .0, Advisory schreibt sie aus). Nur
     * abschliessende Null-Segmente; Nicht-Null- und Buchstaben-Segmente bleiben unangetastet
     * ("3.9.1" -> "3.9.1", "3.10" -> "3.10", "3.0-rc1" -> "3.0-rc1"). Erzeugt nie den Leerstring
     * (das erste Segment steht nie unter dem Muster). Nur intern fuer isVulnerable().
     *
     * @param   string  $v  Version (bereits per isVersion validiert)
     *
     * @return string
     */
    private static function canonVer($v)
    {
        return preg_replace('~(?:\.0+)+$~', '', trim((string) $v));
    }

    /**
     * Kanal-/Build-Label von einem Versions-String abtrennen. Erweiterungs-Manifeste haengen
     * haeufig ein beschreibendes Wort an die eigentliche Nummer an ("4.1.4 Stable", "1.2.3 Beta 2",
     * "5.0 RC", "6.1 Release"). Das ist KEIN Bestandteil der Versionsnummer und laesst isVersion()
     * am Leerzeichen scheitern -> ohne Bereinigung bliebe die Verwundbarkeitswarnung komplett aus
     * (Live-Fall jDownloads "4.1.4 Stable"). Ein Whitespace trennt die Nummer stets vom Kanalwort,
     * daher wird alles ab dem ersten Whitespace verworfen. Bindestrich-angehaengte Pre-Release-Tags
     * ("3.0-rc1") bleiben unberuehrt (kein Space) und werden weiter von isVersion/version_compare
     * getragen. Nur intern fuer isVulnerable().
     *
     * @param   string  $v  Roh-Version (evtl. mit Kanal-Suffix)
     *
     * @return string
     */
    private static function stripVerLabel($v)
    {
        return preg_replace('~\s.*$~s', '', trim((string) $v));
    }

    /**
     * Installierte Erweiterungen gegen die Warnliste pruefen.
     *
     * @return array Liste der Treffer: name, element, installed, below, severity, note, advisory
     */
    public static function checkExtensions()
    {
        $db = htp_db();

        if (!$db) {
            return array();
        }

        $list = self::effectiveVuln();

        if (empty($list)) {
            return array();
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('element', 'type', 'folder', 'client_id', 'manifest_cache', 'enabled')))
                ->from($db->quoteName('#__extensions'));
            $db->setQuery($query);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        // Installierte Erweiterungen indizieren: element[:folder] => version
        $installed = array();

        foreach ($rows as $row) {
            $element = isset($row['element']) ? (string) $row['element'] : '';

            if ($element === '') {
                continue;
            }

            $version = '';

            if (!empty($row['manifest_cache'])) {
                $cache = json_decode((string) $row['manifest_cache'], true);

                if (is_array($cache) && isset($cache['version'])) {
                    $version = (string) $cache['version'];
                }
            }

            $folder = isset($row['folder']) ? (string) $row['folder'] : '';
            $key    = strtolower($element) . '|' . strtolower($folder);

            // Dubletten (z. B. verwaister Alt-Eintrag derselben Erweiterung)
            // duerfen den guten Eintrag nicht mit einer alten Version
            // ueberschreiben -> die hoehere gueltige Version gewinnt.
            if (isset($installed[$key]) && self::isVersion($installed[$key]['version'])
                && (!self::isVersion($version) || version_compare($version, $installed[$key]['version'], '<='))) {
                continue;
            }

            $installed[$key] = array(
                'version'   => $version,
                'enabled'   => !empty($row['enabled']),
                'type'      => isset($row['type']) ? (string) $row['type'] : '',
                'element'   => $element,
                'folder'    => $folder,
                'client_id' => isset($row['client_id']) ? (int) $row['client_id'] : 0,
            );
        }

        $hits = array();

        foreach ($list as $vuln) {
            $key = strtolower($vuln['element']) . '|' . strtolower($vuln['folder']);

            if (!isset($installed[$key])) {
                // Ohne Folder-Angabe auch ohne Ordner suchen
                if ($vuln['folder'] === '') {
                    $key = strtolower($vuln['element']) . '|';
                }

                if (!isset($installed[$key])) {
                    continue;
                }
            }

            $have = $installed[$key];

            // Der manifest_cache ist nur eine Momentaufnahme vom Installations-
            // zeitpunkt und kann veralten (DB-Restore ueber neuere Dateien,
            // manuelles FTP-Update, ein Update das nur Teil-Erweiterungen neu
            // registriert). Dann meldet er eine alte, scheinbar verwundbare
            // Version, obwohl die Dateien laengst gepatcht sind -> Fehlalarm.
            // Massgeblich ist daher die echte Version aus dem XML-Manifest auf
            // der Platte; der Cache dient nur als Rueckfallebene.
            $disk      = self::onDiskVersion($have['type'], $have['element'], $have['folder'], $have['client_id']);
            $effective = ($disk !== '') ? $disk : $have['version'];

            // J3-JoomShaper-Auto-Patch: eine per offiziellem Vendor-J3-Paket gefixte Erweiterung
            // (Marker vorhanden) ist NICHT verwundbar - auch wenn ihre Versionsnummer im J3-Zweig
            // unter dem Feed-'below' bleibt (Helix Ultimate haelt 2.1.4-j3sec < 2.2.10). Der Hook ist
            // nur auf Joomla 3.10 aktiv (isPatched) und betrifft nur helixultimate/helix3. Die 'below'
            // der KONKRETEN Luecke wird mitgegeben: liegt sie ueber dem, was das gepinnte J3-Paket
            // abdeckt (coversBelow), unterdrueckt isPatched NICHT -> eine spaeter dazugekommene,
            // hoehere Luecke wird nie faelschlich als gepatcht verschluckt (fail-open zur Warnung).
            $j3patched = class_exists('HtpJoomShaperPatch')
                && HtpJoomShaperPatch::isPatched($vuln['element'], isset($vuln['below']) ? $vuln['below'] : '');

            if (!$j3patched
                && self::isVulnerable($effective, $vuln['below'], isset($vuln['above']) ? $vuln['above'] : '')) {
                $hits[] = array(
                    'name'      => $vuln['name'],
                    'element'   => $vuln['element'],
                    'type'      => $have['type'],
                    'folder'    => $have['folder'],
                    'client_id' => $have['client_id'],
                    'installed' => $effective,
                    'below'     => $vuln['below'],
                    'severity'  => $vuln['severity'],
                    // Deckung konsistent fuer builtin (roh, ohne Feld -> Allowlist) UND Feed
                    // (via normaliseVuln gesetzt -> expliziter Wert gewinnt).
                    'mitigated' => isset($vuln['mitigated'])
                        ? !empty($vuln['mitigated'])
                        : in_array(strtolower((string) $vuln['element']), self::mitigatedElements(), true),
                    'note'      => $vuln['note'],
                    'note_en'   => isset($vuln['note_en']) ? $vuln['note_en'] : '',
                    'advisory'  => $vuln['advisory'],
                    'advisory_en' => isset($vuln['advisory_en']) ? $vuln['advisory_en'] : '',
                    'enabled'   => $have['enabled'],
                );
            }
        }

        return $hits;
    }

    /**
     * Stabiler Schluessel je angreifbarer Erweiterung (Aufschieben-Marker haengt daran).
     * Pro Installation trifft hoechstens EIN Vuln-Eintrag je Erweiterung (disjunkte Ranges),
     * daher reicht element|type|folder zur eindeutigen Zuordnung.
     *
     * @param   array  $hit  Fund aus checkExtensions()
     *
     * @return string
     */
    public static function vulnExtKey($hit)
    {
        return strtolower((string) (isset($hit['element']) ? $hit['element'] : '')) . '|'
            . (isset($hit['type']) ? (string) $hit['type'] : '') . '|'
            . (isset($hit['folder']) ? (string) $hit['folder'] : '');
    }

    /** Severity als Rang (kritisch > hoch > mittel) - fuer den Eskalations-Vergleich. */
    private static function sevRank($s)
    {
        $s = (string) $s;

        return $s === 'critical' ? 3 : ($s === 'high' ? 2 : 1);
    }

    /**
     * REINE Entscheidung (testbar): Ist dieser Fund durch einen frueher gesetzten
     * "Aufschieben"-Marker noch abgedeckt - oder ist der Marker VERFALLEN (dann wieder warnen)?
     * Verfall bei (1) geaenderter installierter Version ODER (2) eskalierter Severity.
     * So schlaeft niemand versehentlich durch ein Update ODER eine verschaerfte Bedrohung.
     *
     * @param   array  $hit       aktueller Fund (installed, severity)
     * @param   mixed  $ackEntry  gespeicherter Marker {version, severity, ...} oder nichts
     *
     * @return boolean  true = weiterhin aufgeschoben (nicht warnen), false = warnen
     */
    public static function vulnAckActive($hit, $ackEntry)
    {
        if (!is_array($ackEntry)) {
            return false;
        }

        $ackVer = isset($ackEntry['version']) ? (string) $ackEntry['version'] : '';
        $nowVer = isset($hit['installed']) ? (string) $hit['installed'] : '';

        if ($ackVer === '' || $ackVer !== $nowVer) {
            return false; // Version geaendert (oder Marker ohne Version) -> Lage neu bewerten
        }

        $ackSev = isset($ackEntry['severity']) ? $ackEntry['severity'] : 'high';
        $nowSev = isset($hit['severity']) ? $hit['severity'] : 'high';

        if (self::sevRank($nowSev) > self::sevRank($ackSev)) {
            return false; // Schweregrad eskaliert -> wieder warnen
        }

        // Fix-Version ('below') geaendert: der Feed hat die Luecke auf eine andere Fix-Version
        // umgehaengt ODER es ist ein ANDERER CVE-Eintrag derselben Erweiterung, der sich denselben
        // extKey (element|type|folder) teilt (mehrere Linien pro Erweiterung sind by design moeglich)
        // -> Lage neu bewerten, wieder warnen. So kann ein alter "Aufschieben"-Marker NIE still eine
        // NEUE Luecke derselben Erweiterung mit-verdecken. Alt-Marker ohne 'below' (Bestand) greifen
        // unveraendert weiter (dann entscheiden nur Version + Severity).
        if (isset($ackEntry['below'])
            && (string) $ackEntry['below'] !== (string) (isset($hit['below']) ? $hit['below'] : '')) {
            return false;
        }

        return true;
    }

    /**
     * Fund-Liste in AKTIV (warnen: Ampel rot + Mail) und AUFGESCHOBEN (still) teilen -
     * EINE Quelle der Wahrheit fuer Ampel/UI (app.php) UND Mail-Erinnerung (watch.php),
     * damit beide garantiert konsistent entscheiden. Fail-safe: alles ohne gueltiges,
     * nicht-verfallenes Ack landet in AKTIV (wird gewarnt).
     *
     * @param   array  $vulnList  Funde aus checkExtensions()
     * @param   mixed  $vulnAck   cfg['vuln_ack'] (extKey => Marker) oder nichts
     *
     * @return array array('active' => [...], 'acked' => [...])
     */
    public static function vulnPartition($vulnList, $vulnAck)
    {
        $active = array();
        $acked  = array();

        if (!is_array($vulnList)) {
            return array('active' => $active, 'acked' => $acked);
        }

        $vulnAck = is_array($vulnAck) ? $vulnAck : array();

        foreach ($vulnList as $hit) {
            $key = self::vulnExtKey($hit);

            if (isset($vulnAck[$key]) && self::vulnAckActive($hit, $vulnAck[$key])) {
                $acked[] = $hit;
            } else {
                $active[] = $hit;
            }
        }

        return array('active' => $active, 'acked' => $acked);
    }

    /**
     * Verdaechtige JCE-Editor-Profile erkennen (typische Folge des JCE-Upload-
     * Hacks): ein VEROEFFENTLICHTES Profil, das der Public-Gruppe (Gast, ID 1)
     * im Frontend Zugriff auf ein UPLOAD-faehiges Plugin gibt. Legitime JCE-
     * Profile sprechen die Public-Gruppe NIE an (sie nutzen Gruppen 3-8) -> sehr
     * hohe Treffersicherheit, praktisch kein Fehlalarm.
     *
     * Liefert NUR positive Funde; ein leeres Ergebnis ist KEINE Garantie, dass
     * JCE sauber ist (andere Angriffswege existieren).
     *
     * @return array Liste ['id' => int, 'name' => string]
     */
    public static function checkJceProfiles()
    {
        $db = htp_db();

        if (!$db) {
            return array();
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('id', 'name', 'types', 'area', 'plugins', 'params')))
                ->from($db->quoteName('#__wf_profiles'))
                ->where($db->quoteName('published') . ' = 1');
            $db->setQuery($query, 0, 200);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            // Tabelle fehlt -> JCE nicht installiert
            return array();
        } catch (Throwable $e) {
            return array();
        }

        if (!is_array($rows)) {
            return array();
        }

        $hits = array();

        foreach ($rows as $row) {
            if (!self::isSuspiciousJceProfile(
                isset($row['types']) ? $row['types'] : '',
                isset($row['area']) ? $row['area'] : 0,
                isset($row['plugins']) ? $row['plugins'] : '',
                isset($row['params']) ? $row['params'] : ''
            )) {
                continue;
            }

            $hits[] = array(
                'id'   => isset($row['id']) ? (int) $row['id'] : 0,
                'name' => isset($row['name']) ? (string) $row['name'] : '',
            );
        }

        return $hits;
    }

    /**
     * Alle AKTUELL als eindeutig boesartig erkannten JCE-Profile loeschen. Es
     * wird FRISCH mit derselben strengen 3-Bedingungs-Logik geprueft (Public-
     * Gruppe 1 + Frontend + Upload-Plugin) -> es werden ausschliesslich diese
     * Profile per id geloescht, nie legitime. id-Werte sind Integer (keine
     * Injection). Loescht die Zeilen aus #__wf_profiles.
     *
     * @return array ['deleted' => int, 'failed' => int]
     */
    public static function deleteSuspiciousJceProfiles()
    {
        $ids = array();

        foreach (self::checkJceProfiles() as $hit) {
            if ((int) $hit['id'] > 0) {
                $ids[] = (int) $hit['id'];
            }
        }

        if (empty($ids)) {
            return array('deleted' => 0, 'failed' => 0);
        }

        $db = htp_db();

        if (!$db) {
            return array('deleted' => 0, 'failed' => count($ids));
        }

        try {
            $query = $db->getQuery(true)
                ->delete($db->quoteName('#__wf_profiles'))
                ->where($db->quoteName('id') . ' IN (' . implode(',', array_map('intval', $ids)) . ')');
            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            return array('deleted' => count($ids), 'failed' => 0);
        } catch (Exception $e) {
            return array('deleted' => 0, 'failed' => count($ids));
        } catch (Throwable $e) {
            return array('deleted' => 0, 'failed' => count($ids));
        }
    }

    /**
     * Reine Klassifizierung eines JCE-Profils (testbar). Verdaechtig, wenn
     * EINES zutrifft:
     *  A) Das Profil erlaubt ausfuehrbare Upload-Endungen (php-Familie/Wildcard)
     *     in params.*.filetypes - gruppen-/bereichsunabhaengig. Kein legitimes
     *     Profil laesst php-Uploads zu (JCE blockt sie ab Werk) -> das ist bereits
     *     eine Hintertuer.
     *  B) Offenes Upload-Profil im Frontend: Gruppen enthalten Public (ID 1) ODER
     *     sind LEER (jedes legit Profil listet explizit Gruppen 3-8; leer = keine
     *     Einschraenkung = gilt fuer alle, inkl. Gast), UND das Profil ist
     *     upload-faehig (erkanntes Plugin oder features.upload in params).
     *
     * Deckt damit auch die Angreifer-Profile mit LEEREM types ab (die der reinen
     * "muss Public enthalten"-Regel frueher entgingen).
     *
     * @param   string  $typesStr    Komma-Liste der Gruppen-IDs
     * @param   int     $area        0 = beide, 1 = Frontend, 2 = Backend
     * @param   string  $pluginsStr  Komma-Liste der Plugin-Schluessel
     * @param   string  $paramsStr   JSON der Profil-Parameter (filetypes/features)
     *
     * @return boolean
     */
    public static function isSuspiciousJceProfile($typesStr, $area, $pluginsStr, $paramsStr = '')
    {
        // A) Gefaehrliche Upload-Endungen -> immer verdaechtig (kein legit Profil).
        if (self::jceParamsAllowDangerousUpload($paramsStr)) {
            return true;
        }

        // B) Offenes Upload-Profil im Frontend.
        $area = (int) $area;

        if ($area !== 0 && $area !== 1) {
            return false;
        }

        $types     = array_filter(array_map('trim', explode(',', (string) $typesStr)), 'strlen');
        $groupOpen = empty($types) || in_array('1', $types, true);

        return $groupOpen && self::jceUploadCapable($pluginsStr, $paramsStr);
    }

    /**
     * Ist ein JCE-Profil upload-faehig? Erkanntes Upload-Plugin in der
     * plugins-Spalte ODER features.upload aktiv in den params.
     *
     * @param   string  $pluginsStr  Komma-Liste der Plugin-Schluessel
     * @param   string  $paramsStr   JSON der Profil-Parameter
     *
     * @return boolean
     */
    private static function jceUploadCapable($pluginsStr, $paramsStr)
    {
        $uploadPlugins = array('browser', 'upload', 'media', 'mediamanager', 'imgmanager', 'filemanager', 'fileupload', 'fileproxy', 'file', 'image');
        $plugins       = array_map('trim', explode(',', strtolower((string) $pluginsStr)));

        if (array_intersect($uploadPlugins, $plugins)) {
            return true;
        }

        // features.upload = 1 (Upload aktiv, auch ohne Eintrag in der plugins-Spalte)
        return (bool) preg_match('~"upload"\s*:\s*(?:1|true|"1")~i', (string) $paramsStr);
    }

    /**
     * Erlaubt ein JCE-Profil das Hochladen ausfuehrbarer Dateien? Prueft alle
     * params.*.filetypes-Listen auf eine php-Familien-Endung oder eine Wildcard.
     * Token-genau (durch , ; = getrennt) -> "php" als WORT, nicht als Teilstring
     * (kein Fehlalarm auf z. B. "phpsomething"). Bewusst eng auf die vom
     * PHP-Handler ausgefuehrte Familie (echter RCE-Vektor) -> ~0 Fehlalarm.
     *
     * @param   string  $paramsStr  JSON der Profil-Parameter
     *
     * @return boolean
     */
    private static function jceParamsAllowDangerousUpload($paramsStr)
    {
        $p = (string) $paramsStr;

        if ($p === '' || !preg_match_all('~"filetypes"\s*:\s*"([^"]*)"~i', $p, $mm)) {
            return false;
        }

        $dangerous = array(
            'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8',
            'phtml', 'phtm', 'phps', 'pht', 'phar', 'phpt',
        );

        foreach ($mm[1] as $val) {
            $ft = strtolower($val);

            if (strpos($ft, '*') !== false) {
                return true; // Wildcard = alles erlaubt
            }

            foreach (preg_split('~[,;=]+~', $ft) as $tok) {
                if (in_array(trim($tok), $dangerous, true)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Tatsaechlich installierte Version aus dem XML-Manifest auf der Platte.
     *
     * Wird nur fuer verdaechtige Treffer aufgerufen und korrigiert veraltete
     * manifest_cache-Eintraege. Liefert die echte Version oder '' wenn kein
     * passendes Manifest gefunden/lesbar war.
     *
     * @param   string  $type      component|plugin|module|library|package|template|file
     * @param   string  $element   z. B. com_jce, jce, nrframework, astroid
     * @param   string  $folder    Plugin-Gruppe (z. B. system), sonst ''
     * @param   int     $clientId  0 = Site, 1 = Administrator
     *
     * @return string
     */
    public static function onDiskVersion($type, $element, $folder, $clientId)
    {
        $element = (string) $element;
        $folder  = (string) $folder;
        $type    = strtolower((string) $type);

        // Defensiv: nur unverdaechtige Zeichen, keine Pfad-Traversal.
        if ($element === ''
            || preg_match('#[^A-Za-z0-9_.\-]#', $element)
            || preg_match('#[^A-Za-z0-9_.\-]#', $folder)) {
            return '';
        }

        $root  = htp_root();
        $admin = htp_admin_root();
        $bare  = preg_replace('#^com_#', '', $element);
        $cands = array();

        switch ($type) {
            case 'component':
                // Komponenten-Manifest liegt i. d. R. im Admin-Teil; der
                // Dateiname muss nicht gleich dem element sein (JCE: jce.xml,
                // nicht com_jce.xml). Site-Verzeichnis als Rueckfall.
                foreach (array($admin . '/components/' . $element, $root . '/components/' . $element) as $dir) {
                    $cands[] = $dir . '/' . $element . '.xml';
                    $cands[] = $dir . '/' . $bare . '.xml';

                    foreach ((array) @glob($dir . '/*.xml') as $g) {
                        $cands[] = $g;
                    }
                }
                break;

            case 'plugin':
                $cands[] = $root . '/plugins/' . $folder . '/' . $element . '/' . $element . '.xml';
                break;

            case 'module':
                $dir     = ($clientId ? $admin : $root) . '/modules/' . $element;
                $cands[] = $dir . '/' . $element . '.xml';
                break;

            case 'library':
                $cands[] = $admin . '/manifests/libraries/' . $element . '.xml';
                $cands[] = $root . '/libraries/' . $element . '/' . $element . '.xml';
                break;

            case 'package':
                $cands[] = $admin . '/manifests/packages/' . $element . '.xml';
                break;

            case 'template':
                $dir     = ($clientId ? $admin : $root) . '/templates/' . $element;
                $cands[] = $dir . '/templateDetails.xml';
                break;

            case 'file':
                $cands[] = $admin . '/manifests/files/' . $element . '.xml';
                break;
        }

        foreach ($cands as $file) {
            $ver = self::versionFromManifest($file, $type);

            if ($ver !== '') {
                return $ver;
            }
        }

        return '';
    }

    /**
     * Liest die <version> aus einem Joomla-Erweiterungsmanifest.
     *
     * Akzeptiert nur echte Manifeste (<extension>/<install>) mit passendem Typ
     * und plausibler Versionsangabe; sonst ''. XXE-sicher (kein LIBXML_NOENT,
     * LIBXML_NONET unterbindet Netzwerkzugriffe).
     *
     * @param   string  $file        Absoluter Pfad zur XML-Datei
     * @param   string  $expectType  Erwarteter Erweiterungstyp ('' = egal)
     *
     * @return string
     */
    public static function versionFromManifest($file, $expectType = '')
    {
        if (!is_string($file) || $file === '' || !is_file($file) || !is_readable($file)) {
            return '';
        }

        $size = @filesize($file);

        if ($size === false || $size <= 0 || $size > 512000) {
            return '';
        }

        // 1) Bevorzugt sauber via SimpleXML (validiert Wurzel + Typ).
        if (function_exists('simplexml_load_file')) {
            $prev = libxml_use_internal_errors(true);
            $xml  = @simplexml_load_file($file, 'SimpleXMLElement', LIBXML_NONET);
            libxml_clear_errors();
            libxml_use_internal_errors($prev);

            if ($xml instanceof SimpleXMLElement) {
                $rootName = strtolower($xml->getName());

                if ($rootName === 'extension' || $rootName === 'install') {
                    $t = strtolower((string) $xml['type']);

                    if ($expectType !== '' && $t !== '' && $t !== strtolower($expectType)) {
                        // Eindeutig falscher Typ -> nicht weiterraten.
                        return '';
                    }

                    $ver = isset($xml->version) ? trim((string) $xml->version) : '';

                    if (self::isVersion($ver)) {
                        return $ver;
                    }
                }
            }
        }

        // 2) Rueckfall (kein/fehlgeschlagenes SimpleXML): gezielt per Regex.
        //    Macht den Check unabhaengig von der ext-simplexml-Verfuegbarkeit.
        $data = @file_get_contents($file, false, null, 0, 512000);

        if (!is_string($data) || strpos($data, '<') === false) {
            return '';
        }

        // Muss ein Erweiterungsmanifest sein.
        if (!preg_match('#<\s*(?:extension|install)\b#i', $data)) {
            return '';
        }

        if ($expectType !== ''
            && preg_match('#<\s*(?:extension|install)\b[^>]*\btype\s*=\s*"([^"]*)"#i', $data, $tm)
            && $tm[1] !== ''
            && strtolower($tm[1]) !== strtolower($expectType)) {
            return '';
        }

        if (preg_match('#<version>\s*([0-9][0-9A-Za-z.\-]{0,38})\s*</version>#i', $data, $vm)) {
            $ver = trim($vm[1]);

            return self::isVersion($ver) ? $ver : '';
        }

        return '';
    }

    // ─────────────────────────────────────────────────────────────────
    //  Feed-Cache-Speicher: eigene Blob-Tabelle (#__htprotect_store) statt der
    //  64-KB-`params`-Spalte. Der Cache selbst (Signaturen fuer WAF + Scanner)
    //  bleibt unveraendert - nur SEIN Lagerort wandert. Alle Store-Methoden sind
    //  fail-open: schlaegt der Store fehl, faellt der Aufrufer auf den Legacy-Ort
    //  (#__extensions.params) zurueck. Altbestand wird einmalig migriert.
    // ─────────────────────────────────────────────────────────────────

    /**
     * Store-Tabelle bei Bedarf anlegen (einmal je Request gemerkt). Bewusst ohne
     * ENGINE-/CHARSET-Klausel: der Inhalt ist reines ASCII (gzip+base64), damit ist
     * die Spalten-Kodierung egal und CREATE laeuft auf jeder MySQL-/MariaDB-Version.
     *
     * @param   object  $db  DB-Treiber
     *
     * @return boolean  true, wenn die Tabelle nutzbar ist
     */
    private static function storeEnsure($db)
    {
        if (self::$storeReady !== null) {
            return self::$storeReady;
        }

        self::$storeReady = false;

        try {
            $db->setQuery(
                'CREATE TABLE IF NOT EXISTS ' . $db->quoteName(self::STORE_TABLE) . ' ('
                . $db->quoteName('name') . ' VARCHAR(64) NOT NULL, '
                . $db->quoteName('data') . ' MEDIUMTEXT NULL, '
                . $db->quoteName('ts') . ' INT NOT NULL DEFAULT 0, '
                . 'PRIMARY KEY (' . $db->quoteName('name') . ')'
                . ')'
            );

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            self::$storeReady = true;
        } catch (Exception $e) {
            self::$storeReady = false;
        } catch (Throwable $e) {
            self::$storeReady = false;
        }

        return self::$storeReady;
    }

    /**
     * Einen Blob aus dem Store lesen und dekodieren. null, wenn Store nicht
     * verfuegbar oder Schluessel fehlt (-> Aufrufer nutzt den Legacy-Ort).
     *
     * @param   string  $name  Schluessel
     *
     * @return array|null
     */
    private static function storeGet($name)
    {
        $db = htp_db();

        if (!$db) {
            return null;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('data'))
                ->from($db->quoteName(self::STORE_TABLE))
                ->where($db->quoteName('name') . ' = ' . $db->quote($name));
            $db->setQuery($query);
            $raw = $db->loadResult();

            return is_string($raw) ? self::storeDecode($raw) : null;
        } catch (Exception $e) {
            return null;
        } catch (Throwable $e) {
            return null;
        }
    }

    /**
     * Einen Blob in den Store schreiben (Upsert, treiberneutral ohne ON DUPLICATE
     * KEY). Legt die Tabelle bei Bedarf an. Fail-open: false bei Store-/DB-Fehler.
     *
     * @param   string  $name  Schluessel
     * @param   array   $data  Nutzdaten
     *
     * @return boolean
     */
    private static function storePut($name, $data)
    {
        $db = htp_db();

        if (!$db || !self::storeEnsure($db)) {
            return false;
        }

        $enc = self::storeEncode($data);

        if ($enc === null) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('name'))
                ->from($db->quoteName(self::STORE_TABLE))
                ->where($db->quoteName('name') . ' = ' . $db->quote($name));
            $db->setQuery($query);
            $exists = $db->loadResult();

            if ($exists !== null) {
                $query = $db->getQuery(true)
                    ->update($db->quoteName(self::STORE_TABLE))
                    ->set($db->quoteName('data') . ' = ' . $db->quote($enc))
                    ->set($db->quoteName('ts') . ' = ' . (int) time())
                    ->where($db->quoteName('name') . ' = ' . $db->quote($name));
            } else {
                $query = $db->getQuery(true)
                    ->insert($db->quoteName(self::STORE_TABLE))
                    ->columns(array($db->quoteName('name'), $db->quoteName('data'), $db->quoteName('ts')))
                    ->values($db->quote($name) . ', ' . $db->quote($enc) . ', ' . (int) time());
            }

            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            return true;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Nutzdaten -> Speicherstring. Bevorzugt gzip+base64 ('z:'); ohne zlib roh
     * ('r:'). Beides reines ASCII -> spaltenkodierungs- und AV-scanner-freundlich.
     *
     * @param   array  $data
     *
     * @return string|null
     */
    private static function storeEncode($data)
    {
        $json = json_encode($data);

        if (!is_string($json) && defined('JSON_INVALID_UTF8_SUBSTITUTE')) {
            $json = json_encode($data, JSON_INVALID_UTF8_SUBSTITUTE);
        }

        if (!is_string($json)) {
            return null;
        }

        if (function_exists('gzencode')) {
            $gz = @gzencode($json, 6);

            if (is_string($gz) && $gz !== '') {
                return 'z:' . base64_encode($gz);
            }
        }

        return 'r:' . $json;
    }

    /**
     * Speicherstring -> Nutzdaten. Versteht 'z:' (gzip+base64) und 'r:' (roh).
     *
     * @param   string  $raw
     *
     * @return array|null
     */
    private static function storeDecode($raw)
    {
        if (!is_string($raw) || strlen($raw) < 2) {
            return null;
        }

        $tag  = substr($raw, 0, 2);
        $body = substr($raw, 2);

        if ($tag === 'z:') {
            if (!function_exists('gzdecode')) {
                return null;
            }

            $gz = base64_decode($body, true);

            if ($gz === false) {
                return null;
            }

            $json = @gzdecode($gz);
        } elseif ($tag === 'r:') {
            $json = $body;
        } else {
            return null;
        }

        if (!is_string($json)) {
            return null;
        }

        $dec = json_decode($json, true);

        return is_array($dec) ? $dec : null;
    }

    /**
     * Legacy-Cache aus #__extensions.params lesen (Altbestand vor der Auslagerung).
     *
     * @param   string  $key  htp_defs | htp_malware
     *
     * @return array|null
     */
    private static function legacyParamGet($key)
    {
        $db = htp_db();

        if (!$db) {
            return null;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);
            $raw = $db->loadResult();

            if (is_string($raw) && $raw !== '') {
                $decoded = json_decode($raw, true);

                if (is_array($decoded) && isset($decoded[$key])) {
                    // Neuere Ablage: komprimierter String (storeEncode, 'z:'/'r:') -> dekodieren.
                    // Alt-Bestand: rohes Array -> unveraendert nutzen (rueckwaertskompatibel; auch
                    // der kleine malware-Cache kann hier noch roh liegen).
                    if (is_string($decoded[$key])) {
                        $dec = self::storeDecode($decoded[$key]);

                        if (is_array($dec)) {
                            return $dec;
                        }
                    } elseif (is_array($decoded[$key])) {
                        return $decoded[$key];
                    }
                }
            }
        } catch (Exception $e) {
            // kein Legacy
        } catch (Throwable $e) {
            // kein Legacy
        }

        return null;
    }

    /**
     * Migrierten Legacy-Schluessel aus #__extensions.params entfernen, damit die
     * Spalte wieder schrumpft. Best effort; schreibt NUR, wenn wirklich noch etwas
     * zu entfernen ist (danach No-Op). Laesst alle anderen params-Schluessel intakt.
     *
     * @param   string  $key  htp_defs | htp_malware
     *
     * @return void
     */
    private static function purgeLegacyParamKey($key)
    {
        $db = htp_db();

        if (!$db) {
            return;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);
            $raw = $db->loadResult();

            if (!is_string($raw) || $raw === '') {
                return;
            }

            $decoded = json_decode($raw, true);

            if (!is_array($decoded) || !array_key_exists($key, $decoded)) {
                return;
            }

            unset($decoded[$key]);
            $json = json_encode($decoded);

            if (!is_string($json)) {
                return;
            }

            $query = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote($json))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }
        } catch (Exception $e) {
            // egal
        } catch (Throwable $e) {
            // egal
        }
    }

    /**
     * Zwischengespeicherte (nachgeladene) Definitionen lesen.
     *
     * @return array
     */
    public static function loadCached()
    {
        if (self::$cacheMemo !== null) {
            return self::$cacheMemo;
        }

        self::$cacheMemo = array();

        // Primaerer Ort: der eigene Blob-Store (nicht die 64-KB-params-Spalte).
        $fromStore = self::storeGet(self::CACHE_KEY);

        if (is_array($fromStore)) {
            self::$cacheMemo = $fromStore;

            return self::$cacheMemo;
        }

        // Fallback: Legacy-Cache aus #__extensions.params (Altbestand). Ist dort
        // noch etwas, wird es EINMALIG in den Store gehoben und aus params entfernt
        // -> danach greift oben immer der Store, dieser Zweig laeuft nie wieder.
        $legacy = self::legacyParamGet(self::CACHE_KEY);

        if (is_array($legacy)) {
            self::$cacheMemo = $legacy;

            if (self::storePut(self::CACHE_KEY, $legacy)) {
                self::purgeLegacyParamKey(self::CACHE_KEY);
            }
        }

        return self::$cacheMemo;
    }

    /**
     * Nachgeladene Definitionen speichern (andere Parameter bleiben erhalten).
     *
     * @param   array  $data  Zu speichernde Definitionen
     *
     * @return boolean
     */
    public static function saveCached($data)
    {
        // Primaer in den eigenen Blob-Store (kein 64-KB-Deckel). Bei Erfolg den
        // ggf. noch in params liegenden Alt-Cache entfernen (Migration).
        if (self::storePut(self::CACHE_KEY, $data)) {
            // Prozess-Cache auffrischen, damit wafTriggers() im selben Request
            // (z. B. direkt nach refresh()) die neuen Signaturen sieht.
            self::$cacheMemo = $data;
            self::purgeLegacyParamKey(self::CACHE_KEY);

            return true;
        }

        // Fallback (Store nicht verfuegbar): wie bisher nach #__extensions.params.
        $db = htp_db();

        if (!$db) {
            return false;
        }

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName('params'))
                ->from($db->quoteName('#__extensions'))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);
            $raw = $db->loadResult();

            $params = array();

            if (is_string($raw) && $raw !== '') {
                $decoded = json_decode($raw, true);

                if (is_array($decoded)) {
                    $params = $decoded;
                }
            }

            // Cache KOMPRIMIERT ablegen (wie der Blob-Store, storeEncode: json -> gzip -> base64):
            // roh ist der defs-Cache ~64 KB und sprengte die 64-KB-TEXT-Spalte -> der Write bricht
            // ab, fetched_ts/etag persistieren NICHT, und der Waechter holt defs.json bei JEDEM Tick
            // neu (Server-Haemmern, s. Feed-Last-Analyse). gzip bringt ~64 KB auf ~15 KB und bleibt
            // zugleich AV-opak (keine Signaturmuster im Klartext -> keine Imunify/CloudAV-Flaeche).
            $encoded = self::storeEncode($data);
            $params[self::CACHE_KEY] = $encoded;
            $json = ($encoded !== null) ? json_encode($params) : false;

            // Reicht der Platz nicht sicher (Puffer bis 60 KB; auf Hostern ohne CREATE-Recht fuer den
            // Blob-Store liegen ggf. weitere ausgelagerte Bloecke mit in params), NUR die winzigen
            // Abruf-Metadaten sichern (KEINE Signaturen). So haelt die 6h-Sperre (kein Neu-Hol-Sturm),
            // und die Signaturen bleiben die eingebauten (effective() overlayt Cache-Sigs nur, wenn
            // vorhanden -> sauberer, sicherer Rueckfall auf builtinSignatures()).
            if (!is_string($json) || strlen($json) > 60000) {
                $slim = array(
                    'fetched_ts' => isset($data['fetched_ts']) ? (int) $data['fetched_ts'] : time(),
                    'etag'       => isset($data['etag']) ? (string) $data['etag'] : '',
                    'modified'   => isset($data['modified']) ? (string) $data['modified'] : '',
                    'version'    => isset($data['version']) ? (string) $data['version'] : '',
                );
                $params[self::CACHE_KEY] = self::storeEncode($slim);
                $json = json_encode($params);
            }

            if (!is_string($json)) {
                return false;
            }

            $query = $db->getQuery(true)
                ->update($db->quoteName('#__extensions'))
                ->set($db->quoteName('params') . ' = ' . $db->quote($json))
                ->where($db->quoteName('type') . ' = ' . $db->quote('component'))
                ->where($db->quoteName('element') . ' = ' . $db->quote('com_htprotect'));
            $db->setQuery($query);

            if (method_exists($db, 'execute')) {
                $db->execute();
            } else {
                $db->query();
            }

            self::$cacheMemo = $data;

            return true;
        } catch (Exception $e) {
            return false;
        } catch (Throwable $e) {
            return false;
        }
    }

    /**
     * Eindeutiger Schluessel eines Warnlisten-Eintrags.
     *
     * @param   array  $ext  Eintrag
     *
     * @return string
     */
    private static function vulnKey($ext)
    {
        // Der Schluessel MUSS die Versionsreihe ('above') einschliessen: eine
        // Erweiterung kann in ZWEI getrennten Linien verwundbar sein (z. B.
        // DPCalendar J3 8.18.0-8.19.3 UND J4+ 9.0.0-10.11.1; iCagenda 3.x UND
        // 4.x). Ohne 'above' im Schluessel ueberschreibt der zweite Eintrag den
        // ersten in effectiveVuln() -> nur EINE Linie wuerde geprueft, die
        // andere bliebe voellig unerkannt (DPCalendar-10.11.0-Bug 2026-07-14).
        // Innerhalb derselben Linie (gleiches 'above') ersetzt der Feed-Eintrag
        // weiterhin den eingebauten - so bleibt die Live-Aktualisierung intakt.
        return strtolower($ext['element']) . '|'
            . (isset($ext['folder']) ? strtolower($ext['folder']) : '') . '|'
            . (isset($ext['above']) ? (string) $ext['above'] : '');
    }
}
