<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpContent: Inhalts-Analyse-Engine (aus HtpStorage isoliert). Traegt die
 * mustertragende Malware-/Defacement-Erkennung. BEWUSST als eigene, neutral
 * benannte Datei: sollte ein Server-Virenscanner (Imunify o. ae.) diese Datei
 * je fehlmelden/entfernen, degradiert NUR der Malware-Scanner - der Rest von
 * HTProtect (WAF, Auto-Updates, Schutzschild, Backups) laeuft weiter, weil
 * HtpStorage nur ueber class_exists-gegatete Wrapper hierher delegiert.
 *
 * Angriffs-Literale sind fragmentiert (PHP-Compile-Faltung -> byte-identisch,
 * 0 Laufzeitkosten); test/scannercheck.php erzwingt das dauerhaft.
 */

defined('_JEXEC') or die;

class HtpContent
{
    /**
     * Defacement-Visitenkarte: das klassische Angeber-Schema "<verb> by <handle>"
     * (Aktionswort + Handle). Near-zero-FP - echte Fotos/Grafiken und
     * legitime Dateien tragen diese Signatur nicht. Verlangt einen Handle hinter
     * dem "by" (2-40 Nicht-Leerzeichen), damit blosse Wortvorkommen nicht zaehlen.
     * Bewusst OHNE generisches "owned" (zu haeufig in legitimen Texten/Code).
     */
    const DEFACE_MARK_RE = '~\b(?:h[a4]' . 'ck(?:ed|3d)|def[a4]' . 'ced|pw' . 'n(?:ed|3d)|0wn' . '(?:ed|3d)|r0' . '0t(?:ed|3d))\s+by\s+[^\s<>"\x00]{2,40}~i';

    /**
     * Wie DEFACE_MARK_RE, aber am ANFANG verankert (^) - fuer die Defacement-
     * Landeseite als reiner Text/HTML: dort FUEHRT die Visitenkarte den sichtbaren
     * Text an. So bleibt ein normaler Text, der ein solches Schema nur erwaehnt, unberuehrt.
     */
    const DEFACE_TEXT_RE = '~^(?:h[a4]' . 'ck(?:ed|3d)|def[a4]' . 'ced|pw' . 'n(?:ed|3d)|0wn' . '(?:ed|3d)|r0' . '0t(?:ed|3d))\s+by\s+[^\s<>"\x00]{2,40}~i';

    /**
     * Bausteine fuer die Schad-Muster (Funktionsnamen + bekannte Schad-Strings),
     * AUS FRAGMENTEN zusammengesetzt: so zeigt der Quelltext dieser Scanner-Engine
     * die zusammenhaengenden Muster nicht im Klartext. Sonst melden manche Hoster-
     * Virenscanner ausgerechnet DIESE Datei faelschlich als Schadcode - wie ein
     * Virenscanner, der die Signaturdatei eines anderen Scanners anschlaegt. Die
     * zusammengesetzten Werte sind BYTE-IDENTISCH zu den frueheren Literalen; die
     * malwareSignature-Testbatterie (logiccheck) prueft jeden Zweig unveraendert.
     *
     * @return array
     */
    public static function malSigParts()
    {
        static $p = null;

        if ($p !== null) {
            return $p;
        }

        $ev = 'e' . 'val';           $as = 'ass' . 'ert';            $cf = 'create' . '_function';
        $sy = 'sys' . 'tem';         $sx = 'shell_e' . 'xec';        $pt = 'pass' . 'thru';
        $xc = 'ex' . 'ec';           $po = 'po' . 'pen';             $pr = 'proc' . '_open';
        $pc = 'pcntl_e' . 'xec';     $b6 = 'base64' . '_decode';     $gi = 'gz' . 'inflate';
        $gu = 'gz' . 'uncompress';   $gd = 'gz' . 'decode';          $sr = 'str' . '_rot13';
        $hx = 'hex' . '2bin';        $uu = 'convert' . '_uudecode';  $cu = 'call' . '_user_func';
        $fpc = 'file' . '_put_contents'; $fw = 'fw' . 'rite';        $mu = 'move' . '_uploaded_file';
        $pg = 'preg' . '_replace';       $ss = 'strip' . 'slashes';

        $p = array(
            'image'     => "$ev|$as|$sy|$sx|$pt|$xc|$po|$pr|$b6|$gi|$gu|$gd|$sr|$cf|$cu|$fpc|$fw|$mu|$pg",
            'dec'       => "$b6|$gi|$gu|$gd|$sr|$hx|$uu",
            'dec2'      => "$b6|$gi|$gu|$gd|$sr|$hx",
            'evalish'   => "$ev|$as|$cf",
            'mixed'     => "$ev|$as|$hx|$b6|$gi|$gu|$gd|$sr|$cf|$sx|$pr|$pt|$sy|$po",
            'exec1'     => "$sy|$sx|$pt|$po|$pr|$xc|$pc",
            'exec2'     => "$sy|$sx|$pt|$po|$pr|$pc",
            'exec3'     => "$sy|$sx|$pt|$xc|$po|$pr|$pc",
            'fileman'   => 'PHP File ' . 'manager ver 1.4',
            'filemanRe' => 'PHP File ' . 'manager',
            'phpfilter' => 'php:' . '//filter',
            'berhasil'  => 'Berh' . 'asil',
            'gagal'     => 'Ga' . 'gal',
            'ev'        => $ev,
            'as'        => $as,
            'ss'        => $ss,
        );

        return $p;
    }

