<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * .htaccess-Waechter: merkt sich Pruefsummen aller von HTProtect
 * verwalteten Dateien (Haupt-.htaccess, /administrator/.htaccess,
 * Mini-.htaccess der Upload-Ordner) und erkennt Fremdaenderung oder
 * Loeschung. Bietet Ein-Klick-Wiederherstellung und liefert die Vorfaelle
 * fuer den Backend-Alarm und die Mail-Benachrichtigung.
 */

defined('_JEXEC') or die;

class HtpWatch
{
    /**
     * Parameter-Schluessel fuer den Benachrichtigungs-Status (Anti-Spam).
     */
    const NOTIFIED_KEY = 'htp_incidents';

    /**
     * Wiedererinnerung an einen WEITERHIN bestehenden (oder wiederkehrenden)
     * Vorfall: Standard und Grenzen in Tagen. Neue Vorfaelle gehen sofort raus;
     * ein stehendes Problem hoechstens alle N Tage erneut. Damit kann es keine
     * Mail-Flut geben - auch nicht bei Datei-"Flattern" (staendiges Aendern/
     * Wiederherstellen durch ein anderes Tool). Pro Site einstellbar.
     */
    const REMINDER_DEFAULT_DAYS = 14;
    const REMINDER_MIN_DAYS     = 2;
    const REMINDER_MAX_DAYS     = 30;

    /**
     * Eingestellte Wiedererinnerung in Tagen, auf [MIN, MAX] begrenzt.
     *
     * @param   mixed  $days  Rohwert (Konfiguration/Formular)
     *
     * @return int
     */
    public static function clampReminderDays($days)
    {
        $days = (int) $days;

        if ($days < self::REMINDER_MIN_DAYS) {
            return self::REMINDER_MIN_DAYS;
        }

        if ($days > self::REMINDER_MAX_DAYS) {
            return self::REMINDER_MAX_DAYS;
        }

        return $days;
    }

    /**
     * Wiedererinnerungs-Abstand aus der Konfiguration in Sekunden.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return int
     */
    public static function reminderInterval($cfg)
    {
        $days = isset($cfg['notify_reminder_days']) ? $cfg['notify_reminder_days'] : self::REMINDER_DEFAULT_DAYS;

        return self::clampReminderDays($days) * 86400;
    }

    /**
     * Verwundbare-Erweiterungen-Treffer in Melde-/Mail-Ereignisse umwandeln.
     * Pure Funktion (testbar): liefert pro Eintrag Pfad/Art/Label und den
     * SPRACHRICHTIGEN Advisory-Link (HtpDefs::localized -> reist im Feed mit,
     * also auch fuer nachgeladene Eintraege korrekt de/en).
     *
     * @param   array  $vuln  Treffer aus HtpDefs::checkExtensions()
     *
     * @return array Ereignisse (kind = 'vulnerable')
     */
    public static function vulnEvents($vuln)
    {
        $events = array();

        if (!is_array($vuln)) {
            return $events;
        }

        foreach ($vuln as $v) {
            if (!is_array($v)) {
                continue;
            }

            $element  = isset($v['element']) ? (string) $v['element'] : '';
            $advisory = class_exists('HtpDefs')
                ? HtpDefs::localized($v, 'advisory')
                : (isset($v['advisory']) ? (string) $v['advisory'] : '');

            $events[] = array(
                'path'     => 'ext:' . $element,
                'kind'     => 'vulnerable',
                'label'    => htp_t(
                    'COM_HTPROTECT_VULN_MAIL_LABEL',
                    isset($v['name']) ? $v['name'] : $element,
                    isset($v['installed']) ? $v['installed'] : '',
                    isset($v['below']) ? $v['below'] : ''
                ),
                'advisory' => $advisory,
            );
        }

        return $events;
    }

    /**
     * Aktuell verwaltete Dateien ermitteln: relPath => absPath.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array
     */
    public static function managedFiles($cfg)
    {
        $root  = htp_docroot();
        $files = array();

        // Haupt-.htaccess, sofern von uns - ABER nicht im Hands-off-Modus: ist die
        // .htaccess extern verwaltet (z. B. Admin Tools Pro), wuerde sie sich staendig
        // aendern -> Fehlalarm-Mails ("ausserhalb HTProtect geaendert"). Daher dann
        // gar nicht ueberwachen.
        $status = HtpStorage::status($cfg);

        if (empty($cfg['htaccess_external']) && !empty($status['exists']) && !empty($status['ours'])) {
            $files['.htaccess'] = HtpStorage::htaccessPath();
        }

        // Backend-Schutz
        if (class_exists('HtpGuard')) {
            $guard = HtpGuard::status();

            if (!empty($guard['active'])) {
                // Die den /administrator-URL schuetzende .htaccess liegt im WEB-Docroot
                // (public/administrator im PF-Modus) - dieselbe Aufloesung wie check() via
                // htp_docroot(). Klassisch == htp_admin_root(); PF -> die tatsaechlich
                // ausgelieferte Datei wird ueberwacht statt der wirkungslosen oberhalb public.
                $files['administrator/.htaccess'] = htp_web_admin_root() . '/.htaccess';
            }
        }

        // Upload-Ordner-Schild. Volatile System-/Cache-Ordner werden bewusst
        // NICHT ueberwacht: ihre Mini-.htaccess wird durch Cache-/Temp-Leerung
        // (Joomla) bzw. Admin Tools staendig entfernt und neu geschrieben - das
        // ist kein Sicherheitsvorfall, und der Ordner ist ohnehin per
        // Haupt-.htaccess komplett gesperrt. Verhindert Fehlalarm-Mails.
        if (class_exists('HtpDirShield')) {
            foreach (HtpDirShield::managedFiles($cfg) as $rel) {
                if (self::isVolatile($rel)) {
                    continue;
                }

                $files[$rel] = $root . '/' . $rel;
            }
        }

        return $files;
    }

    /**
     * Manifest (relPath => sha1) der aktuell verwalteten Dateien neu
     * berechnen und in der Konfiguration ablegen. Vom Aufrufer nach jedem
     * erfolgreichen Schreiben aufzurufen.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return void
     */
    public static function remember(&$cfg)
    {
        $manifest = array();

        foreach (self::managedFiles($cfg) as $rel => $abs) {
            if (is_file($abs)) {
                // NORMALISIERTER Hash statt Rohinhalt: die von HTProtect selbst
                // erzeugten Schutzdateien tragen nachweislich VOLATILE, funktionslose
                // Bestandteile (Kommentar-Zeitstempel "Stand: ...", Versions-/Fingerabdruck-
                // Marker, DirShield "v=", der beim 1-Klick-Login ein-/ausgebaute
                // SSO-Guard-Pass-Block). Wuerde man den Rohinhalt hashen, meldete der
                // Waechter jedes blosse Neuschreiben als Fremdaenderung (Fehlalarm).
                // normalize() entfernt AUSSCHLIESSLICH diese inerten Teile; jede
                // funktionale Apache-Direktive bleibt voll im Hash (echte Manipulation
                // wird weiterhin erkannt). Siehe normalize()/canonComments()/stripGuardPasses().
                $manifest[$rel] = self::normHash($rel, (string) @file_get_contents($abs));
            }
        }

        $cfg['watch_manifest']    = $manifest;
        $cfg['watch_htp_version'] = defined('HTP_VERSION') ? (string) HTP_VERSION : '';
    }

