PluginProbe ʕ •ᴥ•ʔ
Booking for Appointments and Events Calendar – Amelia / 2.4.8
Booking for Appointments and Events Calendar – Amelia v2.4.8
2.4.9 2.4.8 2.4.7 2.4.6 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4 trunk 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.15 1.2.16 1.2.17 1.2.18 1.2.19 1.2.2 1.2.20 1.2.21 1.2.22 1.2.23 1.2.24 1.2.25 1.2.26 1.2.27 1.2.28 1.2.29 1.2.3 1.2.30 1.2.31 1.2.32 1.2.33 1.2.34 1.2.35 1.2.36 1.2.37 1.2.38 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 2.0 2.0.1 2.0.2 2.1 2.1.1 2.1.2 2.1.3 2.2 2.2.1 2.3
ameliabooking / src / Infrastructure / Services / Logger / LogSanitizer.php
ameliabooking / src / Infrastructure / Services / Logger Last commit date
JsonLineFormatter.php 6 days ago LogDirectoryManager.php 6 days ago LogRetentionCleanupService.php 6 days ago LogSanitizer.php 6 days ago MonologChannelLogger.php 6 days ago MonologLoggerFactory.php 6 days ago RequestIdProcessor.php 6 days ago
LogSanitizer.php
352 lines
1 <?php
2
3 namespace AmeliaBooking\Infrastructure\Services\Logger;
4
5 /**
6 * Class LogSanitizer
7 *
8 * Redacts known sensitive keys and masks emails / phone numbers in message / context / extra before write.
9 * Invokable Monolog processor (callable); does not implement ProcessorInterface to avoid
10 * Strauss-broken phpstan Record imports on CI.
11 *
12 * @package AmeliaBooking\Infrastructure\Services\Logger
13 */
14 class LogSanitizer
15 {
16 private const REDACT_KEYS = [
17 'password',
18 'pwd',
19 'secret',
20 'token',
21 'accesstoken',
22 'refreshtoken',
23 'apikey',
24 'apikeyid',
25 'bearer',
26 'authorization',
27 'clientsecret',
28 'privatekey',
29 'cvv',
30 'cvc',
31 'cardnumber',
32 'pan',
33 'iban',
34 'ssn',
35 'webhooksecret',
36 'signature',
37 'clientid',
38 'phone',
39 'phonenumber',
40 'mobile',
41 'telephone',
42 'msisdn',
43 'to',
44 'customer',
45 'customername',
46 'firstname',
47 'lastname',
48 'fullname',
49 'address',
50 'street',
51 'postalcode',
52 'zip',
53 'zipcode',
54 'booking',
55 'bookings',
56 'payment',
57 'payments',
58 'creditcard',
59 'cardholder',
60 'cardholdername',
61 ];
62
63 /**
64 * @param array $record
65 *
66 * @return array
67 */
68 public function __invoke(array $record): array
69 {
70 if (isset($record['message']) && is_string($record['message'])) {
71 $record['message'] = $this->sanitizeString($record['message']);
72 }
73
74 $record['context'] = $this->sanitize($record['context'] ?? []);
75 $record['extra'] = $this->sanitize($record['extra'] ?? []);
76
77 return $record;
78 }
79
80 /**
81 * @param mixed $value
82 *
83 * @return mixed
84 */
85 private function sanitize($value)
86 {
87 if ($value instanceof \Throwable) {
88 return $this->sanitizeThrowable($value);
89 }
90
91 if (is_object($value)) {
92 if ($value instanceof \JsonSerializable) {
93 return $this->sanitize($value->jsonSerialize());
94 }
95
96 if ($value instanceof \Traversable) {
97 return $this->sanitize(iterator_to_array($value));
98 }
99
100 return $this->sanitize((array) $value);
101 }
102
103 if (is_array($value)) {
104 $sanitized = [];
105
106 foreach ($value as $key => $item) {
107 if (is_string($key) && $this->isSensitiveKey($key)) {
108 $sanitized[$key] = '***REDACTED***';
109 continue;
110 }
111
112 $sanitized[$key] = $this->sanitize($item);
113 }
114
115 return $sanitized;
116 }
117
118 if (is_string($value)) {
119 return $this->sanitizeString($value);
120 }
121
122 return $value;
123 }
124
125 private function isSensitiveKey(string $key): bool
126 {
127 $normalized = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $key));
128
129 return in_array($normalized, self::REDACT_KEYS, true);
130 }
131
132 private function sanitizeString(string $value): string
133 {
134 $decoded = json_decode($value, true);
135
136 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
137 $encoded = json_encode(
138 $this->sanitize($decoded),
139 JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE
140 );
141
142 if (is_string($encoded)) {
143 return $encoded;
144 }
145 }
146
147 $value = preg_replace(
148 '/(password|pwd|secret|token|api[_-]?key|bearer|authorization)\s*[:=]\s*[^&\s"\']+/i',
149 '$1=***REDACTED***',
150 $value
151 );
152 $value = preg_replace(
153 '/"(password|pwd|secret|token|api[_-]?key|bearer|authorization|'
154 . 'firstName|lastName|email|phone|customer|booking|payment|address|'
155 . 'cardNumber|pan|creditCard|cardHolder)"\s*:\s*"[^"]*"/i',
156 '"$1":"***REDACTED***"',
157 $value
158 );
159
160 $value = preg_replace_callback(
161 '/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/',
162 function (array $matches): string {
163 return $this->maskEmail($matches[0]);
164 },
165 $value
166 );
167
168 return preg_replace_callback(
169 '/(?<!\w)(?:\+|00)?\d[\d\s().\-]{5,18}\d(?!\d)/',
170 function (array $matches): string {
171 if (
172 $this->isValidCalendarDate($matches[0])
173 || filter_var($matches[0], FILTER_VALIDATE_IP) !== false
174 ) {
175 return $matches[0];
176 }
177
178 return $this->maskPhone($matches[0]);
179 },
180 $value
181 );
182 }
183
184 private function isValidCalendarDate(string $value): bool
185 {
186 if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts) !== 1) {
187 return false;
188 }
189
190 return checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
191 }
192
193 private function maskEmail(string $email): string
194 {
195 [$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
196
197 $masked = mb_substr($local, 0, 1) . str_repeat('*', max(mb_strlen($local) - 1, 1));
198
199 return $masked . '@' . $domain;
200 }
201
202 private function maskPhone(string $phone): string
203 {
204 $digits = preg_replace('/\D+/', '', $phone);
205
206 if ($digits === null || strlen($digits) < 7) {
207 return '***REDACTED***';
208 }
209
210 return '***' . substr($digits, -4);
211 }
212
213 private function sanitizeThrowable(\Throwable $exception): array
214 {
215 $data = [
216 'class' => get_class($exception),
217 'message' => $this->sanitizeString($exception->getMessage()),
218 'code' => $exception->getCode(),
219 'file' => $this->relativizePath($exception->getFile()),
220 'line' => $exception->getLine(),
221 ];
222
223 $caller = $this->findAmeliaCaller($exception);
224
225 if ($caller !== null) {
226 $data['caller'] = $caller;
227 }
228
229 return $data;
230 }
231
232 /**
233 * Resolve the Amelia service/handler method that led to the failure (not a full trace).
234 */
235 private function findAmeliaCaller(\Throwable $exception): ?string
236 {
237 $pluginRoot = defined('AMELIA_PATH') && is_string(AMELIA_PATH) && AMELIA_PATH !== ''
238 ? rtrim(str_replace('\\', '/', AMELIA_PATH), '/') . '/'
239 : null;
240
241 $trace = $exception->getTrace();
242
243 if ($pluginRoot !== null) {
244 foreach ($trace as $index => $frame) {
245 $file = isset($frame['file']) && is_string($frame['file'])
246 ? str_replace('\\', '/', $frame['file'])
247 : null;
248
249 if ($file === null || strpos($file, $pluginRoot) !== 0) {
250 continue;
251 }
252
253 if ($this->isLoggingGluePath($file)) {
254 continue;
255 }
256
257 // Frame $index is an Amelia call site; the Amelia method that made the call is usually the next frame.
258 $next = $trace[$index + 1] ?? null;
259
260 if (
261 is_array($next)
262 && isset($next['class'])
263 && is_string($next['class'])
264 && strpos($next['class'], 'AmeliaBooking\\') === 0
265 && !$this->isLoggingGlueClass($next['class'])
266 ) {
267 return $this->formatCaller($next['class'], $next['function'] ?? null);
268 }
269
270 // Fallback: failing Amelia method on this frame (e.g. repository method name).
271 if (
272 isset($frame['class'])
273 && is_string($frame['class'])
274 && strpos($frame['class'], 'AmeliaBooking\\') === 0
275 ) {
276 return $this->formatCaller($frame['class'], $frame['function'] ?? null);
277 }
278
279 return $this->relativizePath($file) . (isset($frame['line']) ? ':' . $frame['line'] : '');
280 }
281 }
282
283 // Throw site invoked via vendor (e.g. Tactician → handler): use first Amelia class on the stack.
284 foreach ($trace as $frame) {
285 if (
286 !isset($frame['class'])
287 || !is_string($frame['class'])
288 || strpos($frame['class'], 'AmeliaBooking\\') !== 0
289 || $this->isLoggingGlueClass($frame['class'])
290 ) {
291 continue;
292 }
293
294 return $this->formatCaller($frame['class'], $frame['function'] ?? null);
295 }
296
297 return null;
298 }
299
300 private function formatCaller(string $class, ?string $function): string
301 {
302 $shortClass = strrpos($class, '\\') !== false
303 ? substr($class, strrpos($class, '\\') + 1)
304 : $class;
305
306 if ($function === null || $function === '') {
307 return $shortClass;
308 }
309
310 return $shortClass . '::' . $function;
311 }
312
313 private function isLoggingGluePath(string $normalizedPath): bool
314 {
315 return strpos($normalizedPath, '/Infrastructure/Services/Logger/') !== false
316 || strpos($normalizedPath, '/Infrastructure/CommandBus/LoggingMiddleware.php') !== false;
317 }
318
319 private function isLoggingGlueClass(string $class): bool
320 {
321 return strpos($class, 'AmeliaBooking\\Infrastructure\\Services\\Logger\\') === 0
322 || $class === 'AmeliaBooking\\Infrastructure\\CommandBus\\LoggingMiddleware';
323 }
324
325 /**
326 * Prefer plugin-relative paths (src/...), then WP-root-relative, else basename.
327 * Avoids leaking absolute server paths like /var/www/html/...
328 */
329 private function relativizePath(string $path): string
330 {
331 $normalized = str_replace('\\', '/', $path);
332
333 $pluginRoot = defined('AMELIA_PATH') && is_string(AMELIA_PATH) && AMELIA_PATH !== ''
334 ? rtrim(str_replace('\\', '/', AMELIA_PATH), '/') . '/'
335 : null;
336
337 if ($pluginRoot !== null && strpos($normalized, $pluginRoot) === 0) {
338 return substr($normalized, strlen($pluginRoot));
339 }
340
341 if (defined('ABSPATH') && is_string(ABSPATH) && ABSPATH !== '') {
342 $wpRoot = rtrim(str_replace('\\', '/', ABSPATH), '/') . '/';
343
344 if (strpos($normalized, $wpRoot) === 0) {
345 return substr($normalized, strlen($wpRoot));
346 }
347 }
348
349 return basename($normalized);
350 }
351 }
352