*/ private function registered_abilities(): iterable { return $this->registry->all(); } /** * Fingerprint the registry and report drift. */ public function scan(): void { $wp_current = $this->wp_version(); $current = array(); foreach ( $this->registered_abilities() as $ability ) { if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) || ! method_exists( $ability, 'get_label' ) || ! method_exists( $ability, 'get_description' ) || ! method_exists( $ability, 'get_category' ) ) { continue; } $name = (string) $ability->get_name(); if ( self::META_KEY === $name ) { continue; } $label = (string) $ability->get_label(); $description = (string) $ability->get_description(); $category = (string) $ability->get_category(); $current[ $name ] = array( // Hashed over the WHOLE definition, while only an excerpt is // stored below. Hashing the excerpt instead would let an // ability pad past the cap and then change its tail unseen. 'hash' => hash( 'sha256', (string) wp_json_encode( array( $name, $label, $description, $category ) ) ), 'label' => $this->excerpt( $label ), 'description' => $this->excerpt( $description ), 'category' => $this->excerpt( $category ), ); } $stored = get_option( self::OPTION, false ); // First scan: baseline quietly. if ( ! is_array( $stored ) ) { update_option( self::OPTION, $this->with_meta( $current, $wp_current ), false ); return; } $wp_previous = isset( $stored[ self::META_KEY ]['wp'] ) ? (string) $stored[ self::META_KEY ]['wp'] : null; $new = array(); $modified = array(); $details = array( 'new' => array(), 'changed' => array(), 'wp' => array( 'previous' => $wp_previous, 'current' => $wp_current, ), ); // Whether any record is still the bare hash written before AF-12.9. $upgraded = false; foreach ( $current as $name => $record ) { if ( ! array_key_exists( $name, $stored ) ) { $new[] = $name; $details['new'][ $name ] = $this->definition( $record ); /** * Fires when a previously unseen ability is registered. * * @param string $name Ability name. */ do_action( 'adaf_anomaly_new_ability', $name ); continue; } $previous = $stored[ $name ]; if ( ! is_array( $previous ) ) { $upgraded = true; } if ( $this->stored_hash( $previous ) !== $record['hash'] ) { $modified[] = $name; $details['changed'][ $name ] = array( // Null where the baseline predates AF-12.9: the hash said // something changed, but the old text was never kept. 'before' => is_array( $previous ) ? $this->definition( $previous ) : null, 'after' => $this->definition( $record ), ); /** * Fires when a known ability's definition changes. * * @param string $name Ability name. */ do_action( 'adaf_anomaly_ability_changed', $name ); } } if ( array() === $new && array() === $modified ) { // No drift, but the baseline can still be behind: records written // before AF-12.9 carry no text to diff against, and a stale // WordPress version would later be reported as if core had just // changed. Both are corrected silently, exactly once. if ( $upgraded || ( null !== $wp_previous && $wp_previous !== $wp_current ) ) { update_option( self::OPTION, $this->with_meta( array_merge( $stored, $current ), $wp_current ), false ); } return; } /** * Fires once per scan with everything that drifted, so one * plugin update registering N abilities is one event - and one * email - rather than N. * * @param list $new Previously unseen ability names. * @param list $modified Abilities whose definition changed. * @param array $details Definitions either side of each * change, and the WordPress version * at this scan and the last. Added in * AF-12.9; listeners registered for * two arguments are unaffected. */ do_action( 'adaf_anomaly_ability_drift', $new, $modified, $details ); // Merge rather than replace: an ability absent this scan keeps its // stored fingerprint. Abilities vanish briefly (conditional // registration, plugins mid-auto-update), and pruning them made every // reappearance alert as "new" again; retained, an unchanged // reappearance is silent while a changed one still alerts. update_option( self::OPTION, $this->with_meta( array_merge( $stored, $current ), $wp_current ), false ); } /** * The running WordPress version. * * Read from the global rather than `get_bloginfo( 'version' )` for the * same reason the registry is read directly (AF-15.2): the helper runs a * filter any plugin may hook, and a monitor must not depend on code it * watches for the facts it reports. */ private function wp_version(): string { return isset( $GLOBALS['wp_version'] ) ? (string) $GLOBALS['wp_version'] : ''; } /** * Stamp scan metadata onto a set of fingerprint records. * * @param array $records Fingerprint records by ability name. * @param string $wp WordPress version at this scan. * @return array */ private function with_meta( array $records, string $wp ): array { $records[ self::META_KEY ] = array( 'wp' => $wp ); return $records; } /** * The fingerprint held by a stored record, in either shape. * * @param mixed $record Stored record: an array since AF-12.9, a bare hash before it. */ private function stored_hash( $record ): string { if ( is_array( $record ) ) { return isset( $record['hash'] ) ? (string) $record['hash'] : ''; } return (string) $record; } /** * The human-readable half of a record, without its hash. * * @param array $record Fingerprint record. * @return array */ private function definition( array $record ): array { return array( 'label' => isset( $record['label'] ) ? (string) $record['label'] : '', 'description' => isset( $record['description'] ) ? (string) $record['description'] : '', 'category' => isset( $record['category'] ) ? (string) $record['category'] : '', ); } /** * Cap one field of a definition. * * These strings come from whoever registered the ability, so the option * they land in must not be theirs to grow without limit. * * @param string $value Field value. */ private function excerpt( string $value ): string { return mb_strlen( $value ) > self::EXCERPT ? mb_substr( $value, 0, self::EXCERPT ) : $value; } }