path, array( $f->path ) ) ); } foreach ( Text::extensions( $f->path ) as $ext ) { if ( in_array( $ext, Assumptions::EXEC, true ) ) { $data = self::pluginDataFile( $f, $ctx ); if ( null !== $data ) { return array( Finding::make( 'uploads_plugin_data', $f->path, array( $f->path, $data[0], $data[1] ) ) ); } return array( Finding::make( 'uploads_exec_extension', $f->path, array( $f->path, $ext ) ) ); } } $name = strtolower( Text::basename( $f->path ) ); if ( '.htaccess' === $name || '.user.ini' === $name ) { if ( null === $f->content ) { return array(); // Too large or not read: counted by the accumulator. } // PHP handler or prepend rules of control 6 first (orchestrator decision 2026-10-23). $exec = HtaccessRules::executionFindings( $f, $ctx ); if ( $exec ) { $e = $exec[0]; return array( Finding::make( 'uploads_access_exec', $f->path, array( $f->path, $e->args[1], $e->args[2] ), $e->excerpt ) ); } return self::onlyAllowedLines( $f->content ) ? array() : array( Finding::make( 'uploads_access_file', $f->path, array( $f->path ) ) ); } if ( $f->size <= Assumptions::CONTENT_MAX_BYTES && null !== $f->head ) { $head = substr( $f->head, 0, Assumptions::HEAD_BYTES ); $hit = Text::firstMarker( $head, array( 'path, array( $f->path, $hit[0] ), Text::excerptAround( $head, $hit[1] ) ) ); } } return array(); } /** * Known data file of an installed plugin (Assumptions::KNOWN_PLUGIN_DATA_FILES): * under its folder of the uploads folder and starting with an inert opening block. * * @return array{0:string,1:string}|null Slug and opening block, or null. */ private static function pluginDataFile( FileEntry $f, Context $ctx ) { $rel = substr( $f->path, strlen( $ctx->uploadsDir ) + 1 ); $head = null !== $f->head ? $f->head : (string) $f->content; foreach ( Assumptions::KNOWN_PLUGIN_DATA_FILES as $dir => $slug ) { if ( Text::startsWith( $rel, $dir . '/' ) && in_array( $slug, $ctx->installedSlugs(), true ) ) { $block = self::inertOpening( $head ); return null === $block ? null : array( $slug, $block ); } } return null; } /** * Opening block of a file that runs nothing (DECISIONS 2026-10-24, D-01 reste): "", as in PHP; "#[" is an attribute, not a comment. Scanned by hand, no regex. * * @return string|null The block up to the stop statement, whitespace collapsed, or null. */ public static function inertOpening( string $s ) { $s = substr( $s, 0, Assumptions::HEAD_BYTES ); $len = strlen( $s ); if ( 0 !== strncmp( $s, '' ) ) { return null; // The block closes inside the comment, before any stop. } $i = $end; } elseif ( '/' === $c && '*' === substr( $s, $i + 1, 1 ) ) { $close = strpos( $s, '*/', $i + 2 ); if ( false === $close ) { return null; } $i = $close + 2; } else { foreach ( Assumptions::DATA_FILE_STOPS as $stop ) { if ( substr( $s, $i, strlen( $stop ) ) === $stop ) { return Text::cut( Patterns::collapse( substr( $s, 0, $i + strlen( $stop ) ) ), Assumptions::EXCERPT_MAX_CHARS ); } } return null; } } return null; } /** * index.php of at most 200 bytes made only of a PHP opening tag, comments, * whitespace and an optional closing tag, and since DECISIONS 24/10 (faux constat * WPForms) header() and http_response_code() calls with literal arguments and a final * exit or die (guardStatement()). Scanned by hand, no regex. */ public static function isSilenceIndex( FileEntry $f ): bool { if ( 'index.php' !== strtolower( Text::basename( $f->path ) ) || $f->size > Assumptions::SILENCE_INDEX_MAX_BYTES ) { return false; } $s = null !== $f->content ? $f->content : (string) $f->head; if ( strlen( $s ) > Assumptions::SILENCE_INDEX_MAX_BYTES || 0 !== strncasecmp( $s, '' ); if ( false !== $close ) { return '' === trim( substr( $s, $i + $close + 2 ) ); } $i = $end; } elseif ( '/' === $c && '*' === substr( $s, $i + 1, 1 ) ) { $close = strpos( $s, '*/', $i + 2 ); if ( false === $close ) { return false; } $i = $close + 2; } elseif ( '?' === $c && '>' === substr( $s, $i + 1, 1 ) ) { return '' === trim( substr( $s, $i + 2 ) ); } else { $next = $ended ? null : self::guardStatement( $s, $i ); if ( null === $next ) { return false; } list( $i, $ended ) = $next; } } return true; } /** * One guard statement at $i: header( ... ); or http_response_code( ... ); with literal * arguments (literalArgument()), or exit / die, bare or with literal arguments, ended by * ";" (exit and die also by a closing tag). WPForms Lite 2.0.3 uploads/wpforms/cache/index.php. * * @return array{0:int,1:bool}|null Offset after the statement and whether it was exit or die, or null. */ private static function guardStatement( string $s, int $i ) { foreach ( array( 'header', 'http_response_code', 'exit', 'die' ) as $name ) { if ( substr( $s, $i, strlen( $name ) ) !== $name ) { continue; } $exit = 'exit' === $name || 'die' === $name; $j = self::skipSpace( $s, $i + strlen( $name ) ); if ( $exit && ';' === substr( $s, $j, 1 ) ) { return array( $j + 1, true ); } if ( '(' !== substr( $s, $j, 1 ) ) { return null; } $j = self::skipSpace( $s, $j + 1 ); if ( ! $exit || ')' !== substr( $s, $j, 1 ) ) { for ( ;; ) { $j = self::literalArgument( $s, $j ); if ( null === $j ) { return null; } $j = self::skipSpace( $s, $j ); if ( ',' !== substr( $s, $j, 1 ) ) { break; } $j = self::skipSpace( $s, $j + 1 ); } } if ( ')' !== substr( $s, $j, 1 ) ) { return null; } $j = self::skipSpace( $s, $j + 1 ); if ( ';' === substr( $s, $j, 1 ) ) { return array( $j + 1, $exit ); } return $exit && '?>' === substr( $s, $j, 2 ) ? array( $j, true ) : null; } return null; } /** * Literal argument at $j: terms joined by ".", each a single-quoted string without * backslash, a double-quoted string without backslash or "$", digits, true, false, or * $_SERVER['SERVER_PROTOCOL'] (the 404 line of WPForms). Anything else → null. * * @return int|null Offset after the argument, or null. */ private static function literalArgument( string $s, int $j ) { for ( ;; ) { $c = substr( $s, $j, 1 ); if ( "'" === $c || '"' === $c ) { $close = strpos( $s, $c, $j + 1 ); if ( false === $close ) { return null; } $inner = substr( $s, $j + 1, $close - $j - 1 ); if ( false !== strpos( $inner, '\\' ) || ( '"' === $c && false !== strpos( $inner, '$' ) ) ) { return null; } $j = $close + 1; } elseif ( '' !== $c && ctype_digit( $c ) ) { $j += strspn( $s, '0123456789', $j ); } elseif ( 'true' === substr( $s, $j, 4 ) ) { $j += 4; } elseif ( 'false' === substr( $s, $j, 5 ) ) { $j += 5; } elseif ( "\$_SERVER['SERVER_PROTOCOL']" === substr( $s, $j, 27 ) ) { $j += 27; } else { return null; } $k = self::skipSpace( $s, $j ); if ( '.' !== substr( $s, $k, 1 ) ) { return $j; } $j = self::skipSpace( $s, $k + 1 ); } } private static function skipSpace( string $s, int $j ): int { return $j + strspn( $s, " \t\r\n", $j ); } /** * Only deny from all, Require all denied, Options -Indexes, Order ..., IfModule, Files and * FilesMatch containers, directives that remove PHP execution, the Header lines of WPForms, * comments, empty lines. */ public static function onlyAllowedLines( string $content ): bool { $exact = array_merge( Assumptions::UPLOADS_ACCESS_ALLOWED_LINES, Assumptions::UPLOADS_ACCESS_FILES_CLOSE, Assumptions::UPLOADS_ACCESS_NO_EXEC_LINES, Assumptions::UPLOADS_ACCESS_WPFORMS_HEADER_LINES ); foreach ( Text::lines( substr( $content, 0, Assumptions::CONTENT_MAX_BYTES ) ) as $line ) { $t = strtolower( Patterns::collapse( $line ) ); if ( '' === $t || '#' === $t[0] || ';' === $t[0] ) { continue; } if ( in_array( $t, $exact, true ) ) { continue; } // , , and their closing lines (DECISIONS 2026-10-24): the lines inside are still judged. if ( Assumptions::UPLOADS_ACCESS_CONTAINER_CLOSE === $t || Patterns::isIfModuleOpen( $t ) || Patterns::isFilesOpen( $t ) ) { continue; } $prefixed = false; foreach ( array_merge( Assumptions::UPLOADS_ACCESS_ALLOWED_PREFIXES, Assumptions::UPLOADS_ACCESS_NO_EXEC_PREFIXES ) as $p ) { if ( Text::startsWith( $t, $p ) ) { $prefixed = true; } } if ( ! $prefixed ) { return false; } } return true; } }