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;
}
}