captaincore-manager/app/ScanQueueCLI.php
Austin Ginder 6ec5f8f27e 🐛 FIX: Filter
2026-03-23 11:15:27 -04:00

346 lines
11 KiB
PHP

<?php
namespace CaptainCore;
class ScanQueueCLI {
/**
* Generate a prioritized scan queue for Security Finder audits.
*
* Queries all production environments, checks audit coverage via
* Security Finder's scan-queue API, and returns the top sites
* ranked by number of unaudited components.
*
* ## OPTIONS
*
* [--limit=<number>]
* : Number of sites to return. Default 5.
*
* [--format=<format>]
* : Output format. Accepts table, json, csv. Default table.
*
* [--all]
* : Show all sites needing audits, not just the top N.
*
* ## EXAMPLES
*
* wp captaincore scan-queue
* wp captaincore scan-queue --limit=10
* wp captaincore scan-queue --format=json
* wp captaincore scan-queue --all --format=csv
*
* @when after_wp_load
*/
public function __invoke( $args = [], $assoc_args = [] ) {
$limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 5;
$format = $assoc_args['format'] ?? 'table';
$show_all = isset( $assoc_args['all'] );
$quiet = $format !== 'table';
$log = function( $msg ) use ( $quiet ) {
if ( ! $quiet ) { \WP_CLI::log( $msg ); }
};
// Step 1: Gather all production environments with SSH + plugin/theme data
$log( 'Gathering production environment data...' );
$sites_data = self::gather_sites();
$log( sprintf( 'Found %d active production sites.', count( $sites_data ) ) );
// Step 2: Build site component lists and SSH map (keyed by site_id for uniqueness)
$payload = [];
$ssh_map = [];
foreach ( $sites_data as $site ) {
$components = [];
if ( ! empty( $site->plugins ) ) {
$plugins = json_decode( $site->plugins );
if ( is_array( $plugins ) ) {
foreach ( $plugins as $p ) {
if ( $p->status === 'active' || $p->status === 'must-use' ) {
$components[] = [
'slug' => $p->name,
'version' => $p->version,
'type' => $p->status === 'must-use' ? 'mu-plugin' : 'plugin',
];
}
}
}
}
if ( ! empty( $site->themes ) ) {
$themes = json_decode( $site->themes );
if ( is_array( $themes ) ) {
foreach ( $themes as $t ) {
if ( $t->status === 'active' ) {
$components[] = [
'slug' => $t->name,
'version' => $t->version,
'type' => 'theme',
];
}
}
}
}
if ( empty( $components ) ) {
continue;
}
// Build site slug from full domain (strip protocol, www, trailing slashes, and path)
$domain = $site->home_url ? preg_replace( '#^https?://(www\.)?#', '', $site->home_url ) : $site->name;
$domain = preg_replace( '#/.*$#', '', $domain ); // strip path
$site_slug = preg_replace( '/[^a-z0-9]/', '', $domain );
$site_id = (int) $site->site_id;
$payload[] = [
'site_id' => $site_id,
'site_slug' => $site_slug,
'domain' => $domain,
'components' => $components,
];
// Store SSH connection info keyed by site_id (guaranteed unique)
if ( ! empty( $site->address ) && ! empty( $site->username ) ) {
$ssh = "{$site->username}@{$site->address}";
if ( ! empty( $site->port ) && $site->port !== '22' ) {
$ssh .= " -p {$site->port}";
}
$ssh_map[ $site_id ] = [
'ssh' => $ssh,
'domain' => $domain,
'site_id' => $site_id,
'name' => $site->name,
'provider' => $site->provider,
'home_directory' => $site->home_directory,
];
}
}
// Step 3: Check audit coverage — try local MySQL tables, fall back to HTTP API
$log( 'Checking audit coverage via Security Finder...' );
$local_result = self::scan_queue_local( $payload, $log );
$all_queue = $local_result !== false ? $local_result : null;
if ( $all_queue === null ) {
// Fall back to HTTP API
$api_url = self::api_url();
if ( ! $api_url ) {
\WP_CLI::error( 'Security Finder tables not found and SECURITY_FINDER_API_URL is not configured.' );
return;
}
$all_queue = [];
$chunks = array_chunk( $payload, 200 );
$progress = $quiet ? null : \WP_CLI\Utils\make_progress_bar( 'Processing', count( $chunks ) );
foreach ( $chunks as $chunk ) {
$response = wp_remote_post( $api_url . '/api.php?action=scan-queue', [
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( $chunk ),
'timeout' => 60,
'sslverify' => false,
] );
if ( is_wp_error( $response ) ) {
\WP_CLI::warning( 'API error: ' . $response->get_error_message() );
if ( $progress ) { $progress->tick(); }
continue;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! empty( $body['queue'] ) ) {
$all_queue = array_merge( $all_queue, $body['queue'] );
}
if ( $progress ) { $progress->tick(); }
}
if ( $progress ) { $progress->finish(); }
}
if ( empty( $all_queue ) ) {
\WP_CLI::success( 'All sites are fully covered! No audits needed.' );
return;
}
// Re-sort combined results
usort( $all_queue, function ( $a, $b ) {
$diff = $b['needs_audit'] - $a['needs_audit'];
if ( $diff !== 0 ) return $diff;
return $b['total_components'] - $a['total_components'];
} );
// Trim to limit
if ( ! $show_all ) {
$all_queue = array_slice( $all_queue, 0, $limit );
}
// Step 4: Enrich with SSH connection strings (keyed by site_id)
$output = [];
foreach ( $all_queue as $item ) {
$ssh_info = $ssh_map[ $item['site_id'] ] ?? null;
$row = [
'site_slug' => $item['site_slug'],
'domain' => $item['domain'],
'needs_audit' => $item['needs_audit'],
'total' => $item['total_components'],
'coverage' => $item['coverage_pct'] . '%',
'ssh' => $ssh_info ? $ssh_info['ssh'] : 'N/A',
];
$output[] = $row;
}
$log( '' );
$log( sprintf( 'Sites needing audits: %d (showing %s)', count( $all_queue ), $show_all ? 'all' : "top $limit" ) );
if ( $format === 'json' ) {
// For JSON, include full details including unaudited_slugs and SSH
$json_output = [];
foreach ( $all_queue as $item ) {
$ssh_info = $ssh_map[ $item['site_id'] ] ?? null;
$json_output[] = array_merge( $item, [
'ssh' => $ssh_info ? $ssh_info['ssh'] : null,
'site_id' => $ssh_info ? $ssh_info['site_id'] : $item['site_id'],
'name' => $ssh_info ? $ssh_info['name'] : null,
'provider' => $ssh_info ? $ssh_info['provider'] : null,
'home_directory' => $ssh_info ? $ssh_info['home_directory'] : null,
] );
}
echo wp_json_encode( $json_output, JSON_PRETTY_PRINT ) . "\n";
return;
}
\WP_CLI\Utils\format_items( $format, $output, [ 'site_slug', 'domain', 'needs_audit', 'total', 'coverage', 'ssh' ] );
}
/**
* Gather all production environments with SSH credentials and plugin/theme data.
*/
private static function gather_sites() {
global $wpdb;
$sites_table = $wpdb->prefix . 'captaincore_sites';
$env_table = $wpdb->prefix . 'captaincore_environments';
return $wpdb->get_results( "
SELECT s.site_id, s.name, s.provider,
e.address, e.username, e.port, e.home_url, e.home_directory,
e.plugins, e.themes
FROM {$env_table} e
JOIN {$sites_table} s ON e.site_id = s.site_id
WHERE s.status = 'active'
AND s.provider IS NOT NULL
AND e.environment = 'Production'
AND e.home_url IS NOT NULL AND e.home_url != ''
AND e.plugins IS NOT NULL
ORDER BY s.name ASC
" );
}
/**
* Check audit coverage directly via local MySQL tables.
* Returns array of queue items, or false if tables don't exist.
*/
private static function scan_queue_local( array $sites, callable $log ) {
global $wpdb;
$audits_t = "{$wpdb->prefix}captaincore_sf_audits";
$components_t = "{$wpdb->prefix}captaincore_sf_components";
$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $audits_t ) );
if ( empty( $table_exists ) ) {
return false;
}
$log( 'Using local MySQL tables for audit coverage check.' );
$results = [];
foreach ( $sites as $site ) {
$site_id = $site['site_id'] ?? 0;
$site_slug = $site['site_slug'] ?? '';
$components = $site['components'] ?? [];
if ( ! $site_slug || empty( $components ) ) {
continue;
}
$total = count( $components );
$audited = 0;
$stale = 0;
$unaudited = 0;
$unaudited_list = [];
foreach ( $components as $comp ) {
$slug = $comp['slug'] ?? '';
$version = $comp['version'] ?? '';
$type = $comp['type'] ?? '';
if ( ! $slug ) {
continue;
}
if ( $type === 'mu-plugin' && ( $version === '' || $version === null ) ) {
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT c.id, a.audit_date FROM {$components_t} c JOIN {$audits_t} a ON c.audit_id = a.id WHERE c.slug = %s AND c.component_type = %s ORDER BY a.audit_date DESC LIMIT 1",
$slug, $type
) );
} else {
if ( $type ) {
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT c.id, a.audit_date FROM {$components_t} c JOIN {$audits_t} a ON c.audit_id = a.id WHERE c.slug = %s AND c.version = %s AND c.component_type = %s ORDER BY a.audit_date DESC LIMIT 1",
$slug, $version, $type
) );
} else {
$row = $wpdb->get_row( $wpdb->prepare(
"SELECT c.id, a.audit_date FROM {$components_t} c JOIN {$audits_t} a ON c.audit_id = a.id WHERE c.slug = %s AND c.version = %s ORDER BY a.audit_date DESC LIMIT 1",
$slug, $version
) );
}
}
if ( ! $row ) {
$unaudited++;
$unaudited_list[] = $slug;
} else {
$days_ago = (int) ( ( time() - strtotime( $row->audit_date ) ) / 86400 );
if ( $days_ago > 90 ) {
$stale++;
$unaudited_list[] = $slug;
} else {
$audited++;
}
}
}
$needs_audit = $unaudited + $stale;
if ( $needs_audit === 0 ) {
continue;
}
$results[] = [
'site_id' => $site_id,
'site_slug' => $site_slug,
'domain' => $site['domain'] ?? $site_slug,
'total_components' => $total,
'audited' => $audited,
'stale' => $stale,
'unaudited' => $unaudited,
'needs_audit' => $needs_audit,
'coverage_pct' => $total > 0 ? round( ( $audited / $total ) * 100 ) : 0,
'unaudited_slugs' => $unaudited_list,
];
}
return $results;
}
/**
* Get the Security Finder API base URL.
*/
private static function api_url() {
if ( defined( 'SECURITY_FINDER_API_URL' ) ) {
return rtrim( SECURITY_FINDER_API_URL, '/' );
}
return get_option( 'security_finder_api_url', '' );
}
}