    /**
     * Inhalt einer Datei auf eindeutige Malware-Muster pruefen. Jede einzelne
     * Signatur ist fuer sich praktisch fehlalarm-frei (legitimer Code schreibt
     * gefaehrliche Funktionen klein und kanonisch, nutzt kein goto-Flattening
     * und keine Funktionsnamen-Tarnung). Liefert einen kurzen Grund-Code oder ''.
     *
     * @param   string  $content  Datei-Inhalt (Kopf+Ende genuegt)
     *
     * @return string '' = unauffaellig, sonst Grund:
     *                goto|evaldecode|strrev|mixedcase|wrapper|rce
     */
    public static function malwareSignature($content)
    {
        $c = (string) $content;

        // Eigene Komponenten-/Schutz-Inhalte nie als Schaddatei melden.
        if (stripos($c, 'htprotect') !== false) {
            return '';
        }

        // Such-Bausteine (fragmentiert zusammengesetzt, byte-identisch zu vorher).
        $mp = self::malSigParts();

        // (0) Als Bild getarnte PHP-Shell: bekannte Bild-Magic GANZ am Anfang
        //     ZUSAMMEN mit einem PHP-Open-Tag im Inhalt. Faengt u. a. die JCE-
        //     Shells vom Muster "Bild-Magic + PHP-Open-Tag + per Konkatenation
        //     verschleierte Befehlsausfuehrung auf Nutzereingabe", die sonst durch
        //     jede Pruefung fallen (fremde .gif-Endung, kein PHP-Anfang).
        //
        //     WICHTIG: "<?php" ist als 5-Byte-Folge praktisch nie zufaelliger
        //     Bildinhalt und zaehlt allein. "<?=" bzw. "<?" + Whitespace sind
        //     aber nur das Bytepaar 0x3C3F (+ Trennzeichen) und kommen in ganz
        //     normalen Bilddaten vor -> sie zaehlen NUR mit echter PHP-Senke/
        //     Superglobal dahinter. Sonst Fehlalarm auf unveraenderte Core-Bilder
        //     (z. B. administrator/templates/hathor/...icon-48-install.png).
        if (preg_match('~^(?:GIF8[79]a|\xff\xd8\xff|\x89PNG|BM|RIFF.{4}WEBP|II\*\x00|MM\x00\*|\x00\x00\x01\x00)~s', $c)
            && (
                preg_match('~<\?php\b~', $c)
                || (preg_match('~<\?(?:=|\s)~', $c)
                    && preg_match('~\$_(?:GET|POST|REQUEST|COOKIE|SERVER|FILES|ENV)\b|(?<![\w$>:])(?:' . $mp['image'] . ')\s*\(~i', $c))
            )) {
            return 'image_shell';
        }

        // (0b) Defacement-GRAFIK ohne PHP: eine echte Bild-Datei (Bild-Magic ganz
        //      am Anfang), die zusaetzlich einen Angeber-Marker traegt - typisch
        //      hinter dem Bild-Trailer ans Dateiende geheftet (z. B. die als
        //      harmlose .gif in /images abgelegte Defacement-Grafik). Das Bild-
        //      Magic ist hier ZWINGEND -> null Fehlalarm auf echten Fotos, die
        //      diese Signatur ohnehin nie enthalten. KEIN PHP noetig (image_shell
        //      greift nur bei PHP-Code; diese Visitenkarte hat keinen).
        if (preg_match('~^(?:GIF8[79]a|\xff\xd8\xff|\x89PNG|BM|RIFF.{4}WEBP|II\*\x00|MM\x00\*|\x00\x00\x01\x00)~s', $c)
            && preg_match(self::DEFACE_MARK_RE, $c)) {
            return 'defacement';
        }

        // (0c) Defacement-LANDESEITE als reiner Text/HTML (OHNE Bild-Magic): eine
        //      kleine Datei, deren SICHTBARER Text (Tags entfernt) MIT einer
        //      Defacement-Visitenkarte (Aktionswort + Handle) BEGINNT - z. B. eine
        //      nackte Lande-HTML, die mit einem solchen Schema anfaengt. FP-Schutz
        //      dreifach: nur KURZE Dateien (<= 8 KB), kurzer SICHTTEXT (<= 600
        //      Zeichen) und die Visitenkarte FUEHRT den Text an (^) - ein Dokument/
        //      Changelog/Advisory, das ein solches Schema nur ERWAEHNT (langer Text,
        //      beginnt nicht damit), bleibt unberuehrt; das generische Besitz-Wort
        //      ohne Leetspeak-Variante ist bewusst ausgenommen.
        if (strlen($c) > 0 && strlen($c) <= 8192) {
            $vis = trim((string) preg_replace('~\s+~', ' ', strip_tags($c)));

            if ($vis !== '' && strlen($vis) <= 600 && preg_match(self::DEFACE_TEXT_RE, $vis)) {
                return 'defacement';
            }
        }

        // (0d) SEO-Cloaking-Defacement (gekaperte Seite, z. B. untergeschobene index.html):
        //      canonical nennt EINE Domain, aber >=2 hreflang-/amphtml-Alternates zeigen auf
        //      EINE ANDERE Domain -> die Suchmaschinen-Autoritaet wird auf eine Fremd-/Spam-/
        //      Casino-Seite umgeleitet. Nur der Kopf wird betrachtet (dort steht canonical/
        //      hreflang) -> auch bei grossen Seiten billig. FP-frei: echte mehrsprachige
        //      Seiten verlinken hreflang auf die EIGENE (canonical-)Domain. Ein statischer
        //      Datei-Scan (auch der manuelle) faengt so die Casino-Kaperung.
        if (self::seoCloakHtml($c) !== '') {
            return 'defacement';
        }

        // Deobfuskat NUR fuer die Erkennung: \xNN- und \NNN(oktal)-Escapes zu
        // Bytes aufloesen, damit hex-/oktal-getarnte Loader (z. B. "zip://"-
        // Include oder "_REQUEST") erkannt werden. Literaler Text bleibt
        // erhalten -> $d ist eine Obermenge von $c.
        $d = $c;

        if (strpos($c, '\\x') !== false || preg_match('~\\\\[0-7]{2,3}~', $c)) {
            $d = preg_replace_callback('~\\\\x([0-9A-Fa-f]{2})~', function ($m) {
                return chr(hexdec($m[1]));
            }, $c);
            $d = preg_replace_callback('~\\\\([0-7]{1,3})~', function ($m) {
                return chr(octdec($m[1]) & 0xFF);
            }, (string) $d);

            if (!is_string($d) || $d === '') {
                $d = $c;
            }
        }

        // (1) goto-Flattener (Control-Flow-Flattening): der Obfuskator zerlegt Code in
        //     Bloecke mit je EIGENEM Label -> viele DISTINCTE Labels. Legitimer
        //     goto-Einsatz nutzt WENIGE, WIEDERVERWENDETE, sprechende Labels (z. B. 7x
        //     "goto done;" in Quix TwigEngine, "goto handle_value;/prepare_value;" in
        //     Symfony VarExporter) - das ist KEINE Obfuskation. Darum nur werten, wenn
        //     >=5 DISTINCTE Labels auftreten (blosse Statement-Zahl reicht NICHT).
        //     FP-Fix 20260630: TwigEngine.php/Exporter.php waren legit.
        if (preg_match_all('~\bgoto\s+([A-Za-z0-9_]{1,40})\s*;~', $c, $gm) >= 5
            && count(array_unique($gm[1])) >= 5) {
            return 'goto';
        }

        // (2) Code-Ausfuehrung auf das Ergebnis eines Decoders angewandt - auch
        //     ueber mehrere verschachtelte Zwischen-Funktionen - sowie include/
        //     require eines Decoder-Ergebnisses. Klassische verschluesselte Payload.
        $dec = '(?:' . $mp['dec'] . ')';

        // Kommentar-Injektion zwischen dem Eval-Funktionsnamen und der '(' bzw. zwischen
        // den Funktionen - Kommentarzeichen als Trenner - ist eine haeufige Obfuskation
        // (bricht den Eval-Aufruf auf; die Title-Case-Schreibweise ist zudem aus mixedcase
        // ausgenommen). Darum die Pruefung ZUSAETZLICH auf einer kommentar-bereinigten
        // Kopie fahren. FP-frei: das Entfernen von /* */ kann Marker nur WEGnehmen, nie
        // erzeugen (ein Decoder-Aufruf in einem Kommentar verschwindet, statt zu treffen).
        // FN 20260627 (Cookie-basierte Hintertueren newsfeedmv/overridefeed/...).
        $dnc  = preg_replace('~/\*.*?\*/~s', '', $d);
        $hays = ($dnc === null || $dnc === $d) ? array($d) : array($d, $dnc);

        foreach ($hays as $hay) {
            if (preg_match('~\b(?:' . $mp['evalish'] . ')\s*\(\s*@?\s*(?:[a-zA-Z_]\w*\s*\(\s*@?\s*){0,5}' . $dec . '\s*\(~i', $hay)
                || preg_match('~\b(?:include|require)(?:_once)?\b\s*\(?\s*@?\s*' . $dec . '\s*\(~i', $hay)) {
                return 'evaldecode';
            }
        }

        // (3) Schad-Loader ueber einen gefaehrlichen Stream-Wrapper. ENTSCHEIDEND
        //     gegen Fehlalarme: ein Wrapper ist nur boesartig, wenn er in eine
        //     CODE-AUSFUEHRUNG fliesst (include/require/eval-artig) ODER der
        //     data://-base64 selbst PHP-Code traegt. Legitime Libs LESEN damit nur
        //     DATEN (getimagesize/fopen/XMLReader auf data:// bzw. zip://) und haben
        //     keinen Code-Sink - das blosse Vorkommen reicht also NICHT. Behebt die
        //     wrapper-FP-Klasse generisch: PhpSpreadsheet/box-spout (zip://-XLSX),
        //     gumlet/exif/PHPWord/php-future (data://-Bilddaten). Auswertung auf $d
        //     (hex-/oktal-deobfuskiert), damit getarnte Loader erkannt werden.
        $wrapPat = '(?:(?:zip|ph' . 'ar|rar|ogg|compress\.zlib)://[^\s"\'<>]+#'
            . '|da' . 'ta://[a-z0-9.+/-]*;base' . '64|' . $mp['phpfilter'] . ')';

        if (// include/require eines gefaehrlichen Wrappers/Filters (String-Literal):
            preg_match('~\b(?:include|require)(?:_once)?\b\s*\(?\s*@?\s*[\'"]\s*' . $wrapPat . '~i', $d)
            // eval-artig auf einen (ggf. ueber Lese-Funktionen geholten) Wrapper:
            || preg_match('~\b(?:' . $mp['evalish'] . ')\s*\(\s*@?\s*(?:[a-zA-Z_]\w*\s*\(\s*@?\s*){0,5}[\'"]\s*' . $wrapPat . '~i', $d)
            // php://filter-Quelltext-Leak ueber eine Datei-Lese-Funktion (wie bisher):
            || preg_match('~\b(?:file_get_contents|fopen|readfile|file|show_source|highlight_file|simplexml_load_file)\s*\(\s*@?\s*[\'"]\s*' . $mp['phpfilter'] . '~i', $d)
            // data://-base64, dessen Payload mit einem PHP-Open-Tag beginnt (<?php/<?=)
            // -> Code im Wrapper, auch ohne erkennbaren Sink (z. B. data-flow):
            || preg_match('~(?i:da' . 'ta://[a-z0-9.+/-]*;base' . '64,)\s*(?:PD' . '9w|PD' . '89)~', $d)) {
            return 'wrapper';
        }

        // (3b) Eingeschleuster Hintertuer-Loader: error-unterdruecktes include/require
        //      eines LITERALEN ABSOLUTEN Pfads (z. B. @include_once('/home/.../x.php')).
        //      Genau so haengt eine Hintertuer ihren Loader an Kerndateien/Templates
        //      (configuration.php, Template-index.php). Legitimer Code laedt ueber
        //      JPATH_*-Konstanten oder __DIR__, NIE einen hartkodierten Absolutpfad
        //      mit @ -> near-zero-FP (gegen eine komplette echte Joomla-Seite: 0).
        if (preg_match('~@\s*(?:include|require)(?:_once)?\s*\(\s*[\'"](?:/|[a-zA-Z]:[\\\\/])~i', $c)) {
            return 'inject';
        }

        // (4) Gefaehrliche Funktion rueckwaerts geschrieben und zur Laufzeit per
        //     strrev() zusammengesetzt (eine gaengige Namens-Tarnung).
        if (preg_match('~str' . 'rev\s*\(\s*[\'"](?:lave|edoced|46esab|etalfnizg|sserpmocnuzg|31_?tor_?rts|noitcnuf_' . 'etaerc|met' . 'sys|cexe_' . 'llehs|nepo_' . 'corp|urht' . 'ssap|tres' . 'sa)[\'"]\s*\)~i', $c)) {
            return 'strrev';
        }

        // (4b) Funktionsname/Code zur Laufzeit aus einem String per ARITHMETISCH
        //      verschleierten Index zusammengesetzt - z. B. $s[(0 * 6) + 3],
        //      $s[(73 - 69) / 4]. NUR die GEKLAMMERTE Zwei-Op-Form [(N op N) op N]
        //      werten (>=4). OHNE Klammer-Pflicht traefe [N op N op N] sonst Datums-/
        //      Versions-Strings in Klammern: [2019-01-24] = [N-N-N] = ZWEI Operatoren
        //      (FP mod_easyfolderlisting/CHANGELOG.php, 62 Datumszeilen!); und die
        //      Ein-Op-Form [N op N] traf Regex-ZEICHENKLASSEN [0-9]/[2-9] (das '-' =
        //      Range-Bindestrich). Der Obfuskator (3PJcpMFsD8B.php) KLAMMERT seine
        //      Index-Arithmetik ausnahmslos -> Klammern verpflichtend = FP-frei.
        if (preg_match_all('~\[\s*\(\s*-?\d{1,4}\s*[-+*/]\s*-?\d{1,4}\s*\)\s*[-+*/]\s*-?\d{1,4}\s*\]~', $c) >= 4) {
            return 'idxbuild';
        }

        // (4c) Schadskript-Auth: verschachteltes md5(md5(...$var...)) gegen einen
        //      HARDCODIERTEN 32-hex-Hash im Code (Request-Variable im inneren md5).
        //      Legitimer Code vergleicht md5(md5()) gegen einen GESPEICHERTEN Hash
        //      (DB/Config), NIE gegen ein Literal in der Datei. Deckt double..quad
        //      ab. FN 20260629: md5(md5($_GET-Wert)) === "572a25b4…" (passwort-
        //      gegatete chinesische Filemanager-Shell, verdoppelter Pfad).
        if (preg_match('~(?:md5\s*\(\s*){2,4}\$.{0,160}?===?\s*[\'"][0-9a-f]{32}[\'"]~is', $c)
            || preg_match('~[\'"][0-9a-f]{32}[\'"]\s*===?\s*(?:md5\s*\(\s*){2,4}\$~is', $c)) {
            return 'webshell';
        }

        // (5) Mixed-Case-Tarnung einer gefaehrlichen Funktion als Aufruf - mit
        //     Grossbuchstabe MITTEN im Namen, danach "(" (auch hinter Kommentaren).
        //     Kanonisch klein geschriebene Aufrufe loesen NICHT aus (keine Fehlalarme).
        if (preg_match_all(
            '~(?<![\\w$>:])(' . $mp['mixed'] . ')'
            . '(?=(?:\s|/\*[^*]{0,300}?\*/|//[^\n]*\n|\#[^\n]*\n){0,5}\()~i',
            $c,
            $mm
        )) {
            foreach ($mm[1] as $tok) {
                // Tarnung = ein Grossbuchstabe MITTEN im Namen (ab Position 1) UND
                // der Name nicht durchgaengig GROSS: hex2bIn, EvAl, GZinflate.
                // Ausgenommen (kein Treffer):
                //  (a) Title-Case (erstes Zeichen gross, Rest klein) = System, Eval -
                //      OOP-Namen UND Prosa wie "Content Management System (CMS)";
                //  (b) durchgaengig GROSS = Konstanten/Excel-Funktionen (HEX2BIN).
                // Kanonisch klein loest ohnehin nie aus. (Nur "hat gross UND klein" zu
                // pruefen wuerde Title-Case faelschlich treffen = der Regressions-FP.)
                if (preg_match('~[A-Z]~', substr($tok, 1)) && preg_match('~[a-z]~', $tok)) {
                    return 'mixedcase';
                }
            }
        }

        // (6) Befehls-Shell (RCE): Exec-Funktion DIREKT auf eine Superglobal,
        //     oder mehrere verschiedene Exec-Funktionen (Fallback-Kette) +
        //     Nutzereingabe. Beides ist in legitimem Code praktisch nie der Fall.
        if (preg_match('~\b(?:' . $mp['exec1'] . ')\s*\(\s*@?\s*\$_(?:GET|POST|REQUEST|COOKIE|SERVER|ENV)\b~i', $c)) {
            return 'rce';
        }

        if (preg_match('~\$_(?:GET|POST|REQUEST|COOKIE)\b~', $c)
            && preg_match_all('~(?<![\w$>:])(' . $mp['exec2'] . ')\s*\(~i', $c, $em)) {
            $distinct = array();

            foreach ($em[1] as $fn) {
                $distinct[strtolower($fn)] = 1;
            }

            if (count($distinct) >= 3) {
                return 'rce';
            }
        }

        // (6b) Backtick-Operator (fuehrt einen Shell-Befehl aus) auf eine Superglobal, EINZEILIG.
        //      Klassische Mini-Shell (als .txt/Bild getarnt, kurzer Echo-Tag mit
        //      Backtick auf $_GET[...]). Bewusst eng: nur dieselbe Zeile, kein
        //      $_SERVER (zu haeufig legitim) UND keine PHP-String-Begrenzer (" ')
        //      zwischen den Backticks. Letzteres entlarvt den haeufigsten FEHLALARM:
        //      MySQL-Identifier-Backticks (`spalte`) in einem PHP-String, die ueber
        //      Konkatenation eine Superglobal dazwischen haben - z. B.
        //      "`".$col."` LIKE '%".$_GET['q']."%'" (Schwarm-Meldung ACL Manager /
        //      ChronoForms). Ein echter Shell-Backtick (`id;$_GET[x]`) hat dort nie
        //      ein " oder '. ZUSAETZLICH verlangt die Regel ARRAY-Zugriff auf die
        //      Superglobal ($_GET[...]): ein echter Befehls-Backtick liest einen
        //      KONKRETEN Parameter, waehrend eine blanke Superglobal in einem Doc-
        //      Kommentar (`$_REQUEST`/`$_POST`) kein Befehl ist (Schwarm-Meldung
        //      EasyCalcCheck Plus, viele gut dokumentierte Plugins) -> kein Fehlalarm.
        if (preg_match('~`[^`\n"\']{0,200}\$_(?:GET|POST|REQUEST|COOKIE)\s*\[[^`\n"\']{0,200}`~', $c)) {
            return 'rce';
        }

        // (6c) Eval-Familie fuehrt Nutzereingabe DIREKT als Code aus (Ein-Zeilen-
        //      Backdoor ohne Decoder - die Decoder-Variante deckt der evaldecode-
        //      Zweig oben ab).
        //        Ein eval-Aufruf fuehrt IMMER Code aus (nie einen Vergleich) -> eine
        //          Superglobal als alleiniges Argument (ggf. via stripslashes verpackt)
        //          ist zweifelsfrei eine Backdoor.
        //        Ein assert-Aufruf KANN legitim einen Boolean pruefen (Vergleich oder
        //          2. Meldungs-Argument). Darum NUR die reine Ausfuehrungs-Form: die
        //          Superglobal-Array-Zelle ist das ALLEINIGE Argument (schliessende
        //          Klammer direkt danach) -> kein Fehlalarm auf Boolean-Asserts.
        //      Legitimer Code fuehrt eine Superglobal nie als Code aus -> FP-frei.
        if (preg_match('~\b' . $mp['ev'] . '\s*\(\s*@?\s*(?:' . $mp['ss'] . '\s*\(\s*)?\$_(?:GET|POST|REQUEST|COOKIE|SERVER|ENV)\b~i', $c)
            || preg_match('~\b' . $mp['as'] . '\s*\(\s*@?\s*\$_(?:GET|POST|REQUEST|COOKIE)\s*\[[^\]]{1,80}\]\s*\)~i', $c)) {
            return 'rce';
        }

        // (7) JCE-Profil-IMPORTDATEI mit Hack-Merkmalen: ein exportiertes JCE-
        //     Editor-Profil, das der Public-Gruppe (ID 1) im Frontend ein Upload-
        //     Plugin gibt - die Vorlage des JCE-Upload-Hacks. Gleiche, an echten
        //     Profilen verifizierte 3-Bedingungs-Logik wie die DB-Erkennung
        //     (legitime Profile sprechen die Public-Gruppe nie an) -> ~0 FP.
        if (stripos($c, '<plugins') !== false
            && (stripos($c, 'profiles') !== false || stripos($c, '<jce') !== false)
            && preg_match('~<types>\s*[0-9, ]*?\b1\b~i', $c)
            && preg_match('~<area>\s*[01]\s*</area>~i', $c)
            && preg_match('~<plugins>[^<]*\b(?:browser|upload|media|mediamanager|imgmanager|filemanager|fileupload|fileproxy)\b~i', $c)) {
            return 'jceprofile';
        }

        // (8) Schadskript-Fingerabdruck: die beiden indonesischen Status-Woerter eines
        //     Dateimanager-/Hintertuer-Shells (Erfolg/Fehler) ZUSAMMEN mit Nutzer-
        //     eingabe. In legitimem Code praktisch nie.
        if (preg_match('~\b' . $mp['berhasil'] . '\b~i', $c) && preg_match('~\b' . $mp['gagal'] . '\b~i', $c)
            && preg_match('~\$_(?:GET|POST|FILES|REQUEST|SERVER)\b~', $c)) {
            return 'webshell';
        }

        // (9) Verschleierte Funktions-Tabelle: viele hex-codierte String-Literale,
        //     die zu gefaehrlichen Funktionsnamen dekodieren. Legitimer Code codiert
        //     nie Funktionsnamen als Hex -> ab 6 Treffern eindeutig.
        if (preg_match_all('~[\'"]([0-9a-fA-F]{12,})[\'"]~', $c, $hm)) {
            $fnish = 0;

            foreach ($hm[1] as $hx) {
                if (strlen($hx) % 2 !== 0) {
                    continue;
                }

                $dec = @pack('H*', $hx);

                if (is_string($dec) && preg_match('~^[a-z_][a-z0-9_]{2,30}$~', $dec)) {
                    $fnish++;

                    if ($fnish >= 6) {
                        return 'hexfuncs';
                    }
                }
            }
        }

        // (10) Dateimanager-Hintertuer der SP-Page-Builder-Kampagne: ein fester
        //      Signatur-String dieser Hintertuer. Eindeutig, in legitimem Code
        //      praktisch nie.
        if (stripos($c, $mp['fileman']) !== false) {
            return 'filemanager';
        }

        // (11) In legitime Dateien injizierte, obfuskierte JavaScript-Malware
        //      (z. B. die "jmtouch"-Kampagne im tickres-Ticketsystem). Kennzeichen
        //      ist der String-Array-Rotations-Obfuskator (javascript-obfuscator /
        //      obfuscator.io): eine Endlos-Wahr-Schleife, die das String-Array
        //      per push/shift rotiert, plus viele _0x<hex>-Tokens.
        //      Diese exakte Kombination kommt in legitimem (auch minifiziertem)
        //      Code praktisch nie vor -> eindeutig, 0 Fehlalarm.
        if (preg_match('~whi' . 'le\s*\(\s*!\s*!\s*\[\s*\]\s*\)~', $c)
            && preg_match('~\[\s*[\'"]pu' . 'sh[\'"]\s*\]\s*\(\s*_0x[0-9a-f]+\s*\[\s*[\'"]shi' . 'ft[\'"]~i', $c)
            && preg_match_all('~_0' . 'x[0-9a-f]{4,7}\b~i', $c) >= 5) {
            return 'jsmalware';
        }

        // (Feed) Nachladbare, deklarative Zusatz-Signaturen. ADDITIV: wir sind
        // nur hier, wenn KEIN eingebauter Detektor oben griff -> der Feed kann ein
        // Built-in-Ergebnis nie aendern, nur einen Nicht-Treffer zu einem Treffer
        // machen. Leerer/fehlender Feed -> Rueckgabe '' wie bisher (bit-identisch).
        // Inhalte mit "htprotect" sind oben bereits ausgeschlossen.
        $feed = (class_exists('HtpDefs') && method_exists('HtpDefs', 'malwareSigs'))
            ? HtpDefs::malwareSigs()
            : array();

        return self::matchFeed($c, $feed);
    }

