File Manager
Viewing File: xray-profiler-log.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;
if (!class_exists('\XrayProfiler\Log')) {
class Log
{
/**
* @var string
*/
private $website = '';
/**
* @var string
*/
private $user = '';
/**
* @var string
*/
private $request_uri = '';
/**
* @var int
*/
private $http_code = 0;
/**
* @var array<string>
*
* PHP Core Exceptions
*/
public $core_exceptions = array(
E_ERROR => 'E_ERROR', //1
E_WARNING => 'E_WARNING', //2
E_PARSE => 'E_PARSE', //4
E_NOTICE => 'E_NOTICE', //8
E_CORE_ERROR => 'E_CORE_ERROR', //16
E_CORE_WARNING => 'E_CORE_WARNING', //32
E_COMPILE_ERROR => 'E_COMPILE_ERROR', //64
E_COMPILE_WARNING => 'E_COMPILE_WARNING', //128
E_USER_ERROR => 'E_USER_ERROR', //256
E_USER_WARNING => 'E_USER_WARNING', //512
E_USER_NOTICE => 'E_USER_NOTICE', //1024
E_STRICT => 'E_STRICT', //2048
E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', //4096
E_DEPRECATED => 'E_DEPRECATED', //8192
E_USER_DEPRECATED => 'E_USER_DEPRECATED', //16384
E_ALL => 'E_ALL', //32767
);
/**
* @var array<array<int|string|null>>
*/
private $data = array();
/**
* @var self|null
*/
private static $instance = null;
private function __construct()
{
}
private function __clone()
{
}
/**
* @return string
*/
protected function wpHomeConstant()
{
if (defined('WP_HOME') && WP_HOME) {
return (string)WP_HOME;
}
return '';
}
/**
* @return string
*/
protected function wpHomeOption()
{
if (function_exists('get_option')) {
$home = get_option('home');
if (is_string($home)) {
return $home;
}
}
return '';
}
/**
* @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 = (string)$_SERVER['SERVER_NAME'];
}
return $this->website;
}
/**
* @return string
*/
public function user()
{
if (!empty($this->user)) {
return $this->user;
}
$parse = parse_url($this->website());
if (is_array($parse) and array_key_exists('host', $parse)) {
$this->user = $parse['host'];
}
return $this->user;
}
/**
* @return string
*/
public function requestUri()
{
if (! empty($this->request_uri)) {
return $this->request_uri;
}
if (is_array($_SERVER) && array_key_exists('REQUEST_URI', $_SERVER)) {
/* keep path only; drop query/fragment - they may carry secrets:
tokens, signed-URL params, emails, API keys */
$uri = (string)$_SERVER['REQUEST_URI'];
$uri = explode('?', $uri, 2)[0];
$this->request_uri = explode('#', $uri, 2)[0];
}
return $this->request_uri;
}
/**
* @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;
}
/**
* @return self
*/
public static function instance()
{
if (is_null(self::$instance)) {
self::$instance = new self();
self::$instance->clean();
}
return self::$instance;
}
/**
* @return array
*/
public function getData()
{
return $this->data;
}
/**
* @param int $errno
* @param string $errstr
* @param ?string $errfile
* @param ?int $errline
*
* @return void
*/
public function setData($errno, $errstr, $errfile = null, $errline = null)
{
$this->data[] = array(
'message' => $errstr,
'type' => isset($this->core_exceptions[$errno])
? $this->core_exceptions[$errno]
: 'Undefined: ' . $errno,
'filename' => $errfile,
'lineno' => $errline,
);
}
/**
* @return string|bool
*/
public function sendData()
{
$data = $this->prepareSentryData($this->data);
if (empty($data)) {
return false;
}
$sentry_response = $this->sendSentryData($data);
if (!$sentry_response) {
return false;
}
$this->clean();
return $sentry_response;
}
/**
* @param array $data
*
* @return string|bool
*/
private function prepareSentryData($data)
{
if (empty($data)) {
return false;
}
$formatted = array();
foreach ($data as $row) {
$formatted[] = array(
"stacktrace" => array(
"frames" => array(
array(
// minimize: drop server path prefix so the OS username is not leaked
"filename" => $this->minimizePath($row["filename"]),
"lineno" => $row["lineno"],
)
),
"frames_omitted" => null,
),
"type" => $row['type'],
// minimize: scrub embedded absolute paths and cap length
"value" => $this->minimizeMessage($row['message']),
);
}
$xray_version = defined('XRAY_RELEASE') ? XRAY_RELEASE : null;
$sentry = array(
"release" => $this->release($xray_version),
"tags" => array(
"php_version" => phpversion(),
"xray_version" => $xray_version,
),
"extra" => array(
'website' => $this->website(),
'request_uri' => $this->requestUri(),
'http_code' => $this->httpCode(),
),
"user" => array(
"username" => $this->user(),
),
"sentry.interfaces.Exception" => array(
"exc_omitted" => null,
"values" => $formatted,
),
);
// Defence-in-depth against json_encode() returning false (which
// would make sendData()'s empty() guard silently drop the whole
// Sentry POST). On PHP 7.2+ this flag substitutes U+FFFD for any
// malformed UTF-8 in the payload. On older runtimes the flag is
// undefined so flags=0; there truncateUtf8() guarantees OUR
// truncation never introduces malformed UTF-8, so the patch does no
// harm. Note: a RAW error message already malformed before
// truncation can still fail json_encode on PHP <7.2 — that is the
// exact pre-patch behavior, pre-existing and out of scope here (not
// a regression introduced by this fix).
$flags = defined('JSON_INVALID_UTF8_SUBSTITUTE') ? JSON_INVALID_UTF8_SUBSTITUTE : 0;
return json_encode($sentry, $flags);
}
/**
* Reduce the server path used for the frame.filename field to the file
* basename, and redact any leaf that is not a recognised source/asset
* file to the literal token '<path>'. In practice frame.filename is
* PHP's errfile, which is always the executing source/template script,
* so its basename ends in a known code/template/asset extension (.php,
* .phtml, .tpl, .twig, .js, .css, ...) and is kept (e.g. "index.php",
* "loader.php", "style.css"). Any other leaf is redacted: a bare home
* or vhost directory leaf is the account name — possibly a DOTTED OS
* username (/home/john.doe) or a domain-named Plesk vhost
* (/var/www/vhosts/example.com) — and does NOT end in a source
* extension. Matching on a source/asset extension allowlist (rather
* than the earlier "any dot means it's a file" test) closes the dotted
* directory/username/domain leak that the dot heuristic missed, while
* still keeping legitimate dotted source files. The OS username /
* filesystem layout therefore never egresses regardless of layout.
*
* @param string|null $path
*
* @return string|null
*/
private function minimizePath($path)
{
if (!is_string($path) || $path === '') {
return $path;
}
$normalized = trim(str_replace('\\', '/', $path), '/');
if ($normalized === '') {
return '<path>';
}
$segments = explode('/', $normalized);
$basename = end($segments);
// Keep the basename only when it is plausibly a real source/asset
// file (an errfile always is); otherwise it is a bare
// directory/account leaf (dotless OR dotted username/domain) and
// must be redacted.
$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 '<path>';
}
return $basename;
}
/**
* Minimize a raw PHP error message before it enters the payload: redact
* any embedded absolute filesystem path to the literal token '<path>'
* and cap the overall length. PHP error strings routinely embed server
* paths, query fragments and runtime values that need not be disclosed
* off-host. A constant '<path>' replacement (rather than retaining the
* basename) ensures a message can never leak a username/domain/layout
* regardless of where the path terminates — e.g. an open_basedir or
* disk-quota error names a directory whose leaf segment is the account
* name or vhost domain.
*
* @param string|null $message
*
* @return string|null
*/
private function minimizeMessage($message)
{
if (!is_string($message) || $message === '') {
return $message;
}
// Bound the input BEFORE scrubbing: the path-scrub regex must never
// run on a huge subject. A PHP error string can be tens of KB (E_ALL,
// big traces, var_dumps); on the default pcre.jit=1 the path regex on
// such a subject can trip the PCRE JIT stack limit, making
// preg_replace return NULL. Capping first (UTF-8-safe) keeps the
// regex subject tiny (<= ~1027 bytes) so that never happens; we
// remember whether we cut, then scrub the small result and re-append
// the '...' suffix afterwards.
$max_length = 1024;
$truncated = false;
if (strlen($message) > $max_length) {
$message = $this->truncateUtf8($message, $max_length);
$truncated = true;
}
// Redact absolute *nix paths to the literal token '<path>'. Two
// alternatives:
// 1. multi-segment (/a/b/c) matched anywhere, so a path embedded
// mid-token (e.g. "in/home/bob/x") is still redacted;
// 2. single-segment (/home, /var) matched ONLY at a token boundary
// (start, whitespace or a delimiter -- the negative lookbehind
// rejects a preceding word char or '/'). The boundary guard is
// what keeps benign mid-word slashes like "and/or" or
// "read/write" intact, so we close the bare-directory leak
// (/home, /var in open_basedir / quota errors) without
// over-redacting ordinary prose.
$scrubbed = preg_replace(
'#/(?:[^\s/:"\']+/)+[^\s/:"\']+|(?<![\w/])/[^\s/:"\']+#',
'<path>',
$message
);
if (!is_string($scrubbed)) {
// FAIL CLOSED: on any PCRE engine error never emit the raw
// message (it could carry an unredacted absolute path); redact
// the whole value instead.
return '<redacted>';
}
if ($truncated) {
$scrubbed .= '...';
}
return $scrubbed;
}
/**
* Truncate a string to at most $max BYTES without splitting a UTF-8
* multibyte sequence. The output is always <= $max bytes and never ends
* with a partial codepoint, so it is guaranteed valid UTF-8 (assuming
* the input prefix was). Pure PHP with no mbstring/iconv dependency, so
* it behaves identically on every supported runtime (PHP 5.4+), unlike
* mb_strcut which silently degrades to a byte-wise substr when mbstring
* is not loaded.
*
* @param string $s
* @param int $max
*
* @return string
*/
private function truncateUtf8($s, $max)
{
if (!is_string($s) || strlen($s) <= $max) {
return $s;
}
$s = substr($s, 0, $max);
$len = strlen($s);
// Count trailing UTF-8 continuation bytes (10xxxxxx).
$cont = 0;
while ($cont < $len && (ord($s[$len - 1 - $cont]) & 0xC0) === 0x80) {
$cont++;
}
if ($cont < $len) {
$lead = ord($s[$len - 1 - $cont]);
$expected = 1;
if ($lead >= 0xF0) {
$expected = 4;
} elseif ($lead >= 0xE0) {
$expected = 3;
} elseif ($lead >= 0xC0) {
$expected = 2;
}
// If the final multibyte sequence is incomplete, drop it whole.
if (($cont + 1) < $expected) {
$len = $len - 1 - $cont;
}
}
return substr($s, 0, $len);
}
/**
* @param string|bool $data
*
* @return string|bool
*/
private function sendSentryData($data)
{
if (!function_exists('curl_init') || empty($data)) {
return false;
}
$ch = curl_init();
$sentry_key = '8e6821f19d214977ace88586bc8569fd';
$url = 'https://cl.sentry.cloudlinux.com/api/23/store/';
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'X-Sentry-Auth: Sentry sentry_version=7,sentry_timestamp=' . time()
. ',sentry_client=php-curl/1.0,sentry_key=' . $sentry_key,
));
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
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;
}
/**
* @return void
*/
public function clean()
{
$this->data = array();
$this->website = '';
$this->user = '';
$this->request_uri = '';
$this->http_code = 0;
}
/**
* Builds the Sentry release string based on X-Ray version.
*
* @param string|null $xray_version X-Ray version from XRAY_RELEASE constant.
*
* @return string Sentry release string.
*
* @since 0.5-23
*/
public function release($xray_version)
{
$version = 'dev';
if (! empty($xray_version)) {
preg_match('/\d+\.\d+(\.\d+)?-\d+/', $xray_version, $matches);
if (! empty($matches)) {
$version = $matches[0];
}
}
return 'xray-php-profiler@' . $version;
}
}
}