File Manager
Viewing File: xray-profiler-collector-shortcodes.php
<?php
/**
* Copyright (с) Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2022 All Rights Reserved
*
* Licensed under CLOUD LINUX LICENSE AGREEMENT
* https://www.cloudlinux.com/legal/
*/
namespace XrayProfiler;
use Closure;
use Exception;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;
use ReflectionObject;
if (!class_exists('\XrayProfiler\CollectorShortcode')) {
class CollectorShortcode extends Collector
{
/**
* @var int
*/
private $next_id = 0;
/**
* @var array<string,array<int>>
*/
public $tag_id = array();
/**
* @var array<string,array<string>>
*/
private $parsed_handlers = array();
/**
* @var self|null
*/
private static $instance = null;
private function __construct()
{
}
private function __clone()
{
}
/**
* @return self
*/
public static function instance()
{
if (is_null(self::$instance)) {
self::$instance = new self();
self::$instance->clean();
}
return self::$instance;
}
/**
* @return int
*/
public function getNextId()
{
return $this->next_id;
}
/**
* @return int
*/
public function incrNextId()
{
return $this->next_id++;
}
/**
* @param string $tag
* @param int $id
* @return void
*/
public function setTagId($tag, $id)
{
$this->tag_id[$tag][] = $id;
}
/**
* @param string $tag
* @param bool $replace
* @return int
*/
public function popTagId($tag, $replace = true)
{
$id = 0;
if (array_key_exists($tag, $this->tag_id)) {
$ids = $this->tag_id[$tag];
if (!is_null($last = array_pop($ids))) {
$id = $last;
}
if ($replace === true) {
$this->tag_id[$tag] = $ids;
}
}
return $id;
}
/**
* @param string $tag
*
* @return array<string,string>|null
*/
public function getParsedHandlers($tag)
{
if (array_key_exists($tag, $this->parsed_handlers)) {
return $this->parsed_handlers[$tag];
}
return null;
}
/**
* @param string $tag
* @param array<string,string> $data
*
* @return void
*/
public function setParsedHandlers($tag, $data)
{
$this->parsed_handlers[$tag] = $data;
}
/**
* @param string $tag
* @param int $id
*
* @return array<string,float|int|string|null>|null
* [
* 'shortcode_id' => (int)
* 'handler' => (string)
* 'name' => (string)
* 'plugin' => (string)
* 'duration' => (int)
* 'attrs_json' => (string)
* 'timer_start' => (float)
*]
*/
public function getShortcode($tag, $id)
{
$data = $this->getData();
if (array_key_exists($tag, $data) && array_key_exists($id, $data[$tag])) {
return $data[$tag][$id];
}
return null;
}
/**
* @param string $tag
* @param int $id
* @param array<string,float|int|string|null> $data
*
* @return void
*/
public function setShortcode($tag, $id, $data)
{
$shortcodes = $this->getData();
if (!array_key_exists($tag, $shortcodes)) {
$shortcodes[$tag] = array();
}
$shortcodes[$tag][$id] = $data;
$this->setData($shortcodes);
}
/**
* @param array|string $attr
*
* @return array<int, array<string|mixed>>
*/
public function prepareAttributes($attr)
{
$attrs = array();
if (is_array($attr) && !empty($attr)) {
foreach ($attr as $key => $val) {
$attrs[] = array(
'type' => 'key',
'key' => $key,
'val' => $this->redactAttributeValue($key, $val),
);
}
} elseif (is_string($attr) && !empty($attr)) {
$attrs[] = array(
'type' => 'string',
'key' => '',
'val' => $this->redactAttributeValue('', $attr),
);
}
return $attrs;
}
/**
* Benign attribute names that are NEVER redacted, regardless of any
* secret-looking substring they may contain (e.g. "author" contains
* "auth"). Checked first so the secret-token substring match below can
* be unanchored without re-introducing collisions on presentational
* attributes.
*
* @var array<int,string>
*/
private static $benign_attr_keys = array(
'author', 'id', 'ids', 'src', 'url', 'href', 'link', 'class',
'align', 'size', 'width', 'height', 'title', 'alt', 'name', 'slug',
'type', 'color', 'style', 'target', 'rel', 'caption', 'label',
'tag', 'category', 'date',
// Common presentational keys that collide with a secret-token
// substring (keyword/keywords -> "key") -- exempt so the substring
// match below does not over-redact ordinary shortcode telemetry.
'keyword', 'keywords',
);
/**
* Secret-looking key tokens. Matched as case-insensitive SUBSTRINGS of
* the (non-allowlisted) attribute name, so glued/camelCase secret keys
* with no separators (secretkey, accesskey, apitoken, authtoken,
* passphrase, passkey, userpass, consumerkey, encryptionkey, hmacsig,
* SecretAccessKey, clientsecret, privatekey, ...) are caught alongside
* separated variants (api_key, auth_token). Substring matching is
* deliberate: under-redacting a glued secret name (data exposure) is
* worse than over-redacting a rare presentational name. The benign
* allowlist above exempts the common presentational keys that would
* otherwise collide (author, keyword, ...).
*
* @var array<int,string>
*/
private static $secret_key_tokens = array(
'secret', 'password', 'passwd', 'pass', 'token', 'apikey',
'api_key', 'api', 'key', 'auth', 'credential', 'bearer', 'dsn',
'privatekey', 'accesskey', 'signature', 'sig',
);
/**
* Redact secret-looking shortcode attribute values before they are
* serialized into attrs_json and exported off-box to telemetry.
* Attribute names and overall structure are preserved; only values
* flagged by a non-allowlisted secret-looking key name (substring
* match), an email address, or a credentialed URL are replaced with a
* placeholder. Allowlisted presentational keys (id/src/url/author/...)
* are always kept. Retained values are capped at 256 chars as a
* backstop; non-scalars are left to the JSON encoder.
*
* @param int|string $key
* @param mixed $val
*
* @return mixed
*/
private function redactAttributeValue($key, $val)
{
if (!is_string($val)) {
return $val;
}
$key_str = is_string($key) ? $key : '';
$key_lc = strtolower($key_str);
// Benign-key allowlist: presentational attributes are never
// redacted, which resolves the author/auth substring collision
// without anchoring the secret-token match below.
$is_benign = $key_lc !== '' && in_array($key_lc, self::$benign_attr_keys, true);
if (!$is_benign && $key_lc !== '') {
// Secret-looking key names: substring match so glued/camelCase
// keys (secretkey, accesskey, apitoken, passphrase, passkey,
// userpass, SecretAccessKey, ...) are caught, not just separated
// variants. The benign allowlist above exempts the common
// presentational keys that would otherwise collide (keyword/...).
foreach (self::$secret_key_tokens as $token) {
if (strpos($key_lc, $token) !== false) {
return '[REDACTED]';
}
}
}
// Value-shape rules apply even to benign-but-non-allowlisted keys,
// catching secrets hiding under an innocuous attribute name.
// Email address.
if (preg_match('/[^\s@]+@[^\s@]+\.[^\s@]+/', $val)) {
return '[REDACTED]';
}
// URL embedding credentials (scheme://user:pass@host).
if (preg_match('#[a-z][a-z0-9+.-]*://[^/\s:@]+:[^/\s@]+@#i', $val)) {
return '[REDACTED]';
}
// No bare value-length/entropy redaction: it masks public asset
// paths, slug ids and long URLs. Glued secret keys are already
// covered by the substring key match above, so a length heuristic
// would only re-introduce over-redaction of public URLs/paths.
// Backstop: cap retained value length to limit accidental leakage.
if (strlen($val) > 256) {
return substr($val, 0, 256) . '...[truncated]';
}
return $val;
}
/**
* @param string $tag
* @param object|callable $fn
*
* @return array<string,string>
*/
public function parseHandler($tag, $fn)
{
if ($cache = $this->getParsedHandlers($tag)) {
return $cache;
}
$handler = '';
$plugin = '';
if (
class_exists('ReflectionClass') &&
class_exists('ReflectionObject') &&
class_exists('ReflectionFunction')
) {
try {
$parse = array(
'path' => '',
'handler' => '',
);
if (is_array($fn)) {
// Class::method
$parse = $this->parseHandlerArray($fn);
} elseif (is_object($fn)) {
// Object/Closure/Invoke
$parse = $this->parseHandlerObject($fn);
} elseif (is_string($fn)) {
// Function string
$parse = $this->parseHandlerString($fn);
}
$path = $parse['path'];
$handler = $parse['handler'];
if (!empty($path)) {
$plugin = $this->pluginOrThemeName($path);
}
if (empty($handler)) {
xray_profiler_log(
E_USER_NOTICE,
"Can't parse handler: " . print_r($fn, true),
__FILE__,
__LINE__
);
}
} catch (Exception $e) {
$message = "Catch Reflection error Exception: "
. $e->getMessage()
. ', with handler: '
. print_r($fn, true);
xray_profiler_log(E_USER_WARNING, $message, $e->getFile(), $e->getLine());
}
} else {
xray_profiler_log(E_USER_NOTICE, "Can't parse handler, Reflection doesn't exists", __FILE__, __LINE__);
}
$result = array(
'handler' => $handler,
'plugin' => $plugin,
);
$this->setParsedHandlers($tag, $result);
return $result;
}
/**
* @param array<mixed> $fn
*
* @return array<string,string>
* @throws ReflectionException
*/
public function parseHandlerArray($fn)
{
$path = '';
$handler = '';
$fn = array_values($fn);
if (!empty($fn)) {
if (is_object($fn[0])) {
$parse = $this->parseHandlerObject($fn[0]);
} elseif (is_string($fn[0])) {
$parse = $this->parseHandlerString($fn[0]);
}
if (!empty($parse['handler'])) {
$path = $parse['path'];
$handler = $parse['handler'];
if (array_key_exists(1, $fn) && is_string($fn[1])) {
$handler .= '::' . $fn[1];
}
}
}
return array(
'path' => $path,
'handler' => $handler,
);
}
/**
* @param object $fn
*
* @return array<string,string>
* @throws ReflectionException
*/
public function parseHandlerObject($fn)
{
$ref = new ReflectionObject($fn);
$name = $ref->getName();
$path = '';
$handler = '';
if (!empty($name)) {
$handler = $name;
$file = $ref->getFileName();
if (!empty($file)) {
$path = $file;
}
if ($name == 'Closure' && $fn instanceof Closure) {
$ref = new ReflectionFunction($fn);
$name = $ref->getName();
if (!empty($name)) {
$file = $ref->getFileName();
if (!empty($file)) {
$path = $file;
}
}
}
}
return array(
'path' => $path,
'handler' => $handler,
);
}
/**
* @param string $fn
*
* @return array<string,string>
*/
public function parseHandlerString($fn)
{
$path = '';
$handler = '';
$class = $fn;
$ref = null;
if (strpos($fn, '::') !== false) {
$parts = explode('::', $fn);
$class = array_shift($parts);
}
if (class_exists($class)) {
$ref = new ReflectionClass($class);
} elseif (function_exists($class)) {
$ref = new ReflectionFunction($class);
}
if ($ref) {
$handler = $fn;
$name = $ref->getName();
if (! empty($name)) {
$file = $ref->getFileName();
if (! empty($file)) {
$path = $file;
}
}
}
return array(
'path' => $path,
'handler' => $handler,
);
}
/**
* @param string $path
*
* @return string
*/
public function pluginOrThemeName($path)
{
$name = '';
$pattern = '/wp-content\/(mu-plugins\/|plugins\/|themes\/)(.*?)(\/|.php)/is';
preg_match($pattern, $path, $matches);
if (!empty($matches) && array_key_exists(2, $matches)) {
if (strpos($matches[1], 'themes') === 0) {
$name = 'Theme: ';
}
$name .= $matches[2];
}
return $name;
}
/**
* @param false|string $return
* @param string $tag
* @param array|string $attr
* @param array $m
*
* @return false|string
*/
public function preDoShortcodeTagEarly($return, $tag, $attr, $m)
{
$this->incrNextId();
$id = $this->getNextId();
$this->setTagId($tag, $id);
$attrs = $this->prepareAttributes($attr);
$parseHandler = $this->parseHandler($tag, $GLOBALS['shortcode_tags'][$tag]);
$handler = $parseHandler['handler'];
$plugin = $parseHandler['plugin'];
if (empty($attrs) || !($attrs_json = json_encode($attrs))) {
$attrs_json = 'null';
}
$data = [
'shortcode_id' => $id,
'handler' => empty($handler) ? 'Unknown' : $handler,
'name' => $tag,
'plugin' => empty($plugin) ? 'Unknown' : $plugin,
'duration' => 0,
'attrs_json' => $attrs_json,
'timer_start' => microtime(true),
];
$this->setShortcode($tag, $id, $data);
return $return;
}
/**
* @param false|string $return
* @param string $tag
* @param array|string $attr
* @param array $m
*
* @return false|string
*/
public function preDoShortcodeTagLate($return, $tag, $attr, $m)
{
if ($return !== false) {
$this->popTagId($tag);
}
return $return;
}
/**
* @param false|string $output
* @param string $tag
* @param array|string $attr
* @param array $m
*
* @return false|string
*/
public function doShortcodeTagLate($output, $tag, $attr, $m)
{
$timer_end = microtime(true);
$id = $this->popTagId($tag);
$shortcode = $this->getShortcode($tag, $id);
if (empty($shortcode)) {
xray_profiler_log(
E_USER_NOTICE,
'Can\'t find shortcode ' . $tag . ' id: ' . $id . ' in ' . __METHOD__,
__FILE__,
__LINE__
);
} else {
$timer_start = floatval($shortcode['timer_start']);
$diff = $timer_end - $timer_start;
$duration = number_format($diff * 1000000, 0, '', '');
$shortcode['duration'] = $duration;
unset($shortcode['timer_start']);
$tag = $shortcode['name'] . '';
$this->setShortcode($tag, $id, $shortcode);
}
return $output;
}
/**
* @return array
*/
public function getXrayData()
{
$shortcodes = $this->data;
$items = array();
foreach ($shortcodes as $iterations) {
$items = array_merge($items, $iterations);
}
usort($items, function ($a, $b) {
if ($a['duration'] == $b['duration']) {
return 0;
}
return ($a['duration'] < $b['duration']) ? 1 : -1;
});
foreach ($items as &$item) {
if (array_key_exists('timer_start', $item)) {
unset($item['timer_start']);
}
}
return array_slice($items, 0, 20);
}
/**
* @return $this
*/
public function clean()
{
$this->data = array();
$this->next_id = 0;
$this->tag_id = array();
$this->parsed_handlers = array();
return $this;
}
}
}