PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / feedback / PiiRedactor.php

PiiRedactor.php in 404 Solution trunk, at includes/feedback/PiiRedactor.php

430 lines 17.7 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 // WordPress keeps the installed plugin's autoloader alive while replacing the
9 // plugin directory. Its cached classmap therefore cannot know about helper
10 // classes first introduced by the incoming release. PiiRedactor is reachable
11 // during the updater's uploaded-attachment cleanup, so load its same-directory
12 // collaborators by stable path before declaring the host class. This keeps the
13 // in-flight old-classmap/new-code request from fatalling; the ordinary
14 // classmap entries remain available for callers that use a helper directly.
15 require_once __DIR__ . '/RequestCredentialRedactor.php';
16 require_once __DIR__ . '/SensitiveValueMask.php';
17 require_once __DIR__ . '/OpaqueTokenClassifier.php';
18
19 /**
20 * Centralized PII redaction layer for all outgoing logs and reports.
21 *
22 * Every string that leaves the plugin (debug file, error_log, HTTP report,
23 * email fallback, admin-screen excerpts) passes through redact() before
24 * reaching its destination. This class owns the patterns that must RECOGNIZE
25 * an unlabeled shape in free-form text -- an address, an IP, a path, a
26 * database identifier, an opaque token -- and the order they run in. Callers
27 * never build their own PII patterns.
28 *
29 * Two collaborators own the parts that are not shape recognition:
30 * ABJ_404_Solution_RequestCredentialRedactor masks the values that request
31 * text labels by name (headers, cookies, form fields, nonces), and
32 * ABJ_404_Solution_SensitiveValueMask decides what a masked value looks like.
33 *
34 * Configurable via $options passed to redact():
35 * 'redact_ips' => bool (default true, controls IP address hashing)
36 */
37 class ABJ_404_Solution_PiiRedactor {
38
39 /**
40 * Final filename extensions that are NOT delegated top-level domains, so a
41 * token ending in one cannot be a deliverable email address no matter how
42 * email-shaped it looks. Checked against the IANA root zone
43 * (data.iana.org/TLD/tlds-alpha-by-domain.txt, version 2026062302).
44 *
45 * Membership here is the one thing standing between a real address and the
46 * log file, so the list may only ever grow with extensions that are absent
47 * from the root zone. Several obvious candidates are deliberately missing
48 * because they ARE real gTLDs: .zip, .mov, .app, .dev, .page, .link, .map,
49 * and .md. Entries must be lowercase and letters-only; the email pattern
50 * only matches a letters-only final label, so anything else is unreachable
51 * (a '@2x.woff2' URL never matches the pattern in the first place).
52 *
53 * @var array<int, string>
54 */
55 const NON_TLD_FILE_EXTENSIONS = array(
56 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg', 'tif', 'tiff', 'heic',
57 'css', 'js', 'mjs', 'scss', 'less', 'json', 'xml', 'txt', 'html', 'htm', 'php',
58 'woff', 'ttf', 'otf', 'eot',
59 'pdf', 'csv', 'webm', 'ogg', 'wav',
60 );
61
62 /** @var ABJ_404_Solution_Functions */
63 private $f;
64
65 /**
66 * @param ABJ_404_Solution_Functions $functions
67 */
68 public function __construct($functions) {
69 $this->f = $functions;
70 }
71
72 /**
73 * Redact PII from a text string.
74 *
75 * @param string $text The text to redact
76 * @param array<string, mixed> $options Optional configuration overrides
77 * @return string Redacted text
78 */
79 public function redact($text, $options = array()) {
80 $redactIps = !isset($options['redact_ips']) || $options['redact_ips'];
81
82 $text = $this->stripUrlQueryStrings($text);
83 $text = $this->stripPathQueryStrings($text);
84 $text = ABJ_404_Solution_RequestCredentialRedactor::redact($text);
85 $text = $this->redactEmails($text);
86
87 if ($redactIps) {
88 $text = $this->redactIpv4($text);
89 $text = $this->redactIpv6($text);
90 }
91
92 $text = $this->redactUsernames($text);
93 $text = $this->redactDatabaseAccountNames($text);
94 $text = $this->redactDisplayNames($text);
95 $text = $this->redactAbsolutePaths($text);
96 $text = $this->redactDatabaseIdentifiers($text);
97 $text = $this->redactLongTokens($text);
98
99 return $text;
100 }
101
102 // =========================================================================
103 // URL / query-string stripping
104 // =========================================================================
105
106 /**
107 * @param string $text @return string
108 *
109 * The query-string match excludes closing quote/bracket characters in
110 * addition to whitespace: a URL quoted in log text (e.g. `Captured 404
111 * for "/page?utm_source=x" creating a record.`) was matching greedily
112 * past the query string's end and swallowing the closing quote too,
113 * corrupting the rest of the sentence.
114 */
115 private function stripUrlQueryStrings(string $text): string {
116 return preg_replace('/(https?:\/\/[^\s?]+)\?[^\s"\'\)\]\}]*/', '$1', $text) ?? $text;
117 }
118
119 /** @param string $text @return string */
120 private function stripPathQueryStrings(string $text): string {
121 return preg_replace('/(?<![A-Za-z0-9:@])(\/[^\s?#]*)\?[^\s"\'\)\]\}]*/', '$1', $text) ?? $text;
122 }
123
124 // =========================================================================
125 // Email addresses
126 // =========================================================================
127
128 /**
129 * @param string $text @return string
130 *
131 * The pattern must be email-SHAPED, not merely '\S+@\S+'. The old form
132 * matched from the first non-space character to the last, so the most
133 * common '@' in a WordPress URL -- a retina asset such as
134 * '/wp-content/uploads/logo@2x.png' -- was rewritten to '/wp***@2***-hash'
135 * in the debug log and the crash report, destroying the very URL a 404
136 * plugin exists to diagnose.
137 *
138 * The grammar is WordPress's own: the local-part class and the per-label
139 * domain class are the two character classes is_email() applies, and the
140 * requirement that the domain carry at least two labels is is_email()'s
141 * `2 > count( $subs )` rule. Anything WordPress would refuse to call an
142 * email address is therefore not treated as one here either. Only '/' is
143 * withheld from is_email()'s local-part class, because it is a path
144 * separator in the text this class processes; '@' is added so that a
145 * multi-'@' token is masked whole rather than leaving its head in the
146 * clear.
147 *
148 * Being email-shaped is necessary but not sufficient: '2x.png' is a
149 * perfectly good domain shape. The final label therefore also has to be a
150 * plausible TLD, which is what NON_TLD_FILE_EXTENSIONS decides. Narrowing
151 * must never cost a redaction that used to happen, so the domain still
152 * accepts a bare IPv4 literal ('root@192.168.0.5') and a trailing sentence
153 * period is still allowed to follow the address.
154 */
155 private function redactEmails(string $text): string {
156 $local = '[A-Za-z0-9!#$%&\'*+=?^_`{|}~.@-]+';
157 $fqdn = '(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}';
158 $ipv4Literal = '(?:\d{1,3}\.){3}\d{1,3}';
159
160 return preg_replace_callback(
161 '/' . $local . '@(?:' . $fqdn . '|' . $ipv4Literal . ')(?![A-Za-z0-9-])/',
162 function ($matches) {
163 if (self::endsWithNonTldFileExtension($matches[0])) {
164 return $matches[0];
165 }
166 return ABJ_404_Solution_SensitiveValueMask::maskEmail($matches[0]);
167 },
168 $text
169 ) ?? $text;
170 }
171
172 /**
173 * @param string $token an email-shaped token
174 * @return bool true when the token's final label is a file extension that
175 * no registry has delegated, which makes the token a filename rather than
176 * an address.
177 */
178 private static function endsWithNonTldFileExtension(string $token): bool {
179 $lastDot = strrpos($token, '.');
180 if ($lastDot === false) {
181 return false;
182 }
183
184 $extension = strtolower(substr($token, $lastDot + 1));
185
186 return in_array($extension, self::NON_TLD_FILE_EXTENSIONS, true);
187 }
188
189 // =========================================================================
190 // IP addresses
191 // =========================================================================
192
193 /**
194 * @param string $text @return string
195 *
196 * Two constraints keep this from matching ordinary dotted runs that are
197 * not network data. Each octet is range-checked (0-255), so a decimal
198 * sequence like "1024.768.900.640" is not an address; and the match may
199 * not be adjacent to another dotted-number segment, so "1.2.3.4.5" is
200 * left whole instead of having its fourth segment hashed. The trailing
201 * guard deliberately still allows a plain sentence period, so
202 * "blocked 203.0.113.42." is redacted normally.
203 *
204 * The octet accepts leading zeros ("010.000.000.001") because some
205 * proxies and legacy access logs zero-pad. Narrowing the range must
206 * never cost a redaction that used to happen: over-redaction is
207 * fail-safe here, under-redaction leaks an address.
208 */
209 private function redactIpv4(string $text): string {
210 $octet = '(?:25[0-5]|2[0-4]\d|[01]?\d?\d)';
211
212 return preg_replace_callback(
213 '/(?<![\w.])(?:' . $octet . '\.){3}' . $octet . '(?!\w)(?!\.\d)/',
214 function ($matches) {
215 return $this->f->md5lastOctet($matches[0]);
216 },
217 $text
218 ) ?? $text;
219 }
220
221 /**
222 * @param string $text @return string
223 *
224 * The alternation must not accept a bare '::', and the boundary guards
225 * must exclude ordinary identifier characters rather than only hex digits
226 * and colons. Without both, every PHP static-call frame in a stack trace
227 * ('ABJ_404_Solution_ErrorHandler::processFatalError') matched as an IPv6
228 * address and had its method name replaced with a hash on the way into
229 * the debug log and the crash report -- destroying exactly the
230 * identifiers a crash needs to be triaged. A bare '::' is the
231 * unspecified address and identifies nobody, so declining to redact it
232 * costs no privacy; every form that names a host is still matched,
233 * including the loopback '::1'.
234 */
235 private function redactIpv6(string $text): string {
236 $group = '[0-9a-fA-F]{1,4}';
237
238 $address =
239 '(?:' . $group . ':){7}' . $group
240 . '|(?:' . $group . ':){1,7}:'
241 . '|(?:' . $group . ':){1,6}:' . $group
242 . '|(?:' . $group . ':){1,5}(?::' . $group . '){1,2}'
243 . '|(?:' . $group . ':){1,4}(?::' . $group . '){1,3}'
244 . '|(?:' . $group . ':){1,3}(?::' . $group . '){1,4}'
245 . '|(?:' . $group . ':){1,2}(?::' . $group . '){1,5}'
246 . '|' . $group . ':(?::' . $group . '){1,6}'
247 . '|:(?::' . $group . '){1,7}';
248
249 return preg_replace_callback(
250 '/(?<![0-9A-Za-z_:])(?:' . $address . ')(?![0-9A-Za-z_:])/',
251 function ($matches) {
252 return $this->f->md5lastOctet($matches[0]);
253 },
254 $text
255 ) ?? $text;
256 }
257
258 // =========================================================================
259 // Usernames, database account names, and display names
260 // =========================================================================
261
262 /** @param string $text @return string */
263 private function redactUsernames(string $text): string {
264 return preg_replace_callback(
265 '/\b(current\s+)?user(name)?:\s*(\S+)/i',
266 function ($matches) {
267 $prefix = $matches[1] . 'user' . $matches[2] . ': ';
268 return $prefix . ABJ_404_Solution_SensitiveValueMask::maskText($matches[3]);
269 },
270 $text
271 ) ?? $text;
272 }
273
274 /**
275 * @param string $text @return string
276 *
277 * MySQL names an account as 'user'@'host' and quotes both halves in its
278 * "Access denied for user 'wpuser'@'localhost'" error, which lands in the
279 * debug log and the crash report verbatim. The old email pattern masked
280 * that form only by accident (it matched any two non-space runs joined by
281 * '@'), and narrowing the email pattern to WordPress's is_email() grammar
282 * would silently drop the masking with it. The account name is kept masked
283 * here instead, deliberately and in the layer that already masks the other
284 * database identifiers. The host half stays readable: 'localhost' versus a
285 * remote host is the diagnostic the error exists to carry, and this class
286 * already lets the site's own hostname through elsewhere.
287 *
288 * Both quotes are required. Without them the pattern would re-mask an
289 * address the email pass has already masked, since redact() runs this
290 * after redactEmails().
291 */
292 private function redactDatabaseAccountNames(string $text): string {
293 return preg_replace_callback(
294 "/'([^'@\\s]{1,80})'@'([^'@\\s]{0,255})'/",
295 function ($matches) {
296 return "'" . ABJ_404_Solution_SensitiveValueMask::maskText($matches[1]) . "'@'" . $matches[2] . "'";
297 },
298 $text
299 ) ?? $text;
300 }
301
302 /** @param string $text @return string */
303 private function redactDisplayNames(string $text): string {
304 return preg_replace_callback(
305 '/\bdisplay\s+name:\s*([^\n,]+)/i',
306 function ($matches) {
307 return 'display name: ' . ABJ_404_Solution_SensitiveValueMask::maskText(trim($matches[1]));
308 },
309 $text
310 ) ?? $text;
311 }
312
313 // =========================================================================
314 // Absolute file paths
315 // =========================================================================
316
317 /** @param string $text @return string */
318 private function redactAbsolutePaths(string $text): string {
319 $wpMarkers = 'wp-content|wp-admin|wp-includes|wp-login\\.php|wp-config\\.php|wp-cron\\.php|wp-blog-header\\.php';
320
321 $text = preg_replace(
322 '/(^|[\s\(])(\/[^\s\(]+?)\/(' . $wpMarkers . ')\b/i',
323 '$1/$3',
324 $text
325 ) ?? $text;
326
327 $text = preg_replace(
328 '/\b[a-z]:\\\\[^\s]+\\\\(' . $wpMarkers . ')\b/i',
329 '\\\\$1',
330 $text
331 ) ?? $text;
332
333 return $text;
334 }
335
336 // =========================================================================
337 // Database identifiers (name + prefix)
338 // =========================================================================
339
340 /**
341 * @param string $text @return string
342 *
343 * Both lookbehinds exclude a preceding '/' (in addition to identifier
344 * characters) so a DB name or table prefix that happens to also be a
345 * substring of a file path is not redacted -- e.g. this plugin's own
346 * dev environment sets DB_NAME to '404-solution', identical to its
347 * slug, so without the '/' exclusion "/plugins/404-solution/
348 * 404-solution.php" was redacted to ".../dbname.php", destroying the
349 * actual filename in stack traces and log lines.
350 */
351 private function redactDatabaseIdentifiers(string $text): string {
352 $dbname = $this->getActualDatabaseNameForRedaction();
353 if ($dbname !== '' && strlen($dbname) >= 3 && $dbname !== 'dbname') {
354 $text = preg_replace(
355 '/(?<![A-Za-z0-9_\/-])' . preg_quote($dbname, '/') . '(?=[.`])/',
356 'dbname',
357 $text
358 ) ?? $text;
359 }
360
361 $prefix = $this->getActualPrefixForRedaction();
362 if ($prefix !== '' && strlen($prefix) >= 3 && $prefix !== 'wp_') {
363 $text = preg_replace(
364 '/(?<![A-Za-z0-9_\/-])' . preg_quote($prefix, '/') . '(?=[A-Za-z])/',
365 'wp_',
366 $text
367 ) ?? $text;
368 }
369
370 return $text;
371 }
372
373 // =========================================================================
374 // Opaque tokens
375 // =========================================================================
376
377 /**
378 * @param string $text @return string
379 *
380 * This pass recognizes the SHAPE -- a long run of token characters -- and
381 * decides what a secret is replaced with. Whether a given candidate is
382 * actually key material rather than a post slug or a plugin identifier is
383 * a calibrated policy question, and it belongs to
384 * ABJ_404_Solution_OpaqueTokenClassifier; the length bar is read from that
385 * class so the pattern and the policy cannot disagree about it.
386 */
387 private function redactLongTokens(string $text): string {
388 $minimumLength = ABJ_404_Solution_OpaqueTokenClassifier::MIN_SECRET_LENGTH;
389
390 return preg_replace_callback(
391 '/\b([A-Za-z0-9_-]{' . $minimumLength . ',})\b/',
392 function ($matches) {
393 if (!ABJ_404_Solution_OpaqueTokenClassifier::isOpaqueSecret($matches[1])) {
394 return $matches[1];
395 }
396 return 'token-' . substr(md5($matches[1]), 0, 8);
397 },
398 $text
399 ) ?? $text;
400 }
401
402 // =========================================================================
403 // Database context helpers
404 // =========================================================================
405
406 /** @return string */
407 private function getActualPrefixForRedaction() {
408 global $wpdb;
409 if (isset($wpdb) && is_object($wpdb) && isset($wpdb->prefix) && is_string($wpdb->prefix)) {
410 return $wpdb->prefix;
411 }
412 return '';
413 }
414
415 /** @return string */
416 private function getActualDatabaseNameForRedaction() {
417 global $wpdb;
418 if (isset($wpdb) && is_object($wpdb) && isset($wpdb->dbname) && is_string($wpdb->dbname)) {
419 return $wpdb->dbname;
420 }
421 if (defined('DB_NAME')) {
422 $name = constant('DB_NAME');
423 if (is_string($name) && $name !== '') {
424 return $name;
425 }
426 }
427 return '';
428 }
429 }
430