array(), 'limitations' => array() ); if ( ! $previous || ! empty( $current['limitations'] ) || ! empty( $previous['limitations'] ) ) { $result['limitations'][] = 'Persistence change comparison requires two complete inventory observations; no trusted-clean baseline is inferred.'; return $result; } foreach ( array( 'mu_plugins', 'drop_ins', 'scheduled_tasks' ) as $kind ) { try { $before = self::normalize( $previous[ $kind ] ?? null, $kind ); $after = self::normalize( $current[ $kind ] ?? null, $kind ); } catch ( \Throwable $error ) { $result['limitations'][] = 'Persistence change comparison unavailable for ' . $kind . ': inventory shape or budget is unsupported.'; continue; } foreach ( array_unique( array_merge( array_keys( $before ), array_keys( $after ) ) ) as $key ) { $old = $before[ $key ] ?? null; $new = $after[ $key ] ?? null; if ( $old === $new ) { continue; } $change = null === $old ? 'added' : ( null === $new ? 'removed' : 'changed' ); $label = 'scheduled_tasks' === $kind ? 'recurring scheduled hook' : ( 'mu_plugins' === $kind ? 'must-use plugin' : 'WordPress drop-in' ); $result['findings'][] = Finding::make( array( 'location' => 'persistence:' . $kind . '/' . substr( hash( 'sha256', $key ), 0, 32 ), 'component_version' => 'persistence-inventory-v1', 'detector_id' => 'inventory.persistence-change', 'detector_version' => '1.1.0', 'content_hash' => hash( 'sha256', json_encode( array( $kind, $key, $new ), JSON_THROW_ON_ERROR ) ), 'category' => 'configuration', 'severity' => 'removed' === $change ? 'info' : 'medium', 'confidence' => 'informational', 'reason' => 'An observed ' . $label . ' was ' . $change . ' since the previous completed scan. Confirm whether this execution-surface change was intended.', 'evidence' => array( array( 'type' => 'persistence_inventory_change', 'surface' => $kind, 'change' => $change, 'before' => $old, 'after' => $new ) ), 'limitations' => array( 'Both inventories are untrusted observations. An ordinary installation, update or removal can explain this change.', 'Header metadata and recurring-hook names do not prove malicious behavior or identify who changed the site.', 'Content changes without a metadata change are assessed by file inspection, not by this inventory comparison.', 'One-time tasks, task arguments and private customer data are not included.' ), 'recommended_action' => 'Confirm the change with the site owner or responsible plugin maintainer and review related scan findings. No plugin, file or scheduled task was modified.', ) ); } } return $result; } private static function normalize( mixed $items, string $kind ): array { if ( ! is_array( $items ) || count( $items ) > ( 'scheduled_tasks' === $kind ? 500 : 200 ) ) { throw new \RuntimeException( 'Unsupported inventory.' ); } $out = array(); foreach ( $items as $item ) { if ( ! is_array( $item ) ) { throw new \RuntimeException( 'Unsupported inventory item.' ); } if ( 'scheduled_tasks' === $kind ) { if ( ! is_int( $item['interval'] ?? null ) || ! is_string( $item['hook'] ?? null ) || ! is_string( $item['schedule'] ?? null ) || strlen( $item['hook'] ) > 200 || strlen( $item['schedule'] ) > 100 ) { throw new \RuntimeException( 'Unsupported task inventory.' ); } if ( $item['interval'] <= 0 ) { continue; } $key = json_encode( array( $item['hook'], $item['schedule'] ), JSON_THROW_ON_ERROR ); $out[ $key ] = array( 'hook' => $item['hook'], 'schedule' => $item['schedule'], 'interval_seconds' => $item['interval'] ); } else { if ( ! is_string( $item['file'] ?? null ) || strlen( $item['file'] ) > 500 || ! is_string( $item['version'] ?? null ) || strlen( $item['version'] ) > 100 ) { throw new \RuntimeException( 'Unsupported component inventory.' ); } $out[ $item['file'] ] = array( 'file' => $item['file'], 'version' => $item['version'] ); } } ksort( $out, SORT_STRING ); return $out; } }