mirror of
https://gh.llkk.cc/https://github.com/CaptainCore/captaincore-manager.git
synced 2026-07-27 14:03:52 +08:00
990 lines
36 KiB
PHP
990 lines
36 KiB
PHP
<?php
|
||
|
||
namespace CaptainCore;
|
||
|
||
class ComponentQueueCLI {
|
||
|
||
/**
|
||
* Generate a prioritized queue of un-audited component hashes across the fleet.
|
||
*
|
||
* Unlike scan-queue (site-centric), this command returns unique component
|
||
* builds that need auditing, deduplicated by content hash. For each hash,
|
||
* it picks one source site to download from.
|
||
*
|
||
* ## OPTIONS
|
||
*
|
||
* [--limit=<number>]
|
||
* : Number of components to return. Default 20.
|
||
*
|
||
* [--format=<format>]
|
||
* : Output format. Accepts table, json. Default table.
|
||
*
|
||
* [--all]
|
||
* : Show all un-audited hashes, not just the top N.
|
||
*
|
||
* [--model=<id>]
|
||
* : Canonical auditor ID (e.g. claude-opus-4-7). When set, hashes audited only by
|
||
* other models are still returned — supports layered multi-model audits.
|
||
*
|
||
* ## EXAMPLES
|
||
*
|
||
* wp captaincore component-queue
|
||
* wp captaincore component-queue --limit=50
|
||
* wp captaincore component-queue --format=json
|
||
* wp captaincore component-queue --model=claude-opus-4-7
|
||
*
|
||
* @when after_wp_load
|
||
*/
|
||
public function __invoke( $args = [], $assoc_args = [] ) {
|
||
$limit = isset( $assoc_args['limit'] ) ? (int) $assoc_args['limit'] : 20;
|
||
$format = $assoc_args['format'] ?? 'table';
|
||
$show_all = isset( $assoc_args['all'] );
|
||
$model = isset( $assoc_args['model'] ) ? (string) $assoc_args['model'] : '';
|
||
|
||
$quiet = $format !== 'table';
|
||
$log = function( $msg ) use ( $quiet ) {
|
||
if ( ! $quiet ) { \WP_CLI::log( $msg ); }
|
||
};
|
||
|
||
$log( 'Gathering fleet component inventory...' );
|
||
$sites_data = self::gather_sites();
|
||
$log( sprintf( 'Found %d active production sites.', count( $sites_data ) ) );
|
||
|
||
// Build a map of unique hashes → component info + source sites
|
||
$hash_map = []; // hash → {slug, version, type, sites, source_ssh}
|
||
$no_hash = []; // slug|version|type → {slug, version, type, sites, source_ssh} (legacy, no hash)
|
||
|
||
foreach ( $sites_data as $site ) {
|
||
$ssh = '';
|
||
if ( ! empty( $site->address ) && ! empty( $site->username ) ) {
|
||
$ssh = "{$site->username}@{$site->address}";
|
||
if ( ! empty( $site->port ) && $site->port !== '22' ) {
|
||
$ssh .= " -p {$site->port}";
|
||
}
|
||
}
|
||
|
||
$components = self::extract_components( $site );
|
||
|
||
foreach ( $components as $comp ) {
|
||
$hash = $comp['hash'] ?? '';
|
||
|
||
if ( $hash ) {
|
||
if ( ! isset( $hash_map[ $hash ] ) ) {
|
||
$hash_map[ $hash ] = [
|
||
'hash' => $hash,
|
||
'slug' => $comp['slug'],
|
||
'version' => $comp['version'],
|
||
'type' => $comp['type'],
|
||
'title' => $comp['title'] ?? $comp['slug'],
|
||
'sites' => 0,
|
||
'source_ssh' => $ssh,
|
||
];
|
||
}
|
||
$hash_map[ $hash ]['sites']++;
|
||
} else {
|
||
$key = "{$comp['type']}|{$comp['slug']}|{$comp['version']}";
|
||
if ( ! isset( $no_hash[ $key ] ) ) {
|
||
$no_hash[ $key ] = [
|
||
'hash' => '',
|
||
'slug' => $comp['slug'],
|
||
'version' => $comp['version'],
|
||
'type' => $comp['type'],
|
||
'title' => $comp['title'] ?? $comp['slug'],
|
||
'sites' => 0,
|
||
'source_ssh' => $ssh,
|
||
];
|
||
}
|
||
$no_hash[ $key ]['sites']++;
|
||
}
|
||
}
|
||
}
|
||
|
||
$all_components = array_merge( array_values( $hash_map ), array_values( $no_hash ) );
|
||
$log( sprintf( 'Found %d unique component builds (%d with hashes, %d without).', count( $all_components ), count( $hash_map ), count( $no_hash ) ) );
|
||
|
||
// Check which hashes/components have been audited
|
||
$log( 'Checking audit coverage...' );
|
||
$unaudited = self::filter_unaudited( $all_components, $model );
|
||
|
||
if ( empty( $unaudited ) ) {
|
||
\WP_CLI::success( 'All component builds are audited! 100% coverage.' );
|
||
return;
|
||
}
|
||
|
||
// Sort by site count descending (most fleet exposure first)
|
||
usort( $unaudited, function ( $a, $b ) {
|
||
return $b['sites'] - $a['sites'];
|
||
} );
|
||
|
||
if ( ! $show_all ) {
|
||
$unaudited = array_slice( $unaudited, 0, $limit );
|
||
}
|
||
|
||
$log( '' );
|
||
$log( sprintf( 'Un-audited components: %d (showing %s)', count( $unaudited ), $show_all ? 'all' : "top $limit" ) );
|
||
|
||
if ( $format === 'json' ) {
|
||
echo wp_json_encode( $unaudited, JSON_PRETTY_PRINT ) . "\n";
|
||
return;
|
||
}
|
||
|
||
// Table output — truncate hash for display
|
||
$table_output = array_map( function( $item ) {
|
||
return [
|
||
'hash' => $item['hash'] ? substr( $item['hash'], 0, 12 ) . '...' : 'none',
|
||
'slug' => $item['slug'],
|
||
'version' => $item['version'] ?: '-',
|
||
'type' => $item['type'],
|
||
'sites' => $item['sites'],
|
||
'source' => $item['source_ssh'] ?: 'N/A',
|
||
];
|
||
}, $unaudited );
|
||
|
||
\WP_CLI\Utils\format_items( $format, $table_output, [ 'hash', 'slug', 'version', 'type', 'sites', 'source' ] );
|
||
}
|
||
|
||
/**
|
||
* Extract active plugins/themes from a site row, including hashes.
|
||
*/
|
||
public static function extract_components( $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',
|
||
'hash' => $p->hash ?? '',
|
||
'title' => html_entity_decode( $p->title ?? $p->name ),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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',
|
||
'hash' => $t->hash ?? '',
|
||
'title' => html_entity_decode( $t->title ?? $t->name ),
|
||
];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Loose files and mu-plugin metadata from environment details
|
||
if ( ! empty( $site->details ) ) {
|
||
$details = json_decode( $site->details );
|
||
|
||
// Note: _mu_plugins directory hash is intentionally excluded from the queue.
|
||
// MU-plugins are tracked via the manifest-based approach (individual slugs),
|
||
// not whole-directory hashes. The hash is still stored for drift detection.
|
||
|
||
// Core extra/modified file hashes
|
||
if ( ! empty( $details->core_file_hashes ) ) {
|
||
foreach ( $details->core_file_hashes as $path => $hash ) {
|
||
$components[] = [
|
||
'slug' => $path,
|
||
'version' => '',
|
||
'type' => 'file',
|
||
'hash' => $hash,
|
||
'title' => "core: $path",
|
||
];
|
||
}
|
||
}
|
||
|
||
// Loose wp-content PHP file hashes
|
||
if ( ! empty( $details->loose_file_hashes ) ) {
|
||
foreach ( $details->loose_file_hashes as $path => $hash ) {
|
||
$components[] = [
|
||
'slug' => $path,
|
||
'version' => '',
|
||
'type' => 'file',
|
||
'hash' => $hash,
|
||
'title' => "wp-content: $path",
|
||
];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $components;
|
||
}
|
||
|
||
/**
|
||
* Annotate each component with audit-state flags WITHOUT dropping anything.
|
||
* Used by slug-latest mode so it can see the full fleet (including audited
|
||
* versions) when picking each slug's actual latest version — then decide
|
||
* surfaceability per-slug instead of per-hash.
|
||
*
|
||
* Annotated keys:
|
||
* _audited_by_anyone — bool, THIS exact content hash exists in any-model manifest
|
||
* _audited_by_me — bool, this hash exists in $model's manifest (false when $model is '')
|
||
* _audit_tier — 0 if not audited by anyone OR audited only by me;
|
||
* 1 if audited by someone else but not by $model
|
||
* (the existing tier semantics callers already use)
|
||
* _code_audited — bool, the registry holds at least one audit of this
|
||
* component's (slug, version) under ANY hash. This is the
|
||
* "is this CODE reviewed?" signal — distinct from
|
||
* _audited_by_anyone (which is per exact content hash).
|
||
* A build resurfaces in the queue when a per-site file
|
||
* (cache, license token, .build stamp) forks its content
|
||
* hash; those new hashes are absent from the manifest
|
||
* (_audited_by_anyone=false) even though the code is fully
|
||
* audited (_code_audited=true). Callers use this to route
|
||
* a row to a cheap variant-merge instead of a full re-audit.
|
||
*
|
||
* Fail-open: if the registry isn't configured or the fetch fails, every
|
||
* component is annotated as tier-0 / un-audited so the queue still works.
|
||
*/
|
||
public static function annotate_tiers( array $components, string $model = '' ): array {
|
||
if ( ! RegistryClient::ready() ) {
|
||
foreach ( $components as &$c ) {
|
||
$c['_audit_tier'] = 0;
|
||
$c['_audited_by_anyone'] = false;
|
||
$c['_audited_by_me'] = false;
|
||
$c['_code_audited'] = false;
|
||
}
|
||
return $components;
|
||
}
|
||
|
||
$type_to_endpoint = [
|
||
'plugin' => 'plugins',
|
||
'theme' => 'themes',
|
||
'mu-plugin' => 'mu-plugins',
|
||
'file' => 'files',
|
||
];
|
||
|
||
$any = [];
|
||
$mine = [];
|
||
$needed_types = [];
|
||
foreach ( $components as $c ) {
|
||
$t = $c['type'] ?? '';
|
||
if ( isset( $type_to_endpoint[ $t ] ) ) {
|
||
$needed_types[ $t ] = true;
|
||
}
|
||
}
|
||
$any_sv = []; // [type]["slug|version"] => true — (slug,version) audited under ANY hash
|
||
foreach ( array_keys( $needed_types ) as $t ) {
|
||
$endpoint = $type_to_endpoint[ $t ];
|
||
$any[ $t ] = RegistryClient::manifest( $endpoint, '' );
|
||
$mine[ $t ] = $model !== '' ? RegistryClient::manifest( $endpoint, $model ) : [];
|
||
|
||
// The manifest is hash-keyed, but each entry carries its own slug+version.
|
||
// Roll those up into a (slug,version) coverage set so we can tell "this
|
||
// CODE is audited" even when the fleet's current content hashes are new
|
||
// per-site variants the manifest has never seen.
|
||
$sv = [];
|
||
if ( is_array( $any[ $t ] ) ) {
|
||
foreach ( $any[ $t ] as $meta ) {
|
||
if ( ! is_array( $meta ) ) {
|
||
continue;
|
||
}
|
||
$s = (string) ( $meta['slug'] ?? '' );
|
||
if ( $s === '' ) {
|
||
continue;
|
||
}
|
||
$sv[ $s . '|' . (string) ( $meta['version'] ?? '' ) ] = true;
|
||
}
|
||
}
|
||
$any_sv[ $t ] = $sv;
|
||
}
|
||
|
||
foreach ( $components as &$comp ) {
|
||
$hash = $comp['hash'] ?? '';
|
||
$type = $comp['type'] ?? '';
|
||
|
||
$any_manifest = $any[ $type ] ?? null;
|
||
$mine_manifest = $mine[ $type ] ?? [];
|
||
|
||
if ( $hash === '' || ! is_array( $any_manifest ) ) {
|
||
$comp['_audited_by_anyone'] = false;
|
||
$comp['_audited_by_me'] = false;
|
||
$comp['_audit_tier'] = 0;
|
||
$comp['_code_audited'] = false;
|
||
continue;
|
||
}
|
||
|
||
$by_anyone = isset( $any_manifest[ $hash ] );
|
||
$by_me = $model !== '' && isset( $mine_manifest[ $hash ] );
|
||
$comp['_audited_by_anyone'] = $by_anyone;
|
||
$comp['_audited_by_me'] = $by_me;
|
||
$comp['_audit_tier'] = ( $by_anyone && ! $by_me ) ? 1 : 0;
|
||
|
||
$sv_key = (string) ( $comp['slug'] ?? '' ) . '|' . (string) ( $comp['version'] ?? '' );
|
||
$comp['_code_audited'] = isset( $any_sv[ $type ][ $sv_key ] );
|
||
}
|
||
return $components;
|
||
}
|
||
|
||
/**
|
||
* Filter components to only those that haven't been audited (by the given
|
||
* model, if scoped) and tag each with an audit-state tier the caller can
|
||
* use for ordering. Reads from the WP Registry plugin's manifest via
|
||
* RegistryClient (direct REST, app-password auth, transient-cached).
|
||
*
|
||
* Returned items are annotated with a `_audit_tier` key:
|
||
* 0 = NEVER audited by anyone (highest priority — completely fresh)
|
||
* 1 = audited by another model, NOT by $model (older audit, layered re-audit eligible)
|
||
*
|
||
* When $model is empty, every kept item is tier 0 (any-model-unaudited).
|
||
*
|
||
* Fail-open: if the registry isn't configured or the fetch fails, every
|
||
* component is treated as tier-0 unaudited. That's strictly more work but
|
||
* never silently skips a real vulnerability.
|
||
*/
|
||
public static function filter_unaudited( array $components, string $model = '' ) {
|
||
$annotated = self::annotate_tiers( $components, $model );
|
||
|
||
$out = [];
|
||
foreach ( $annotated as $comp ) {
|
||
if ( ! empty( $comp['_audited_by_me'] ) ) {
|
||
continue; // covered by this model — skip entirely
|
||
}
|
||
if ( ! empty( $comp['_audited_by_anyone'] ) && $model === '' ) {
|
||
continue; // default queue: hide everything anyone has audited
|
||
}
|
||
$out[] = $comp;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* Get all distinct hashes for a given slug+version+type across the fleet,
|
||
* with source SSH info and site count per hash.
|
||
*/
|
||
public static function get_hashes_for_version( string $slug, string $version, string $type = 'plugin' ): array {
|
||
$sites_data = self::gather_sites();
|
||
$hashes = []; // hash → { hash, sites, source_ssh, home_directory }
|
||
|
||
foreach ( $sites_data as $site ) {
|
||
$ssh = '';
|
||
if ( ! empty( $site->address ) && ! empty( $site->username ) ) {
|
||
$ssh = "{$site->username}@{$site->address}";
|
||
if ( ! empty( $site->port ) && $site->port !== '22' ) {
|
||
$ssh .= " -p {$site->port}";
|
||
}
|
||
}
|
||
$home_directory = $site->home_directory ?? '';
|
||
|
||
$components = self::extract_components( $site );
|
||
|
||
foreach ( $components as $comp ) {
|
||
$h = $comp['hash'] ?? '';
|
||
if ( ! $h ) {
|
||
continue;
|
||
}
|
||
if ( $comp['slug'] !== $slug || $comp['version'] !== $version || $comp['type'] !== $type ) {
|
||
continue;
|
||
}
|
||
if ( ! isset( $hashes[ $h ] ) ) {
|
||
$hashes[ $h ] = [
|
||
'hash' => $h,
|
||
'sites' => 0,
|
||
'source_ssh' => $ssh,
|
||
'home_directory' => $home_directory,
|
||
];
|
||
}
|
||
$hashes[ $h ]['sites']++;
|
||
}
|
||
}
|
||
|
||
// Sort by site count descending
|
||
usort( $hashes, function ( $a, $b ) {
|
||
return $b['sites'] - $a['sites'];
|
||
} );
|
||
|
||
return array_values( $hashes );
|
||
}
|
||
|
||
/**
|
||
* Build a version-grouped queue: aggregate by slug+version instead of individual hash.
|
||
* Each entry includes total sites, number of distinct hashes, and the most common hash with source_ssh.
|
||
*/
|
||
public static function build_version_queue( array $hash_map ): array {
|
||
$version_map = []; // "type|slug|version" → aggregated data
|
||
|
||
foreach ( $hash_map as $hash => $comp ) {
|
||
$key = "{$comp['type']}|{$comp['slug']}|{$comp['version']}";
|
||
if ( ! isset( $version_map[ $key ] ) ) {
|
||
$version_map[ $key ] = [
|
||
'slug' => $comp['slug'],
|
||
'version' => $comp['version'],
|
||
'type' => $comp['type'],
|
||
'title' => $comp['title'] ?? $comp['slug'],
|
||
'sites' => 0,
|
||
'hashes_count' => 0,
|
||
'hash' => '',
|
||
'source_ssh' => '',
|
||
'home_directory' => '',
|
||
'audit_tier' => $comp['_audit_tier'] ?? 0,
|
||
'_max_sites' => -1,
|
||
'_primary_tier' => PHP_INT_MAX, // tier of the currently-selected primary hash
|
||
];
|
||
}
|
||
$version_map[ $key ]['sites'] += $comp['sites'];
|
||
$version_map[ $key ]['hashes_count']++;
|
||
|
||
// Worst-case tier wins: if any hash for this slug+version is fully
|
||
// unaudited (tier 0), the version-group is treated as unaudited.
|
||
$tier = (int) ( $comp['_audit_tier'] ?? 0 );
|
||
if ( $tier < $version_map[ $key ]['audit_tier'] ) {
|
||
$version_map[ $key ]['audit_tier'] = $tier;
|
||
}
|
||
|
||
// Pick the primary hash to surface to clients. Prefer a hash whose
|
||
// audit_tier is lowest (i.e. genuinely needs work), then within that
|
||
// tier prefer the most common hash. Otherwise the queue would point
|
||
// crews at an already-audited hash even though other variants for
|
||
// the same slug+version are still unaudited.
|
||
$current_tier = (int) $version_map[ $key ]['_primary_tier'];
|
||
$current_max = (int) $version_map[ $key ]['_max_sites'];
|
||
$is_better = $tier < $current_tier
|
||
|| ( $tier === $current_tier && $comp['sites'] > $current_max );
|
||
|
||
if ( $is_better ) {
|
||
$version_map[ $key ]['_primary_tier'] = $tier;
|
||
$version_map[ $key ]['_max_sites'] = $comp['sites'];
|
||
$version_map[ $key ]['hash'] = $comp['hash'];
|
||
$version_map[ $key ]['source_ssh'] = $comp['source_ssh'] ?? '';
|
||
$version_map[ $key ]['home_directory'] = $comp['home_directory'] ?? '';
|
||
}
|
||
}
|
||
|
||
// Remove internal tracking fields
|
||
$result = array_values( $version_map );
|
||
foreach ( $result as &$item ) {
|
||
unset( $item['_max_sites'], $item['_primary_tier'] );
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Per-slug aggregation for customer-safety prioritization.
|
||
*
|
||
* Takes the FULL annotated fleet view (output of annotate_tiers — every
|
||
* component, audited or not) and:
|
||
* 1. Groups by slug, walking every known fleet version.
|
||
* 2. Computes the slug's "auditable set" — the set of versions worth
|
||
* auditing for this slug:
|
||
* - wp.org plugins/themes: every fleet version whose version_compare
|
||
* is >= the version published on api.wordpress.org. Bogus stamps
|
||
* higher than wp.org-latest are in the set; stale older versions
|
||
* are excluded.
|
||
* - Premium components (api.wordpress.org returns no data): the
|
||
* fleet-effective-latest (highest version with at least
|
||
* max(2, ceil(1% × total slug sites)) sites running it, with a
|
||
* fallback to the absolute highest version when no version meets
|
||
* the bar) plus every version higher than it.
|
||
* 3. Surfaces ONE row per slug for the lowest-priority item still in the
|
||
* auditable set — bogus-first ordering, meaning highest-version first
|
||
* so customer-pinned/malware-suspect stamps get the first audit pass.
|
||
* The slug only drops out of the queue when EVERY hash in the
|
||
* auditable set has been audited under the policy.
|
||
*
|
||
* That keeps audit coverage anchored to a vendor-relevant version
|
||
* boundary instead of just "the highest fleet version" — a single
|
||
* customer-pinned 9999 stamp won't satisfy the slug's coverage when the
|
||
* real wp.org-latest is still un-audited.
|
||
*
|
||
* Row shape:
|
||
* slug — plugin/theme slug
|
||
* latest_version — version this row points at for audit (newest
|
||
* still-surfaceable version in the auditable set)
|
||
* sites — total fleet sites running ANY version of this slug
|
||
* versions_in_fleet — distinct version count
|
||
* hash — content hash for the audit target on that version
|
||
* source_ssh, home_directory — for downloading the audit target
|
||
* audit_tier — tier of the surfaced primary hash (0 = fresh,
|
||
* 1 = audited by another model, surfaced under
|
||
* model-mode for layered re-audit)
|
||
* type, title — passthroughs
|
||
* sites_on_latest — fleet sites running the surfaced version
|
||
*/
|
||
public static function build_slug_latest_queue( array $annotated_components, string $model = '' ): array {
|
||
$slug_map = []; // "type|slug" → bucket
|
||
|
||
foreach ( $annotated_components as $comp ) {
|
||
$hash = $comp['hash'] ?? '';
|
||
if ( $hash === '' ) {
|
||
// Hash-less components don't make sense in a slug-latest view —
|
||
// surface them via the default or version-grouped queues.
|
||
continue;
|
||
}
|
||
|
||
$key = "{$comp['type']}|{$comp['slug']}";
|
||
$ver = $comp['version'] ?? '';
|
||
if ( ! isset( $slug_map[ $key ] ) ) {
|
||
$slug_map[ $key ] = [
|
||
'slug' => $comp['slug'],
|
||
'type' => $comp['type'],
|
||
'title' => $comp['title'] ?? $comp['slug'],
|
||
'total_sites' => 0,
|
||
'versions' => [],
|
||
];
|
||
}
|
||
|
||
if ( ! isset( $slug_map[ $key ]['versions'][ $ver ] ) ) {
|
||
$slug_map[ $key ]['versions'][ $ver ] = [
|
||
'version' => $ver,
|
||
'sites' => 0,
|
||
'hashes' => [], // every annotated component for this slug+version
|
||
];
|
||
}
|
||
|
||
$slug_map[ $key ]['total_sites'] += $comp['sites'];
|
||
$slug_map[ $key ]['versions'][ $ver ]['sites'] += $comp['sites'];
|
||
$slug_map[ $key ]['versions'][ $ver ]['hashes'][] = $comp;
|
||
}
|
||
|
||
$result = [];
|
||
foreach ( $slug_map as $bucket ) {
|
||
if ( empty( $bucket['versions'] ) ) {
|
||
continue;
|
||
}
|
||
|
||
// Performance gate — skip wp.org calls for slugs where the caller's
|
||
// policy would drop every hash anyway. Common case: every fleet
|
||
// hash for this slug is already audited under model+policy.
|
||
$any_surfaceable = false;
|
||
foreach ( $bucket['versions'] as $vdata ) {
|
||
foreach ( $vdata['hashes'] as $h ) {
|
||
if ( ! empty( $h['_audited_by_me'] ) ) {
|
||
continue;
|
||
}
|
||
if ( $model === '' && ! empty( $h['_audited_by_anyone'] ) ) {
|
||
continue;
|
||
}
|
||
$any_surfaceable = true;
|
||
break 2;
|
||
}
|
||
}
|
||
if ( ! $any_surfaceable ) {
|
||
continue;
|
||
}
|
||
|
||
$auditable = self::compute_auditable_versions( $bucket );
|
||
if ( empty( $auditable ) ) {
|
||
continue;
|
||
}
|
||
|
||
// Bogus-first: walk versions DESC. A customer-pinned "9999" stamp
|
||
// (above wp.org-latest) gets audited before the real wp.org-latest
|
||
// underneath it. After both are covered, the slug drops out.
|
||
usort( $auditable, function ( $a, $b ) { return version_compare( $b, $a ); } );
|
||
|
||
$picked_ver = null;
|
||
$picked_data = null;
|
||
$picked_hashes = null;
|
||
foreach ( $auditable as $v ) {
|
||
if ( ! isset( $bucket['versions'][ $v ] ) ) {
|
||
continue;
|
||
}
|
||
$vdata = $bucket['versions'][ $v ];
|
||
$surfaceable = [];
|
||
foreach ( $vdata['hashes'] as $h ) {
|
||
if ( ! empty( $h['_audited_by_me'] ) ) {
|
||
continue;
|
||
}
|
||
if ( $model === '' && ! empty( $h['_audited_by_anyone'] ) ) {
|
||
continue;
|
||
}
|
||
$surfaceable[] = $h;
|
||
}
|
||
if ( ! empty( $surfaceable ) ) {
|
||
$picked_ver = $v;
|
||
$picked_data = $vdata;
|
||
$picked_hashes = $surfaceable;
|
||
break;
|
||
}
|
||
}
|
||
if ( $picked_ver === null ) {
|
||
continue;
|
||
}
|
||
|
||
// Primary within the chosen version: lowest tier first (0 beats 1),
|
||
// then most sites. Mirrors build_version_queue.
|
||
usort( $picked_hashes, function ( $a, $b ) {
|
||
$at = (int) ( $a['_audit_tier'] ?? 0 );
|
||
$bt = (int) ( $b['_audit_tier'] ?? 0 );
|
||
if ( $at !== $bt ) {
|
||
return $at - $bt;
|
||
}
|
||
return ( $b['sites'] ?? 0 ) - ( $a['sites'] ?? 0 );
|
||
} );
|
||
$primary = $picked_hashes[0];
|
||
|
||
// Variant-aware coverage across ALL hashes of the picked (slug,version),
|
||
// not just the primary. `_code_audited` answers "is this CODE reviewed?"
|
||
// (any hash of this slug+version audited in the registry); the per-hash
|
||
// `_audited_by_anyone` flags split the fleet's current hashes into
|
||
// already-covered vs new variants. This is what lets a caller route a
|
||
// resurfaced-but-audited build (e.g. a per-site cache/license fork) to a
|
||
// cheap variant merge instead of a wasted full re-audit.
|
||
$audited_variant_count = 0;
|
||
foreach ( $picked_data['hashes'] as $h ) {
|
||
if ( ! empty( $h['_audited_by_anyone'] ) ) {
|
||
$audited_variant_count++;
|
||
}
|
||
}
|
||
$total_variants = count( $picked_data['hashes'] );
|
||
$unaudited_count = $total_variants - $audited_variant_count;
|
||
$code_audited = ! empty( $primary['_code_audited'] );
|
||
|
||
// queue_action reflects ANY-model coverage (the default, no-model flow the
|
||
// scanner uses). In model-layering mode 'covered' means "covered by some
|
||
// model — a layered re-audit candidate for you", not "nothing to do".
|
||
if ( ! $code_audited ) {
|
||
$queue_action = 'full_audit'; // genuinely new code, never audited
|
||
} elseif ( $unaudited_count > 0 ) {
|
||
$queue_action = 'variant_merge'; // code already reviewed; new per-site variants only
|
||
} else {
|
||
$queue_action = 'covered'; // every fleet hash already audited
|
||
}
|
||
|
||
$result[] = [
|
||
'slug' => $bucket['slug'],
|
||
'latest_version' => $picked_ver,
|
||
'sites' => $bucket['total_sites'],
|
||
'versions_in_fleet' => count( $bucket['versions'] ),
|
||
'type' => $bucket['type'],
|
||
'title' => $bucket['title'],
|
||
'hash' => $primary['hash'],
|
||
'hashes_count' => $total_variants,
|
||
'source_ssh' => $primary['source_ssh'] ?? '',
|
||
'home_directory' => $primary['home_directory'] ?? '',
|
||
'audit_tier' => (int) ( $primary['_audit_tier'] ?? 0 ),
|
||
'sites_on_latest' => $picked_data['sites'],
|
||
// New variant-aware signals (see annotate_tiers / _code_audited):
|
||
'code_audited' => $code_audited,
|
||
'audited_variant_count' => $audited_variant_count,
|
||
'unaudited_variant_count' => $unaudited_count,
|
||
'queue_action' => $queue_action,
|
||
];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Compute the slug's auditable version set. For wp.org plugins/themes we
|
||
* use api.wordpress.org as the authority — anything in the fleet >= the
|
||
* published version is in scope. For premium (or wp.org-unknown) slugs we
|
||
* fall back to fleet shape: the highest version meeting a deployment
|
||
* threshold plus every fleet version higher than it.
|
||
*
|
||
* Threshold for "real" premium version = max(2, ceil(1% × total slug sites)).
|
||
* A version with fewer sites than the threshold is either a one-off
|
||
* customer pin or a fresh release that hasn't propagated yet — either way
|
||
* it stays surfaced as an audit target (versions higher than effective
|
||
* latest), but doesn't get to ANCHOR the auditable set.
|
||
*/
|
||
private static function compute_auditable_versions( array $bucket ): array {
|
||
$versions = array_keys( $bucket['versions'] );
|
||
$versions = array_values( array_filter( $versions, function ( $v ) { return $v !== ''; } ) );
|
||
if ( empty( $versions ) ) {
|
||
return [];
|
||
}
|
||
|
||
$type = $bucket['type'] ?? 'plugin';
|
||
$wporg_latest = null;
|
||
if ( $type === 'plugin' || $type === 'theme' ) {
|
||
$wporg_latest = self::wporg_latest_version( $bucket['slug'], $type );
|
||
}
|
||
|
||
if ( $wporg_latest !== null ) {
|
||
return array_values( array_filter( $versions, function ( $v ) use ( $wporg_latest ) {
|
||
return version_compare( $v, $wporg_latest, '>=' );
|
||
} ) );
|
||
}
|
||
|
||
// Premium / wp.org-unknown: fleet-shape rule.
|
||
$total = (int) ( $bucket['total_sites'] ?? 0 );
|
||
$threshold = max( 2, (int) ceil( 0.01 * $total ) );
|
||
|
||
$sorted_desc = $versions;
|
||
usort( $sorted_desc, function ( $a, $b ) { return version_compare( $b, $a ); } );
|
||
|
||
$effective_latest = null;
|
||
foreach ( $sorted_desc as $v ) {
|
||
$sites = (int) ( $bucket['versions'][ $v ]['sites'] ?? 0 );
|
||
if ( $sites >= $threshold ) {
|
||
$effective_latest = $v;
|
||
break;
|
||
}
|
||
}
|
||
if ( $effective_latest === null ) {
|
||
// No version meets the threshold — typical for very small slugs
|
||
// where 1% rounds to 1 and every version has a single site. Fall
|
||
// back to the absolute highest version so we still pick something.
|
||
$effective_latest = $sorted_desc[0];
|
||
}
|
||
|
||
return array_values( array_filter( $versions, function ( $v ) use ( $effective_latest ) {
|
||
return version_compare( $v, $effective_latest, '>=' );
|
||
} ) );
|
||
}
|
||
|
||
/**
|
||
* Authoritative latest version for a wp.org-hosted plugin/theme via
|
||
* api.wordpress.org. Returns null for premium / non-listed components
|
||
* (api.wordpress.org returns an error), or when the lookup fails.
|
||
*
|
||
* Transient-cached for 1 hour to keep queue endpoint latency bounded.
|
||
* First call on a cold cache for a 4k-slug queue makes ~hundreds of
|
||
* wp.org HTTP calls (gated by the surfaceability short-circuit in
|
||
* build_slug_latest_queue, so only slugs with unaudited hashes pay the
|
||
* cost). Subsequent calls within the cache window are instant.
|
||
*
|
||
* Fail-open: every failure path caches '__none__' so we don't hammer
|
||
* wp.org on the same slug repeatedly. Net effect = treat that slug as
|
||
* premium for the cache window, which is the safe degradation.
|
||
*/
|
||
public static function wporg_latest_version( string $slug, string $type = 'plugin' ): ?string {
|
||
if ( $slug === '' ) {
|
||
return null;
|
||
}
|
||
if ( $type !== 'plugin' && $type !== 'theme' ) {
|
||
return null;
|
||
}
|
||
|
||
$cache_key = 'cc_wporg_latest_' . $type . '_' . md5( $slug );
|
||
$cached = get_transient( $cache_key );
|
||
if ( $cached !== false ) {
|
||
return $cached === '__none__' ? null : (string) $cached;
|
||
}
|
||
|
||
$endpoint = $type === 'theme' ? 'themes/info/1.2' : 'plugins/info/1.2';
|
||
$action = $type === 'theme' ? 'theme_information' : 'plugin_information';
|
||
$url = "https://api.wordpress.org/{$endpoint}/?action={$action}&request[slug]=" . rawurlencode( $slug );
|
||
|
||
$response = wp_remote_get( $url, [ 'timeout' => 3 ] );
|
||
if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) !== 200 ) {
|
||
set_transient( $cache_key, '__none__', HOUR_IN_SECONDS );
|
||
return null;
|
||
}
|
||
|
||
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
||
if ( ! is_array( $data ) || isset( $data['error'] ) || empty( $data['version'] ) ) {
|
||
set_transient( $cache_key, '__none__', HOUR_IN_SECONDS );
|
||
return null;
|
||
}
|
||
|
||
set_transient( $cache_key, $data['version'], HOUR_IN_SECONDS );
|
||
return $data['version'];
|
||
}
|
||
|
||
/**
|
||
* Gather all production environments with SSH + plugin/theme data + details.
|
||
*/
|
||
public 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, e.details
|
||
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
|
||
" );
|
||
}
|
||
|
||
/**
|
||
* Build the "update-before-audit" queue: one row per plugin/theme slug that
|
||
* has audit OR upgrade work outstanding, with the version it should move to.
|
||
*
|
||
* Unlike build_slug_latest_queue (which only surfaces slugs whose LATEST
|
||
* build is still unaudited), this includes slugs whose latest is already
|
||
* audited but that still have stale, unaudited OLDER versions installed —
|
||
* those are the highest-value pure-drain upgrades (steering them to the
|
||
* already-audited latest clears the queue with no follow-up audit).
|
||
*
|
||
* wp.org enrichment is intentionally NOT done here (it would mean hundreds
|
||
* of serial HTTP calls). Pass $wporg_lookup = true only from a context that
|
||
* can absorb that latency (cron warm); otherwise the caller overlays cached
|
||
* wp.org versions afterward via wporg_latest_version (transient-backed).
|
||
*
|
||
* Per-row fields:
|
||
* slug, type, title
|
||
* fleet_latest — highest version present in the fleet
|
||
* wporg_latest — wp.org published latest (null if premium/unknown or not looked up)
|
||
* update_to — version to move to: wp.org-latest when known+>=fleet, else fleet_latest
|
||
* update_source — 'wp.org' when update_to came from wp.org, else 'drift'
|
||
* steer_target — version `drift --steer` actually reaches (always fleet_latest)
|
||
* steer_reaches — bool: does steer reach update_to (false = whole fleet trails wp.org)
|
||
* needs_update — bool: sites not all on update_to, or fleet trails wp.org
|
||
* latest_audited — bool: every hash variant at fleet_latest is already audited
|
||
* unaudited — bool: any hash (any version) is not yet audited
|
||
* missing_hashes — bool: some installs lack content hashes (need sync-data)
|
||
* sites, sites_on_latest, versions_in_fleet
|
||
*/
|
||
public static function build_update_queue( array $annotated_components, bool $wporg_lookup = false ): array {
|
||
$buckets = []; // "type|slug" → bucket
|
||
|
||
foreach ( $annotated_components as $comp ) {
|
||
$type = $comp['type'] ?? '';
|
||
$slug = $comp['slug'] ?? '';
|
||
if ( $slug === '' || ( $type !== 'plugin' && $type !== 'theme' ) ) {
|
||
continue;
|
||
}
|
||
// Hard rule: child themes are per-site unique customizations that
|
||
// merely share a slug. Grouping them and steering one over another
|
||
// would overwrite a site's bespoke child theme. Never surface them
|
||
// in the update queue (they're still audited via the normal queue).
|
||
if ( $type === 'theme' && stripos( $slug, '-child' ) !== false ) {
|
||
continue;
|
||
}
|
||
$key = "{$type}|{$slug}";
|
||
if ( ! isset( $buckets[ $key ] ) ) {
|
||
$buckets[ $key ] = [
|
||
'slug' => $slug,
|
||
'type' => $type,
|
||
'title' => $comp['title'] ?? $slug,
|
||
'total_sites' => 0,
|
||
'unaudited_any' => false,
|
||
'missing_hashes'=> false,
|
||
'ver_sites' => [], // version → site count
|
||
'ver_unaudited' => [], // version → true if any unaudited hash
|
||
];
|
||
}
|
||
|
||
$ver = $comp['version'] ?? '';
|
||
$sites = (int) ( $comp['sites'] ?? 0 );
|
||
$buckets[ $key ]['total_sites'] += $sites;
|
||
if ( $ver !== '' ) {
|
||
$buckets[ $key ]['ver_sites'][ $ver ] = ( $buckets[ $key ]['ver_sites'][ $ver ] ?? 0 ) + $sites;
|
||
}
|
||
|
||
$hash = $comp['hash'] ?? '';
|
||
if ( $hash === '' ) {
|
||
$buckets[ $key ]['missing_hashes'] = true;
|
||
} elseif ( empty( $comp['_audited_by_anyone'] ) ) {
|
||
$buckets[ $key ]['unaudited_any'] = true;
|
||
if ( $ver !== '' ) {
|
||
$buckets[ $key ]['ver_unaudited'][ $ver ] = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
$out = [];
|
||
foreach ( $buckets as $b ) {
|
||
if ( empty( $b['ver_sites'] ) ) {
|
||
continue;
|
||
}
|
||
// Versions newest → oldest.
|
||
$versions = array_keys( $b['ver_sites'] );
|
||
usort( $versions, function ( $a, $c ) { return version_compare( $c, $a ); } );
|
||
$total = (int) $b['total_sites'];
|
||
|
||
// Dominant = most-installed version (what most sites actually run).
|
||
// Used as the "installed" baseline; robust to single-site bogus pins.
|
||
$dominant = $versions[0];
|
||
$maxsites = -1;
|
||
foreach ( $b['ver_sites'] as $v => $n ) {
|
||
if ( $n > $maxsites ) {
|
||
$maxsites = $n;
|
||
$dominant = $v;
|
||
}
|
||
}
|
||
|
||
// Fleet's newest version that MORE THAN ONE site runs — mirrors the
|
||
// CLI's bogus-safe auto target. A version pinned on a single site is
|
||
// almost always a dev / anti-update stamp (9999, 99999999, or any
|
||
// arbitrary value), so we never trust the raw max. Value-agnostic:
|
||
// the signal is share (one site), not the number.
|
||
$fleet_newest_multisite = $versions[0];
|
||
foreach ( $versions as $v ) {
|
||
if ( ( $b['ver_sites'][ $v ] ?? 0 ) >= 2 ) {
|
||
$fleet_newest_multisite = $v;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// wp.org's published version is the authority when the slug is on
|
||
// wp.org; otherwise fall back to the fleet's newest multi-site build.
|
||
$wporg = null;
|
||
if ( $wporg_lookup ) {
|
||
$wporg = self::wporg_latest_version( $b['slug'], $b['type'] );
|
||
} else {
|
||
// Overlay-from-cache only: read the transient without a live call.
|
||
$cache_key = 'cc_wporg_latest_' . $b['type'] . '_' . md5( $b['slug'] );
|
||
$cached = get_transient( $cache_key );
|
||
if ( $cached !== false ) {
|
||
$wporg = ( $cached === '__none__' ) ? null : (string) $cached;
|
||
}
|
||
}
|
||
|
||
if ( $wporg ) {
|
||
$update_to = $wporg;
|
||
$source = 'wp.org';
|
||
} else {
|
||
$update_to = $fleet_newest_multisite;
|
||
$source = 'drift';
|
||
}
|
||
|
||
$sites_on_target = (int) ( $b['ver_sites'][ $update_to ] ?? 0 );
|
||
$fleet_has_target = $sites_on_target > 0;
|
||
|
||
// The version a drift-steer can actually reach now: the target when a
|
||
// fleet site already runs it (drift sources the zip from a peer), else
|
||
// the newest multi-site build the fleet has. Reaching a wp.org release
|
||
// that no site runs yet needs a normal `wp plugin update`, not a steer.
|
||
$steer_to = $fleet_has_target ? $update_to : $fleet_newest_multisite;
|
||
$steer_reaches = ( version_compare( (string) $steer_to, (string) $update_to ) === 0 );
|
||
|
||
$needs_update = ( $sites_on_target < $total );
|
||
|
||
// Whether the target build is already audited (an update would be a
|
||
// pure drain). Unknown when no fleet site runs the target yet.
|
||
$latest_audited = $fleet_has_target && empty( $b['ver_unaudited'][ $update_to ] );
|
||
|
||
// Only surface rows that have outstanding work (audit or upgrade).
|
||
if ( ! $b['unaudited_any'] && ! $needs_update ) {
|
||
continue;
|
||
}
|
||
|
||
$out[] = [
|
||
'slug' => $b['slug'],
|
||
'type' => $b['type'],
|
||
'title' => $b['title'],
|
||
'installed' => $dominant,
|
||
'wporg_latest' => $wporg ?: null,
|
||
'update_to' => $update_to,
|
||
'update_source' => $source,
|
||
'steer_to' => $steer_to,
|
||
'steer_reaches' => $steer_reaches,
|
||
'fleet_has_target' => $fleet_has_target,
|
||
'needs_update' => $needs_update,
|
||
'latest_audited' => $latest_audited,
|
||
'unaudited' => (bool) $b['unaudited_any'],
|
||
'missing_hashes' => (bool) $b['missing_hashes'],
|
||
'sites' => $total,
|
||
'sites_on_target' => $sites_on_target,
|
||
'versions_in_fleet' => count( $versions ),
|
||
];
|
||
}
|
||
|
||
return $out;
|
||
}
|
||
|
||
}
|