    /**
     * Treffer der nachladbaren, deklarativen Feed-Signaturen. REIN/testbar.
     * '' = kein Treffer, sonst 'feed:<id>'.
     *
     * @param   string  $content  Datei-Inhalt
     * @param   array   $sigs     Signaturen (HtpDefs::malwareSigs())
     *
     * @return string
     */
    public static function matchFeed($content, $sigs)
    {
        if (!is_array($sigs) || empty($sigs)) {
            return '';
        }

        $raw = (string) $content;
        $low = strtolower($raw);

        foreach ($sigs as $sig) {
            if (self::matchDeclarativeSig($low, $raw, $sig)) {
                $id = (is_array($sig) && isset($sig['id']) && $sig['id'] !== '') ? (string) $sig['id'] : 'sig';

                return 'feed:' . $id;
            }
        }

        return '';
    }

    /**
     * EINE deklarative Signatur pruefen. REIN/testbar, rein linear (strpos/
     * substr) -> ReDoS unmoeglich. Treffer nur bei mind. einem POSITIVEN
     * Kriterium (all/any/magic), das erfuellt ist; not unterdrueckt.
     *
     * @param   string  $low  Inhalt einmal kleingeschrieben (all/any/not)
     * @param   string  $raw  Roher Inhalt (magic = Byte-Praefix)
     * @param   array   $sig  Signatur
     *
     * @return boolean
     */
    public static function matchDeclarativeSig($low, $raw, $sig)
    {
        if (!is_array($sig)) {
            return false;
        }

        $all   = (isset($sig['all'])   && is_array($sig['all']))   ? $sig['all']   : array();
        $any   = (isset($sig['any'])   && is_array($sig['any']))   ? $sig['any']   : array();
        $not   = (isset($sig['not'])   && is_array($sig['not']))   ? $sig['not']   : array();
        $magic = (isset($sig['magic']) && is_array($sig['magic'])) ? $sig['magic'] : array();

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

        // magic: Datei muss byte-genau mit einem Praefix beginnen.
        if (!empty($magic)) {
            $hit = false;

            foreach ($magic as $m) {
                if (is_string($m) && $m !== '' && strncmp($raw, $m, strlen($m)) === 0) {
                    $hit = true;
                    break;
                }
            }

            if (!$hit) {
                return false;
            }
        }

        // all: jeder Teilstring muss vorkommen (case-insensitiv).
        foreach ($all as $needle) {
            if (!is_string($needle) || $needle === '' || strpos($low, strtolower($needle)) === false) {
                return false;
            }
        }

        // any: mindestens einer.
        if (!empty($any)) {
            $hit = false;

            foreach ($any as $needle) {
                if (is_string($needle) && $needle !== '' && strpos($low, strtolower($needle)) !== false) {
                    $hit = true;
                    break;
                }
            }

            if (!$hit) {
                return false;
            }
        }

        // not: keiner darf vorkommen (FP-Unterdrueckung).
        foreach ($not as $needle) {
            if (is_string($needle) && $needle !== '' && strpos($low, strtolower($needle)) !== false) {
                return false;
            }
        }

        return true;
    }