    /**
     * Vorfall VERWERFEN: den aktuellen Stand einer verwalteten Datei als neuen
     * Soll-Stand ins Manifest uebernehmen (legitime Aenderung, kein Angriff) - bzw.
     * bei einer geloeschten Datei sie aus dem Manifest entfernen. Es wird NICHTS
     * geschrieben/wiederhergestellt; der Vorfall verschwindet einfach.
     *
     * @param   array   $cfg  Konfiguration (Referenz - Manifest wird aktualisiert)
     * @param   string  $rel  Relativer Pfad aus dem Manifest
     *
     * @return array ['success' => bool, 'message' => string]
     */
    public static function accept(&$cfg, $rel)
    {
        $manifest = isset($cfg['watch_manifest']) && is_array($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array();

        if (!array_key_exists($rel, $manifest)) {
            return array('success' => false, 'message' => htp_t('COM_HTPROTECT_WATCH_RESTORE_UNKNOWN'));
        }

        $abs = self::absFor($rel, htp_docroot());

        if ($abs !== '' && is_file($abs)) {
            $manifest[$rel] = self::normHash($rel, (string) @file_get_contents($abs)); // aktuellen (normalisierten) Inhalt akzeptieren
        } else {
            unset($manifest[$rel]); // geloescht -> nicht mehr ueberwachen
        }

        $cfg['watch_manifest'] = $manifest;

        return array('success' => true, 'message' => htp_t('COM_HTPROTECT_WATCH_DISMISSED', self::label($rel)));
    }

    /**
     * Verwaltete Dateien gegen das Manifest pruefen.
     *
     * @param   array  $cfg  Konfiguration
     *
     * @return array Liste der Vorfaelle: ['path' => rel, 'kind' => 'changed'|'deleted', 'label' => string]
     */
    public static function check(&$cfg)
    {
        $manifest = isset($cfg['watch_manifest']) && is_array($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array();

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

        // ── Update-/Migrations-Rebaseline (analog integrityCheck, storedJv !== jv) ──
        // Ein reiner HTProtect-Versionswechsel loest NIE einen Alarm aus: die
        // normalisierten Hashes sind zwar versions-/zeitstempelunabhaengig (Punkt 3
        // ist damit an sich schon erledigt) - hier wird das aber ZUSAETZLICH strukturell
        // abgesichert und deckt zugleich die EINMALIGE Umstellung ab: ein Manifest aus
        // einer aelteren Version (<=2.5.1) enthaelt noch ROH-Hashes; die passen nicht zu
        // den neuen normalisierten Hashes. Ohne Behandlung gaebe das genau EINEN
        // Fehlalarm nach dem Update ("alle Schutzdateien veraendert"). Loesung wie beim
        // Kerndatei-Check: bei Versionswechsel wird die Basislinie aus den AKTUELL
        // verwalteten Dateien NEU (normalisiert) grundiert und dieser eine Lauf loest
        // kein changed/deleted aus. Das ist FP-frei und - da der alte Roh-Waechter eine
        // vor dem Update eingeschleuste Aenderung bereits gemeldet haette - kein neues
        // Loch (dieselbe Vertrauensannahme wie die bereits ausgelieferte core-Re-Baseline).
        // Ab dem naechsten Lauf greift der volle normalisierte Vergleich (unten): eine
        // echte, NACH dem Update eingeschleuste funktionale Direktive wird erkannt.
        $storedVer = isset($cfg['watch_htp_version']) ? (string) $cfg['watch_htp_version'] : '';
        $curVer    = defined('HTP_VERSION') ? (string) HTP_VERSION : '';

        if ($storedVer !== $curVer) {
            self::rebaselineWatch($cfg, $curVer);

            return array();
        }

        $root      = htp_docroot();
        $incidents = array();
        $healed    = array(); // Content-Anker: Drifts, die sich als UNSER EIGENER Bau erwiesen

        foreach ($manifest as $rel => $hash) {
            // Volatile Cache-/Temp-Ordner nicht alarmieren - auch wenn ein
            // aelteres Manifest sie noch enthaelt (greift sofort nach dem
            // Update, ohne dass erst neu gemerkt werden muss).
            if (self::isVolatile($rel)) {
                continue;
            }

            $abs = self::absFor($rel, $root);

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

            if (!is_file($abs)) {
                $incidents[] = array('path' => $rel, 'kind' => 'deleted', 'label' => self::label($rel));
                continue;
            }

            // NORMALISIERT vergleichen (siehe remember()): blosses Neuschreiben mit
            // neuem Zeitstempel/Version oder ein legitim ein-/ausgebauter SSO-Guard-Pass
            // ergibt denselben Hash -> kein Fehlalarm. Eine eingeschleuste FUNKTIONALE
            // Direktive veraendert den normalisierten Inhalt -> "changed".
            $curHash = self::normHash($rel, (string) @file_get_contents($abs));

            if ($curHash === $hash) {
                continue;
            }

            // ── CONTENT-ANKER (Fehlalarm-Schutz gegen Update-/Schreib-Zwischenzustaende) ──
            // Der normalisierte Hash weicht vom Manifest ab - ABER entspricht der Ist-Inhalt
            // EXAKT dem, was HTProtect JETZT selbst erzeugen wuerde? Dann ist es UNSERE eigene
            // Datei, deren Manifest nur DRIFTETE: der RULESET_VERSION-Self-Heal schreibt die
            // .htaccess NACH dem Manifest-Stempel neu (builder.php), zwischen Write und
            // remember() liegt ein blockierender Selbsttest, oder ein voller Config-save()
            // eines anderen Schreibers walzte das frisch gemerkte Manifest zurueck. In ALL
            // diesen Faellen ist die Datei NICHT fremd-manipuliert -> KEIN Vorfall, sondern
            // das Manifest still nachziehen. Nur ein Inhalt, den HTProtect NICHT selbst baut
            // (eingeschleuste FUNKTIONALE Direktive), bleibt ein 'changed'.
            // WICHTIG (Anti-Loch): auf BEIDEN Seiten die WAECHTER-Normalisierung normHash()/
            // canonComments verwenden, NIEMALS HtpBuilder::normalizeForCompare() - letzteres
            // blendet den GESAMTEN Signatur-Block aus und wuerde eine dahinter getarnte
            // Fremd-Direktive maskieren.
            $selfHash = self::selfBuiltNormHash($rel, $cfg);

            if ($selfHash !== null && $selfHash === $curHash) {
                $healed[$rel] = $curHash;
                continue;
            }

            $incidents[] = array('path' => $rel, 'kind' => 'changed', 'label' => self::label($rel));
        }

        // Als "unser eigener Bau" erkannte Drifts race-sicher nachziehen (nur diese Keys,
        // wie rebaselineWatch) - heilt auch bereits verklemmte Sites beim naechsten Lauf
        // (Hintergrund-Tick ODER Backend-Eintritt), ohne auf einen speichernden Aufrufer
        // angewiesen zu sein. saveKeys (nicht save) laedt frisch und ueberschreibt NUR diese
        // Schluessel -> kein Clobbern paralleler Speicherungen.
        if (!empty($healed)) {
            foreach ($healed as $hRel => $hHash) {
                $manifest[$hRel] = $hHash;
            }

            $cfg['watch_manifest'] = $manifest;

            if (class_exists('HtpConfig') && method_exists('HtpConfig', 'saveKeys')) {
                HtpConfig::saveKeys(array('watch_manifest' => $manifest));
            }
        }

        return $incidents;
    }

    /**
     * Content-Anker: normalisierter Hash des Inhalts, den HTProtect fuer diese verwaltete
     * Datei AKTUELL SELBST erzeugen wuerde - oder null, wenn es fuer diesen Pfad keinen
     * bekannten eigenen Builder gibt (dann greift der Anker nicht; der normale
     * Fremdaenderungs-Vergleich in check() bleibt unveraendert).
     *
     * Damit wird ein 'changed' NUR dann unterdrueckt, wenn der Ist-Inhalt exakt unserem
     * eigenen Bau entspricht (Manifest-Drift durch Update-/Schreib-Zwischenzustaende).
     * Deckt die HAUPT-.htaccess ab - die einzige verwaltete Datei, die der RULESET_VERSION-
     * Self-Heal NACH dem Manifest-Stempel neu schreibt und so den beobachteten Update-
     * Fehlalarm ausloest. Rein lesend/deterministisch (HtpBuilder::build ist ein reiner
     * String-Builder ohne Seiteneffekte); der begin-Marker mit Zeitstempel/Version liegt auf
     * einer Kommentarzeile und wird von canonComments ohnehin gestrippt. J3-J6.
     *
     * @param   string  $rel  Relativer Pfad aus dem Manifest
     * @param   array   $cfg  Konfiguration
     *
     * @return string|null Normalisierter Soll-Hash oder null (kein eigener Builder fuer $rel)
     */
    private static function selfBuiltNormHash($rel, $cfg)
    {
        if ($rel === '.htaccess') {
            if (!class_exists('HtpBuilder')) {
                return null;
            }

            // Der Anker darf den Waechter NIEMALS brechen: schlaegt der Build fehl (unerwartete
            // $cfg-Form o. ae.), liefern wir null -> check() faellt auf das normale Verhalten
            // zurueck (wie vor dem Anker), statt eine Exception aus check() zu werfen.
            try {
                $built = (string) HtpBuilder::build($cfg);
            } catch (Exception $e) {
                return null;
            } catch (Throwable $e) {
                return null;
            }

            // Defensiv: nie gegen einen Leerstring ankern (ein leerer Bau wuerde sonst eine
            // ebenfalls geleerte Datei faelschlich als "unser Inhalt" legitimieren).
            if ($built === '') {
                return null;
            }

            return self::normHash('.htaccess', $built);
        }

        return null;
    }

    /**
     * Watch-Manifest neu (normalisiert) grundieren und - race-sicher - persistieren.
     * Wird nur bei Versionswechsel/Format-Umstellung aufgerufen (siehe check()); der
     * Lauf selbst meldet bewusst keinen Vorfall. Persistiert per saveKeys (laedt frisch,
     * schreibt NUR diese Schluessel - kein Ueberschreiben paralleler Backend-Speicherungen,
     * vgl. save() vs saveKeys()), damit auch der headless Plugin-Tick, der sein $cfg nur
     * EINMAL in den Speicher laedt, die Basislinie sofort dauerhaft umstellt und die
     * Umstellung nicht bei jedem Tick wiederholt.
     *
     * @param   array   $cfg     Konfiguration (Referenz)
     * @param   string  $curVer  Aktuelle HTP_VERSION
     *
     * @return void
     */
    private static function rebaselineWatch(&$cfg, $curVer)
    {
        self::remember($cfg); // setzt watch_manifest (normalisiert) + watch_htp_version

        if (class_exists('HtpConfig') && method_exists('HtpConfig', 'saveKeys')) {
            HtpConfig::saveKeys(array(
                'watch_manifest'    => isset($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array(),
                'watch_htp_version' => $curVer,
            ));
        }
    }

    // ─────────────────────────────────────────────────────────────────
    //  Inhalts-Normalisierung fuer den Soll-/Ist-Vergleich
    //
    //  GRUNDREGEL (Analyse-Linse C): ES DUERFEN NUR NACHWEISLICH FUNKTIONSLOSE,
    //  VOLATILE Teile entfernt werden - niemals eine funktionale Apache-Direktive.
    //  Ein Kommentar (Zeile beginnt nach optionalem Whitespace mit '#') aendert das
    //  Apache-Verhalten nie und ist daher sicher auszublenden; alles andere bleibt.
    //  Bewusst wird HtpBuilder::normalizeForCompare() NICHT wiederverwendet: dessen
    //  Schritt (1) blendet den GESAMTEN Signatur-Block inkl. der funktionalen
    //  RewriteCond/RewriteRule bis zum naechsten </IfModule> aus. Fuer die AMPEL
    //  (Soll gegen frisch gebaut) ist das harmlos, fuer den WAECHTER waere es ein
    //  Loch: ein Angreifer koennte hinter den Marker eine boesartige Direktive setzen
    //  und sie verschwaende aus dem Hash. Der Waechter kanonisiert daher WHITELIST-
    //  basiert (nur Kommentarzeilen bzw. den streng strukturell erkannten, von
    //  HTProtect selbst verwalteten SSO-Block), nie per Marker-Bereichsloeschung.
    // ─────────────────────────────────────────────────────────────────

    /**
     * sha1 des fuer den Dateityp normalisierten Inhalts.
     *
     * @param   string  $rel      Relativer Pfad (bestimmt die Normalisierung)
     * @param   string  $content  Rohinhalt
     *
     * @return string
     */
    private static function normHash($rel, $content)
    {
        return sha1(self::normalize($rel, (string) $content));
    }

    /**
     * Inhalt einer HTProtect-GENERIERTEN Schutzdatei fuer den Vergleich normalisieren.
     * Nicht von HTProtect erzeugte Dateien werden roh (unveraendert) verglichen.
     *
     * @param   string  $rel      Relativer Pfad
     * @param   string  $content  Rohinhalt
     *
     * @return string
     */
    private static function normalize($rel, $content)
    {
        $rel = str_replace('\\', '/', (string) $rel);

        if ($rel === 'administrator/.htaccess') {
            // Backend-Passwortschutz: NUR die vom 1-Klick-Login (SSO) selbst
            // verwalteten Guard-Pass-Bloecke werden ausgeblendet (streng strukturell).
            // Der Passwortschutz-Block (AuthType/AuthUserFile/Require ...) besteht aus
            // FUNKTIONALEN Direktiven und bleibt VOLLSTAENDIG im Hash - eine dort
            // eingeschleuste "Satisfy Any"/"Require all granted"-Zeile oder ein
            // geloeschter Require-valid-user faellt sofort auf.
            return self::stripGuardPasses($content);
        }

        if ($rel === '.htaccess' || substr($rel, -10) === '/.htaccess') {
            // Haupt-.htaccess und Upload-Ordner-Mini-.htaccess: reine Kommentarzeilen
            // entfernen. Alle gemeldeten volatilen Teile liegen auf Kommentarzeilen -
            // "Stand:"-Zeitstempel, HTP_VERSION im Kopf, begin-Marker (v=/rs=/cfg=/
            // written=), DirShield "v=", sprachabhaengige Kommentare. Funktionale
            // Direktiven (RewriteCond/RewriteRule, FilesMatch, Require ...) tragen in
            // der erzeugten Datei nie ein fuehrendes '#' und bleiben unberuehrt.
            return self::canonComments($content);
        }

        return (string) $content; // Fremd-/unbekannte Datei -> Rohvergleich
    }

    /**
     * Reine Kommentarzeilen entfernen + Zeilenenden/Leerzeilen vereinheitlichen.
     * Der Anker '^[ \t]*#' ist bewusst strikt: nur eine Zeile, deren erstes
     * Nicht-Whitespace-Zeichen '#' ist, gilt als Kommentar - so kann keine getarnte,
     * von Apache doch verarbeitete Direktive faelschlich als Kommentar gelten.
     *
     * @param   string  $content  Rohinhalt
     *
     * @return string
     */
    private static function canonComments($content)
    {
        $c = str_replace(array("\r\n", "\r"), "\n", (string) $content);
        $c = self::pregSafe('~^[ \t]*#.*$~m', '', $c);   // Kommentarzeilen (inert) entfernen
        $c = self::pregSafe('~[ \t]+$~m', '', $c);        // Zeilenend-Whitespace
        $c = self::pregSafe('~\n{2,}~', "\n", $c);        // Leerzeilen kollabieren

        return trim($c);
    }

    /**
     * NUR die von HtpSso selbst verwalteten Guard-Pass-Bloecke entfernen - streng
     * strukturell: es wird ausschliesslich ein Block getilgt, der Zeile fuer Zeile
     * EXAKT dem von HtpSso::guardPassBlock erzeugten Muster gleicht (Token, Zeitstempel
     * und Session-ID sind die EINZIGEN frei wechselnden Teile). Jede fremde oder
     * eingeschmuggelte Zeile zwischen den Markern bricht das Muster -> der Block bleibt
     * stehen -> die Hash-Abweichung schlaegt als "changed" durch. So bleibt das
     * legitime Ein-/Ausbauen beim Login/Logout folgenlos, waehrend eine ueber die
     * Marker getarnte Direktiven-Injektion NICHT durchrutscht (Analyse-Linse C).
     *
     * @param   string  $content  Rohinhalt der administrator/.htaccess
     *
     * @return string
     */
    private static function stripGuardPasses($content)
    {
        $c = str_replace(array("\r\n", "\r"), "\n", (string) $content);

        // Cookie-Name aus der Quelle (Konstante), Fallback auf den bekannten Wert.
        $cookie = class_exists('HtpSso') ? (string) HtpSso::SSO_COOKIE : 'htp_sso';
        $q      = preg_quote($cookie, '~');

        // Backreference \1: das Token in der SetEnvIf-Zeile und im Schluss-Marker MUSS
        // dem im Kopf-Marker gleichen. Kein '.*' - die inneren Zeilen sind feste
        // Literale, daher kann kein zusaetzlicher Inhalt mitverschluckt werden.
        $pat = '~[ \t]*# HTP-SSO ([a-f0-9]{16,96}) [0-9]+(?:[ \t]+\S{1,128})?\n'
            . '<IfModule mod_setenvif\.c>\n'
            . 'SetEnvIf Cookie "' . $q . '=\1" htp_sso_ok=1\n'
            . '</IfModule>\n'
            . '<IfModule mod_authz_core\.c>\n'
            . 'Require env htp_sso_ok\n'
            . '</IfModule>\n'
            . '# /HTP-SSO \1\n?~';

        $c = self::pregSafe($pat, '', $c);
        $c = self::pregSafe('~\n{2,}~', "\n", $c); // Rest-Leerzeilen des entfernten Blocks kollabieren

        return trim($c);
    }

    /**
     * preg_replace mit Fehlerschutz: liefert bei einem Regex-Fehler (null) den
     * unveraenderten Eingabewert zurueck, damit die Normalisierung nie in einen
     * leeren String kippt (der sonst einen Fehlalarm erzeugen koennte).
     *
     * @param   string  $pattern
     * @param   string  $replacement
     * @param   string  $subject
     *
     * @return string
     */
    private static function pregSafe($pattern, $replacement, $subject)
    {
        $out = preg_replace($pattern, $replacement, (string) $subject);

        return is_string($out) ? $out : (string) $subject;
    }

    /**
     * Eine verwaltete Datei wiederherstellen.
     *
     * @param   array   $cfg  Konfiguration (Referenz - Manifest wird aktualisiert)
     * @param   string  $rel  Relativer Pfad aus dem Manifest
     *
     * @return array ['success' => bool, 'message' => string]
     */
    public static function restore(&$cfg, $rel)
    {
        $manifest = isset($cfg['watch_manifest']) && is_array($cfg['watch_manifest']) ? $cfg['watch_manifest'] : array();

        if (!array_key_exists($rel, $manifest)) {
            return array('success' => false, 'message' => htp_t('COM_HTPROTECT_WATCH_RESTORE_UNKNOWN'));
        }

        $ok      = false;
        $message = '';

        if ($rel === '.htaccess') {
            $content = HtpBuilder::build($cfg);

            if ($content !== '' && HtpStorage::write($content)) {
                $cfg['file_sha1']    = sha1($content);
                $cfg['last_written'] = date('Y-m-d H:i:s');
                $ok      = true;
                $message = htp_t('COM_HTPROTECT_WATCH_RESTORE_MAIN_OK');
            } else {
                $message = htp_t('COM_HTPROTECT_WATCH_RESTORE_MAIN_FAIL');
            }
        } elseif ($rel === 'administrator/.htaccess') {
            if (class_exists('HtpGuard') && HtpGuard::rebuildBlock($cfg)) {
                $ok      = true;
                $message = htp_t('COM_HTPROTECT_WATCH_RESTORE_GUARD_OK');
            } else {
                $message = htp_t('COM_HTPROTECT_WATCH_RESTORE_GUARD_FAIL');
            }
        } elseif (substr($rel, -10) === '/.htaccess') {
            // Upload-Ordner-Schild fuer den betroffenen Ordner neu schreiben
            if (class_exists('HtpDirShield')) {
                $result = HtpDirShield::apply($cfg);
                $ok      = $result['failed'] === 0 && $result['written'] > 0;
                $message = $ok
                    ? htp_t('COM_HTPROTECT_WATCH_RESTORE_DIR_OK', (int) $result['written'])
                    : htp_t('COM_HTPROTECT_WATCH_RESTORE_DIR_FAIL');
            }
        }

        if ($ok) {
            self::remember($cfg);
            HtpConfig::save($cfg);
        }

        return array('success' => $ok, 'message' => $message);
    }

    // ─────────────────────────────────────────────────────────────────
    //  Benachrichtigungs-Status (Anti-Spam: neue Vorfaelle sofort, ein
    //  stehendes Problem hoechstens alle REMINDER_INTERVAL erneut)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Faellige Vorfaelle aus dem gespeicherten Status ermitteln (liest DB).
     * Faellig = noch nie gemeldet ODER zuletzt vor >= $interval Sekunden.
     *
     * @param   array  $incidents  Alle aktuellen Vorfaelle
     * @param   int    $interval   Wiedererinnerungs-Abstand in Sekunden
     *
     * @return array
     */
    public static function dueForNotification($incidents, $interval)
    {
        return self::filterDue($incidents, self::loadNotified(), time(), $interval);
    }

    /**
     * Benachrichtigungs-Status fortschreiben (schreibt DB).
     *
     * @param   array  $current   Alle aktuell bestehenden Vorfaelle
     * @param   array  $mailed    Tatsaechlich in dieser Runde gemeldete Vorfaelle
     * @param   int    $interval  Wiedererinnerungs-Abstand in Sekunden
     *
     * @return void
     */
    public static function recordNotification($current, $mailed, $interval)
    {
        self::saveNotified(self::mergeNotified($current, $mailed, self::loadNotified(), time(), $interval));
    }

    /**
     * Reine Auswahl der faelligen Vorfaelle (ohne DB - testbar).
     *
     * @param   array  $incidents  Aktuelle Vorfaelle
     * @param   array  $notified   key => letzter Meldezeitpunkt
     * @param   int    $now        Aktuelle Zeit
     * @param   int    $interval   Wiedererinnerungs-Abstand in Sekunden
     *
     * @return array
     */
    public static function filterDue($incidents, $notified, $now, $interval)
    {
        $due = array();

        foreach ($incidents as $incident) {
            $key = self::incidentKey($incident);

            if (!isset($notified[$key]) || ($now - (int) $notified[$key]) >= (int) $interval) {
                $due[] = $incident;
            }
        }

        return $due;
    }

    /**
     * Reine Berechnung des neuen Status (ohne DB - testbar).
     *  - jetzt gemeldete Vorfaelle           -> Zeitstempel = jetzt (Kadenz startet neu)
     *  - bestehend, aber nicht jetzt gemeldet -> alten Zeitstempel behalten
     *    (Erinnerungs-Fenster laeuft ab der letzten Mail weiter)
     *  - abgeklungene Vorfaelle               -> behalten bis aelter als
     *    $interval (daempft "Flattern"), danach vergessen
     *  - brandneu, aber nicht gemeldet (z. B. keine E-Mail hinterlegt) -> NICHT
     *    merken, damit es faellig bleibt, sobald eine Adresse gesetzt ist
     *
     * @param   array  $current   Aktuelle Vorfaelle
     * @param   array  $mailed    Jetzt gemeldete Vorfaelle
     * @param   array  $notified  Bisheriger Status (key => ts)
     * @param   int    $now       Aktuelle Zeit
     * @param   int    $interval  Wiedererinnerungs-Abstand in Sekunden
     *
     * @return array
     */
    public static function mergeNotified($current, $mailed, $notified, $now, $interval)
    {
        $next = array();

        foreach ($mailed as $incident) {
            $next[self::incidentKey($incident)] = $now;
        }

        foreach ($current as $incident) {
            $key = self::incidentKey($incident);

            if (!isset($next[$key]) && isset($notified[$key])) {
                $next[$key] = (int) $notified[$key];
            }
        }

        foreach ($notified as $key => $ts) {
            if (!isset($next[$key]) && ($now - (int) $ts) < (int) $interval) {
                $next[$key] = (int) $ts;
            }
        }

        return $next;
    }

    /**
     * Stabiler Schluessel eines Vorfalls (Pfad + Art).
     *
     * @param   array  $incident  Vorfall
     *
     * @return string
     */
    public static function incidentKey($incident)
    {
        $key = $incident['path'] . '|' . $incident['kind'];

        // Optionaler Wert-Diskriminator: erlaubt, dass eine ERNEUTE, inhaltlich
        // ANDERE Aenderung desselben Kontos/Pfads wieder genau eine Mail ausloest
        // (z. B. die zweite, dritte E-Mail-Aenderung eines Super-Users). Ohne
        // 'vkey' bleibt der Schluessel exakt wie bisher -> Dateien/Warnliste/
        // Rogue-Admin/SEO unveraendert (rueckwaertskompatibel).
        if (isset($incident['vkey']) && (string) $incident['vkey'] !== '') {
            $key .= '|' . (string) $incident['vkey'];
        }

        return $key;
    }

    // ─────────────────────────────────────────────────────────────────
    //  Helfer
    // ─────────────────────────────────────────────────────────────────

    /**
     * Liegt der verwaltete Pfad in einem volatilen System-/Cache-Ordner?
     *
     * Solche Ordner (cache, tmp, logs ...) werden durch Joomlas Cache-/Temp-
     * Leerung bzw. durch Admin Tools staendig geleert und neu beschrieben.
     * Ein dort fehlendes Mini-.htaccess ist KEIN Sicherheitsvorfall (der Ordner
     * ist zudem ueber die Haupt-.htaccess komplett gesperrt). Diese Ordner
     * werden daher nicht ueberwacht - sonst gaebe es bei jeder Cache-Leerung
     * eine "Datei geloescht"-Mail.
     *
     * @param   string  $rel  Relativer Pfad (z. B. 'cache/.htaccess')
     *
     * @return boolean
     */
    public static function isVolatile($rel)
    {
        $dir = strtolower(trim(str_replace('\\', '/', (string) $rel), '/'));

        if (substr($dir, -10) === '/.htaccess') {
            $dir = substr($dir, 0, -10);
        }

        $volatile = array(
            'cache',
            'administrator/cache',
            'tmp',
            'logs',
            'log',
            'administrator/logs',
            'cli',
        );

        return in_array($dir, $volatile, true);
    }

    /**
     * Absoluten Pfad eines Manifest-Eintrags bilden (mit Pfad-Schutz).
     *
     * @param   string  $rel   Relativer Pfad
     * @param   string  $root  Joomla-Root
     *
     * @return string '' bei ungueltiger Eingabe
     */
    private static function absFor($rel, $root)
    {
        $rel = str_replace('\\', '/', (string) $rel);

        if ($rel === '' || strpos($rel, '..') !== false || strpos($rel, chr(0)) !== false) {
            return '';
        }

        if (substr($rel, -9) !== '.htaccess') {
            return '';
        }

        return $root . '/' . ltrim($rel, '/');
    }

    /**
     * Lesbare Bezeichnung eines verwalteten Pfads.
     *
     * @param   string  $rel  Relativer Pfad
     *
     * @return string
     */
    private static function label($rel)
    {
        if ($rel === '.htaccess') {
            return htp_t('COM_HTPROTECT_FILELABEL_MAIN');
        }

        if ($rel === 'administrator/.htaccess') {
            return htp_t('COM_HTPROTECT_FILELABEL_GUARD');
        }

        return htp_t('COM_HTPROTECT_FILELABEL_UPLOAD', $rel);
    }

    // ─────────────────────────────────────────────────────────────────
    //  Integritaets-/Anomalie-Pruefung der Einstiegspunkte
    //  (Antwort auf "index.php/configuration.php/Template ueberschrieben?"
    //   - rechte-unabhaengig, was Permissions nicht abfangen koennen.)
    // ─────────────────────────────────────────────────────────────────

    /**
     * Voller Integritaets-/Anomalie-Lauf. Aktualisiert core_manifest/core_jversion
     * in $cfg (Aufrufer speichert) und liefert die Vorfaelle.
     *
     * @param   array  $cfg  Konfiguration (Referenz)
     *
     * @return array Vorfaelle (kind: changed|deleted|malware, path 'core:<rel>')
     */
    public static function integrityCheck(&$cfg)
    {
        $root      = htp_root();
        $incidents = array();

        // ── Einstiegspunkte: Hash + Inhalt, Re-Baseline bei Versionswechsel ──
        $manifest = isset($cfg['core_manifest']) && is_array($cfg['core_manifest']) ? $cfg['core_manifest'] : array();
        $storedJv = isset($cfg['core_jversion']) ? (string) $cfg['core_jversion'] : '';
        $jv       = function_exists('htp_jversion') ? (string) htp_jversion() : '';

        $state = array();

        foreach (self::coreEntryFiles($root) as $rel => $abs) {
            $state[$rel] = array(
                'exists'  => true,
                'hash'    => sha1((string) @file_get_contents($abs)),
                'hostile' => self::contentLooksHostile($abs),
            );
        }

        // Frueher vorhandene, jetzt fehlende Dateien als nicht-existent fuehren
        // (damit 'deleted' greift).
        foreach ($manifest as $rel => $h) {
            if (!isset($state[$rel])) {
                $state[$rel] = array('exists' => false, 'hash' => '', 'hostile' => false);
            }
        }

        foreach (self::coreIntegrityIncidents($manifest, $storedJv, $jv, $state) as $inc) {
            $incidents[] = $inc;
        }

        // Manifest fortschreiben:
        //  - Re-Baseline (leer ODER Versionswechsel): aktuelle Hashes als Basis.
        //  - gleiche Version: bestehende Basis BEHALTEN, damit ein 'changed' bis
        //    zur Behebung weiter erinnert wird; nur neue Dateien aufnehmen.
        $rebaseline  = empty($manifest) || ($storedJv !== $jv);
        $newManifest = $rebaseline ? array() : $manifest;

        foreach ($state as $rel => $st) {
            if (!empty($st['exists']) && ($rebaseline || !isset($newManifest[$rel]))) {
                $newManifest[$rel] = $st['hash'];
            }
        }

        $cfg['core_manifest'] = $newManifest;
        $cfg['core_jversion'] = $jv;

        // ── Reine Inhalts-Anomalie: configuration.php + aktive Templates ──
        foreach (self::anomalyOnlyFiles($root) as $rel => $abs) {
            $hostile = ($rel === 'configuration.php')
                ? self::configHostile($abs)
                : self::contentLooksHostile($abs);

            if ($hostile) {
                $incidents[] = array('path' => 'core:' . $rel, 'kind' => 'malware', 'label' => self::coreLabel($rel));
            }
        }

        return $incidents;
    }

    /**
     * Read-only-Inhalts-Anomaliepruefung der Kerndateien (configuration.php +
     * aktive Templates) OHNE Manifest/Basislinie zu beruehren - fuer die
     * gedrosselte Uebersichts-Schnellpruefung (HtpApp::coreOverviewCheck), damit
     * eine eingeschleuste Kerndatei-Hintertuer (z. B. ein @include_once-Loader in
     * configuration.php) SOFORT in der Uebersicht erscheint, nicht nur im
     * Hintergrundwaechter/Mail. Gleiche Logik wie der Anomalie-Teil von
     * integrityCheck(), nur ohne Seiteneffekte.
     *
     * @return array Liste ['path'=>'core:<rel>','kind'=>'malware','label'=>string]
     */
    public static function coreAnomalyScan()
    {
        $root      = htp_root();
        $incidents = array();

        foreach (self::anomalyOnlyFiles($root) as $rel => $abs) {
            $hostile = ($rel === 'configuration.php')
                ? self::configHostile($abs)
                : self::contentLooksHostile($abs);

            if ($hostile) {
                $incidents[] = array('path' => 'core:' . $rel, 'kind' => 'malware', 'label' => self::coreLabel($rel));
            }
        }

        return $incidents;
    }

    /**
     * REINE Entscheidungslogik fuer die Einstiegspunkte (ohne FS/DB - testbar).
     *
     * @param   array   $manifest   gespeicherte Basis rel => sha1
     * @param   string  $storedJv   gespeicherte Joomla-Version
     * @param   string  $currentJv  aktuelle Joomla-Version
     * @param   array   $state      rel => ['exists'=>bool,'hash'=>string,'hostile'=>bool]
     *
     * @return array
     */
    public static function coreIntegrityIncidents($manifest, $storedJv, $currentJv, $state)
    {
        $manifest   = is_array($manifest) ? $manifest : array();
        $state      = is_array($state) ? $state : array();
        $incidents  = array();
        $rebaseline = empty($manifest) || ((string) $storedJv !== (string) $currentJv);

        foreach ($state as $rel => $st) {
            $exists  = !empty($st['exists']);
            $hash    = isset($st['hash']) ? (string) $st['hash'] : '';
            $hostile = !empty($st['hostile']);

            // Inhalts-Anomalie IMMER melden - auch beim Re-Baseline (eine schon
            // beim Baseline kompromittierte Datei wuerde der Hash sonst "legitimieren").
            if ($exists && $hostile) {
                $incidents[] = array('path' => 'core:' . $rel, 'kind' => 'malware', 'label' => self::coreLabel($rel));
            }

            if ($rebaseline) {
                continue; // kein changed/deleted bei legitimem Versionswechsel
            }

            if (!$exists) {
                if (isset($manifest[$rel])) {
                    $incidents[] = array('path' => 'core:' . $rel, 'kind' => 'deleted', 'label' => self::coreLabel($rel));
                }

                continue;
            }

            if (isset($manifest[$rel]) && $manifest[$rel] !== $hash) {
                $incidents[] = array('path' => 'core:' . $rel, 'kind' => 'changed', 'label' => self::coreLabel($rel));
            }
        }

        return $incidents;
    }

    /**
     * Sieht der Datei-Inhalt nach Schadcode aus? Nutzt die bestehende,
     * fehlalarmarme Engine HtpStorage::malwareSignature() (DRY).
     *
     * @param   string  $abs  Absoluter Pfad
     *
     * @return boolean
     */
    public static function contentLooksHostile($abs)
    {
        if (!is_string($abs) || $abs === '' || !is_file($abs) || !is_readable($abs)) {
            return false;
        }

        $size = @filesize($abs);

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

        if (!class_exists('HtpStorage')) {
            return false;
        }

        $content = (string) @file_get_contents($abs);

        return $content !== '' && HtpStorage::malwareSignature($content) !== '';
    }

    /**
     * Ist eine configuration.php anomal? Positive Erwartung: NUR skalare
     * Property-Zuweisungen einer JConfig-Klasse. Jeder Funktionsaufruf,
     * eval/include/require, ein zweites <?php, Inhalt nach ?>, Backtick oder
     * Variablen-Interpolation = Anomalie. Near-zero Fehlalarm, da ein legitimer
     * Config-Save nur WERTE aendert, nie Code. (Rein/testbar.)
     *
     * @param   string  $content  Dateiinhalt
     *
     * @return boolean
     */
    public static function configIsAnomalous($content)
    {
        $content = (string) $content;

        if (trim($content) === '') {
            return false;
        }

        if (!function_exists('token_get_all')) {
            // Funktionsnamen-Liste AUS FRAGMENTEN zusammengesetzt (byte-identisch
            // zum frueheren Literal-Regex), damit Hoster-Virenscanner DIESE Datei
            // nicht faelschlich als Schadcode melden - dieselbe FP-Haertung wie in
            // content.php (vgl. HtpContent::malSigParts). Nur der seltene PHP-<-5.x-
            // Fallback ohne token_get_all; das Verhalten bleibt unveraendert.
            $ev = 'e' . 'val';            $as = 'ass' . 'ert';
            $sy = 'sys' . 'tem';          $xc = 'ex' . 'ec';
            $sx = 'shell_e' . 'xec';      $pt = 'pass' . 'thru';
            $po = 'po' . 'pen';           $pr = 'proc' . '_open';
            $pc = 'pcntl_e' . 'xec';      $cf = 'create' . '_function';
            $b6 = 'base64' . '_decode';   $gi = 'gz' . 'inflate';
            $gu = 'gz' . 'uncompress';    $sr = 'str' . '_rot13';
            $cu = 'call' . '_user_func';  $cua = 'call' . '_user_func_array';
            $inc = 'include';             $req = 'require';
            $re  = '~\b(?:' . $ev . '|' . $as . '|' . $inc . '|' . $req . '|' . $inc . '_once|' . $req . '_once|'
                . $sy . '|' . $xc . '|' . $sx . '|' . $pt . '|' . $po . '|' . $pr . '|' . $pc . '|' . $cf . '|'
                . $b6 . '|' . $gi . '|' . $gu . '|' . $sr . '|' . $cu . '|' . $cua . ')\b\s*\(~i';

            if (preg_match($re, $content)
                || strpos($content, '`') !== false
                || preg_match('~\?>\s*\S~', $content)) {
                return true;
            }

            return substr_count(strtolower($content), '<?php') > 1;
        }

        $tokens = @token_get_all($content);

        if (!is_array($tokens) || empty($tokens)) {
            return false;
        }

        $openTags = 0;
        $prevSig  = null;

        foreach ($tokens as $tok) {
            if (is_array($tok)) {
                $id = $tok[0];

                if ($id === T_OPEN_TAG || (defined('T_OPEN_TAG_WITH_ECHO') && $id === T_OPEN_TAG_WITH_ECHO)) {
                    if (++$openTags > 1) {
                        return true;
                    }

                    continue;
                }

                if (defined('T_INLINE_HTML') && $id === T_INLINE_HTML) {
                    if (trim((string) $tok[1]) !== '') {
                        return true;
                    }

                    continue;
                }

                $bad = array(T_EVAL, T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_FUNCTION);

                if (defined('T_DOLLAR_OPEN_CURLY_BRACES')) {
                    $bad[] = T_DOLLAR_OPEN_CURLY_BRACES;
                }

                if (defined('T_CURLY_OPEN')) {
                    $bad[] = T_CURLY_OPEN;
                }

                if (in_array($id, $bad, true)) {
                    return true;
                }

                if ($id === T_WHITESPACE || $id === T_COMMENT || (defined('T_DOC_COMMENT') && $id === T_DOC_COMMENT)) {
                    continue;
                }

                $prevSig = $id;

                continue;
            }

            if ($tok === '`') {
                return true;
            }

            if ($tok === '(' && $prevSig !== T_ARRAY) {
                return true; // Funktionsaufruf (array( ... ) ist erlaubt)
            }

            $prevSig = $tok;
        }

        return false;
    }

    /**
     * Ist die configuration.php feindlich? Wie zuvor (Inhalts-Anomalie ODER
     * Malware-Signatur), ABER ein legitimer Multisite-Loader-Stub wird NICHT als
     * Fund gewertet. Solche Stubs (z. B. MightySites) ersetzen die configuration.php
     * durch ein einzelnes 'require_once __DIR__ . "/.../datei.php";' - strukturell
     * identisch zu einem Backdoor-Loader, daher sonst rot. Freigabe NUR, wenn das
     * Ziel existiert, im Erweiterungsbaum liegt und selbst sauber ist; jede
     * gefaehrliche Variante (an echte JConfig angehaengt, Absolutpfad,
     * '@'-Suppression, schmutziges/fremdes Ziel) bleibt ein Fund.
     *
     * @param   string  $abs  Absoluter Pfad zur configuration.php
     *
     * @return  boolean
     */
    public static function configHostile($abs)
    {
        // Harte Malware-Signatur in der Datei selbst -> IMMER Fund (kein Freibrief).
        if (self::contentLooksHostile($abs)) {
            return true;
        }

        $content = (string) @file_get_contents($abs);

        if (!self::configIsAnomalous($content)) {
            return false;
        }

        // Anomal - aber ein sauberer, verifizierter Loader-Stub? Dann kein Fund.
        return !self::configIsLegitLoaderStub($content, $abs);
    }

    /**
     * PURE Formpruefung: Ist $content ein REINER Loader-Stub der Gestalt
     *   <?php require_once __DIR__ . '/pfad/datei.php';
     * (auch JPATH_BASE/JPATH_ROOT/JPATH_SITE als Basis, Mehrfach-Konkatenation
     * erlaubt)? Rueckgabe = Pfad-SUFFIX (beginnt mit '/'), sonst ''.
     *
     * Bewusst STRENG - jede Abweichung -> '' (bleibt gemeldet): genau EIN
     * include/require(_once); Basis MUSS __DIR__/JPATH_* sein (kein hartkodierter/
     * absoluter Literalpfad); nur einfach-quotierte String-Literale + '.'; KEIN
     * '@', kein Backtick, keine Variable, keine Klasse (angehaengter Loader bleibt
     * Fund), kein Funktionsaufruf/Klammern, kein zweiter Tag, kein Inline-HTML.
     *
     * @param   string  $content  Inhalt der configuration.php
     *
     * @return  string  Pfad-Suffix relativ zur Wurzel oder ''
     */
    public static function configStubTarget($content)
    {
        $content = (string) $content;

        if ($content === '' || !function_exists('token_get_all')) {
            return '';
        }

        $tokens = @token_get_all($content);

        if (!is_array($tokens) || empty($tokens)) {
            return '';
        }

        $seenOpen   = 0;
        $incTok     = 0;
        $inExpr     = false;
        $doneStmt   = false;
        $base       = '';
        $suffix     = '';
        $expectPart = true;

        foreach ($tokens as $tok) {
            if (is_array($tok)) {
                $id  = $tok[0];
                $txt = $tok[1];

                if ($id === T_WHITESPACE || $id === T_COMMENT
                    || (defined('T_DOC_COMMENT') && $id === T_DOC_COMMENT)) {
                    continue;
                }

                if ($id === T_OPEN_TAG) {
                    if (++$seenOpen > 1) {
                        return '';
                    }

                    continue;
                }

                if ($doneStmt) {
                    // Nach dem Loader nur noch Close-Tag / reines Whitespace-HTML.
                    if ($id === T_CLOSE_TAG
                        || (defined('T_INLINE_HTML') && $id === T_INLINE_HTML && trim((string) $txt) === '')) {
                        continue;
                    }

                    return '';
                }

                if (defined('T_INLINE_HTML') && $id === T_INLINE_HTML) {
                    if (trim((string) $txt) !== '') {
                        return '';
                    }

                    continue;
                }

                if ($id === T_REQUIRE || $id === T_REQUIRE_ONCE
                    || $id === T_INCLUDE || $id === T_INCLUDE_ONCE) {
                    if (++$incTok > 1 || $seenOpen !== 1) {
                        return '';
                    }

                    $inExpr     = true;
                    $expectPart = true;

                    continue;
                }

                if (!$inExpr) {
                    return ''; // irgendetwas Bedeutungstragendes VOR dem require -> kein Stub
                }

                if ($id === T_DIR) {
                    if (!$expectPart || $base !== '') {
                        return '';
                    }

                    $base       = 'dir';
                    $expectPart = false;

                    continue;
                }

                if ($id === T_STRING) {
                    $u = strtoupper((string) $txt);

                    if (($u === 'JPATH_BASE' || $u === 'JPATH_ROOT' || $u === 'JPATH_SITE')
                        && $expectPart && $base === '') {
                        $base       = 'root';
                        $expectPart = false;

                        continue;
                    }

                    return ''; // jeder andere Bezeichner (dirname, Konstante, ...) -> kein Stub
                }

                if ($id === T_CONSTANT_ENCAPSED_STRING) {
                    if (!$expectPart) {
                        return '';
                    }

                    $lit = self::unquoteSingle((string) $txt);

                    if ($lit === null) {
                        return ''; // nur einfach-quotierte Literale (keine Interpolation)
                    }

                    $suffix    .= $lit;
                    $expectPart = false;

                    continue;
                }

                return ''; // T_VARIABLE / T_EVAL / T_FUNCTION / T_CLASS / Zahl / ...
            }

            if ($tok === ';') {
                if (!$inExpr || $expectPart || $base === '' || $suffix === '') {
                    return '';
                }

                $doneStmt = true;
                $inExpr   = false;

                continue;
            }

            if ($tok === '.') {
                if (!$inExpr || $expectPart) {
                    return '';
                }

                $expectPart = true;

                continue;
            }

            return ''; // '(' ')' '@' '`' ',' ... -> kein sauberer Stub
        }

        if (!$doneStmt || $base === '' || $suffix === '' || $suffix[0] !== '/') {
            return '';
        }

        return $suffix;
    }

    /**
     * Einfach-quotiertes PHP-Literal entpacken (nur '...'); sonst null.
     *
     * @param   string  $tok  Token-Text inkl. Anfuehrungszeichen
     *
     * @return  string|null
     */
    private static function unquoteSingle($tok)
    {
        $tok = (string) $tok;
        $len = strlen($tok);

        if ($len < 2 || $tok[0] !== "'" || $tok[$len - 1] !== "'") {
            return null;
        }

        return strtr(substr($tok, 1, $len - 2), array("\\'" => "'", '\\\\' => '\\'));
    }

    /**
     * Verifiziert einen Loader-Stub am Dateisystem: das per configStubTarget()
     * ermittelte Ziel MUSS existieren, INNERHALB der Wurzel unter einem echten
     * Erweiterungs-/Code-Verzeichnis liegen, eine .php sein und den Malware-Scan
     * bestehen. Nur dann ist der Stub legitim (kein Fund).
     *
     * @param   string  $content    Inhalt der configuration.php
     * @param   string  $configAbs  Absoluter Pfad der configuration.php
     *
     * @return  boolean
     */
    public static function configIsLegitLoaderStub($content, $configAbs)
    {
        $suffix = self::configStubTarget($content);

        if ($suffix === '' || !is_string($configAbs) || $configAbs === '' || !is_file($configAbs)) {
            return false;
        }

        $rootReal = realpath(dirname($configAbs)); // configuration.php liegt in der Wurzel

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

        $rootReal = rtrim(str_replace('\\', '/', $rootReal), '/');
        $tgtReal  = realpath($rootReal . $suffix); // $suffix beginnt mit '/'

        if ($tgtReal === false) {
            return false; // Ziel existiert nicht
        }

        $tgtReal = str_replace('\\', '/', $tgtReal);

        // Containment: Ziel MUSS innerhalb der Wurzel liegen (kein ../-Ausbruch).
        if (strpos($tgtReal, $rootReal . '/') !== 0) {
            return false;
        }

        $rel = substr($tgtReal, strlen($rootReal) + 1);

        // Muss eine .php sein ...
        if (substr(strtolower($rel), -4) !== '.php') {
            return false;
        }

        // ... in einem echten Erweiterungs-/Code-Verzeichnis (nie images/media/tmp/
        // cache/logs/... - dort haust ein eingeschleuster Loader).
        $ok = false;

        foreach (array(
            'components/', 'administrator/components/', 'api/components/',
            'modules/', 'administrator/modules/',
            'plugins/', 'libraries/',
            'templates/', 'administrator/templates/',
        ) as $dir) {
            if (strpos($rel, $dir) === 0) {
                $ok = true;
                break;
            }
        }

        if (!$ok) {
            return false;
        }

        // Ziel selbst muss sauber sein - sonst KEIN Freibrief (echter Fund).
        return !self::contentLooksHostile($tgtReal);
    }

    /**
     * Existierende Front-Controller (Hash-ueberwacht).
     *
     * @param   string  $root  Joomla-Root
     *
     * @return array rel => abs
     */
    private static function coreEntryFiles($root)
    {
        $out = array();

        foreach (array('index.php', 'administrator/index.php', 'api/index.php') as $rel) {
            $abs = $root . '/' . $rel;

            if (is_file($abs)) {
                $out[$rel] = $abs;
            }
        }

        // Public-Folder: der Webserver fuehrt ZUERST die SERVED Einstiegs-Stubs (public/index.php etc.)
        // aus - eine Defacement-/Injection-Aenderung DORT wuerde die reinen Root-Controller oben nicht
        // erfassen. Daher die served Stubs zusaetzlich hash-ueberwachen, unter distinkten Manifest-
        // Schluesseln ('pub/...'). Nur echte Stub-Dateien (keine Symlinks). Klassisch (docroot==root)
        // sind es dieselben Dateien -> per Pfadvergleich uebersprungen (kein Doppel-Eintrag).
        if (function_exists('htp_is_public_folder') && htp_is_public_folder() && function_exists('htp_docroot')) {
            $web   = rtrim(str_replace('\\', '/', (string) htp_docroot()), '/');
            $rootN = rtrim(str_replace('\\', '/', (string) $root), '/');

            if ($web !== '' && $web !== $rootN) {
                foreach (array('index.php', 'administrator/index.php', 'api/index.php') as $rel) {
                    $abs = $web . '/' . $rel;

                    if (is_file($abs) && !@is_link($abs)) {
                        $out['pub/' . $rel] = $abs;
                    }
                }
            }
        }

        return $out;
    }

    /**
     * Nur per Inhalt geprueft (kein Hash): configuration.php + aktive Templates.
     *
     * @param   string  $root  Joomla-Root
     *
     * @return array rel => abs
     */
    private static function anomalyOnlyFiles($root)
    {
        $out  = array();
        $conf = $root . '/configuration.php';

        if (is_file($conf)) {
            $out['configuration.php'] = $conf;
        }

        foreach (self::activeTemplateFiles($root) as $rel => $abs) {
            $out[$rel] = $abs;
        }

        return $out;
    }

    /**
     * Dateien der AKTIVEN Standard-Templates (Site + Admin) - per Inhalt geprueft.
     * Fail-open: ohne DB/Treffer leeres Array.
     *
     * @param   string  $root  Joomla-Root
     *
     * @return array rel => abs
     */
    private static function activeTemplateFiles($root)
    {
        $out = array();
        $db  = htp_db();

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

        try {
            $query = $db->getQuery(true)
                ->select($db->quoteName(array('template', 'client_id')))
                ->from($db->quoteName('#__template_styles'))
                ->where($db->quoteName('home') . ' = ' . $db->quote('1'));
            $db->setQuery($query);
            $rows = $db->loadAssocList();
        } catch (Exception $e) {
            return $out;
        }

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

        foreach ($rows as $r) {
            $tpl = isset($r['template']) ? preg_replace('#[^A-Za-z0-9_]#', '', (string) $r['template']) : '';

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

            if ((int) (isset($r['client_id']) ? $r['client_id'] : 0) === 1) {
                $base  = 'administrator/templates/' . $tpl;
                $names = array('index.php');
            } else {
                $base  = 'templates/' . $tpl;
                $names = array('index.php', 'component.php', 'error.php', 'offline.php');
            }

            foreach ($names as $f) {
                $rel = $base . '/' . $f;
                $abs = $root . '/' . $rel;

                if (is_file($abs)) {
                    $out[$rel] = $abs;
                }
            }
        }

        return $out;
    }

    /**
     * Lesbare Bezeichnung einer ueberwachten Kerndatei.
     *
     * @param   string  $rel  Relativer Pfad (ohne 'core:'-Praefix)
     *
     * @return string
     */
    private static function coreLabel($rel)
    {
        return htp_t('COM_HTPROTECT_FILELABEL_CORE', $rel);
    }

    /**
     * Gemeldete Vorfaelle laden.
     *
     * @return array key => timestamp
     */
    private static function loadNotified()
    {
        $db = htp_db();

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

        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[self::NOTIFIED_KEY]) && is_array($decoded[self::NOTIFIED_KEY])) {
                    return $decoded[self::NOTIFIED_KEY];
                }
            }
        } catch (Exception $e) {
            // leer
        }

        return array();
    }

    /**
     * Gemeldete Vorfaelle speichern (andere Parameter bleiben erhalten).
     *
     * @param   array  $map  key => timestamp
     *
     * @return boolean
     */
    private static function saveNotified($map)
    {
        $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::NOTIFIED_KEY] = $map;
            $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;
        }
    }
}
