PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / PiiRedactor.php

PiiRedactor.php in 404 Solution 4.2.0, at includes/PiiRedactor.php

409 lines 13.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Centralized PII redaction layer for all outgoing logs and reports.
10 *
11 * Every string that leaves the plugin (debug file, error_log, HTTP report,
12 * email fallback, admin-screen excerpts) passes through redact() before
13 * reaching its destination. The class owns the regex catalog and masking
14 * helpers; callers never build their own PII patterns.
15 *
16 * Configurable via $options passed to redact():
17 * 'redact_ips' => bool (default true, controls IP address hashing)
18 */
19 class ABJ_404_Solution_PiiRedactor {
20
21 /** @var ABJ_404_Solution_Functions */
22 private $f;
23
24 /**
25 * @param ABJ_404_Solution_Functions $functions
26 */
27 public function __construct($functions) {
28 $this->f = $functions;
29 }
30
31 /**
32 * Redact PII from a text string.
33 *
34 * @param string $text The text to redact
35 * @param array<string, mixed> $options Optional configuration overrides
36 * @return string Redacted text
37 */
38 public function redact($text, $options = array()) {
39 $redactIps = !isset($options['redact_ips']) || $options['redact_ips'];
40
41 $text = $this->stripUrlQueryStrings($text);
42 $text = $this->stripPathQueryStrings($text);
43 $text = $this->redactAuthorizationHeaders($text);
44 $text = $this->redactCookieValues($text);
45 $text = $this->redactSensitiveFormFields($text);
46 $text = $this->redactEmails($text);
47
48 if ($redactIps) {
49 $text = $this->redactIpv4($text);
50 $text = $this->redactIpv6($text);
51 }
52
53 $text = $this->redactUsernames($text);
54 $text = $this->redactDisplayNames($text);
55 $text = $this->redactAbsolutePaths($text);
56 $text = $this->redactDatabaseIdentifiers($text);
57 $text = $this->redactLongTokens($text);
58 $text = $this->redactNonces($text);
59
60 return $text;
61 }
62
63 // =========================================================================
64 // URL / query-string stripping
65 // =========================================================================
66
67 /** @param string $text @return string */
68 private function stripUrlQueryStrings(string $text): string {
69 return preg_replace('/(https?:\/\/[^\s?]+)\?[^\s]*/', '$1', $text) ?? $text;
70 }
71
72 /** @param string $text @return string */
73 private function stripPathQueryStrings(string $text): string {
74 return preg_replace('/(?<![A-Za-z0-9:@])(\/[^\s?#]*)\?\S*/', '$1', $text) ?? $text;
75 }
76
77 // =========================================================================
78 // Authorization headers
79 // =========================================================================
80
81 /** @param string $text @return string */
82 private function redactAuthorizationHeaders(string $text): string {
83 $text = preg_replace(
84 '/\b(Authorization:\s*)(Bearer|Basic|Digest|Token)\s+\S+/i',
85 '$1$2 [REDACTED]',
86 $text
87 ) ?? $text;
88
89 $text = preg_replace(
90 '/\b(X-API-Key|X-Auth-Token|X-Access-Token):\s*\S+/i',
91 '$1: [REDACTED]',
92 $text
93 ) ?? $text;
94
95 return $text;
96 }
97
98 // =========================================================================
99 // Cookie values
100 // =========================================================================
101
102 /** @param string $text @return string */
103 private function redactCookieValues(string $text): string {
104 $text = preg_replace(
105 '/\b(Cookie|Set-Cookie):\s*\S[^\r\n]*/i',
106 '$1: [REDACTED]',
107 $text
108 ) ?? $text;
109
110 $text = preg_replace(
111 '/(\$_COOKIE\s*\[\s*[\'"][^\'"]*[\'"]\s*\])\s*=\s*[\'"][^\'"]*[\'"]/i',
112 '$1 = \'[REDACTED]\'',
113 $text
114 ) ?? $text;
115
116 return $text;
117 }
118
119 // =========================================================================
120 // Sensitive form / request-body fields
121 // =========================================================================
122
123 /** @param string $text @return string */
124 private function redactSensitiveFormFields(string $text): string {
125 $sensitiveKeys = 'password|passwd|pwd|secret|credit_card|card_number|cvv|ssn|api_key|private_key|access_token|refresh_token';
126
127 $text = preg_replace(
128 '/\b(' . $sensitiveKeys . ')\s*=\s*(?:([\'"])[^\'"]*\2|\S+)/i',
129 '$1=[REDACTED]',
130 $text
131 ) ?? $text;
132
133 $text = preg_replace(
134 '/(\$_(?:POST|GET|REQUEST)\s*\[\s*[\'"](?:' . $sensitiveKeys . ')[\'"]\s*\])\s*(?:=\s*[\'"][^\'"]*[\'"])?/i',
135 '$1=[REDACTED]',
136 $text
137 ) ?? $text;
138
139 $text = preg_replace(
140 '/"(' . $sensitiveKeys . ')"\s*:\s*"[^"]*"/i',
141 '"$1":"[REDACTED]"',
142 $text
143 ) ?? $text;
144
145 return $text;
146 }
147
148 // =========================================================================
149 // Email addresses
150 // =========================================================================
151
152 /** @param string $text @return string */
153 private function redactEmails(string $text): string {
154 return preg_replace_callback(
155 '/\S+@\S+/',
156 function ($matches) {
157 return $this->maskEmailAdaptive($matches[0]);
158 },
159 $text
160 ) ?? $text;
161 }
162
163 // =========================================================================
164 // IP addresses
165 // =========================================================================
166
167 /** @param string $text @return string */
168 private function redactIpv4(string $text): string {
169 return preg_replace_callback(
170 '/\b(?:\d{1,3}\.){3}\d{1,3}\b/',
171 function ($matches) {
172 return $this->f->md5lastOctet($matches[0]);
173 },
174 $text
175 ) ?? $text;
176 }
177
178 /** @param string $text @return string */
179 private function redactIpv6(string $text): string {
180 return preg_replace_callback(
181 '/(?<![0-9A-Fa-f:])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:))(?![0-9A-Fa-f:])/',
182 function ($matches) {
183 return $this->f->md5lastOctet($matches[0]);
184 },
185 $text
186 ) ?? $text;
187 }
188
189 // =========================================================================
190 // Usernames and display names
191 // =========================================================================
192
193 /** @param string $text @return string */
194 private function redactUsernames(string $text): string {
195 return preg_replace_callback(
196 '/\b(current\s+)?user(name)?:\s*(\S+)/i',
197 function ($matches) {
198 $prefix = $matches[1] . 'user' . $matches[2] . ': ';
199 return $prefix . $this->maskTextAdaptive($matches[3]);
200 },
201 $text
202 ) ?? $text;
203 }
204
205 /** @param string $text @return string */
206 private function redactDisplayNames(string $text): string {
207 return preg_replace_callback(
208 '/\bdisplay\s+name:\s*([^\n,]+)/i',
209 function ($matches) {
210 return 'display name: ' . $this->maskTextAdaptive(trim($matches[1]));
211 },
212 $text
213 ) ?? $text;
214 }
215
216 // =========================================================================
217 // Absolute file paths
218 // =========================================================================
219
220 /** @param string $text @return string */
221 private function redactAbsolutePaths(string $text): string {
222 $wpMarkers = 'wp-content|wp-admin|wp-includes|wp-login\\.php|wp-config\\.php|wp-cron\\.php|wp-blog-header\\.php';
223
224 $text = preg_replace(
225 '/(^|[\s\(])(\/[^\s\(]+?)\/(' . $wpMarkers . ')\b/i',
226 '$1/$3',
227 $text
228 ) ?? $text;
229
230 $text = preg_replace(
231 '/\b[a-z]:\\\\[^\s]+\\\\(' . $wpMarkers . ')\b/i',
232 '\\\\$1',
233 $text
234 ) ?? $text;
235
236 return $text;
237 }
238
239 // =========================================================================
240 // Database identifiers (name + prefix)
241 // =========================================================================
242
243 /** @param string $text @return string */
244 private function redactDatabaseIdentifiers(string $text): string {
245 $dbname = $this->getActualDatabaseNameForRedaction();
246 if ($dbname !== '' && strlen($dbname) >= 3 && $dbname !== 'dbname') {
247 $text = preg_replace(
248 '/(?<![A-Za-z0-9_-])' . preg_quote($dbname, '/') . '(?=[.`])/',
249 'dbname',
250 $text
251 ) ?? $text;
252 }
253
254 $prefix = $this->getActualPrefixForRedaction();
255 if ($prefix !== '' && $prefix !== 'wp_') {
256 $text = preg_replace(
257 '/(?<![A-Za-z0-9_-])' . preg_quote($prefix, '/') . '(?=[A-Za-z])/',
258 'wp_',
259 $text
260 ) ?? $text;
261 }
262
263 return $text;
264 }
265
266 // =========================================================================
267 // Tokens and nonces
268 // =========================================================================
269
270 /** @param string $text @return string */
271 private function redactLongTokens(string $text): string {
272 return preg_replace_callback(
273 '/\b([A-Za-z0-9_-]{40,})\b/',
274 function ($matches) {
275 return 'token-' . substr(md5($matches[1]), 0, 8);
276 },
277 $text
278 ) ?? $text;
279 }
280
281 /** @param string $text @return string */
282 private function redactNonces(string $text): string {
283 return preg_replace_callback(
284 '/_wpnonce=([A-Za-z0-9]+)/',
285 function ($matches) {
286 return '_wpnonce=nonce-' . substr(md5($matches[1]), 0, 8);
287 },
288 $text
289 ) ?? $text;
290 }
291
292 // =========================================================================
293 // Masking helpers
294 // =========================================================================
295
296 /**
297 * @param string $email
298 * @return string
299 */
300 public function maskEmailAdaptive($email) {
301 if (empty($email) || strpos($email, '@') === false) {
302 return $email;
303 }
304
305 $parts = explode('@', $email);
306 if (count($parts) != 2) {
307 return $this->maskTextAdaptive($email);
308 }
309
310 list($username, $fullDomain) = $parts;
311
312 $domainParts = explode('.', $fullDomain);
313 if (count($domainParts) > 1) {
314 if (in_array(end($domainParts), array('uk', 'au', 'nz', 'za'))) {
315 array_pop($domainParts);
316 array_pop($domainParts);
317 } else {
318 array_pop($domainParts);
319 }
320 }
321 $domain = implode('.', $domainParts);
322
323 $usernameLen = strlen($username);
324 if ($usernameLen <= 4) {
325 $usernameVisible = 1;
326 } elseif ($usernameLen <= 9) {
327 $usernameVisible = 2;
328 } else {
329 $usernameVisible = 3;
330 }
331
332 $domainLen = strlen($domain);
333 $domainVisible = max(1, (int)ceil($domainLen * 0.3));
334
335 $maskedUsername = substr($username, 0, $usernameVisible) . '***';
336 $maskedDomain = empty($domain) ? '' : substr($domain, 0, $domainVisible) . '***';
337
338 if (defined('AUTH_SALT')) {
339 $hash = substr(md5(AUTH_SALT . $email), 0, 4);
340 } else {
341 $hash = substr(md5($email), 0, 4);
342 }
343
344 if (!empty($maskedDomain)) {
345 return $maskedUsername . '@' . $maskedDomain . '-' . $hash;
346 }
347 return $maskedUsername . '@-' . $hash;
348 }
349
350 /**
351 * @param string $text
352 * @return string
353 */
354 public function maskTextAdaptive($text) {
355 if (empty($text)) {
356 return $text;
357 }
358
359 $text = trim($text);
360 $textLen = strlen($text);
361
362 if ($textLen <= 4) {
363 $visible = 1;
364 } elseif ($textLen <= 9) {
365 $visible = 2;
366 } else {
367 $visible = 3;
368 }
369
370 $masked = substr($text, 0, $visible) . '***';
371
372 if (defined('AUTH_SALT')) {
373 $hash = substr(md5(AUTH_SALT . $text), 0, 4);
374 } else {
375 $hash = substr(md5($text), 0, 4);
376 }
377
378 return $masked . '-' . $hash;
379 }
380
381 // =========================================================================
382 // Database context helpers
383 // =========================================================================
384
385 /** @return string */
386 private function getActualPrefixForRedaction() {
387 global $wpdb;
388 if (isset($wpdb) && is_object($wpdb) && isset($wpdb->prefix) && is_string($wpdb->prefix)) {
389 return $wpdb->prefix;
390 }
391 return '';
392 }
393
394 /** @return string */
395 private function getActualDatabaseNameForRedaction() {
396 global $wpdb;
397 if (isset($wpdb) && is_object($wpdb) && isset($wpdb->dbname) && is_string($wpdb->dbname)) {
398 return $wpdb->dbname;
399 }
400 if (defined('DB_NAME')) {
401 $name = constant('DB_NAME');
402 if (is_string($name) && $name !== '') {
403 return $name;
404 }
405 }
406 return '';
407 }
408 }
409