uploadsDir ) ) { return self::ZONE_UPLOADS; } if ( Context::under( $path, $ctx->muPluginsDir ) ) { return self::ZONE_MU_PLUGINS; } if ( Context::under( $path, $ctx->pluginsDir ) ) { return self::ZONE_PLUGINS; } if ( Context::under( $path, $ctx->contentDir ) ) { $rel = substr( $path, strlen( $ctx->contentDir ) + 1 ); return false === strpos( $rel, '/' ) ? self::ZONE_CONTENT_TOP : self::ZONE_CONTENT; } if ( Text::startsWith( $path, 'wp-admin/' ) || Text::startsWith( $path, 'wp-includes/' ) ) { return self::ZONE_CORE_DIR; } return false === strpos( $path, '/' ) ? self::ZONE_ROOT : self::ZONE_OTHER; } /** * What the adapter should read for this file. 'content' is only honoured * when $size <= Assumptions::CONTENT_MAX_BYTES; above, leave content null * (the accumulator lists the file as not read). * * @return array{md5:bool, sha256:bool, head:bool, content:bool} */ public static function needs( string $path, int $size, Context $ctx ): array { $zone = self::zone( $path, $ctx ); $name = Text::basename( $path ); $hash = $size <= Assumptions::HASH_MAX_BYTES ? self::sumsServe( $path, $zone, $ctx ) : ''; $n = array( 'md5' => 'md5' === $hash, 'sha256' => 'sha256' === $hash, 'head' => in_array( $zone, array( self::ZONE_UPLOADS, self::ZONE_MU_PLUGINS, self::ZONE_CONTENT_TOP ), true ), 'content' => false, ); // A small index.php of a plugin folder is read to tell a silence index (D-04). if ( self::ZONE_PLUGINS === $zone && 'index.php' === strtolower( $name ) && $size <= Assumptions::SILENCE_INDEX_MAX_BYTES ) { $n['head'] = true; } if ( self::ZONE_UPLOADS === $zone ) { $lower = strtolower( $name ); $n['content'] = '.htaccess' === $lower || '.user.ini' === $lower; } elseif ( HtaccessRules::applies( $path ) || ( self::ZONE_ROOT === $zone && 'wp-config.php' === $name ) ) { $n['content'] = true; } elseif ( self::ZONE_MU_PLUGINS === $zone ) { $rel = substr( $path, strlen( $ctx->muPluginsDir ) + 1 ); $n['content'] = false === strpos( $rel, '/' ) && Text::isPhpFile( $rel ); } if ( $size > Assumptions::CONTENT_MAX_BYTES ) { $n['content'] = false; } return $n; } /** * Which sum a rule compares for this file of the root, wp-admin/, wp-includes/ or * the plugins folder: 'md5', 'sha256' (plugin file whose published entry has no md5) * or '' (D-05: hashed only when present in the sums, or PHP). */ private static function sumsServe( string $path, string $zone, Context $ctx ): string { if ( self::ZONE_ROOT === $zone || self::ZONE_CORE_DIR === $zone ) { $listed = null !== $ctx->coreChecksums && isset( $ctx->coreChecksums->sums[ $path ] ); return $listed || Text::isPhpFile( $path ) ? 'md5' : ''; } if ( self::ZONE_PLUGINS !== $zone ) { return ''; } $rel = substr( $path, strlen( $ctx->pluginsDir ) + 1 ); $pos = strpos( $rel, '/' ); if ( false === $pos ) { $slug = 'hello.php' === $rel ? '' : Context::slugOf( $rel ); $inner = $rel; } else { $slug = substr( $rel, 0, $pos ); $inner = substr( $rel, $pos + 1 ); } if ( '' !== $slug && isset( $ctx->pluginChecksums[ $slug ] ) && $ctx->pluginChecksums[ $slug ]->has( $inner ) ) { $entry = $ctx->pluginChecksums[ $slug ]->files[ $inner ]; if ( $entry['md5'] ) { return 'md5'; } return $entry['sha256'] ? 'sha256' : ''; } return Text::isPhpFile( $path ) ? 'md5' : ''; } /** * Whether a file of the root, wp-admin/, wp-includes/ or the plugins folder is above * Assumptions::HASH_MAX_BYTES: never hashed, listed as too large to be compared (D-05). */ public static function tooLargeToCompare( FileEntry $f, Context $ctx ): bool { return $f->size > Assumptions::HASH_MAX_BYTES && in_array( self::zone( $f->path, $ctx ), array( self::ZONE_ROOT, self::ZONE_CORE_DIR, self::ZONE_PLUGINS ), true ); } /** * First-level content sub-folder of a PHP file no rule judges (not uploads, plugins, * mu-plugins, themes), or null (Q-01: listed under Not looked at with its count). * * @return string|null Path of the folder relative to ABSPATH. */ public static function unjudgedContentFolder( FileEntry $f, Context $ctx ) { if ( self::ZONE_CONTENT !== self::zone( $f->path, $ctx ) || ! Text::isPhpFile( $f->path ) ) { return null; } $rel = substr( $f->path, strlen( $ctx->contentDir ) + 1 ); $folder = substr( $rel, 0, (int) strpos( $rel, '/' ) ); return Assumptions::CONTENT_THEMES_FOLDER === $folder ? null : $ctx->contentDir . '/' . $folder; } /** * Whether a content the domain needed was not read because of its size * (SPEC « fichiers > 5 Mo non lus »). Uploads files above the limit are not * checked for a PHP opening tag either. */ public static function tooLarge( FileEntry $f, Context $ctx ): bool { if ( $f->size <= Assumptions::CONTENT_MAX_BYTES ) { return false; } if ( self::ZONE_UPLOADS === self::zone( $f->path, $ctx ) ) { return true; } $n = self::needs( $f->path, Assumptions::CONTENT_MAX_BYTES, $ctx ); return $n['content']; } /** @return Finding[] */ public static function inspect( FileEntry $f, Context $ctx ): array { if ( ! $f->readable ) { return array(); } $out = array(); switch ( self::zone( $f->path, $ctx ) ) { case self::ZONE_UPLOADS: return UploadsRules::inspect( $f, $ctx ); case self::ZONE_MU_PLUGINS: $out = ContentDirRules::inspectMuPlugin( $f, $ctx ); break; case self::ZONE_PLUGINS: $out = PluginRules::inspect( $f, $ctx ); if ( self::coveredByPluginSums( $f->path, $ctx ) ) { return $out; } break; case self::ZONE_CONTENT_TOP: $out = ContentDirRules::inspectContentTop( $f, $ctx ); break; case self::ZONE_ROOT: case self::ZONE_CORE_DIR: $out = CoreRules::inspect( $f, $ctx ); break; case self::ZONE_OTHER: $out = self::otherFolder( $f ); break; } if ( HtaccessRules::applies( $f->path ) ) { $out = array_merge( $out, HtaccessRules::inspect( $f, $ctx ) ); } return $out; } /** * PHP file of a root sub-folder other than wp-admin/, wp-includes/ and the content * folder (Q-01): suspect in .well-known/ or cgi-bin/, otherwise a member of the * "to identify" line of its folder. * * @return Finding[] */ private static function otherFolder( FileEntry $f ): array { if ( ! Text::isPhpFile( $f->path ) ) { return array(); } $folder = substr( $f->path, 0, (int) strpos( $f->path, '/' ) ); if ( in_array( strtolower( $folder ), Assumptions::NO_PHP_ROOT_FOLDERS, true ) ) { return array( Finding::make( 'other_dir_suspect_php', $f->path, array( $f->path, $folder ) ) ); } return array( Finding::make( 'other_dir_php', $f->path, array( $f->path, $folder ) ) ); } /** A plugin file listed in its WordPress.org checksums is covered by control 2 only. */ private static function coveredByPluginSums( string $path, Context $ctx ): bool { $rel = substr( $path, strlen( $ctx->pluginsDir ) + 1 ); $pos = strpos( $rel, '/' ); if ( false === $pos ) { return false; } $slug = substr( $rel, 0, $pos ); return isset( $ctx->pluginChecksums[ $slug ] ) && $ctx->pluginChecksums[ $slug ]->has( substr( $rel, $pos + 1 ) ); } /** Path of the core checksums this file stands for, or null (root, wp-admin/, wp-includes/ only). */ public static function coreKey( FileEntry $f, Context $ctx ) { $zone = self::zone( $f->path, $ctx ); if ( self::ZONE_ROOT !== $zone && self::ZONE_CORE_DIR !== $zone ) { return null; } if ( null === $ctx->coreChecksums || ! isset( $ctx->coreChecksums->sums[ $f->path ] ) ) { return null; } return $f->path; } }