    /**
     * Feed-getriebene Angreifer-DATEINAMEN (nicht Inhalt): prueft den bereits
     * kleingeschriebenen Basisnamen gegen kuratierte Marker-Teilstrings. Deckt
     * Datei-KLASSEN ab, die der Inhalts-Scan gar nicht liest (z. B. .json-
     * Schreibrechte-Sonden). REIN/linear (strpos) -> ReDoS unmoeglich. Die
     * Marker werden im signierten Feed FP-frei kuratiert (abgegrenzte Angreifer-
     * Kennungen); die Engine selbst traegt bewusst KEIN Literal-Muster.
     *
     * @param   string  $base      Basisname, bereits kleingeschrieben
     * @param   array   $nameSigs  Liste [{id, m:[marker,...]}, ...]
     *
     * @return string '' | 'feed:<id>'
     */
    public static function matchFeedName($base, $nameSigs)
    {
        if (!is_string($base) || $base === '' || !is_array($nameSigs) || empty($nameSigs)) {
            return '';
        }

        foreach ($nameSigs as $sig) {
            if (!is_array($sig) || !isset($sig['m']) || !is_array($sig['m'])) {
                continue;
            }

            foreach ($sig['m'] as $needle) {
                if (is_string($needle) && $needle !== '' && strpos($base, $needle) !== false) {
                    $id = (isset($sig['id']) && $sig['id'] !== '') ? (string) $sig['id'] : 'name';

                    return 'feed:' . $id;
                }
            }
        }

        return '';
    }

    /**
     * Bekannte Schad-Dateinamen, die IMMER als boesartig gelten - unabhaengig
     * von Endung und Inhalt (z. B. als Bild getarnte Marker/Payloads dieses
     * Angriffs). Vergleich ueber den Basisnamen (klein), greift in jedem Ordner.
     *
     * @param   string  $rel  Relativer Pfad ab Webspace-Root
     *
     * @return string '' | 'knownbad'
     */
    public static function malwareKnownBadName($rel)
    {
        static $bad = array(
            'toggige-arrow.jpg' => 1,
            'accesson.php'      => 1, // dokumentierter Hintertuer-Einschleuser (CMS-Plugin-Tarnung), in Joomla nie legitim
        );

        $base = strtolower(basename(str_replace('\\', '/', (string) $rel)));

        return isset($bad[$base]) ? 'knownbad' : '';
    }

    /**
     * Verdacht allein aus Lage/Endung (inhaltsunabhaengig) - faengt Schaddateien,
     * die inhaltlich harmlos aussehen (z. B. nackte Uploader):
     *  - JEDE ausfuehrbare PHP-Datei im /images-Ordner (Joomla legt dort nie PHP
     *    ab; HTProtects Dirshield blockt die Ausfuehrung dort ohnehin).
     *  - JEDE .phtml-Datei webspace-weit (in Joomla praktisch nie legitim).
     *  - JEDE .shtml/.shtm-Datei webspace-weit (Server-Side-Includes; in Joomla
     *    nie regulaer, bei aktuellen Angriffen als Einschleuser/Bruecken-Seite/SSI-RCE
     *    missbraucht).
     *  - JEDE als Bild getarnte PHP-Datei (Doppelendung .php.gif/.jpg/.png ...),
     *    webspace-weit (klassische Schadskript-Tarnung, oft mit GIF-Magic).
     *
     * @param   string  $rel  Relativer Pfad ab Webspace-Root (Forward-Slashes)
     *
     * @return string '' | 'images_php' | 'media_php' | 'phtml' | 'shtml' | 'php_image'
     */
    public static function malwareLocationReason($rel)
    {
        $rel = ltrim(str_replace('\\', '/', (string) $rel), '/');

        // Public-Folder-Modus: liegt ein Upload real UNTER dem Webroot-Ordner (z. B.
        // "public/images/x.php" statt "images/x.php" - kommt vor, wenn der Ordner echt in public/
        // liegt statt als Symlink auf den App-Stamm), das fuehrende Docroot-Segment fuer die
        // Lage-Anker (images/, tmp/, media/...) wegnormalisieren - sonst feuert images_php/media_php
        // fuer den haeufigsten Joomla-Angriff (PHP im Upload-Ordner) nicht. NUR im PF-Modus aktiv;
        // in klassischen Installs (htp_is_public_folder()=false) unveraendert.
        if (function_exists('htp_is_public_folder') && htp_is_public_folder() && defined('JPATH_PUBLIC')) {
            $pb = basename(rtrim(str_replace('\\', '/', JPATH_PUBLIC), '/'));

            if ($pb !== '' && strpos($rel, $pb . '/') === 0) {
                $rel = substr($rel, strlen($pb) + 1);
            }
        }

        $ext = strtolower((string) pathinfo($rel, PATHINFO_EXTENSION));

        if ($ext === 'phtml') {
            return 'phtml';
        }

        if ($ext === 'shtml' || $ext === 'shtm') {
            return 'shtml';
        }

        // Als Bild getarnte PHP-Datei: Doppelendung .php.<bild> (z. B. shell.php.gif,
        // oft mit GIF-Magic). In Joomla nie legitim - klassische Schadskript-Tarnung.
        if (preg_match('~\.php[0-9]?\.(gif|jpe?g|png|webp|bmp|ico|svg|tiff?)$~', strtolower(basename($rel)))) {
            return 'php_image';
        }

        static $exec = array(
            'php' => 1, 'php3' => 1, 'php4' => 1, 'php5' => 1, 'php6' => 1,
            'php7' => 1, 'php8' => 1, 'pht' => 1, 'phtm' => 1, 'phps' => 1, 'phar' => 1,
        );

        if (isset($exec[$ext]) && stripos($rel, 'images/') === 0) {
            return 'images_php';
        }

        // Direkt in tmp/ abgelegte ausfuehrbare Datei (php-Varianten, pht/phtm,
        // phps, phar) ODER svg = klassische Upload-Sonde: der Angreifer legt
        // "<name>.<endung>" in vielen Endungen ab und ruft jede auf, um zu sehen,
        // welche der Server als PHP ausfuehrt (danach echte Shell mit der
        // Gewinner-Endung). In Joomlas tmp/ hat eine solche Datei nie etwas zu
        // suchen. BEWUSST nur DIREKTE Kinder von tmp/ (^tmp/<datei>$): echte
        // Installationen entpackt Joomla nach tmp/<unterordner>/... - die bleiben
        // unberuehrt (kein Fehlalarm). Eigene HTProtect-Laufzeitdateien in tmp/
        // (htprotect_*.json/.state/.triggers, index.html) sind weder exec noch svg.
        if ((isset($exec[$ext]) || $ext === 'svg') && preg_match('~^tmp/[^/]+$~', $rel)) {
            return 'tmp_exec';
        }

        // PHP unter media/com_admin/, media/regularlabs/ oder media/com_sppagebuilder/:
        // typische Persistenz-/Upload-Ablage der SP-Page-Builder-Kampagne (users.php
        // mehrfach kopiert; die SP-Page-Builder-RCE laedt Shells gezielt nach
        // media/com_sppagebuilder/assets/iconfont/ hoch). Diese Medien-Ordner
        // enthalten nie legitimes PHP. index.php wird hier NICHT mehr pauschal per
        // NAME ausgenommen (ein als "index.PHP" getarnter Shell entkam so komplett -
        // Schwarm 20260815); die inhaltsbewusste isHarmlessIndexStub-Pruefung in
        // examineScanFile nimmt echte, leere Schutz-Stubs weiterhin FP-frei aus - genau
        // wie schon bei der images_php-Regel oben (die kannte diese Namens-Ausnahme nie).
        if (isset($exec[$ext])
            && (stripos($rel, 'media/com_admin/') === 0
                || stripos($rel, 'media/regularlabs/') === 0
                || stripos($rel, 'media/com_sppagebuilder/') === 0)) {
            return 'media_php';
        }

        return '';
    }

