Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,10 @@ analytics, CRM, newsletter, ads, comments, captcha, CDN — with the evidence fo
each: an active plugin, a settings option by name, a script host in the theme or
on the rendered home page, a form wired to the service, or embedded content. Each
says whether an account must be reconnected and whether a credential is set on
WordPress. No key, token or password is exported: a credential is only checked
for being set, in memory, and the export names the setting, never its value. The
WordPress. No key, token or password is exported, and no setting is opened to look for
one: a setting whose name says credential counts as set when it is not empty
(the database answers that, not the value), and the export names the setting,
never its value. The
list is shown on the Bridge screen and reaches Migrate's intake as an
`integration_reconnect_required` issue.

Expand Down
4 changes: 2 additions & 2 deletions contentrain-bridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Plugin Name: Contentrain Bridge
* Plugin URI: https://github.com/Contentrain/wordpress-bridge
* Description: Model and export WordPress content and interface text as Contentrain JSON/Markdown, with optional GitHub delivery.
* Version: 0.8.3
* Version: 0.8.4
* Requires at least: 6.2
* Requires PHP: 7.4
* Author: Contentrain
Expand All @@ -16,7 +16,7 @@
*/

defined( 'ABSPATH' ) || exit;
define( 'CONTENTRAIN_BRIDGE_VERSION', '0.8.3' );
define( 'CONTENTRAIN_BRIDGE_VERSION', '0.8.4' );
define( 'CONTENTRAIN_BRIDGE_FILE', __FILE__ );

