…" (print_r). The value is * hidden when the name names a secret (is_secret_key()). */ private const INLINE_PAIR = '/(?|=|:)\s*)(["\']?)([^\s"\'&,;<>]{4,})/'; /** * A credential sent in an Authorization header, as a log line shows it. * It holds a digit, so prose ("Basic authentication failed") is left. */ private const INLINE_AUTH = '/\b(Bearer|Basic)(\s+)(?=[A-Za-z0-9._~+\/=-]*[0-9])([A-Za-z0-9._~+\/=-]{12,})/i'; /** * Values a secret-named pair keeps, as redact() keeps booleans and empty * values: whether something is set stays readable. */ private const PLAIN_VALUES = array( 'true', 'false', 'null', 'none', 'yes', 'no', 'on', 'off', 'empty', 'unset' ); /** * Whether a key names a secret. * * @param int|string $key Array key or setting name. */ public static function is_secret_key( $key ): bool { if ( ! is_string( $key ) || '' === $key ) { return false; } $spaced = (string) preg_replace( '/([a-z0-9])([A-Z])/', '$1 $2', $key ); $words = preg_split( '/[^a-z0-9]+/', strtolower( $spaced ), -1, PREG_SPLIT_NO_EMPTY ); $words = is_array( $words ) ? $words : array(); if ( array_intersect( $words, self::ABOUT_WORDS ) ) { return false; } foreach ( $words as $i => $word ) { if ( in_array( $word, self::SECRET_WORDS, true ) ) { return true; } if ( in_array( $word, array( 'key', 'keys' ), true ) && $i > 0 && in_array( $words[ $i - 1 ], self::KEY_QUALIFIERS, true ) ) { return true; } } return false; } /** * Whether a row's name (an option name or meta key read through SQL) * marks its value as a secret, by the wider rule SQL applies: see * NAME_PATTERN. GripMCP's own state and Freemius's are included. * * @param string $name Option name or meta key. */ public static function is_secret_row_name( string $name ): bool { $name = strtolower( $name ); return 1 === preg_match( '/' . self::NAME_PATTERN . '/D', $name ) || 0 === strpos( $name, 'fs_' ) || false !== strpos( $name, 'gripmcp_' ) || 'session_tokens' === $name; } /** * A whole setting read by its name, with its secrets hidden: all of it * when the name itself names a secret ("mailserver_pass", "smtp_pwd"), * and otherwise the secrets inside it. * * @param string $name Setting name. * @param mixed $value Value, as get_option() returns it. * @param array $found Paths of what was hidden, added to. * @return mixed */ public static function redact_setting( string $name, $value, array &$found = array() ) { return self::redact( $value, $found, $name, self::is_secret_key( $name ) ); } /** * Whether a value has the shape of a well-known secret. * * @param mixed $value Value. */ public static function is_secret_value( $value ): bool { if ( ! is_string( $value ) || strlen( $value ) < 12 || self::PLACEHOLDER === $value ) { return false; } foreach ( self::SECRET_VALUE_PATTERNS as $pattern ) { if ( preg_match( $pattern, $value ) ) { return true; } } return false; } /** * A setting's value with its secrets replaced by the placeholder. * * Booleans and empty values are kept, so the assistant can still tell a * secret that is set from one that is not. * * @param mixed $value Value, as get_option() returns it. * @param array $found Paths of what was hidden, added to. * @param string $path Path of this value, for $found. * @param bool $secret Whether a key above this one names a secret. * @param int $depth Nesting depth. * @return mixed */ public static function redact( $value, array &$found = array(), string $path = '', bool $secret = false, int $depth = 0 ) { if ( $value instanceof \__PHP_Incomplete_Class ) { // Stored data, never a live object: every property is walked, // private ones included. $value = (array) $value; } elseif ( is_object( $value ) ) { $value = get_object_vars( $value ); } // Deeper than any real setting: shown as the placeholder rather than // walked, so nothing below it can slip through unchecked. if ( $depth > 64 ) { $found[] = '' === $path ? '(value)' : $path; return self::PLACEHOLDER; } if ( is_array( $value ) ) { // WordPress's and WooCommerce's settings shape: a field whose type // is "password" keeps its secret under "value". $password_field = isset( $value['type'] ) && 'password' === $value['type']; $out = array(); foreach ( $value as $key => $item ) { $child = '' === $path ? (string) $key : $path . '.' . $key; // A label or id under a secret stays readable, but is still // walked: a secret can sit inside it or have a secret's shape. if ( $secret && in_array( (string) $key, self::DESCRIPTOR_KEYS, true ) ) { $out[ $key ] = self::redact( $item, $found, $child, false, $depth + 1 ); continue; } $hide = $secret || self::is_secret_key( $key ) || ( $password_field && in_array( (string) $key, array( 'value', 'default' ), true ) ); $out[ $key ] = self::redact( $item, $found, $child, $hide, $depth + 1 ); } return $out; } // A secret held as JSON or serialized text is hidden whole below, not // walked: inside it, only the parts named as secrets would be. if ( is_string( $value ) && ! $secret ) { $nested = self::redact_text( $value, $found, $path, $depth + 1 ); if ( $nested !== $value ) { return $nested; } } if ( ( $secret && ( is_string( $value ) || is_int( $value ) || is_float( $value ) ) && '' !== (string) $value ) || self::is_secret_value( $value ) ) { $found[] = '' === $path ? '(value)' : $path; return self::PLACEHOLDER; } return $value; } /** * A stored text value (serialized PHP or JSON, as the database holds a * setting) with the secrets inside it replaced. Text that is neither is * returned as it is. * * @param string $text Text. * @param array $found Paths of what was hidden, added to. * @param string $path Path of this value, for $found. * @param int $depth Nesting depth, carried into redact(). */ public static function redact_text( string $text, array &$found = array(), string $path = '', int $depth = 0 ): string { $trimmed = ltrim( $text ); if ( '' === $trimmed ) { return $text; } if ( is_serialized( $text ) ) { // Values only: an object comes back as __PHP_Incomplete_Class and is // never instantiated. $data = @unserialize( trim( $text ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- reading stored settings to hide their secrets; no class is allowed. if ( is_array( $data ) ) { $before = count( $found ); $clean = self::redact( $data, $found, $path, false, $depth ); return count( $found ) > $before ? serialize( $clean ) : $text; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- written back in the form it was stored in. } // An object cannot be written back as it was stored, so one that // holds a secret is hidden whole. if ( is_object( $data ) ) { $before = count( $found ); self::redact( $data, $found, $path, false, $depth ); if ( count( $found ) > $before ) { array_splice( $found, $before ); $found[] = '' === $path ? '(value)' : $path; return self::PLACEHOLDER; } } return $text; } if ( '{' === $trimmed[0] || '[' === $trimmed[0] ) { $data = json_decode( $text, true ); if ( is_array( $data ) ) { $before = count( $found ); $clean = self::redact( $data, $found, $path, false, $depth ); return count( $found ) > $before ? (string) wp_json_encode( $clean ) : $text; } } return $text; } /** * Whatever a tool returns from code it does not control (an ability's * result, a recorded crash), with its secrets hidden: by redact() first, * then every remaining text value by redact_inline(), so a key written * into a message or a string field is caught too. * * @param mixed $value Value. * @param array $found Paths of what was hidden, added to. * @param string $path Path of this value, for $found. * @return mixed */ public static function redact_output( $value, array &$found = array(), string $path = '' ) { return self::inline_walk( self::redact( $value, $found, $path ), $found, $path, 0 ); } /** * redact_inline() on every text value of a redacted value. * * @param mixed $value Value, after redact(). * @param array $found Paths of what was hidden, added to. * @param string $path Path of this value. * @param int $depth Nesting depth. * @return mixed */ private static function inline_walk( $value, array &$found, string $path, int $depth ) { if ( is_array( $value ) && $depth <= 64 ) { foreach ( $value as $key => $item ) { $value[ $key ] = self::inline_walk( $item, $found, '' === $path ? (string) $key : $path . '.' . $key, $depth + 1 ); } return $value; } return is_string( $value ) ? self::redact_inline( $value, $found, $path ) : $value; } /** * Text with the secrets written inside it hidden: well-known key * formats anywhere, the value after a secret's name ("password=…", * "\"api_key\": \"…\""), and an Authorization header's credential. Only * what is shown is changed; nothing stored is. * * @param string $text Text. * @param array $found Paths of what was hidden, added to (one per text that had any). * @param string $path Path of this text, for $found. */ public static function redact_inline( string $text, array &$found = array(), string $path = '' ): string { if ( strlen( $text ) < 8 || self::PLACEHOLDER === $text ) { return $text; } $hits = 0; foreach ( self::INLINE_SECRET_PATTERNS as $pattern ) { $text = (string) preg_replace( $pattern, self::PLACEHOLDER, $text, -1, $count ); $hits += $count; } $text = (string) preg_replace_callback( self::INLINE_PAIR, static function ( array $match ) use ( &$hits ): string { $value = $match[4]; if ( false !== strpos( $value, self::PLACEHOLDER ) || in_array( strtolower( $value ), self::PLAIN_VALUES, true ) || ! self::is_secret_key( $match[1] ) ) { return $match[0]; } ++$hits; return $match[1] . $match[2] . $match[3] . self::PLACEHOLDER; }, $text ); $text = (string) preg_replace_callback( self::INLINE_AUTH, static function ( array $match ) use ( &$hits ): string { ++$hits; return $match[1] . $match[2] . self::PLACEHOLDER; }, $text ); if ( $hits > 0 ) { $found[] = '' === $path ? '(value)' : $path; } return $text; } /** * A command line with the value of each flag named as a secret * ("--user_pass=…", "--password …") shown as the placeholder, for * wherever a WP-CLI command is shown to a person or kept in a record. * The value is the whole shell word, as the command parser reads it: * quoted parts, backslash escapes and plain characters up to the first * unquoted space. A flag followed by another flag has no value. A * command that sets a setting named as a secret from a plain word * ("option update mailserver_pass hunter2") has that word hidden too. * Text with neither is returned as it is. * * @param string $command Command line. */ public static function redact_command( string $command ): string { $command = self::redact_setter_value( $command ); if ( false === strpos( $command, '--' ) ) { return $command; } return (string) preg_replace_callback( '/(--([A-Za-z0-9_-]+))(=|\s+(?!-))(' . self::SHELL_WORD . ')/', static function ( array $match ): string { return self::is_secret_key( $match[2] ) ? $match[1] . $match[3] . self::PLACEHOLDER : $match[0]; }, $command ); } /** * A command line that sets a setting named as a secret from a plain word * ("option update mailserver_pass hunter2", "post meta update 5 api_key * x") with that word, and any --value=…, shown as the placeholder. Words * are read as Command_Parser reads them: flags are skipped wherever they * stand and a leading "wp" is dropped. Only text that starts as such a * command is changed, so prose such as "update your password" is not. * * @param string $command Command line. */ private static function redact_setter_value( string $command ): string { if ( ! preg_match_all( '/' . self::SHELL_WORD . '/', $command, $found, PREG_OFFSET_CAPTURE ) ) { return $command; } $words = array(); foreach ( $found[0] as $word ) { if ( 0 !== strpos( (string) $word[0], '--' ) ) { $words[] = array( (string) $word[0], (int) $word[1] ); } } $plain = static function ( string $word ): string { return strtolower( trim( $word, '"\'' ) ); }; if ( isset( $words[0] ) && 'wp' === $plain( $words[0][0] ) ) { array_shift( $words ); } // "site option update" sets a network option the same way. if ( isset( $words[1] ) && in_array( $plain( $words[0][0] ), array( 'site', 'network' ), true ) && 'option' === $plain( $words[1][0] ) ) { array_shift( $words ); } $name = -1; if ( isset( $words[1] ) && in_array( $plain( $words[0][0] ) . ' ' . $plain( $words[1][0] ), array( 'option update', 'option add', 'transient set', 'config set' ), true ) ) { $name = 2; } elseif ( isset( $words[2] ) && 'meta' === $plain( $words[1][0] ) && in_array( $plain( $words[2][0] ), array( 'update', 'add' ), true ) ) { $name = 4; // meta update . } if ( $name < 0 || ! isset( $words[ $name ] ) || ! self::is_secret_key( trim( $words[ $name ][0], '"\'' ) ) ) { return $command; } $cuts = array(); if ( isset( $words[ $name + 1 ] ) ) { $cuts[] = array( $words[ $name + 1 ][1], strlen( $words[ $name + 1 ][0] ) ); } // The value can also come as --value=…, before or after the name. foreach ( $found[0] as $word ) { if ( 0 === stripos( (string) $word[0], '--value=' ) ) { $cuts[] = array( (int) $word[1] + 8, strlen( (string) $word[0] ) - 8 ); } } // Right to left, so earlier offsets stay valid. usort( $cuts, static function ( array $a, array $b ): int { return $b[0] <=> $a[0]; } ); foreach ( $cuts as $cut ) { $command = substr_replace( $command, self::PLACEHOLDER, $cut[0], $cut[1] ); } return $command; } /** * A path or URL with the value of each query parameter named as a * secret ("?password=…") shown as the placeholder. A request inside a * REST batch can carry its parameters in its path. * * @param string $text Path or URL. */ public static function redact_query( string $text ): string { if ( false === strpos( $text, '?' ) || false === strpos( $text, '=' ) ) { return $text; } return (string) preg_replace_callback( '/([?&;])([^=&;#\s]+)=([^&;#\s]*)/', static function ( array $match ): string { // "meta[api_key]" is read as its words, so a nested key counts. return self::is_secret_key( rawurldecode( $match[2] ) ) ? $match[1] . $match[2] . '=' . self::PLACEHOLDER : $match[0]; }, $text ); } /** * Whether a value sent to be saved holds the placeholder anywhere. * * @param mixed $value Value. */ public static function has_placeholder( $value ): bool { if ( is_array( $value ) ) { foreach ( $value as $item ) { if ( self::has_placeholder( $item ) ) { return true; } } return false; } return is_string( $value ) && false !== strpos( $value, self::PLACEHOLDER ); } /** * A value sent to be saved, with each placeholder put back to the value * stored in the same place. A placeholder with nothing stored behind it * is listed in $unresolved, and the caller must not save the value. * * @param mixed $sent Value sent. * @param mixed $stored Value stored now. * @param array $unresolved Paths of placeholders with no stored value, added to. * @param string $path Path of this value. * @return mixed */ public static function restore( $sent, $stored, array &$unresolved = array(), string $path = '' ) { if ( self::PLACEHOLDER === $sent ) { if ( null === $stored || is_array( $stored ) || is_object( $stored ) ) { $unresolved[] = '' === $path ? '(value)' : $path; return $sent; } return $stored; } if ( is_array( $sent ) ) { $stored = is_object( $stored ) ? get_object_vars( $stored ) : $stored; $out = array(); foreach ( $sent as $key => $item ) { $child = '' === $path ? (string) $key : $path . '.' . $key; $out[ $key ] = self::restore( $item, is_array( $stored ) && array_key_exists( $key, $stored ) ? $stored[ $key ] : null, $unresolved, $child ); } return $out; } if ( is_string( $sent ) && false !== strpos( $sent, self::PLACEHOLDER ) ) { $unresolved[] = '' === $path ? '(value)' : $path; } return $sent; } }