BxAnalyticsErrors.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * Client errors before they are stored: what is kept of a message and a stack (no query
  13. * strings, no tokens, no addresses), the fingerprint that groups repeats of one error, and
  14. * the route a page belongs to.
  15. *
  16. * Messages and stacks come from visitors' browsers and apps, so everything here treats them
  17. * as untrusted text: control characters go, lengths are capped, and reports escape them.
  18. */
  19. class BxAnalyticsErrors
  20. {
  21. /**
  22. * Kinds a client may send: uncaught JS errors, unhandled promise rejections, React render
  23. * errors (error boundaries), native app errors (the RN global handler), failed requests
  24. * (status 500 and up), and scripts or styles that failed to load.
  25. */
  26. const KINDS = ['js', 'promise', 'react', 'native', 'http', 'resource'];
  27. /**
  28. * The catch-all group for errors past the new groups limit.
  29. */
  30. const KIND_OVERFLOW = 'overflow';
  31. const MAX_MESSAGE_INPUT = 500;
  32. const MAX_MESSAGE = 255;
  33. const MAX_SOURCE = 255;
  34. const MAX_STACK_INPUT = 8000;
  35. const MAX_STACK = 4000;
  36. const MAX_FRAMES = 30;
  37. const REDACTED = '<redacted>';
  38. /**
  39. * Text without control characters, secrets, query strings or the site's own host in
  40. * URLs, cut to a length.
  41. *
  42. * @param string $s the text
  43. * @param int $iMax characters kept
  44. * @param array $aOwnHosts the site's hosts, removed from URLs (other hosts stay)
  45. * @param bool $bLines keep line breaks (stacks)
  46. */
  47. public static function sanitize($s, $iMax, $aOwnHosts = [], $bLines = false)
  48. {
  49. $s = (string)$s;
  50. if($s === '')
  51. return '';
  52. if(!mb_check_encoding($s, 'UTF-8'))
  53. $s = mb_convert_encoding($s, 'UTF-8', 'UTF-8');
  54. $s = str_replace("\r\n", "\n", $s);
  55. $s = preg_replace($bLines ? '/[\x00-\x09\x0B-\x1F\x7F]+/u' : '/[\x00-\x1F\x7F]+/u', ' ', $s);
  56. $aHosts = array_map('mb_strtolower', array_filter((array)$aOwnHosts));
  57. // URLs: no query string or fragment, and no host when it is the site's own; a
  58. // stack frame's :line:column after the URL stays
  59. $s = preg_replace_callback('~\b(?:https?|wss?)://[^\s()<>"\'`]+~i', function($aMatch) use ($aHosts) {
  60. $sUrl = $aMatch[0];
  61. $sPosition = '';
  62. if(preg_match('/^(.*?)((?::\d+){1,2})$/su', $sUrl, $aPosition)) {
  63. $sUrl = $aPosition[1];
  64. $sPosition = $aPosition[2];
  65. }
  66. $sUrl = preg_replace('/[?#].*$/s', '', $sUrl);
  67. if(preg_match('~^[a-z]+://([^/:]+)(:\d+)?(/.*)?$~i', $sUrl, $aParts) && in_array(mb_strtolower($aParts[1]), $aHosts, true))
  68. $sUrl = isset($aParts[3]) && $aParts[3] !== '' ? $aParts[3] : '/';
  69. return $sUrl . $sPosition;
  70. }, $s);
  71. // a site's own relative URLs (an http error's "HTTP 502 /page.php?i=x&id=5"): only
  72. // the page parameters stay of their query strings
  73. $s = preg_replace_callback('~(?<![\w.:/-])/[^\s()<>"\'`?#]*\?[^\s()<>"\'`#:]*~u', function($aMatch) {
  74. return self::getCleanPath($aMatch[0], false);
  75. }, $s);
  76. $s = self::redact($s);
  77. if($bLines)
  78. $s = preg_replace('/[ ]+\n/u', "\n", $s);
  79. return trim(get_mb_substr(trim($s), 0, $iMax));
  80. }
  81. /**
  82. * Tokens, passwords, emails and long random strings replaced.
  83. */
  84. public static function redact($s)
  85. {
  86. $aPatterns = [
  87. // JSON web tokens
  88. '/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/' => self::REDACTED,
  89. '/\b(Bearer|Basic)\s+[^\s"\'`,;]+/i' => '$1 ' . self::REDACTED,
  90. // values of names that hold secrets: token=..., api_key=..., "password":"...",
  91. // otp: ... (a serialized object or form in a message)
  92. '/(["\']?)\b([A-Za-z_]*(?:token|key|secret|password|passwd|pass|pwd|auth|sid|session|otp))\1(\s*[:=]\s*)(?:"[^"]*"|\'[^\']*\'|[^&\s"\'`,;}]+)/i' => '$1$2$1$3' . self::REDACTED,
  93. '/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}/' => self::REDACTED,
  94. // long hex (hashes, ids) and long base64-like runs with digits and both cases
  95. '/(?<![A-Za-z0-9])[0-9a-fA-F]{24,}(?![A-Za-z0-9])/' => self::REDACTED,
  96. '/(?<![A-Za-z0-9+_-])(?=[A-Za-z0-9+_-]*[0-9])(?=[A-Za-z0-9+_-]*[A-Z])(?=[A-Za-z0-9+_-]*[a-z])[A-Za-z0-9+_-]{24,}={0,2}/' => self::REDACTED,
  97. ];
  98. foreach($aPatterns as $sPattern => $sReplacement) {
  99. $sResult = preg_replace($sPattern, $sReplacement, $s);
  100. if($sResult !== null)
  101. $s = $sResult;
  102. }
  103. return $s;
  104. }
  105. /**
  106. * A stack trace (and a React component stack after it) sanitized, at most 30 frames.
  107. */
  108. public static function sanitizeStack($sStack, $sComponentStack = '', $aOwnHosts = [])
  109. {
  110. $sStack = self::_frames(self::sanitize(get_mb_substr((string)$sStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
  111. $sComponentStack = self::_frames(self::sanitize(get_mb_substr((string)$sComponentStack, 0, self::MAX_STACK_INPUT), self::MAX_STACK_INPUT, $aOwnHosts, true));
  112. if($sComponentStack !== '')
  113. $sStack .= ($sStack !== '' ? "\n--\n" : '') . $sComponentStack;
  114. return get_mb_substr($sStack, 0, self::MAX_STACK);
  115. }
  116. /**
  117. * Noise that is never stored: "Script error." without a stack (a cross-origin script
  118. * the browser hides), and errors of browser extensions.
  119. */
  120. public static function isNoise($sMessage, $sStack, $sSource)
  121. {
  122. if($sStack === '' && preg_match('/^Script error\.?$/i', trim($sMessage)))
  123. return true;
  124. $aFrame = self::getTopFrame($sStack);
  125. foreach([$sSource, $aFrame ? $aFrame['file'] : ''] as $sFile)
  126. if(preg_match('~^[a-z-]*extension://~i', (string)$sFile))
  127. return true;
  128. return false;
  129. }
  130. /**
  131. * Whether a message matches one of the ignore patterns (* for any characters, case
  132. * doesn't matter).
  133. */
  134. public static function isIgnored($sMessage, $aPatterns)
  135. {
  136. foreach($aPatterns as $sPattern) {
  137. $sPattern = trim((string)$sPattern);
  138. if($sPattern === '')
  139. continue;
  140. if(preg_match('/^' . str_replace('\*', '.*', preg_quote($sPattern, '/')) . '$/isu', trim($sMessage)))
  141. return true;
  142. }
  143. return false;
  144. }
  145. /**
  146. * The message with what changes between repeats replaced: UUIDs, hex and numbers,
  147. * quoted values. Line breaks and runs of spaces become one space.
  148. */
  149. public static function normalizeMessage($sMessage)
  150. {
  151. $s = (string)$sMessage;
  152. $s = preg_replace('/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i', '<uuid>', $s);
  153. $s = preg_replace('/\b0x[0-9a-f]+\b/i', '0x0', $s);
  154. $s = preg_replace('/\b(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{8,}\b/i', '<hex>', $s);
  155. // quotes that open a value, not an apostrophe inside a word
  156. $s = preg_replace('/(?<![\p{L}\p{N}])(["\'`])[^"\'`\n]*\1/u', '""', $s);
  157. $s = preg_replace('/\d+(\.\d+)?/', '0', $s);
  158. return trim(preg_replace('/\s+/u', ' ', $s));
  159. }
  160. /**
  161. * The top frame of a stack in the Chrome/V8/Hermes (`at fn (file:1:2)`) or the
  162. * Firefox/Safari (`fn@file:1:2`) format.
  163. *
  164. * @return array|null with `function`, `file`, `line`, `col`
  165. */
  166. public static function getTopFrame($sStack)
  167. {
  168. foreach(explode("\n", (string)$sStack) as $sLine) {
  169. $sLine = trim($sLine);
  170. if($sLine === '' || $sLine === '--')
  171. continue;
  172. if(preg_match('/^at\s+(?:(.+?)\s+\()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/', $sLine, $a) && (isset($a[3]) && $a[3] !== '' || bx_mb_strpos($a[2], '/') !== false))
  173. return self::_frame(isset($a[1]) ? $a[1] : '', $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
  174. if(preg_match('/^([^@\s]*)@(.+?)(?::(\d+))?(?::(\d+))?$/', $sLine, $a))
  175. return self::_frame($a[1], $a[2], isset($a[3]) ? $a[3] : 0, isset($a[4]) ? $a[4] : 0);
  176. }
  177. return null;
  178. }
  179. /**
  180. * A script's path with what changes between deployments replaced by *: the content
  181. * hashes of Next.js chunks (app/[...path]/page-*.js, 5123-*.js and *.js keep apart) and
  182. * of UNA's combined files and other bundles.
  183. */
  184. public static function normalizeFile($sFile)
  185. {
  186. $s = preg_replace('/[?#].*$/s', '', trim((string)$sFile));
  187. $s = preg_replace('~(?<=[/._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=\.m?js$)~i', '*', $s);
  188. $s = preg_replace('/(?<=[._-])(?=[0-9a-z]*[0-9])[0-9a-z]{8,}(?=[._-])/i', '*', $s);
  189. $s = str_replace(self::REDACTED, '*', $s);
  190. return $s;
  191. }
  192. /**
  193. * 16 bytes that are the same for every repeat of an error: kind, normalized message,
  194. * the file and function of the top frame (the source file without a stack), and the
  195. * HTTP status. Line and column are left out: they change with every build of a
  196. * minified script.
  197. *
  198. * @param array $aError with `kind`, `message`, `stack`, `source`, `status`
  199. */
  200. public static function getFingerprint($aError)
  201. {
  202. $aFrame = self::getTopFrame(isset($aError['stack']) ? $aError['stack'] : '');
  203. return substr(hash('sha256', implode('|', [
  204. isset($aError['kind']) ? $aError['kind'] : '',
  205. self::normalizeMessage(isset($aError['message']) ? $aError['message'] : ''),
  206. self::normalizeFile($aFrame ? $aFrame['file'] : (isset($aError['source']) ? $aError['source'] : '')),
  207. $aFrame ? $aFrame['function'] : '',
  208. isset($aError['status']) ? (int)$aError['status'] : 0,
  209. ]), true), 0, 16);
  210. }
  211. /**
  212. * The group past the new groups limit takes.
  213. */
  214. public static function getOverflowError()
  215. {
  216. return ['kind' => self::KIND_OVERFLOW, 'message' => '', 'source' => '', 'line' => 0, 'col' => 0, 'status' => 0, 'stack' => '', 'fatal' => 0];
  217. }
  218. /**
  219. * A page's path as error and vitals hits keep it: of the query string only the page
  220. * parameters `i` and `r` stay (the trackers send no more, this strips a client that
  221. * does), and tokens and emails are redacted. Report filters on the path of these hits
  222. * pass a pageview's path through it too.
  223. *
  224. * @param string $sPath a path with its query string, decoded
  225. * @param bool $bRedact also redact secrets
  226. */
  227. public static function getCleanPath($sPath, $bRedact = true)
  228. {
  229. $aParts = explode('?', preg_replace('/#.*$/s', '', (string)$sPath), 2);
  230. $aKeep = [];
  231. if(isset($aParts[1]))
  232. foreach(explode('&', $aParts[1]) as $sPair) {
  233. $aPair = explode('=', $sPair, 2);
  234. if(in_array($aPair[0], ['i', 'r'], true) && isset($aPair[1]) && $aPair[1] !== '' && !isset($aKeep[$aPair[0]]))
  235. $aKeep[$aPair[0]] = $aPair[0] . '=' . $aPair[1];
  236. }
  237. ksort($aKeep);
  238. $sPath = $aParts[0] . (!empty($aKeep) ? '?' . implode('&', $aKeep) : '');
  239. return $bRedact ? self::redact($sPath) : $sPath;
  240. }
  241. /**
  242. * The page a path belongs to, so one route collects the errors and timings of all its
  243. * items: the query string keeps only `i` (classic pages: page.php?i=view-post), and
  244. * ids, UUIDs, hashes and long tokens in the path become *.
  245. */
  246. public static function getRoute($sPath)
  247. {
  248. $aParts = explode('?', (string)$sPath, 2);
  249. $aSegments = [];
  250. foreach(explode('/', $aParts[0]) as $sSegment)
  251. $aSegments[] = self::_isVariableSegment($sSegment) ? '*' : $sSegment;
  252. $sRoute = implode('/', $aSegments);
  253. if($sRoute === '')
  254. $sRoute = '/';
  255. if(isset($aParts[1]))
  256. foreach(explode('&', $aParts[1]) as $sPair) {
  257. $aPair = explode('=', $sPair, 2);
  258. if($aPair[0] === 'i' && isset($aPair[1]) && $aPair[1] !== '') {
  259. $sRoute .= '?i=' . $aPair[1];
  260. break;
  261. }
  262. }
  263. return get_mb_substr($sRoute, 0, 128);
  264. }
  265. protected static function _isVariableSegment($sSegment)
  266. {
  267. if($sSegment === '')
  268. return false;
  269. return preg_match('/^\d+$/', $sSegment)
  270. || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $sSegment)
  271. || preg_match('/^(?=[0-9a-f]*[0-9])[0-9a-f]{12,}$/i', $sSegment)
  272. || (get_mb_len($sSegment) >= 20 && preg_match('/^(?=.*[0-9])[A-Za-z0-9_-]+$/', $sSegment) && preg_match('/[A-Z]/', $sSegment))
  273. || $sSegment === self::REDACTED;
  274. }
  275. protected static function _frame($sFunction, $sFile, $iLine, $iCol)
  276. {
  277. $sFile = trim(preg_replace('/^address at\s+/i', '', trim($sFile)));
  278. $sFunction = trim(preg_replace('/^(async|new)\s+/', '', trim($sFunction)));
  279. return ['function' => $sFunction, 'file' => $sFile, 'line' => (int)$iLine, 'col' => (int)$iCol];
  280. }
  281. protected static function _frames($sStack)
  282. {
  283. if($sStack === '')
  284. return '';
  285. $aLines = [];
  286. foreach(explode("\n", $sStack) as $sLine) {
  287. $sLine = rtrim($sLine);
  288. if(trim($sLine) === '')
  289. continue;
  290. $aLines[] = $sLine;
  291. if(count($aLines) >= self::MAX_FRAMES)
  292. break;
  293. }
  294. return implode("\n", $aLines);
  295. }
  296. }
  297. /** @} */