content ) { return array(); } if ( '.htaccess' === Text::basename( $f->path ) ) { return self::htaccess( $f->path, $f->content, $ctx ); } return self::ini( $f->path, $f->content, $ctx ); } /** * Only the findings of the PHP handler rule (AddHandler, AddType, SetHandler) and of the * auto_prepend_file / auto_append_file rule, for an access file of the uploads folder * (.htaccess through the .htaccess reading, .user.ini through the ini reading). * Orchestrator decision 2026-10-23: such a file in uploads is an anomaly of control 3. * * @return Finding[] */ public static function executionFindings( FileEntry $f, Context $ctx ): array { if ( null === $f->content ) { return array(); } $all = '.htaccess' === strtolower( Text::basename( $f->path ) ) ? self::htaccess( $f->path, $f->content, $ctx ) : self::ini( $f->path, $f->content, $ctx ); $out = array(); foreach ( $all as $finding ) { if ( in_array( $finding->code, array( 'htaccess_php_handler', 'php_prepend_append', 'php_prepend_plugin' ), true ) ) { $out[] = $finding; } } return $out; } /** @return Finding[] */ private static function htaccess( string $path, string $content, Context $ctx ): array { $out = array(); $pending = array(); $files = array(); foreach ( Text::lines( substr( $content, 0, Assumptions::CONTENT_MAX_BYTES ) ) as $line ) { $tokens = Patterns::tokens( $line ); if ( ! $tokens || '#' === $tokens[0][0] ) { continue; } $shown = Text::cut( trim( $line ), Assumptions::EXCERPT_MAX_CHARS ); $dir = strtolower( $tokens[0] ); if ( '" ) : ''; } elseif ( '' === $dir || '' === $dir ) { array_pop( $files ); } elseif ( 'rewritecond' === $dir ) { $var = isset( $tokens[1] ) ? strtoupper( $tokens[1] ) : ''; // A negated condition ("!...") excludes visitors, it does not target them: // silent (orchestrator decision 2026-10-23, WP Super Cache mobile exclusions). $negated = isset( $tokens[2] ) && '!' === substr( $tokens[2], 0, 1 ); if ( ! $negated && self::visitorVariable( $var ) ) { $pending[] = trim( $var ); } } elseif ( 'rewriterule' === $dir ) { $target = isset( $tokens[2] ) ? $tokens[2] : ''; $host = self::externalHost( $target, $ctx ); if ( '' !== $host ) { $out[] = Finding::make( 'htaccess_external_redirect', $path, array( $path, 'RewriteRule', $host, $shown ), $shown ); } // "-" means no substitution: not a rewrite (hotlink blocking with [F] stays silent). if ( $pending && '' !== $target && '-' !== $target ) { $out[] = Finding::make( 'htaccess_conditional_rewrite', $path, array( $path, $pending[0], $shown ), $shown ); } $pending = array(); } elseif ( in_array( $dir, array( 'redirect', 'redirectmatch', 'redirectpermanent', 'redirecttemp' ), true ) ) { $host = self::externalHost( (string) end( $tokens ), $ctx ); if ( '' !== $host ) { $out[] = Finding::make( 'htaccess_external_redirect', $path, array( $path, $tokens[0], $host, $shown ), $shown ); } } elseif ( 'addhandler' === $dir || 'addtype' === $dir ) { $out = array_merge( $out, self::addHandler( $path, $tokens, $shown ) ); } elseif ( 'sethandler' === $dir || 'forcetype' === $dir ) { $out = array_merge( $out, self::setHandler( $path, 'sethandler' === $dir ? 'SetHandler' : 'ForceType', $tokens, $files, $shown ) ); } elseif ( 'php_value' === $dir || 'php_admin_value' === $dir ) { if ( isset( $tokens[1] ) && in_array( strtolower( $tokens[1] ), Assumptions::PREPEND_DIRECTIVES, true ) ) { $target = isset( $tokens[2] ) ? $tokens[2] : ''; $out = array_merge( $out, self::prepend( $path, strtolower( $tokens[1] ), $target, $ctx ) ); } } elseif ( 'errordocument' === $dir && isset( $tokens[2] ) ) { $host = self::externalHost( $tokens[2], $ctx ); if ( '' !== $host ) { $out[] = Finding::make( 'htaccess_external_error_document', $path, array( $path, $host, $shown ), $shown ); } } } return $out; } /** @return Finding[] */ private static function ini( string $path, string $content, Context $ctx ): array { $out = array(); foreach ( Text::lines( substr( $content, 0, Assumptions::CONTENT_MAX_BYTES ) ) as $line ) { $t = trim( $line ); if ( '' === $t || ';' === $t[0] || '#' === $t[0] ) { continue; } $eq = strpos( $t, '=' ); if ( false === $eq ) { continue; } $key = strtolower( trim( substr( $t, 0, $eq ) ) ); if ( in_array( $key, Assumptions::PREPEND_DIRECTIVES, true ) ) { $out = array_merge( $out, self::prepend( $path, $key, trim( substr( $t, $eq + 1 ) ), $ctx ) ); } } return $out; } /** @return Finding[] */ private static function prepend( string $path, string $directive, string $target, Context $ctx ): array { $target = trim( $target, " \t\"'" ); if ( '' === $target || 'none' === strtolower( $target ) ) { return array(); } $args = array( $path, $directive, $target ); // Known firewall target of an installed plugin (DECISIONS 2026-10-24, D-02, D-03). $name = Text::basename( str_replace( '\\', '/', $target ) ); if ( isset( Assumptions::KNOWN_PREPEND_TARGETS[ $name ] ) && self::insideSite( $target, $ctx ) && in_array( Assumptions::KNOWN_PREPEND_TARGETS[ $name ], $ctx->installedSlugs(), true ) ) { $args[] = Assumptions::KNOWN_PREPEND_TARGETS[ $name ]; return array( Finding::make( 'php_prepend_plugin', $path, $args ) ); } return array( Finding::make( 'php_prepend_append', $path, $args ) ); } /** Absolute target under ABSPATH, without "..". A relative target is not proven inside. */ private static function insideSite( string $target, Context $ctx ): bool { $t = str_replace( '\\', '/', $target ); if ( '' === $ctx->absPath || false !== strpos( $t, '/../' ) ) { return false; } return Text::startsWith( $t, $ctx->absPath ); } /** @return Finding[] */ private static function addHandler( string $path, array $tokens, string $shown ): array { if ( ! isset( $tokens[1] ) || false === stripos( $tokens[1], 'php' ) ) { return array(); } foreach ( array_slice( $tokens, 2 ) as $ext ) { $ext = strtolower( ltrim( trim( $ext, "\"'" ), '.' ) ); if ( '' !== $ext && ! in_array( $ext, Assumptions::EXEC, true ) ) { return array( Finding::make( 'htaccess_php_handler', $path, array( $path, $tokens[0], $ext, $shown ), $shown ) ); } } return array(); } /** * SetHandler (or ForceType, D-11) to a PHP handler or type: outside / * it applies to every file of the folder; inside, the words of the pattern are read as * extensions and any word that is not a PHP extension is reported. * * @return Finding[] */ private static function setHandler( string $path, string $directive, array $tokens, array $files, string $shown ): array { if ( ! isset( $tokens[1] ) || false === stripos( $tokens[1], 'php' ) ) { return array(); } $pattern = $files ? (string) end( $files ) : ''; $words = self::words( $pattern ); if ( ! $words ) { return array( Finding::make( 'htaccess_php_handler', $path, array( $path, $directive, '*', $shown ), $shown ) ); } foreach ( $words as $w ) { if ( ! self::phpLikeWord( $w ) ) { return array( Finding::make( 'htaccess_php_handler', $path, array( $path, $directive, $w, $shown ), $shown ) ); } } return array(); } /** * Alphanumeric words of a Files pattern, lowercased. Hand-made split, no regex. * * @return string[] */ private static function words( string $pattern ): array { $words = array(); $cur = ''; $len = min( strlen( $pattern ), Assumptions::LINE_MAX_CHARS ); for ( $i = 0; $i <= $len; $i++ ) { $c = $i < $len ? $pattern[ $i ] : ' '; if ( ctype_alnum( $c ) ) { $cur .= strtolower( $c ); } elseif ( '' !== $cur ) { $words[] = $cur; $cur = ''; } } return $words; } /** EXEC extension, "php" followed by digits (php2, php9), or digits alone. */ private static function phpLikeWord( string $w ): bool { if ( in_array( $w, Assumptions::EXEC, true ) || ctype_digit( $w ) ) { return true; } return Text::startsWith( $w, 'php' ) && ctype_digit( substr( $w, 3 ) ); } /** * Referer or user agent variable of a RewriteCond, uppercased: %{HTTP_REFERER}, * %{HTTP_USER_AGENT}, or the generic %{HTTP:Referer}, %{HTTP:User-Agent} (D-10). */ private static function visitorVariable( string $variable ): bool { foreach ( array( '%{HTTP_REFERER}', '%{HTTP_USER_AGENT}', '%{HTTP:REFERER}', '%{HTTP:USER-AGENT}' ) as $v ) { if ( false !== strpos( $variable, $v ) ) { return true; } } return false; } /** Host of an absolute target when it is not one of the site hosts; '' otherwise. */ private static function externalHost( string $target, Context $ctx ): string { $host = Text::hostOf( $target ); if ( '' === $host || false !== strpos( $host, '%' ) || false !== strpos( $host, '$' ) || false !== strpos( $host, '{' ) ) { return ''; } return Text::isSiteHost( $host, $ctx->siteHosts ) ? '' : $host; } }