foreach ( array( 'policy', 'files', 'exporter', 'menus', 'source', 'languages', 'inventory', 'delta', 'seo-render', 'seo', 'redirects', 'routing', 'scanner', 'text', 'coverage', 'integrations', 'acf', 'models', 'validator', 'rawir', 'jobs', 'github', 'key', 'remote', 'robots', 'admin' ) as $contentrain_bridge_class ) {
Expand Down
4 changes: 2 additions & 2 deletions includes/class-contentrain-bridge-coverage.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,8 @@ public static function report( $job ) {
// ---- Widgets and Customizer: exported as interface text when that scan ran. ----
$text = ! empty( $job['options']['scan_sources'] ) ? 'exported:interface-text' : 'excluded:interface-text-scan-off';
$widgets = 0;
foreach ( $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'widget\\_%'" ) as $name ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Independent enumeration.
$widgets += count( array_filter( (array) get_option( $name ), 'is_array' ) );
foreach ( Text::widgets() as $widget ) {
$widgets += count( array_filter( (array) $widget->get_settings(), 'is_array' ) );
}
$sources[] = self::source( 'widgets', 'site', $widgets, $widgets ? array( $text => $widgets ) : array() );
$mods = array_filter( (array) get_theme_mods(), 'is_string' );
Expand Down
49 changes: 10 additions & 39 deletions includes/class-contentrain-bridge-integrations.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* a settings option by name, a script loaded from the service's domain, a form
* wired to it, or content embedded from it.
*
* Credentials never leave. An option's value is read only to answer "is a key
* set?" as a boolean, in memory; what is written is the option's *name*, the
* setting's *name*, a domain, a plugin slug. A migrated site has to connect
* Credentials never leave, and no setting is opened to look for one: a
* credential-named option that is not empty counts as set (the database
* answers that); what is written is the option's *name*, the setting's
* *name*, a domain, a plugin slug. A migrated site has to connect
* again with its own credentials, and this is the list of what to connect.
*/
final class Integrations {
Expand Down Expand Up @@ -93,13 +94,12 @@ public static function detect( $post_ids = array(), $html = '' ) {
}
}

// 2. Settings, by option name. The value is inspected for "is a credential set", never kept.
// 2. Settings, by option name. A credential-named setting that is not empty counts as a credential set; no value is read.
global $wpdb;
$names = $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name NOT LIKE '\\_%transient\\_%'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerates option names only.
$names = $wpdb->get_results( "SELECT option_name, option_value <> '' AS has_value FROM {$wpdb->options} WHERE option_name NOT LIKE '\\_%transient\\_%'", OBJECT_K ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerates option names, and whether each is empty; no value is read.
foreach ( $catalog as $id => list( $name, $category, , $pattern ) ) {
foreach ( preg_grep( $pattern, $names ) as $option ) {
$set = self::secret_keys( get_option( $option ), preg_match( self::SECRET_KEY, $option ) ? $option : '' );
$add( $id, $name, $category, 'option-key', $option . ( $set ? ' (' . implode( ', ', $set ) . ')' : '' ), true, (bool) $set );
foreach ( preg_grep( $pattern, array_keys( $names ) ) as $option ) {
$add( $id, $name, $category, 'option-key', $option, true, $names[ $option ]->has_value && preg_match( self::SECRET_KEY, $option ) );
}
}
foreach ( (array) get_option( 'jetpack_active_modules', array() ) as $module ) {
Expand Down Expand Up @@ -165,35 +165,6 @@ public static function detect( $post_ids = array(), $html = '' ) {
);
}

/**
* Names of credential settings that hold a value. The value is looked at to
* tell "set" from "empty" and discarded; only the key name comes back.
*/
private static function secret_keys( $value, $option_name = '' ) {
$set = array();
if ( '' !== $option_name && is_scalar( $value ) && '' !== trim( (string) $value ) ) {
$set[] = $option_name;
}
$walk = static function ( $node, $path ) use ( &$walk, &$set ) {
if ( is_object( $node ) ) {
$node = get_object_vars( $node );
}
if ( ! is_array( $node ) ) {
return;
}
foreach ( $node as $key => $child ) {
$name = '' === $path ? (string) $key : $path . '.' . $key;
if ( is_scalar( $child ) && preg_match( self::SECRET_KEY, (string) $key ) && '' !== trim( (string) $child ) ) {
$set[] = $name;
} else {
$walk( $child, $name );
}
}
};
$walk( is_string( $value ) && is_serialized( $value ) ? unserialize( $value, array( 'allowed_classes' => false, 'max_depth' => 16 ) ) : $value, '' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Class instantiation is explicitly disabled; nothing is kept but key names.
return array_values( array_unique( $set ) );
}

/** Forms connected to a service: Contact Form 7 integrations, WPForms providers, Gravity Forms feeds. */
private static function form_links() {
global $wpdb;
Expand All @@ -202,15 +173,15 @@ private static function form_links() {
if ( is_array( $cf7 ) ) {
foreach ( array( 'recaptcha' => 'recaptcha', 'sendinblue' => 'brevo', 'constant_contact' => 'constant-contact', 'stripe' => 'stripe' ) as $key => $service ) {
if ( ! empty( $cf7[ $key ] ) ) {
$out[] = array( $service, 'contact-form-7 integration: ' . $key, (bool) self::secret_keys( $cf7[ $key ] ) || ! empty( $cf7[ $key ] ) );
$out[] = array( $service, 'contact-form-7 integration: ' . $key, true );
}
}
}
$providers = get_option( 'wpforms_providers' );
foreach ( is_array( $providers ) ? array_keys( $providers ) : array() as $provider ) {
$service = array( 'mailchimpv3' => 'mailchimp', 'hubspot' => 'hubspot', 'convertkit' => 'convertkit', 'sendinblue' => 'brevo', 'zapier' => 'zapier', 'salesforce' => 'salesforce' )[ $provider ] ?? null;
if ( $service ) {
$out[] = array( $service, 'wpforms provider: ' . $provider, (bool) self::secret_keys( $providers[ $provider ] ) );
$out[] = array( $service, 'wpforms provider: ' . $provider, ! empty( $providers[ $provider ] ) );
}
}
$feeds = $wpdb->prefix . 'gf_addon_feed';
Expand Down
23 changes: 17 additions & 6 deletions includes/class-contentrain-bridge-text.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ public static function settings( $locale ) {
$found[] = self::occurrence( (string) get_option( $option ), 'option:' . $option, 'option', $option, $locale, array( 'target' => $target ) );
}
// Classic widgets keep a title and sometimes HTML text; block widgets keep block markup.
foreach ( self::widget_options() as $option ) {
foreach ( (array) get_option( $option, array() ) as $number => $instance ) {
foreach ( self::widgets() as $widget ) {
if ( 'recent-comments' === $widget->id_base ) {
continue;
}
foreach ( (array) $widget->get_settings() as $number => $instance ) {
if ( ! is_array( $instance ) ) {
continue;
}
$source = $option . '#' . $number;
$source = $widget->option_name . '#' . $number;
if ( isset( $instance['title'] ) ) {
$found[] = self::occurrence( (string) $instance['title'], 'widget:title', 'widget', $source, $locale );
}
Expand Down Expand Up @@ -303,9 +306,17 @@ private static function split( $found ) {
return $out;
}

private static function widget_options() {
global $wpdb;
return (array) $wpdb->get_col( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'widget\\_%' AND option_name <> 'widget_recent-comments' ORDER BY option_name" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerates widget option names; no API lists them.
/** The widgets WordPress has registered, by option name; each reads only its own settings. */
public static function widgets() {
global $wp_widget_factory;
$widgets = array();
foreach ( (array) ( $wp_widget_factory->widgets ?? array() ) as $widget ) {
if ( $widget instanceof \WP_Widget ) {
$widgets[ $widget->option_name ] = $widget;
}
}
ksort( $widgets );
return array_values( $widgets );
}

private static function landmark( $element ) {
Expand Down
7 changes: 6 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: export, migration, headless, astro, content
Requires at least: 6.2
Tested up to: 7.1
Requires PHP: 7.4
Stable tag: 0.8.3
Stable tag: 0.8.4
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Expand Down Expand Up @@ -90,6 +90,11 @@ The plugin stores export ownership and a source revision marker. It sends nothin

== Changelog ==

= 0.8.4 =

* Widgets are read through WordPress's own widget registry: each registered widget reads only its own settings. The export no longer lists option names from the database to find widgets, so a widget whose plugin is inactive is not counted or scanned, as WordPress does not show it either.
* Connected services are found by setting names only. A setting whose name marks a credential (key, token, secret) counts as set when it is not empty; the database answers that, and the value is never read. Evidence names the setting ("mc4wp"), not the keys inside it. A form wired to a service (Contact Form 7, WPForms) counts as connected when its account entry exists; the keys inside it are not inspected.

= 0.8.3 =

* The content type list on the export screen shows each type's published count as a number ("Pages (7 published)", plus private ones when there are any). It used to join the per-status counts as text ("Pages (07010000000000)").
Expand Down
2 changes: 1 addition & 1 deletion tests/integrations.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ function check( $value, $message ) {
$all = '';
foreach ( array_keys( $job['files'] ) as $path ) { if ( 0 !== strpos( $path, 'media/' ) ) { $all .= Files::read( $dir, $path ); } }
check( ! array_filter( $secrets, static function ( $v ) use ( $all ) { return false !== strpos( $all, $v ); } ) && false === strpos( $all, 'never-export-gf-feed-key' ), 'no credential in any of ' . count( $job['files'] ) . ' exported files' );
check( (bool) preg_grep( '/^mc4wp \(api_key\)$/', array_column( $by['mailchimp']['evidence'], 'detail' ) ), 'evidence names the setting that holds the key ("mc4wp (api_key)"), never its value' );
check( in_array( 'mc4wp', array_column( $by['mailchimp']['evidence'], 'detail' ), true ), 'evidence names the setting by its name ("mc4wp"), never its value' );

// ---- What the admin screen and Migrate get. ----
$summary = Jobs::summary( $job );
Expand Down
Loading