    /**
     * Ist eine direkt in tmp/ liegende .php-Datei (Verdikt 'tmp_exec') in Wahrheit
     * ein LEGITIMES Joomla-Installer-Script und keine Angreifer-Upload-Sonde?
     *
     * Hintergrund (Forenfall webbie07): Joomla entpackt beim Installieren/Updaten
     * einer Erweiterung das im Manifest (Tag "scriptfile", ueblicher Dateiname
     * script.php) referenzierte Installer-Script transient nach tmp/ - u. a.
     * HTProtects EIGENES script.php. Die reine Lage-Regel flaggte das als
     * "Upload-Sonde" = Selbst-Fehlalarm.
     *
     * SICHERER Diskriminator (kann per Definition keine funktionierende Sonde
     * durchlassen): Eine Upload-Sonde lebt vom DIREKTEN URL-Aufruf (welche Endung
     * laeuft als PHP?). Ein 'defined(_JEXEC) or die'-Waechter laesst die Datei beim
     * Direktzugriff sofort sterben - sie kann also gar nichts proben. Zusaetzlich
     * muss sie erkennbar ein Joomla-Installer-/Framework-Script sein (InstallerScript-
     * Klasse/Adapter/Namespace ODER mehrere Install-Hook-Methoden). Der Inhalts-
     * Signatur-Scan (malwareSignature) laeuft an ALLEN Aufrufstellen ohnehin ZUERST
     * bzw. wird hier zusaetzlich vorgeschaltet -> echte Schad-Signaturen bleiben
     * erfasst; nur das pauschale Lage-Verdikt entfaellt. Teilmenge -> keine neuen FN.
     *
     * @param   string  $content  Bereits gelesener Datei-Inhalt
     *
     * @return boolean true = legitimes Installer-Script -> nicht als tmp_exec flaggen
     */
    public static function tmpExecLooksBenign($content)
    {
        $c = (string) $content;

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

        // 1) _JEXEC-Waechter zwingend (sonst keine gueltige Joomla-In-App-Datei).
        if (!preg_match('~defined\s*\(\s*[\'"]_JEXEC[\'"]\s*\)~', $c)) {
            return false;
        }

        // 2) Erkennbar ein Joomla-Installer-/Framework-Script.
        $low = strtolower($c);

        if (strpos($low, 'installerscript') !== false
            || strpos($low, 'installeradapter') !== false
            || strpos($low, 'joomla\\cms\\installer') !== false) {
            return true;
        }

        return preg_match_all('~function\s+(?:preflight|postflight|install|update|uninstall)\s*\(~i', $c) >= 2;
    }

    /**
     * Ist eine (als PHP-Kandidat gescannte, weil mit `<?xml` beginnende) .svg-Datei
     * inhaltlich eine HARMLOSE Vektorgrafik? Dann unterdrueckt sie das pauschale
     * Lage-Verdikt 'tmp_exec' (haeufiger Fehlalarm: Erweiterungen legen generierte
     * Status-/Icon-SVGs transient in tmp/ ab; die Lageregel flaggt JEDE .svg direkt
     * in tmp/ INHALTSUNABHAENGIG als "Upload-Sonde").
     *
     * SICHER (Teilmenge der bisherigen Erkennung -> kann keine funktionierende
     * Sonde/Shell durchlassen): eine reine <svg>-Grafik fuehrt serverseitig NICHTS
     * aus. Jedes Ausfuehrungs-/Skript-Indiz laesst die Datei WEITER als tmp_exec
     * flaggen: echter PHP-Open-Tag (<?php/<?= - NICHT die harmlose <?xml-Deklaration),
     * eingebettetes Skript-Tag, HTML-Event-Handler (on...=), JS-Pseudo-URI,
     * <foreignObject> (kann HTML/Skript tragen), interne DTD-ENTITY (XXE). Als SVG
     * getarnte PHP-/XSS-Shells bleiben damit erfasst.
     *
     * @param   string  $content  Bereits gelesener Datei-Inhalt
     *
     * @return boolean true = harmlose Vektorgrafik -> nicht als tmp_exec flaggen
     */
    public static function svgLooksBenign($content)
    {
        $c = (string) $content;

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

        $low = strtolower($c);

        // Muss ueberhaupt eine SVG sein (sonst nicht unser Fall - .php-Upload-Sonden
        // regelt tmpExecLooksBenign; ohne <svg gaebe es nichts zu entschaerfen).
        if (strpos($low, '<svg') === false) {
            return false;
        }

        // Jedes Code-/Skript-/XXE-Indiz -> NICHT harmlos (weiter als tmp_exec flaggen).
        if (strpos($low, '<?php') !== false
            || strpos($low, '<?=') !== false
            || strpos($low, '<scr' . 'ipt') !== false
            || strpos($low, 'java' . 'script:') !== false
            || strpos($low, '<foreignobject') !== false
            || strpos($low, '<!entity') !== false
            || preg_match('~\son[a-z]+\s*=~', $low)) {
            return false;
        }

        return true;
    }

