*
* PHP Core Exceptions
*/
public $codes = array(
E_ERROR => 'E_ERROR',
E_WARNING => 'E_WARNING',
E_PARSE => 'E_PARSE',
E_NOTICE => 'E_NOTICE',
E_CORE_ERROR => 'E_CORE_ERROR',
E_CORE_WARNING => 'E_CORE_WARNING',
E_COMPILE_ERROR => 'E_COMPILE_ERROR',
E_COMPILE_WARNING => 'E_COMPILE_WARNING',
E_USER_ERROR => 'E_USER_ERROR',
E_USER_WARNING => 'E_USER_WARNING',
E_USER_NOTICE => 'E_USER_NOTICE',
E_STRICT => 'E_STRICT',
E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
E_DEPRECATED => 'E_DEPRECATED',
E_USER_DEPRECATED => 'E_USER_DEPRECATED',
E_ALL => 'E_ALL',
);
/**
* Environment.
*
* @var string
*/
private $environment;
/**
* Constructor.
*
* @param string $environment Environment.
*/
public function __construct( $environment ) {
$this->environment = $environment;
add_action( 'cl_smart_advice_set_error', array( $this, 'error' ), 10, 5 );
add_action( 'cl_smart_advice_set_error_handler', array( $this, 'setErrorHandler' ), 10, 0 );
add_action( 'cl_smart_advice_restore_error_handler', array( $this, 'restoreErrorHandler' ), 10, 0 );
}
/**
* Get constant home.
*
* @return string
*/
protected function wpHomeConstant() {
if ( defined( 'WP_HOME' ) && WP_HOME ) {
return (string) WP_HOME;
}
return '';
}
/**
* Get option home.
*
* @return string
*/
protected function wpHomeOption() {
if ( function_exists( 'get_option' ) ) {
$home = get_option( 'home' );
if ( is_string( $home ) ) {
return $home;
}
}
return '';
}
/**
* Get home path.
*
* @return string
*/
public function homePath() {
if ( ! empty( $this->home_path ) ) {
return $this->home_path;
}
if ( defined( 'ABSPATH' ) ) {
return ABSPATH;
}
return $this->home_path;
}
/**
* Get website.
*
* @return string
*/
public function website() {
if ( ! empty( $this->website ) ) {
return $this->website;
}
$wp_home_constant = $this->wpHomeConstant();
$wp_home_option = $this->wpHomeOption();
if ( ! empty( $wp_home_constant ) ) {
$this->website = $wp_home_constant;
} elseif ( ! empty( $wp_home_option ) ) {
$this->website = $wp_home_option;
} elseif ( is_array( $_SERVER ) && array_key_exists( 'SERVER_NAME', $_SERVER ) ) {
$this->website = esc_url_raw( wp_unslash( $_SERVER['SERVER_NAME'] ) );
}
return $this->website;
}
/**
* Get user.
*
* @return string
*/
public function user() {
if ( ! empty( $this->user ) ) {
return $this->user;
}
$parse = wp_parse_url( $this->website() );
if ( is_array( $parse ) && array_key_exists( 'host', $parse ) ) {
$this->user = $parse['host'];
}
return $this->user;
}
/**
* Get request uri.
*
* @return string
*/
public function requestUri() {
if ( ! empty( $this->request_uri ) ) {
return $this->request_uri;
}
if ( is_array( $_SERVER ) && array_key_exists( 'REQUEST_URI', $_SERVER ) ) {
$raw = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) );
// Strip query string and userinfo: query params on public routes (e.g.
// the unsubscribe token) are bearer secrets that must not leave the site.
$this->request_uri = $this->strip_uri_secrets( $raw );
}
return $this->request_uri;
}
/**
* Drop the query string and any userinfo from a URI, leaving only the
* (optional) scheme/host and path. Used so request URLs reported in
* telemetry never carry secrets passed as query parameters.
*
* @param string $uri Raw request URI (absolute or path-only).
*
* @return string URI without query string or userinfo.
*/
private function strip_uri_secrets( $uri ) {
$uri = (string) $uri;
// Drop the query string (and any fragment) wholesale, independent of the
// length of individual params, so short tokens are dropped too.
$uri = preg_replace( '/[?#].*$/s', '', $uri );
$parts = wp_parse_url( $uri );
if ( ! is_array( $parts ) ) {
return is_string( $uri ) ? $uri : '';
}
// Reassemble scheme://host/path, deliberately omitting user:pass@ userinfo.
$result = '';
if ( ! empty( $parts['scheme'] ) && ! empty( $parts['host'] ) ) {
$result .= $parts['scheme'] . '://';
}
if ( ! empty( $parts['host'] ) ) {
$result .= $parts['host'];
if ( ! empty( $parts['port'] ) ) {
$result .= ':' . $parts['port'];
}
}
if ( ! empty( $parts['path'] ) ) {
$result .= $parts['path'];
}
return $result;
}
/**
* Get http code.
*
* @return int
*/
public function httpCode() {
if ( ! empty( $this->http_code ) ) {
return $this->http_code;
}
if ( function_exists( 'http_response_code' ) ) {
$this->http_code = (int) http_response_code();
}
return $this->http_code;
}
/**
* Get current server IP Address.
*
* @return string
*/
public function serverIp() {
if ( ! empty( $this->server_ip ) ) {
return $this->server_ip;
}
if ( isset( $_SERVER['SERVER_ADDR'] ) ) {
$ip = filter_var( sanitize_text_field( wp_unslash( $_SERVER['SERVER_ADDR'] ) ), FILTER_VALIDATE_IP );
if ( is_string( $ip ) ) {
$this->server_ip = $ip;
return $this->server_ip;
}
}
if ( function_exists( 'gethostbyname' ) && function_exists( 'gethostname' ) ) {
$hostname = gethostname();
if ( is_string( $hostname ) ) {
$ip = gethostbyname( $hostname );
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
$this->server_ip = $ip;
}
}
}
return $this->server_ip;
}
/**
* Set error handler.
*
* @return void
*/
public function setErrorHandler() {
// @phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
set_error_handler( array( $this, 'error' ), E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED ); // @phpstan-ignore-line
}
/**
* Restore error handler.
*
* @return void
*/
public function restoreErrorHandler() {
restore_error_handler();
}
/**
* Send error.
*
* @param int $errno number.
* @param string $errstr message.
* @param ?string $errfile file.
* @param ?int $errline line.
* @param array $extra extra data.
*
* @return void
*/
public function error( $errno, $errstr, $errfile = null, $errline = null, $extra = array() ) {
if ( in_array( $errno, array( E_DEPRECATED, E_USER_DEPRECATED ) ) ) {
return;
}
$data = $this->data( $errno, $errstr, $errfile, $errline, $extra );
$this->send( $data );
}
/**
* Data.
*
* @param int $errno number.
* @param string $errstr message.
* @param ?string $errfile file.
* @param ?int $errline line.
* @param array $extra extra data.
*
* @return array
*/
public function data( $errno, $errstr, $errfile = null, $errline = null, $extra = array() ) {
$errstr = $this->redact_string( (string) $errstr );
// Redact PII/secrets from the WHOLE extra channel before it leaves the
// site: both the built-in telemetry ($this->extra(), which carries
// request_uri) and the caller-supplied $extra (see redact()).
//
// $this->extra() is listed LAST so the server-derived, already-normalized
// built-in telemetry WINS the merge over caller-supplied keys of the same
// name. This matters because the built-in keys are safe-value keys that
// bypass the token scrubber in redact(): a caller must not be able to
// override e.g. request_uri (normalized by strip_uri_secrets) with a raw
// value carrying a query token that would then ship unscrubbed. Caller
// keys NOT produced by extra() are still merged in and redacted.
$merged_extra = $this->redact(
array_merge(
(array) $extra,
$this->extra()
)
);
return array(
'environment' => $this->environment,
'release' => $this->release(),
'tags' => $this->tags(),
'extra' => $merged_extra,
'user' => $this->userdata(),
'sentry.interfaces.Exception' => array(
'exc_omitted' => null,
'values' => array(
array(
'stacktrace' => array(
'frames' => $this->prepare_stack_frames( $errfile, $errno ),
'frames_omitted' => null,
),
'type' => isset( $this->codes[ $errno ] ) ? $this->codes[ $errno ] : 'Undefined: ' . $errno,
'value' => $errstr,
),
),
),
);
}
/**
* Keys whose values are sensitive (PII or bearer capability) and must not
* be sent off-site, matched case-insensitively as a substring of the key.
*
* @var array
*/
private $sensitive_keys = array(
'email',
'token',
'password',
'passwd',
'secret',
'authorization',
'auth',
'api_key',
'apikey',
'access_key',
'private_key',
);
/**
* Keys whose values are STRUCTURED, server/enum-derived telemetry -- never
* free-form text or visitor-controlled input. Their string values bypass the
* redact_string() opaque-token scrubber (the 24+ char run), which would
* otherwise mask an enum identifier such as advice_type into '[redacted]'.
* Deliberately EXCLUDES visitor-derived keys (request_uri, website,
* home_path): those can carry a path-embedded bearer-like token after query
* stripping, so they must stay on the full scrub -- under-redacting a path
* secret (data exposure) outweighs the rare over-redaction of a 24+ char
* slug. Matched case-insensitively as an EXACT key name (unlike
* $sensitive_keys, which match as a substring). An exact match here also
* EXEMPTS the key from the substring-based sensitive-key removal in redact()
* -- e.g. 'email_type' contains the 'email' substring but holds a category
* enum ('advices'/'reminders'), not an address, so it must not be masked.
* Real address keys ('email', 'user_email', ...) are NOT listed here and so
* stay masked.
*
* @var array
*/
private $safe_value_keys = array(
'http_code',
'server_ip',
'advice_type',
'email_type',
);
/**
* Redacted-value placeholder.
*
* @var string
*/
private $redaction_mask = '[redacted]';
/**
* Redact sensitive entries from a caller-supplied $extra array.
*
* Values keyed by anything matching $sensitive_keys are replaced with the
* mask; the array is walked recursively so nested payloads are covered.
* Scalar string values are additionally scrubbed by redact_string().
*
* @param array $extra Caller-supplied extra data.
*
* @return array Redacted extra data.
*/
private function redact( $extra ) {
$result = array();
foreach ( $extra as $key => $value ) {
// $safe_value_keys is an exact-match allowlist of structured enum
// keys; a key on it is exempt from the broad substring sensitive-key
// removal so e.g. 'email_type' (a category enum) is not masked just
// because its name contains 'email'. Its value is still PII-scrubbed
// via redact_pii_in_string() in the safe-value branch below.
if ( is_string( $key ) && $this->is_sensitive_key( $key )
&& ! $this->is_safe_value_key( $key ) ) {
$result[ $key ] = $this->redaction_mask;
continue;
}
if ( is_array( $value ) ) {
$result[ $key ] = $this->redact( $value );
} elseif ( is_string( $value ) ) {
// Structured, server/enum-derived telemetry (advice_type,
// http_code, server_ip) skips ONLY the broad opaque-token scrub,
// which would otherwise turn a long enum identifier into
// '[redacted]' and ship misleading telemetry; email masking is
// still applied to them as cheap defence in depth. Everything
// else -- including the visitor-derived request_uri/website/
// home_path, which can carry a path-embedded token or email after
// query stripping -- gets the FULL scrub. The sensitive-key
// removal above applies to all.
$result[ $key ] = ( is_string( $key ) && $this->is_safe_value_key( $key ) )
? $this->redact_pii_in_string( $value )
: $this->redact_string( $value );
} else {
$result[ $key ] = $value;
}
}
return $result;
}
/**
* Whether a key name denotes a sensitive value.
*
* @param string $key Array key.
*
* @return bool
*/
private function is_sensitive_key( $key ) {
$key = strtolower( $key );
foreach ( $this->sensitive_keys as $needle ) {
if ( false !== strpos( $key, $needle ) ) {
return true;
}
}
return false;
}
/**
* Whether a key denotes a structured telemetry value that must bypass the
* free-text opaque-token scrubber (see $safe_value_keys).
*
* @param string $key Array key.
*
* @return bool
*/
private function is_safe_value_key( $key ) {
return in_array( strtolower( $key ), $this->safe_value_keys, true );
}
/**
* Redact obvious PII/secrets from a free-form string before it is shipped
* off-site (e.g. embedded in $errstr or a string $extra value).
*
* Masks email addresses and long opaque tokens; leaves ordinary error text
* intact so telemetry stays useful.
*
* @param string $value String to scrub.
*
* @return string Scrubbed string.
*/
private function redact_string( $value ) {
// Unambiguous PII (always masked, even for safe-value keys).
$value = $this->redact_pii_in_string( $value );
// Long opaque tokens (>= 24 chars of base62/url-safe) such as unsubscribe
// tokens or API keys. This broad rule can also mangle legitimate long
// route slugs / identifiers, so redact() relaxes ONLY this rule (not the
// PII rule above) for safe-value keys (see is_safe_value_key()).
$value = preg_replace( '/\b[A-Za-z0-9_-]{24,}\b/', $this->redaction_mask, $value );
return is_string( $value ) ? $value : $this->redaction_mask;
}
/**
* Mask unambiguous PII (email addresses) in a free-form string. This subset
* of redact_string() is applied to EVERY value, including safe-value keys: an
* email embedded in a request_uri path, website or home_path is PII, never a
* benign slug, so it must be scrubbed even when the broad opaque-token rule
* is relaxed for structured telemetry.
*
* @param string $value String to scrub.
*
* @return string Scrubbed string.
*/
private function redact_pii_in_string( $value ) {
$value = preg_replace( '/[\w.+-]+@[\w-]+(?:\.[\w-]+)+/', $this->redaction_mask, $value );
return is_string( $value ) ? $value : $this->redaction_mask;
}
/**
* Send to sentry.
*
* @param array $body send.
*
* @return string|false
*/
public function send( $body ) {
if ( ! function_exists( 'curl_init' ) || empty( $body ) || defined( 'IS_TESTING' ) ) {
return false;
}
$url = 'https://cl.sentry.cloudlinux.com/api/' . $this->project_id() . '/store/';
$headers = array(
'Content-Type: application/json',
'X-Sentry-Auth: Sentry sentry_version=7,sentry_timestamp=' . time() . ',sentry_client=php-curl/1.0,sentry_key=' . $this->key(),
);
$ch = curl_init();
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
curl_setopt( $ch, CURLOPT_URL, $url );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'POST' );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $body ) );
curl_setopt( $ch, CURLOPT_TIMEOUT, 1 );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 1 );
curl_setopt( $ch, CURLOPT_HEADER, 0 );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
$response = curl_exec( $ch );
curl_close( $ch );
return $response;
}
/**
* Minimize an absolute filesystem path before it is shipped off-site.
*
* Mirrors the profiler sibling's minimizePath (F-17): keep only the
* basename when it is plausibly a real source/asset file (so a stack-frame
* filename stays useful), otherwise return the literal '' token. A
* bare directory/account leaf -- e.g. ABSPATH's '/home//...' or
* a docroot -- is therefore reduced to '', so the OS account name and
* filesystem layout never reach Sentry. Telemetry goes to CloudLinux's own
* first-party Sentry, so this is defence-in-depth consistency with F-17, not
* a fix for a cross-tenant leak.
*
* @param string|null $path Absolute path.
*
* @return string|null Basename, '', or the input unchanged when empty.
*/
private function minimize_path( $path ) {
if ( ! is_string( $path ) || '' === $path ) {
return $path;
}
$normalized = trim( str_replace( '\\', '/', $path ), '/' );
if ( '' === $normalized ) {
return '';
}
$segments = explode( '/', $normalized );
$basename = end( $segments );
$source_ext_pattern = '/\.(php\d?|phtml|phps|inc|module|install'
. '|engine|theme|profile|tpl|twig|html?|js|mjs|cjs|jsx|tsx?'
. '|css|s[ac]ss|less|xml|ya?ml|json)$/i';
if ( '' === $basename || ! preg_match( $source_ext_pattern, $basename ) ) {
return '';
}
return $basename;
}
/**
* Prepares stack trace data for Sentry.
*
* @param string $errfile File name.
* @param int $errline Line number.
*
* @return array
*
* @since 0.1-9
*/
private function prepare_stack_frames( $errfile = null, $errline = null ) {
// Default result will contain only the error file and line. The filename
// is path-minimized so an absolute /home//... path is not
// shipped to Sentry (consistency with F-17; see minimize_path()).
$default = array(
array(
'filename' => $this->minimize_path( $errfile ),
'lineno' => $errline,
),
);
if ( ! function_exists( 'debug_backtrace' ) ) {
return $default;
}
$backtrace = debug_backtrace(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace
// Ignore last couple of frames that are not relevant to the error.
$frames_to_omit = array(
'App/Debug.php', // Call to CloudLinux\SmartAdvice\App::prepare_stack_frames().
'App/Debug.php', // Call to CloudLinux\SmartAdvice\App::data().
'wp-includes/class-wp-hook.php', // WordPress executing a filter.
'wp-includes/class-wp-hook.php', // WordPress executing a filter.
'wp-includes/plugin.php', // Call to apply_filters().
);
foreach ( $frames_to_omit as $frame_to_omit ) {
if ( isset( $backtrace[0]['file'] ) && strpos( $backtrace[0]['file'], $frame_to_omit ) !== false ) {
array_shift( $backtrace );
}
}
$frames = array();
while ( ! empty( $backtrace ) ) {
$frame = array_pop( $backtrace );
if ( isset( $frame['file'] ) && isset( $frame['line'] ) ) {
$frames[] = array(
'filename' => $this->minimize_path( $frame['file'] ),
'lineno' => $frame['line'],
);
}
}
if ( empty( $frames ) ) {
return $default;
}
return $frames;
}
/**
* Determines the plugin version.
*
* @return string Plugin version.
*
* @since 0.1-11
*/
public function plugin_version() {
return defined( 'CL_SMART_ADVICE_VERSION' ) ? CL_SMART_ADVICE_VERSION : 'dev';
}
/**
* Get environment name.
*
* @return string Environment name.
*
* @since 0.1-11
*/
public function environment() {
return $this->environment;
}
/**
* Get release name.
*
* @return string Release name.
*
* @since 0.1-11
*/
public function release() {
return 'php-smart-advice-plugin@' . $this->plugin_version();
}
/**
* Get Sentry key.
*
* @return string Sentry Key.
*
* @since 0.1-11
*/
public function key() {
return 'ef44dbb648894027a282678f691abc5a';
}
/**
* Get Sentry project ID.
*
* @return int Sentry project ID.
*
* @since 0.1-11
*/
public function project_id() {
return 24;
}
/**
* Get tags.
*
* @return array Tags.
*
* @since 0.1-11
*/
public function tags() {
return array(
'php_version' => phpversion(),
'plugin_version' => $this->plugin_version(),
'xray_version' => defined( 'CL_SMART_ADVICE_XRAY_RELEASE' ) ? CL_SMART_ADVICE_XRAY_RELEASE : null,
);
}
/**
* Get user data.
*
* @return array User data.
*
* @since 0.1-11
*/
public function userdata() {
return array(
'username' => $this->user(),
);
}
/**
* Get extra data.
*
* @return array Extra data.
*
* @since 0.1-11
*/
public function extra() {
return array(
'website' => $this->website(),
// home_path is ABSPATH ('/home//public_html/...'); minimize
// it so the OS account name / FS layout is not shipped to Sentry
// (consistency with F-17; redact_string alone left the username).
'home_path' => $this->minimize_path( $this->homePath() ),
'request_uri' => $this->requestUri(),
'http_code' => $this->httpCode(),
'server_ip' => $this->serverIp(),
);
}
}