diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/security-checklist.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/security-checklist.php new file mode 100644 index 0000000000..26be24f466 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/security-checklist.php @@ -0,0 +1,539 @@ +queries = []; + + if ( is_object( $wp_object_cache ) ) { + $wp_object_cache->cache = []; + $wp_object_cache->group_ops = []; + $wp_object_cache->memcache_debug = []; + $wp_object_cache->stats = [ 'get' => 0, 'delete' => 0, 'add' => 0 ]; + } +} + +function is_caped_user_login( $user_login ) { + $user = get_user_by( 'login', $user_login ); + return is_caped( $user->ID ); +} + +function get_cves_for_plugin( $slug ) { + static $cve_data; + + if ( is_null( $cve_data ) ) { + $fp = fopen( __DIR__ . '/cve-all.csv', 'r' ); + while ( $line = fgetcsv( $fp ) ) { + list( $cve_id, $date, $plugin_slug, $comparator, $version, $url ) = $line; + @$cve_data[ $plugin_slug ][] = $line; + } + fclose( $fp ); + } + + return $cve_data[ $slug ] ?? []; + +} + +function find_version_after_cve( $cve, $releases ) { + $last = null; + if ( !$releases || !is_array( $releases ) ) { + return; + } + + // Loop backwards through releases and return the one before the first affected version + usort( $releases, function( $a, $b ) { return version_compare( $b['version'], $a['version'] ); } ); + + foreach ( $releases as $release ) { + if ( check_version_against_cve( $release['version'], $cve ) ) { + #var_dump( $release['version'] . ' affected at ' . $cve[3] . ' ' . $cve[4] ); + return $last; + } + $last = $release; + #var_dump( $release['version'] . ' unaffected at ' . $cve[3] . ' ' . $cve[4] ); + } +} + +function check_version_against_cve( $version, $cve ) { + $ops = [ + 'lt' => '<', + 'lte' => '<=', + 'gt' => '>', + 'gte' => '>=', + 'eq' => '=', + ]; + + // true = affected, false = unaffected + return version_compare( $version, $cve[4], $ops[ $cve[3] ] ); +} + +function check_version_against_all_cves( $version, $cves ) { + foreach ( $cves as $cve ) { + if ( !$cve[4] ) { + continue; // skip cves with unknown versions + }; + $affected = check_version_against_cve( $version, $cve ); + if ( $affected ) { + return $cve[0]; // Return the CVE ID + } + } + return false; +} + +function get_trunk_readme_txt( $slug ) { + $readme_url = 'https://plugins.svn.wordpress.org/' . urlencode( $slug ) . '/trunk/'; + + $tmpdir = Filesystem::temp_directory( "readme-{$slug}" ); + + $r = SVN::export( $readme_url, $tmpdir . '/trunk' ); + + $readme = Import::find_readme_file( $tmpdir . '/trunk' ); + + return new Readme_Parser( $readme ); +} + +function print_checklist_item( $checked, $message, $indent = 0 ) { + echo str_repeat( ' ', $indent ); + echo ( $checked ? '✅' : '❌' ); + if ( is_array( $message ) ) { + echo ' ' . ( $checked ? $message[0] : $message[1] ) . "\n"; + } else { + echo ' ' . $message . "\n"; + } +} + +function display_checklist_for_plugin( $plugin_slug, $include_closed = true ) { + global $action_required_2fa; + global $action_required_2fa_plugins; + global $action_required_dormant; + global $action_required_old_committer; + global $action_required_unmapped_committer; + global $last_login; + global $map_usernames; + + $post = Plugin_Directory::get_plugin_post( $plugin_slug ); + + if ( !$post || is_wp_error( $post ) ) { + die( "Unknown post $plugin_slug\n" ); + } + + if ( 'closed' === $post->post_status && !$include_closed ) { + return false; + } + + echo "Plugin: {$post->post_title}\n"; + echo "Slug: {$post->post_name}\n"; + echo "Status: {$post->post_status}\n"; + echo "Installs: " . number_format( $post->active_installs ) . "\n"; + + print_checklist_item( $post->release_confirmation, "Release Confirmation" ); + print_checklist_item( $post->stable_tag && 'trunk' !== $post->stable_tag, "Stable tag {$post->stable_tag}" ); + $readme = get_trunk_readme_txt( $post->post_name ); + if ( $readme ) { + if ( $readme->stable_tag !== 'trunk' && $post->stable_tag === 'trunk' ) { + print_checklist_item( false, "Readme Stable Tag {$readme->stable_tag} does not exist, current release is trunk!", 1 ); + } + } + + print_checklist_item( !Template::is_plugin_outdated( $post ), "Tested Up To WP {$post->tested}" ); + + $committers = Tools::get_plugin_committers( $post ); + $has_2fa = []; + $last_login = []; + + foreach ( $committers as $committer ) { + $user = get_user_by( 'login', $committer ); + $has_2fa[ $committer ] = Two_Factor_Core::is_user_using_two_factor( $user ); + $last_login[ $committer ] = $user->last_logged_in; + if ( $map_usernames && !isset( $map_usernames[ strtolower( $committer ) ] ) ) { + $action_required_unmapped_committer[ $committer ][] = $post->post_name; + } + } + + print_checklist_item( count( array_filter( $has_2fa ) ) === count( $committers ), "All committers use 2FA" ); + if ( count( array_filter( $has_2fa ) ) !== count( $committers ) ) { + foreach ( $has_2fa as $login => $_has_2fa ) { + print_checklist_item( $_has_2fa, "$login 2FA", 1 ); + if ( !$_has_2fa ) { + $action_required_2fa[] = $login; + $action_required_2fa_plugins[] = $post->post_name; + } + } + } + + foreach ( $last_login as $committer => $last_logged_in ) { + if ( !$last_logged_in ) { + print_checklist_item( false, "$committer never logged in!" ); + } else { + $now = new \DateTime( 'now' ); + $last = new \DateTime( $last_logged_in ); + $since = date_diff( $last, $now ); + if ( $since->days > 180 ) { + print_checklist_item( false, "$committer last logged in {$since->days} days ago" ); + $action_required_dormant[] = $committer; + } + } + } + + global $wpdb; + $commit_dates = $wpdb->get_results( $wpdb->prepare( + "SELECT username, MAX(pubdate) as most_recent_commit FROM trac_plugins WHERE `slug` = %s AND category = 'changeset' GROUP BY username ORDER BY username", + $post->post_name + ) ); + + if ( $commit_dates ) { + $committer_dates = wp_list_pluck( $commit_dates, 'most_recent_commit', 'username' ); + foreach ( $committers as $committer ) { + if ( empty( $committer_dates[ $committer ] ) ) { + print_checklist_item( false, "$committer has no commits" ); + } else { + $now = new \DateTime( 'now' ); + $last = new \DateTime( $committer_dates[ $committer ] ); + $since = date_diff( $last, $now ); + if ( $since->days > 365 ) { + print_checklist_item( false, "$committer last commit was {$since->days} days ago" ); + } + } + } + } + + $emails = Tools::get_helpscout_emails( $post ); + $last_email_date = null; + $last_email_login = null; + + foreach( $emails as $email ) { + if ( $email->user_id ) { + $email_user = get_user_by( 'id', $email->user_id ); + if ( in_array( $email_user->user_login, $committers ) ) { + $last_email_date = $email->modified; + $last_email_login = $email_user->user_login; + break; // Stop at the most recent + } + } + } + + $now = new \DateTime( 'now' ); + $last = new \DateTime( $last_email_date ); + $since = date_diff( $last, $now ); + print_checklist_item( $last_email_date && $since->days < 180, "Recent reply to PRT $last_email_login" ); + + foreach ( get_cves_for_plugin( $post->post_name ) as $cve ) { + print_checklist_item( !check_version_against_cve( $post->version, $cve ), "CVE {$cve[0]}" ); + $fixed_in_release = find_version_after_cve( $cve, $post->releases ); + if ( $fixed_in_release ) { + $cve_date = new \DateTime( $cve[1] ); + $fix_date = new \DateTime( '@' . $fixed_in_release['date'] ); + $time_to_fix = date_diff( $fix_date, $cve_date ); + $fix_version = $fixed_in_release['version']; + print_checklist_item( $time_to_fix->days < 30, "CVE fixed in $time_to_fix->days days in $fix_version", 1 ); + } else { + print_checklist_item( false, "fix unknown {$cve[4]} {$cve[3]}", 1 ); + } + + } + + #var_dump( $post->_import_warnings ); + + $recent_releases = array_slice( $post->releases ?: [], 0, 10 ); + #var_dump( $recent_releases ); + echo "Current version $post->version\n"; + echo human_time_diff( get_post_modified_time( 'U', true, $post ), current_time( 'U', true ) ) . "\n"; + display_version_matrix( $post->post_name, $post->version ); + + echo "Recent Releases:\n"; + foreach ( $recent_releases as $release ) { + echo strftime( '%Y-%m-%d %H:%M', $release['date'] ) . "\t" . $release['tag'] . "\n"; + if ( $release['tag'] !== $release['version'] ) { + print_checklist_item( false, "Tag '{$release['tag']}' does not match version '{$release['version']}'", 1 ); + } + print_checklist_item( $release['confirmed'], [ 'Confirmed', 'Unconfirmed' ], 1 ); + print_checklist_item( !empty( $release['committer'] ), [ 'Committer: ' . join( ', ', $release['committer'] ), 'Unknown committer' ], 1 ); + } + + #var_dump( $post->tags ); + # + $yesterday = (new \DateTime('yesterday'))->format( 'Y-m-d' ); + $version_usage = $wpdb->get_results( $wpdb->prepare( "SELECT rev2_plugin_daily_stats.plugin_id, plugin_name, name, `value`, `count` FROM `plugin_list` LEFT JOIN `rev2_plugin_daily_stats` USING (plugin_id) WHERE plugin_name= %s AND `date` = %s AND name='usage' GROUP BY rev2_plugin_daily_stats.plugin_id, name, `value` ORDER BY `value` DESC", $post->post_name, $yesterday ) ); + #var_dump( $version_usage ); + $all_cves = get_cves_for_plugin( $post->post_name ); + #var_dump( 'all_cves', $all_cves ); + $total_usage = 0; + $usage_secure = 0; + $usage_insecure = 0; + $cve_stats = []; + foreach ( $version_usage as $usage ) { + $total_usage += $usage->count; + if ( $cve_id = check_version_against_all_cves( $usage->value, $all_cves ) ) { + $usage_insecure += $usage->count; + #print_checklist_item( false, "Insecure version {$usage->value} ($usage->count)" ); + @$cve_stats[ $cve_id ] += $usage->count; + } else { + $usage_secure += $usage->count; + } + } + echo number_format( $usage_secure / $total_usage * 100.0 ) . "% running secure versions\n"; + echo number_format( $usage_insecure / $total_usage * 100.0 ) . "% running insecure versions\n"; + foreach ( $cve_stats as $cve_id => $cve_count ) { + echo "\t" . $cve_id . ': ' . number_format( $cve_count / $total_usage * 100.0 ) . "%\n"; + } + + return true; +} + +function display_version_matrix( $plugin_slug, $plugin_version ) { + global $wpdb; + + #$version_usage = $wpdb->get_results( $wpdb->prepare( "SELECT rev2_plugin_daily_stats.plugin_id, plugin_name, name, `value`, `count` FROM `plugin_list` LEFT JOIN `rev2_plugin_daily_stats` USING (plugin_id) WHERE plugin_name= %s AND `date` = %s AND name='usage' GROUP BY rev2_plugin_daily_stats.plugin_id, name, `value` ORDER BY `value` DESC", $post->post_name, $yesterday ) ); + + $plugin_id = $wpdb->get_var( $wpdb->prepare( "SELECT plugin_id FROM plugin_list WHERE plugin_name = %s", $plugin_slug ) ); + if ( !$plugin_id ) { + #var_dump( $plugin_id, $wpdb->last_query, $wpdb->last_error ); + return false; + } + #$versions = $wpdb->get_results( $wpdb->prepare( "SELECT version, version_desc, count(*) FROM `plugins_installed` INNER JOIN wp_versions USING(url_id) INNER JOIN wp_version_list USING(wp_version_id) WHERE plugin_id = %d GROUP BY version, wp_version_id ORDER BY version, version_desc DESC + $versions = $wpdb->get_results( $wpdb->prepare( "SELECT version_desc, count(*) AS cc FROM `plugins_installed` INNER JOIN wp_versions USING(url_id) INNER JOIN wp_version_list USING(wp_version_id) WHERE plugin_id = %d AND version = %s GROUP BY wp_version_id ORDER BY version_desc DESC", $plugin_id, $plugin_version ) ); + #var_dump( $wpdb->last_query, $wpdb->last_error, $versions ); + $max_cc = max( wp_list_pluck( $versions, 'cc' ) ); + $total = array_sum( wp_list_pluck( $versions, 'cc' ) ); + $skipped = 0; + for ( $i=0; $i < min( 10, count($versions) ); $i++ ) { + if ( $versions[$i]->cc / $total < 0.001 ) { + $skipped += $versions[$i]->cc; + continue; // skip very small numbers + } + echo str_pad( $versions[$i]->version_desc, 20 ); + echo str_pad( number_format( $versions[$i]->cc / $total * 100.0, 1 ) . '%', 8); + echo str_repeat( '▧', $versions[$i]->cc / $max_cc * 50 ); + echo "\n"; + } + // Everything not explicitly displayed + $remainder = $skipped + array_sum( wp_list_pluck( array_slice( $versions, 10 ), 'cc' ) ); + if ( $remainder ) { + echo "Others "; + echo str_pad( number_format( $remainder / $total * 100.0, 1 ) . '%', 8 ); + echo "\n"; + } + echo number_format( $total ), " total\n"; + +} + +$action_required_2fa = []; +$action_required_dormant = []; +$map_usernames = []; + +if ( isset( $opts['mapfile'] ) ) { + $fp = fopen( $opts['mapfile'], 'r' ); + while ( $line = fgetcsv( $fp ) ) { + $map_usernames[ strtolower( $line[3] ) ] = strtolower( $line[1] ); + } + fclose( $fp ); +} + +if ( isset( $opts['caped'] ) ) { + global $comitters; + global $supes; + global $emeritus_committers; + global $watch_passwords; + + $caped_users = array_merge( $supes, array_keys($committers), $emeritus_committers, $watch_passwords ); + sort( $caped_users ); + $caped_users = array_unique( $caped_users ); + foreach ( $caped_users as $caped_user ) { + $user = get_user_by( 'login', $caped_user ); + print_checklist_item( Two_Factor_Core::is_user_using_two_factor( $user ), "caped user $caped_user 2FA" ); + } + exit; +} + +if ( $opts['plugin'] ) { + display_checklist_for_plugin( $opts['plugin'] ); + echo "\n\n"; + if ( count( $action_required_unmapped_committer ) ) { + echo "Users with unmapped usernames:\n"; + foreach ( $action_required_unmapped_committer as $committer => $plugins ) { + echo $committer . ' (' . join( ', ', $plugins ) . ")\n"; + } + echo "\n\n"; + } + +} +if ( $opts['author'] ) { + $plugins = []; + $authors = explode( ',', $opts['author'] ); + foreach ( $authors as $author ) { + $author_plugins = Tools::get_users_write_access_plugins( $author ); + if ( $author_plugins ) { + $plugins = array_merge( $plugins, $author_plugins ); + } + } + $plugins = array_unique( $plugins ); + foreach ( $plugins as $plugin ) { + if ( display_checklist_for_plugin( $plugin, false ) ) { + echo "\n\n"; + } + } + + if ( $action_required_2fa ) { + sort( $action_required_2fa ); + echo "Users requiring action (2FA):\n\n"; + foreach ( array_unique( $action_required_2fa ) as $username ) { + $username = strtolower( $username ); + echo is_caped_user_login( $username ) ? "$username (caped!)" : "$username"; + if ( isset( $map_usernames[ $username ] ) ) { + echo " (@{$map_usernames[$username]})"; + } + if ( in_array( $username, $action_required_dormant ) ) { + echo " *"; + } + echo "\n"; + } + echo "\n\n"; + } + + if ( $action_required_dormant ) { + sort( $action_required_dormant ); + echo "Users requiring action (dormant committer account):\n\n"; + foreach ( array_unique( $action_required_dormant ) as $username ) { + $username = strtolower( $username ); + echo is_caped_user_login( $username ) ? "$username (caped!)" : "$username"; + if ( isset( $map_usernames[ $username ] ) ) { + echo " @{$map_usernames[$username]}"; + } + echo "\n"; + } + echo "\n\n"; + } + + if ( count( $action_required_unmapped_committer ) ) { + echo "Users with unmapped usernames:\n"; + foreach ( $action_required_unmapped_committer as $committer => $plugins ) { + echo $committer . ' (' . join( ', ', $plugins ) . ")\n"; + } + echo "\n\n"; + } + +} +if ( $opts['top'] || $opts['new'] ) { + while (@ob_end_flush()); + + if ( $opts['top'] ) { + $args = [ + 'post_status' => 'publish', + 'post_type' => 'plugin', + 'meta_key' => 'active_installs', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + 'posts_per_page' => intval( $opts['top'] ?: 5 ) + ]; + } else { + $args = [ + 'post_status' => 'publish', + 'post_type' => 'plugin', + 'orderby' => 'post_modified_gmt', + 'order' => 'DESC', + 'posts_per_page' => intval( $opts['new'] ?: 5 ) + ]; + if ( $opts['installs'] > 0 ) { + $args[ 'meta_key' ] = 'active_installs'; + $args[ 'meta_value' ] = intval( $opts['installs'] ); + $args[' meta_compare' ] = '>='; + } + var_dump( $args ); + } + + $last_active_installs = null; + $q = new \WP_Query( $args ); + var_dump( $q->request ); + foreach ( $q->posts as $i => $post ) { + echo "#$i\n"; + display_checklist_for_plugin( $post->post_name ); + $last_active_installs = $post->active_installs; + echo "\n\n"; + clear_memory_caches(); + flush(); + } + + if ( $action_required_2fa ) { + sort( $action_required_2fa ); + echo "Users requiring action (2FA):\n\n"; + foreach ( array_unique( $action_required_2fa ) as $username ) { + echo is_caped_user_login( $username ) ? "@$username (caped!)\n" : "@$username\n"; + } + echo number_format( count( $action_required_2fa ) ) . " users\n"; + echo "\n\n"; + } + + $action_required_2fa_plugins = array_unique( $action_required_2fa_plugins ); + if ( count( $action_required_2fa_plugins ) ) { + foreach ( $action_required_2fa_plugins as $plugin ) { + echo $plugin . "\n"; + } + echo number_format( count( array_unique( $action_required_2fa_plugins ) ) ) . " plugins require 2FA action\n"; + echo "\n\n"; + } + + echo number_format( $last_active_installs ) . " last active installs\n"; +}