    /**
     * Ist eine .shtml/.shtm-Datei (inhaltlich) eine HARMLOSE Standard-Fehlerseite
     * bzw. ein reines SSI-/HTML-Template? Dann unterdrueckt sie das pauschale
     * Endungs-Verdikt 'shtml' (haeufiger Fehlalarm: Plesk/cPanel/Apache legen
     * cp_errordocument.shtml + 4xx/5xx-Seiten im Webroot ab).
     *
     * BEWUSST ENG (Teilmenge der bisherigen Erkennung -> kann keine neuen
     * False-Negatives erzeugen): nur als harmlos gilt, was ALLE Kriterien erfuellt.
     * Jedes Gefahr-Indiz (PHP-Open-Tag, Backtick, gefaehrliche SSI-Direktive wie
     * #exec/cmd=, fremdes/kletterndes #include-Ziel, Defacement-Visitenkarte oder
     * Schad-Signatur) laesst die Datei WEITER als 'shtml' flaggen. SSI-Shells
     * (<!--#exec-->/#include eines Schadskripts), Bruecken-Seiten und Defacement bleiben erfasst.
     *
     * @param   string  $content  Bereits gelesener Datei-Inhalt
     *
     * @return boolean true = harmlose Fehlerseite/SSI-Template -> nicht flaggen
     */
    public static function shtmlLooksBenign($content)
    {
        $c = (string) $content;

        // Fehlerseiten sind klein. Etwas darueber -> im Zweifel weiter flaggen.
        if ($c === '' || strlen($c) > 16384) {
            return false;
        }

        $low = strtolower($c);

        // Kein PHP, kein Befehls-Backtick.
        if (strpos($low, '<?') !== false || strpos($c, '`') !== false) {
            return false;
        }

        // Kein JavaScript-Injektions-/Eval-Muster: eine echte Standard-Fehlerseite
        // nutzt diese nie. Faengt in .shtml eingebettete JS-Malware (Decoder-/Loader-
        // Ketten), die keine PHP-/SSI-Signatur hat und sonst faelschlich als "harmlos"
        // durchginge. Such-Strings AUS FRAGMENTEN zusammengesetzt (byte-identisch), damit
        // ein Hoster-Virenscanner diese Schutz-Datei nicht selbst fehlmeldet (s.
        // malSigParts). Die Muster-Testbatterie prueft die Wirkung unveraendert.
        $jv = 'ev' . 'al'; $jb = 'at' . 'ob'; $ju = 'un' . 'escape';
        $jf = 'from' . 'CharCode'; $jw = 'document.' . 'wri' . 'te';
        $jt = 'setti' . 'meout'; $jn = 'func' . 'tion';
        foreach (array($jv . '(', $jb . '(', 'String.' . $jf, $ju . '(',
            $jw, 'from' . 'charcode', $jt . '(', $jn . '(') as $needle) {
            if (strpos($low, $needle) !== false) {
                return false;
            }
        }

        // Defacement-Visitenkarte (Verunstaltungs-Parole "... by ...") -> nie harmlos.
        if (preg_match(self::DEFACE_MARK_RE, $c)) {
            return false;
        }

        // Inhalts-Schadsignatur (z. B. eval/decode-Kette in einem getarnten .shtml).
        if (self::malwareSignature($c) !== '') {
            return false;
        }

        // Gefaehrliche SSI: Befehlsausfuehrung in JEDER Form abweisen.
        //   #exec cmd=... / #exec cgi=...  -> RCE
        if (preg_match('~<!--\s*#\s*exec\b~i', $low)) {
            return false;
        }

        // #include nur als LOKALE Einbindung einer .html/.shtml-Geschwisterdatei
        // zulassen: kein cmd=/file=-Klettern, kein absoluter/Schema-Pfad, kein ".."
        if (preg_match_all('~<!--\s*#\s*include\b([^>]*?)-->~i', $low, $inc)) {
            foreach ($inc[1] as $args) {
                // erlaubtes Ziel: virtual="..." oder file="..." auf eine lokale
                // .htm(l)/.shtm(l)-Datei ohne Klettern/Schema/fuehrenden Slash.
                if (!preg_match('~^\s*(?:virtual|file)\s*=\s*"[^":\\\\]+\.s?html?"\s*$~', $args)) {
                    return false;
                }
                if (strpos($args, '..') !== false || preg_match('~=\s*"/~', $args)) {
                    return false;
                }
            }
        }

        // Verbliebene SSI-Direktiven muessen aus dem HARMLOSEN Vokabular stammen
        // (Anzeige/Variablen) - alles andere (z. B. #printenv-Leak, unbekannt) ->
        // weiter flaggen. set/echo/include/config/flastmod/fsize/elif/if/else/endif.
        if (preg_match_all('~<!--\s*#\s*([a-z]+)~i', $low, $dir)) {
            $safe = array('set' => 1, 'echo' => 1, 'include' => 1, 'config' => 1,
                'flastmod' => 1, 'fsize' => 1, 'if' => 1, 'elif' => 1, 'else' => 1, 'endif' => 1);
            foreach ($dir[1] as $name) {
                if (!isset($safe[$name])) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * SEO-Cloaking im HTML erkennen (rein/testbar): canonical nennt EINE Domain, aber
     * >=2 hreflang-/amphtml-Alternates zeigen auf EINE ANDERE (fremde) Domain. Kein Site-
     * Kontext noetig - der Cloak ist die canonical-vs-hreflang-Diskrepanz selbst. Liefert
     * die Fremd-Domain oder ''. FP-frei: echte Multilinguals verlinken hreflang auf die
     * EIGENE canonical-Domain (inkl. Subdomains); ein einzelner Alternate (<2) reicht nie.
     *
     * @param   string  $content
     *
     * @return string
     */
    public static function seoCloakHtml($content)
    {
        $c = (string) $content;

        // Billiges Vor-Gate: ohne <link + (hreflang|amphtml) nichts zu tun.
        if (stripos($c, '<link') === false
            || (stripos($c, 'hreflang') === false && stripos($c, 'amphtml') === false)) {
            return '';
        }

        // Nur der Kopf-Bereich (dort stehen canonical/hreflang) -> billig auch bei grossen Seiten.
        $head  = substr($c, 0, 16384);
        $canon = '';
        $alt   = array();

        if (preg_match_all('~<link\b[^>]*>~i', $head, $tags)) {
            foreach ($tags[0] as $tag) {
                if (!preg_match('~\brel\s*=\s*["\']?\s*([a-z-]+)~i', $tag, $rm)) {
                    continue;
                }

                $rel = strtolower($rm[1]);

                if (!preg_match('~\bhref\s*=\s*["\']?\s*(?:https?:)?//([^/"\'\s>]+)~i', $tag, $hm)) {
                    continue;
                }

                $host = strtolower($hm[1]);

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

                if ($rel === 'canonical') {
                    if ($canon === '') {
                        $canon = $host;
                    }
                } elseif ($rel === 'amphtml' || ($rel === 'alternate' && stripos($tag, 'hreflang') !== false)) {
                    $alt[$host] = (isset($alt[$host]) ? $alt[$host] : 0) + 1;
                }
            }
        }

        // Ohne canonical kein Urteil (FP-Schutz).
        if ($canon === '') {
            return '';
        }

        foreach ($alt as $host => $n) {
            // >=2 Alternates auf EINE Domain, die weder die canonical-Domain noch eine
            // Subdomain-Beziehung dazu ist -> Cloaking.
            if ($n >= 2
                && $host !== $canon
                && substr($host, -(strlen($canon) + 1)) !== '.' . $canon
                && substr($canon, -(strlen($host) + 1)) !== '.' . $host) {
                return $host;
            }
        }

        return '';
    }

    /**
     * Ist eine `index.php` ein HARMLOSER Joomla-Schutz-Stub (leer bzw. nur Kommentar/
     * `exit`/`die`, KEIN echter Code)? Joomla legt solche Stubs flaechendeckend ab,
     * auch in Upload-Ordnern (`images/...`) - dort flaggt die Lageregel `images_php`
     * sie sonst faelschlich. BEWUSST eng: nur fuer den Basisnamen index.php und nur,
     * wenn nach Entfernen von PHP-Tags/Kommentaren/`exit;`/`die;`/Whitespace NICHTS
     * Ausfuehrbares uebrig bleibt. Eine als index.php getarnte Shell (echte
     * Statements) bleibt damit erkannt.
     *
     * @param   string  $rel      Relativer Pfad (fuer den Basisnamen)
     * @param   string  $content  Datei-Inhalt
     *
     * @return boolean
     */
    public static function isHarmlessIndexStub($rel, $content)
    {
        if (strtolower(basename((string) $rel)) !== 'index.php') {
            return false;
        }

        $c = (string) $content;

        if (strlen($c) > 4096) {
            return false; // ein echter Stub ist winzig
        }

        // Block-Kommentare und Zeilenkommentare entfernen.
        $c = (string) preg_replace('~/\*.*?\*/~s', '', $c);
        $c = (string) preg_replace('~(?m)//.*$~', '', $c);
        $c = (string) preg_replace('~(?m)#.*$~', '', $c);

        // PHP-Tags, harmlose Schutz-Anweisungen und Whitespace entfernen.
        $c = str_ireplace(array('<?php', '<?=', '<?', '?>'), '', $c);
        // Zuerst die zusammengesetzte Form `defined(...) or die/exit;` (sonst wuerde
        // die nackte exit/die-Regel das `;` vorab entfernen und den Rest stehenlassen).
        $c = (string) preg_replace('~\bdefined\s*\([^)]*\)\s*or\s+(exit|die)\b\s*(\([^)]*\))?\s*;?~i', '', $c);
        $c = (string) preg_replace('~\b(exit|die)\b\s*(\([^)]*\))?\s*;~i', '', $c);

        // Bleibt etwas Nicht-Whitespace uebrig -> es ist KEIN reiner Stub.
        return trim($c) === '';
    }

    /**
     * Traegt eine Datei mit fremder Endung im Kopf ODER am Ende einen
     * Defacement-Angeber-Marker ("... by &lt;handle&gt;")? Faengt die als
     * harmlose .gif/.png getarnte Defacement-Grafik, deren Marker hinter dem
     * Bild-Trailer ans Dateiende geheftet ist (kein PHP -> foreignHasPhpTag
     * blind). Liest nur Kopf (2 KB) + bei groesseren Dateien Ende (2 KB) -> guenstig.
     *
     * @param   string   $file  Absoluter Pfad
     * @param   integer  $size  Dateigroesse
     *
     * @return boolean
     */
    public static function foreignHasDefaceMark($file, $size)
    {
        $fh = @fopen($file, 'rb');

        if (!$fh) {
            return false;
        }

        $data = (string) @fread($fh, 2048);

        if ($size > 4096) {
            @fseek($fh, -2048, SEEK_END);
            $data .= "\n" . (string) @fread($fh, 2048);
        }

        @fclose($fh);

        return (bool) preg_match(self::DEFACE_MARK_RE, $data);
    }

    /**
     * 1-basierte Zeile der ersten starken Schad-Indikator-Fundstelle im
     * (bereinigten) Inhalt - fuer den "Sprung zur Fundstelle". 0 = nichts
     * Eindeutiges gefunden (z. B. reiner Lage-/Endungs-Treffer) -> Anfang.
     * Es wird die ZEILE (nicht der Byte-Offset) geliefert, damit Mehrbyte-Inhalt
     * im JS unproblematisch ist.
     *
     * @param   string  $content  Bereinigter Inhalt (wie angezeigt)
     *
     * @return int
     */
    public static function malwareHintLine($content)
    {
        $c = (string) $content;

        $mp = self::malSigParts();

        $needles = array(
            '~whi' . 'le\s*\(\s*!\s*!\s*\[~i',                                            // jsmalware
            '~\b(?:' . $mp['evalish'] . ')\s*\(~i',
            '~\b(?:' . $mp['dec2'] . ')\s*\(~i',
            '~\bgoto\s+[A-Za-z0-9_]{1,40}\s*;~',
            '~\b(?:' . $mp['exec3'] . ')\s*\(~i',
            '~(?:zip|ph' . 'ar|rar|compress\.zlib)://[^\s"\'<>]+#~i',
            '~@\s*(?:include|require)(?:_once)?\s*\(\s*[\'"](?:/|[a-zA-Z]:[\\\\/])~i',
            '~' . $mp['phpfilter'] . '~i',
            '~' . $mp['filemanRe'] . '~i',
            '~\$_(?:GET|POST|REQUEST|COOKIE)\b~',
            '~<\?php~i',
        );

        $best = -1;

        foreach ($needles as $re) {
            if (preg_match($re, $c, $m, PREG_OFFSET_CAPTURE) && isset($m[0][1]) && (int) $m[0][1] >= 0) {
                $off = (int) $m[0][1];

                if ($best < 0 || $off < $best) {
                    $best = $off;
                }
            }
        }

        if ($best < 0) {
            return 0;
        }

        return substr_count(substr($c, 0, $best), "\n") + 1;
    }

    /**
     * REIN/testbar: entscheidet, ob ein fp-Eintrag das Urteil unterdrueckt.
     *   - 'only' (optional): nur fuer diese Reasons greifen (sonst irrelevant).
     *   - 'sha256' (optional): exakte Datei -> hash_equals gegen den Datei-Hash.
     *   - all/any/magic/not: deklarativer Fingerprint (gleiche, ReDoS-freie Logik
     *     wie bei den Erkennungs-Signaturen).
     * Treffer eines EINZIGEN Kriteriums genuegt; 'not' unterdrueckt den Treffer.
     *
     * @param   string  $content  Datei-Inhalt (fuer den deklarativen Check)
     * @param   string  $sha256   Datei-sha256 ('' wenn nicht berechnet)
     * @param   string  $reason   Urteil
     * @param   array   $fpList   Normalisierte fp-Eintraege (HtpDefs::malwareFp())
     *
     * @return boolean
     */
    public static function fpSuppresses($content, $sha256, $reason, $fpList)
    {
        if (!is_array($fpList) || empty($fpList) || (string) $reason === '') {
            return false;
        }

        $raw = (string) $content;
        $low = null;

        foreach ($fpList as $fp) {
            if (!is_array($fp)) {
                continue;
            }

            // Reason-Scope: greift dieser Eintrag nur fuer bestimmte Urteile?
            if (isset($fp['only']) && is_array($fp['only']) && !empty($fp['only'])
                && !in_array((string) $reason, $fp['only'], true)) {
                continue;
            }

            // Exakte Datei (un-missbrauchbar, da byte-genau).
            if (isset($fp['sha256']) && $fp['sha256'] !== '' && (string) $sha256 !== ''
                && hash_equals((string) $fp['sha256'], (string) $sha256)) {
                return true;
            }

            // Deklarativer Fingerprint (gleiche Linear-Logik wie matchFeed).
            if (!empty($fp['all']) || !empty($fp['any']) || !empty($fp['magic'])) {
                if ($low === null) {
                    $low = strtolower($raw);
                }

                if (self::matchDeclarativeSig($low, $raw, $fp)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Archiv-Scan-Grenzen (.zip): Performance + Schutz vor Dekompressions-Bomben.
     * BEWUSST hier in HtpContent (reine INHALTS-Erkennung), NICHT in HtpStorage
     * (schreibende Engine): das ZIP-Hineinschauen samt PHP-Tag-Erkennung neben den
     * Schreib-/chmod-/unlink-Primitiven liest ein Server-Virenscanner sonst als
     * "Dropper" (NP1106-3). HtpStorage ruft nur ueber einen class_exists-Wrapper hierher.
     */
    const MAL_ARCHIVE_MAX       = 4194304;  // .zip groesser (Backups) NICHT oeffnen (4 MB)
    const MAL_ARCHIVE_ENTRY_MAX = 524288;   // pro Entry max. 512 KB (entpackt) lesen
    const MAL_ARCHIVE_TOTAL     = 16777216; // 16 MB Gesamt-Entpack-Limit je Archiv
    const MAL_ARCHIVE_ENTRIES   = 256;      // max. Entries je Archiv

    /**
     * In ein .zip hineinschauen: jede kleine Code-Entry (PHP-Tag bzw. kleine Text-Datei)
     * NUR in den Speicher entpacken (nie auf Platte) und durch dieselbe Inhalts-Verdikt-
     * Pipeline schicken wie eine normale Datei (malwareSignature inkl. Feed, danach die
     * kuratierte fp-Unterdrueckung). So fallen in Archive getarnte Einschleuser auf, OHNE
     * dass ein legitimes Erweiterungs-/Backup-Zip einen Fehlalarm ausloest. Streng begrenzt
     * gegen Dekompressions-Bomben; ohne ZipArchive ein No-Op. Reine Erkennung (schreibt
     * nichts) -> gehoert in HtpContent; HtpStorage delegiert nur.
     *
     * @param   string  $p    Absoluter Pfad zum Archiv
     * @param   string  $rel  Relativer Pfad (fuer die Endungs-Pruefung)
     *
     * @return array|null  Treffer (reason 'archive:<inner>') oder null
     */
    public static function examineArchive($p, $rel)
    {
        if (strtolower((string) pathinfo($rel, PATHINFO_EXTENSION)) !== 'zip'
            || !class_exists('ZipArchive')) {
            return null;
        }

        $fsize = (int) @filesize($p);

        if ($fsize <= 0 || $fsize > self::MAL_ARCHIVE_MAX) {
            return null; // leere oder zu grosse Archive (Backups) ueberspringen
        }

        $za = new ZipArchive();

        if (@$za->open($p) !== true) {
            return null;
        }

        // Feed-fp-Liste einmal laden (Unterdrueckung je Entry; sha des ARCHIVS, wie zuvor).
        $fpList   = (class_exists('HtpDefs') && method_exists('HtpDefs', 'malwareFp')) ? HtpDefs::malwareFp() : array();
        $needHash = false;

        foreach ($fpList as $fp) {
            if (is_array($fp) && !empty($fp['sha256'])) {
                $needHash = true;
                break;
            }
        }

        $sha = ($needHash && !empty($fpList)) ? (string) @hash_file('sha256', $p) : '';

        $hit       = null;
        $readTotal = 0;
        $n         = min((int) $za->numFiles, self::MAL_ARCHIVE_ENTRIES);

        for ($i = 0; $i < $n; $i++) {
            $st  = $za->statIndex($i);
            $usz = (is_array($st) && isset($st['size'])) ? (int) $st['size'] : 0;

            if ($usz <= 0 || $usz > self::MAL_ARCHIVE_ENTRY_MAX) {
                continue; // leere/zu grosse Entry ohne Entpacken ueberspringen
            }

            if ($readTotal + $usz > self::MAL_ARCHIVE_TOTAL) {
                break; // Dekompressions-Bomben-Schutz
            }

            $content = @$za->getFromIndex($i, self::MAL_ARCHIVE_ENTRY_MAX);

            if (!is_string($content) || $content === '') {
                continue;
            }

            $readTotal += strlen($content);

            // Nur code-artige Entries (PHP-Tag) bzw. kleine Text-Entries pruefen -
            // grosse reine Binaerdateien (Bilder/Fonts) kosten nur Zeit.
            if (strpos($content, '<?') === false && strlen($content) > 65536) {
                continue;
            }

            $reason = self::malwareSignature($content); // built-ins + Feed

            if ($reason !== '' && !self::fpSuppresses($content, $sha, $reason, $fpList)) {
                $hit = array(
                    'reason'  => 'archive:' . $reason,
                    'size'    => $fsize,
                    'preview' => self::previewSnippet($content),
                );

                break;
            }
        }

        $za->close();

        return $hit;
    }

    /**
     * Charakteristische Dateigroesse der vom JCE-/Upload-Angriff abgelegten
     * boesartigen .htaccess (PHP-Reaktivierung), in Bytes.
     */
    const MAL_HTACCESS_SIZE = 127;

    /**
     * Ist dieser .htaccess-Inhalt das JCE-/Upload-Angriffs-Artefakt? BEWUSST hier in
     * HtpContent (nicht in HtpStorage): diese Datei SCHREIBT keine .htaccess, daher liest
     * ein Server-Virenscanner die Handler-Muster nicht als "schreibt boesartige .htaccess"
     * (Dropper). HtpStorage delegiert nur ueber einen class_exists-gegateten Wrapper hierher.
     *
     * Eindeutige Signatur: eine Files-Regel, die die Pseudo-Endung ".suspected" mit
     * auffuehrt (nur in diesen Artefakten -> groessenunabhaengig sicher). Zusaetzlich (nur
     * bei der charakteristischen Groesse) klassische PHP-REAKTIVIERUNGS-Varianten.
     * HTProtects eigene .htaccess SPERREN PHP -> Ausschluss bei vorhandenem Marker.
     *
     * @param   string   $content  Dateiinhalt
     * @param   integer  $size     Dateigroesse in Bytes (0 = unbekannt/ungated)
     *
     * @return boolean
     */
    public static function isMaliciousHtaccess($content, $size = 0)
    {
        $content = (string) $content;

        if (stripos($content, 'htprotect') !== false) {
            return false;
        }

        // Eindeutige Signatur des realen Artefakts (groessenunabhaengig).
        if (preg_match('~<Files(?:Match)?\b[^>]*\bsuspected\b~i', $content)) {
            return true;
        }

        // PHP-Ausfuehrung fuer eine GETARNTE Endung freigeschaltet (".PHP" gross /
        // Bild-/Asset-Endung auf PHP gemappt) - groessenunabhaengig + FP-frei.
        if (self::htaccessPhpDisguisedExt($content)) {
            return true;
        }

        // PHP-Reaktivierungs-Varianten nur bei der charakteristischen Groesse
        // werten (vermeidet Fehlalarme bei legitimen Hoster-/Handler-Configs).
        if ($size !== 0 && $size !== self::MAL_HTACCESS_SIZE) {
            return false;
        }

        // Handler-/Direktiv-Token bewusst per Konkatenation zusammengesetzt (PHP faltet
        // das beim Compile -> byte-identische Regex, 0 Laufzeitkosten). So steht kein
        // zusammenhaengendes Handler-Muster im Klartext -> ein Server-Virenscanner liest
        // die Erkennungs-Engine nicht als "Einschleuser". Verhalten: siehe htaccesssigcheck.
        return (bool) preg_match(
            '~(?:Add' . 'Type|Add' . 'Handler|Set' . 'Handler|Force' . 'Type)\b[^\n]*(?:x-' . 'httpd|php-' . 'script|fcgid-' . 'script|cgi-' . 'script|\bphp[0-9]?\b)'
            . '|application/x-' . 'httpd-php|php-' . 'script'
            . '|php_(?:admin_)?flag\b[^\n]*\bengine\b[^\n]*\bon\b'
            . '|php_(?:admin_)?value\b[^\n]*\bengine\b'
            . '|Options\b[^\n]*\+Exec' . 'CGI'
            // Ein <Files>-Block, der die PHP-Sperre wieder aufhebt (Angreifer-Muster;
            // HTProtects eigene "denied"-Regel trifft nie zu).
            . '|<Files(?:Match)?\b[^>]*\bph(?:p[0-9]?|tml|t|ar)\b[\s\S]{0,200}?(?:Allow\s+from\s+all|Satisfy\s+Any|Require\s+all\s+granted)~i',
            $content
        );
    }

    /**
     * .htaccess, die PHP-Ausfuehrung fuer eine GETARNTE Endung freischaltet: eine
     * PHP-Handler-Direktive mappt den PHP-Interpreter auf eine Endung, die NICHT die
     * schlichte kleingeschriebene php-Familie ist - also GROSS-/Mischschreibung
     * (".PHP") oder eine Bild-/Asset-/Text-Endung (.gif/.png/.ico/.txt ...). So
     * laeuft eine als Bild/Text getarnte hochgeladene Datei als PHP. Legitime Configs
     * mappen nur ".php" (klein) bzw. hoechstens ".html" (php-in-html, BEWUSST nicht
     * geflaggt) -> FP-frei + GROESSENUNABHAENGIG. FN 20260629: media/.../fonts/.htaccess
     * mappte per Handler-Direktive den PHP-Interpreter auf ".PHP" (Gross-Schreibung).
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return boolean
     */
    private static function htaccessPhpDisguisedExt($content)
    {
        if (!preg_match_all('~(?:Add' . 'Type|Add' . 'Handler|Set' . 'Handler|Force' . 'Type)\b([^\r\n]*)~i', (string) $content, $lines)) {
            return false;
        }

        foreach ($lines[1] as $line) {
            // Direktive muss sich auf PHP-Ausfuehrung beziehen.
            if (!preg_match('~x-' . 'httpd-php|php-' . 'script|application/x-' . 'httpd~i', $line)) {
                continue;
            }

            if (!preg_match_all('~\.([A-Za-z0-9]{1,8})\b~', $line, $exts)) {
                continue;
            }

            foreach ($exts[1] as $ext) {
                $low = strtolower($ext);

                // (a) als php getarnte GROSS-/Mischschreibung (".PHP") - nie legitim.
                if (preg_match('~^php[0-9]?$~', $low) && $ext !== $low) {
                    return true;
                }

                // (b) Bild-/Asset-/Text-Endung auf PHP gemappt = getarntes Schad-Skript.
                if (preg_match('~^(?:gif|jpe?g|png|ico|bmp|svg|webp|tiff?|txt|csv|css|js|json|woff2?|ttf|eot|otf|mp4|webm|ogg|zip|rar)$~', $low)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * REINE Inhaltspruefung (testbar): eine .htaccess, die eine ausfuehrbare
     * Endung in gemischter Gross-/Kleinschreibung listet (also weder durchgaengig
     * klein/gross noch Title-Case). Klassische Angreifer-.htaccess, die Schad-
     * Skripte ueber Handler/Sperre tarnt bzw. Cleanup blockiert. In legitimen
     * Configs NIE so geschrieben - die nutzen (?i) oder die Normalform.
     *
     * Erkennung literal-frei (gleiche FP-Haertung wie malSigParts): JEDE
     * p-h-p-Schreibweise wird per ZEICHENKLASSE erfasst und die drei Normalformen
     * werden ausgeschlossen -> kein zusammenhaengendes Tarn-Muster im Quelltext,
     * das ein Hoster-Scanner als Schadsignatur lesen koennte.
     *
     * @param   string  $content  .htaccess-Inhalt
     *
     * @return boolean
     */
    public static function htaccessContentMalicious($content)
    {
        $content = (string) $content;

        // PHP-Ausfuehrung fuer eine GETARNTE Endung freigeschaltet (".PHP" gross /
        // Bild-/Asset-Endung auf PHP gemappt) - vor dem php-Token-Gate, FP-frei.
        if (self::htaccessPhpDisguisedExt($content)) {
            return true;
        }

        // Defensive Deny-Bloecke (<Files(Match)> ... "Deny from all" / "Require all denied"
        // OHNE Allow/Grant) sind reine Ordner-Haertung: eine .htaccess, die ALLE php-
        // Schreibweisen SPERRT (inkl. HTProtects eigenem DirShield-Block), ist Schutz, keine
        // Tarnung. Solche Bloecke vor der Mischschreib-Pruefung ausblenden -> kein FP auf
        // Upload-Ordner-Haertung (Schwarm 20260802 images/.htaccess). Die Enable-Pfade
        // (htaccessPhpDisguisedExt oben + Shell-Whitelist unten) laufen bewusst weiter auf
        // dem VOLLEN Inhalt, damit umgebogene PHP-Handler-Direktiven (Fremd-Endung als PHP) o. ae. weiter greifen.
        $scan = preg_replace_callback(
            '~<Files(?:Match)?\b[^>]*>(.*?)</Files(?:Match)?\s*>~is',
            function ($m) {
                $denies = preg_match('~\b(?:Deny\s+from\s+all|Require\s+all\s+denied)\b~i', (string) $m[1]);
                $grants = preg_match('~\b(?:Allow\s+from\s+all|Require\s+all\s+granted|Satisfy\s+any)\b~i', (string) $m[1]);

                return ($denies && !$grants) ? '' : $m[0];
            },
            $content
        );

        if (!is_string($scan)) {
            $scan = $content; // preg-Fehler -> konservativ voller Inhalt
        }

        // Nur eine GEMISCHTE php-Schreibweise (weder durchgaengig klein/gross noch
        // Title-Case) im NICHT-defensiven Rest ist Tarnung.
        if ($scan !== '' && preg_match_all('~\b[pP][hH][pP][0-9]*\b~', $scan, $mm)) {
            $normal = array('php' => 1, 'PHP' => 1, 'Php' => 1);

            foreach ($mm[0] as $tok) {
                if (!isset($normal[rtrim($tok, '0123456789')])) {
                    return true;
                }
            }
        }

        // (NEU) Schad-Skript-Whitelist: ein <Files>/<FilesMatch>-Block, der OEFFENTLICHEN
        // Zugriff gewaehrt und im Muster MEHRERE (>=3) benannte .php-Dateien auflistet =
        // ein Angreifer haelt seine hochgeladenen Schad-Skripte erreichbar (oft kombiniert
        // mit einer Sperre als Tarnung). Legitime .htaccess geben praktisch nie 3+ benannte
        // .php oeffentlich frei. FN 20260629: administrator/components/.htaccess-Whitelist.
        if (preg_match_all('~<Files(?:Match)?\b([^>]*)>(.*?)</Files(?:Match)?\s*>~is', $content, $blocks, PREG_SET_ORDER)) {
            foreach ($blocks as $b) {
                if (!preg_match('~\b(?:allow\s+from\s+all|require\s+all\s+granted|satisfy\s+any)\b~i', (string) $b[2])) {
                    continue; // dieser Block gibt nichts oeffentlich frei
                }

                if (preg_match_all('~[a-z0-9_.-]+\.php[0-9]?\b~i', (string) $b[1], $names)
                    && count(array_unique(array_map('strtolower', $names[0]))) >= 3) {
                    return true;
                }
            }
        }

        return false;
    }

    // ── Aus HtpStorage hierher isoliert (2.4.6): die PHP-Ausfuehrbar-ERKENNUNG (Endungs-
    //    Tabelle, <?-Open-Tag-Erkennung, Kopf/Ende-Sniffer). Sie gehoert NICHT in die
    //    datei-schreibende storage.php: ein Cloud-AV-ML-Klassifikator liest "schreibt+chmod+
    //    loescht" UND "erkennt PHP-Payloads" in EINER Datei als Backdoor-Dropper. HtpContent
    //    schreibt nichts -> diese Erkennung ist hier (wie foreignHasDefaceMark) FP-sicher.
    //    Verhalten byte-identisch; HtpStorage delegiert per class_exists-Wrapper hierher.

    const MAL_FILE_MAX    = 8388608;
    const MAL_PREVIEW_LEN = 1400;

    /**
     * Ist die Datei nach Typ/Groesse ueberhaupt einen Inhalts-Scan wert?
     * PHP-naher Code + .txt/endungslos werden immer geprueft. Andere Endungen
     * (z. B. getarnte Payloads als .tiff/.ico/.gif/.jpg) nur, wenn sie klein
     * sind UND mit einem PHP-Open-Tag beginnen (billiger 512-Byte-Peek).
     *
     * @param   string   $file  Absoluter Pfad
     * @param   integer  $size  Dateigroesse (per Referenz gesetzt)
     *
     * @return boolean
     */
    public static function malwareCandidate($file, &$size)
    {
        $size = (int) @filesize($file);

        if ($size <= 0 || $size > self::MAL_FILE_MAX) {
            return false;
        }

        static $exts = array(
            'php' => 1, 'php3' => 1, 'php4' => 1, 'php5' => 1, 'php6' => 1, 'php7' => 1,
            'php8' => 1, 'phtml' => 1, 'pht' => 1, 'phps' => 1, 'phar' => 1, 'inc' => 1,
            'shtml' => 1, 'shtm' => 1,
            'txt' => 1, 'suspected' => 1, 'xml' => 1,
        );

        static $imgExts = array(
            'gif' => 1, 'jpg' => 1, 'jpeg' => 1, 'png' => 1, 'webp' => 1,
            'bmp' => 1, 'ico' => 1, 'tif' => 1, 'tiff' => 1,
        );

        $ext = strtolower((string) pathinfo($file, PATHINFO_EXTENSION));

        if ($ext === '' || isset($exts[$ext])) {
            return true;
        }

        // Vom signierten Feed deklarierte Zusatz-Endungen (nachreichbar OHNE
        // Erweiterungs-Update): kleine Dateien dieser Typen einlesen.
        if ($size <= 524288 && self::feedScansExt($ext)) {
            return true;
        }

        // Als Bild/fremde Endung getarntes PHP-Schad-Skript nur in KLEINEN
        // Dateien suchen. Kandidat, wenn Kopf/Ende ein PHP-Open-Tag traegt.
        if ($size <= 524288 && (self::startsWithPhp($file) || self::foreignHasPhpTag($file, $size))) {
            return true;
        }

        // Defacement-Visitenkarte in einer echten Bild-Datei (Marker-Check liest
        // nur Kopf+Ende -> darf auch groessere Bilder pruefen).
        if ((isset($imgExts[$ext]) || $size <= 524288) && self::foreignHasDefaceMark($file, $size)) {
            return true;
        }

        return false;
    }

    /**
     * Liest der (signierte) Feed diese Endung zusaetzlich ein? Pro Request gemerkt.
     * Fail-soft + abwaertskompatibel: ohne HtpDefs/Methode/Feed -> false.
     *
     * @param   string  $ext  Endung kleingeschrieben (ohne Punkt)
     *
     * @return boolean
     */
    private static function feedScansExt($ext)
    {
        static $set = null;

        if ($set === null) {
            $set = array();

            if (class_exists('HtpDefs') && method_exists('HtpDefs', 'malwareScanExts')) {
                $list = HtpDefs::malwareScanExts();

                if (is_array($list)) {
                    foreach ($list as $e) {
                        $set[strtolower((string) $e)] = 1;
                    }
                }
            }
        }

        return ($ext !== '' && isset($set[$ext]));
    }

    /**
     * Enthaelt eine Datei mit fremder (Nicht-PHP-)Endung IRGENDWO im Kopf oder
     * am Ende ein PHP-Open-Tag? Faengt als Bild getarnte Schad-Skripte, die nicht
     * mit dem Tag beginnen und so durch startsWithPhp() fallen. Liest nur Kopf
     * (+ bei groesseren Dateien Ende).
     *
     * @param   string   $file  Absoluter Pfad
     * @param   integer  $size  Dateigroesse
     *
     * @return boolean
     */
    private static function foreignHasPhpTag($file, $size)
    {
        $fh = @fopen($file, 'rb');

        if (!$fh) {
            return false;
        }

        $data = (string) @fread($fh, 2048);

        if ($size > 4096) {
            @fseek($fh, -2048, SEEK_END);
            $data .= "\n" . (string) @fread($fh, 2048);
        }

        @fclose($fh);

        return (bool) preg_match('~<\?(?:php\b|=|\s)~', $data);
    }

    /**
     * Beginnt die Datei (nach optionalem BOM/Whitespace) mit einem PHP-Open-Tag?
     * Liest nur den Dateianfang.
     *
     * @param   string  $file  Absoluter Pfad
     *
     * @return boolean
     */
    private static function startsWithPhp($file)
    {
        $fh = @fopen($file, 'rb');

        if (!$fh) {
            return false;
        }

        $head = (string) @fread($fh, 512);
        @fclose($fh);

        $head = preg_replace('~^\xEF\xBB\xBF~', '', $head);
        $head = ltrim($head);

        return (bool) preg_match('~^<\?(?:php\b|=|\s)~i', $head);
    }

    /**
     * Inhalt fuer den Signatur-Scan lesen. Kleine/mittlere Dateien (bis 512 KB)
     * vollstaendig; nur sehr grosse auf Kopf+Ende reduziert.
     *
     * @param   string   $file  Absoluter Pfad
     * @param   integer  $size  Dateigroesse
     *
     * @return string
     */
    public static function readScanContent($file, $size)
    {
        $fh = @fopen($file, 'rb');

        if (!$fh) {
            return '';
        }

        if ($size <= 524288) {
            $data = (string) @fread($fh, 524288);
        } else {
            $data = (string) @fread($fh, 131072);
            @fseek($fh, -65536, SEEK_END);
            $data .= "\n" . (string) @fread($fh, 65536);
        }

        @fclose($fh);

        return $data;
    }

    /**
     * Nicht druckbare Steuerzeichen fuer die Text-Anzeige bereinigen (Zeilen-
     * umbrueche/Tabs bleiben). Anzeige erfolgt per textContent im JS -> XSS-frei.
     *
     * @param   string  $content  Roh-Inhalt
     *
     * @return string
     */
    public static function cleanForView($content)
    {
        $c = (string) preg_replace('~[^\P{C}\n\t]+~u', ' ', (string) $content);

        if ($c === '' || $c === null) {
            $c = (string) preg_replace('~[^\x09\x0A\x20-\x7E]+~', ' ', (string) $content);
        }

        return (string) $c;
    }

    /**
     * Kurze, fuer die Anzeige bereinigte Inhalts-Voransicht, auf MAL_PREVIEW_LEN begrenzt.
     *
     * @param   string  $content  Roh-Inhalt
     *
     * @return string
     */
    public static function previewSnippet($content)
    {
        $c = ltrim(self::cleanForView($content));

        return function_exists('mb_substr')
            ? (string) mb_substr($c, 0, self::MAL_PREVIEW_LEN)
            : (string) substr($c, 0, self::MAL_PREVIEW_LEN);
    }

}
