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 / OpaqueTokenClassifier.php

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

206 lines 9.5 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 * Decides whether a long run of token characters is generated key material or
10 * ordinary human-authored text.
11 *
12 * ABJ_404_Solution_PiiRedactor owns the other half of this: it holds the
13 * pattern that FINDS a long token in free-form text, and it decides what a
14 * token that turns out to be a secret is replaced with. This class answers
15 * only the question in between, and it is a policy question rather than a
16 * pattern one, which is why it lives apart. The two halves also fail
17 * differently. A pattern that is too broad looks at text it had no business
18 * reading; this decision, when it goes wrong, either hashes away the URL that
19 * a 404 log line exists to record or writes a live credential to disk in the
20 * clear. Keeping it here puts the calibration that separates those two
21 * outcomes in one auditable place instead of inline in a callback.
22 *
23 * This is the third of PiiRedactor's collaborators that is not shape
24 * recognition, alongside ABJ_404_Solution_RequestCredentialRedactor (masks the
25 * values that request text labels by name) and
26 * ABJ_404_Solution_SensitiveValueMask (decides what a masked value looks
27 * like). This one covers the values that nothing labels, where the token's own
28 * character makeup is the only evidence there is.
29 *
30 * Pure decision logic: no patterns applied to free text, no I/O, no state.
31 */
32 final class ABJ_404_Solution_OpaqueTokenClassifier {
33
34 /**
35 * The length at which a run of token characters becomes worth redacting at
36 * all. Real credential formats (JWT segments, Stripe and GitHub keys, SHA
37 * digests) clear this comfortably; ordinary identifiers in log text do not.
38 *
39 * PiiRedactor builds its candidate pattern from this same constant, so the
40 * bar cannot drift between the pass that finds tokens and the policy that
41 * judges them. Were the two allowed to disagree, a candidate the pattern
42 * accepted but this class considered too short would be reported as benign
43 * and written out in the clear.
44 */
45 const MIN_SECRET_LENGTH = 40;
46
47 /**
48 * The longest run of consonants a readable word segment may contain before
49 * the token is treated as machine-generated. English tops out at four
50 * ("wordpress" and the "html"/"css" style abbreviations that appear in real
51 * slugs both reach exactly four); a random lowercase run of 40+ characters
52 * essentially always exceeds it.
53 */
54 const MAX_CONSONANT_RUN = 4;
55
56 /**
57 * The largest share of a readable token's alphanumerics that may be digits.
58 * Slugs carry a year or a list count; hex digests (~62% digits) and
59 * base36/numeric identifiers (~28%) sit above this line.
60 */
61 const MAX_DIGIT_RATIO = 0.25;
62
63 /**
64 * @param string $token any string; no precondition is assumed beyond that.
65 * @return bool true when the token should be treated as generated key
66 * material and redacted, false when it is short enough to be uninteresting
67 * or recognizable as text a human wrote.
68 *
69 * The polarity is deliberate. Every route to false is an affirmative
70 * finding -- too short to matter, a known plugin identifier, or a token
71 * that reads as words -- so anything this class does not positively
72 * recognize is redacted. A string that is neither recognizably benign nor
73 * a well-formed token (one carrying spaces or punctuation, which
74 * PiiRedactor's pattern would never hand over) therefore fails toward
75 * redaction rather than away from it.
76 */
77 public static function isOpaqueSecret(string $token): bool {
78 if (strlen($token) < self::MIN_SECRET_LENGTH) {
79 return false;
80 }
81
82 return !self::looksLikeOwnIdentifier($token) && !self::looksLikeReadableSlug($token);
83 }
84
85 /**
86 * This plugin uses two long-running naming conventions that routinely
87 * exceed the 40-char long-token threshold above: PascalCase classes
88 * (ABJ_404_Solution_ + a descriptive suffix, e.g. fatal-error messages
89 * like "Class ABJ_404_Solution_RedirectsDenormMaintenanceService not
90 * found") and lowercase option/transient/filter/hook names (abj404_ +
91 * a descriptive suffix, e.g. "Option
92 * abj404_error_handler_allow_admin_fatal_detection_in_cli was not
93 * found"). Both were being redacted into useless 'token-XXXXXXXX'
94 * noise. Real secrets never coincidentally start with either exact
95 * literal prefix, so exempting them does not weaken the redaction.
96 *
97 * @param string $token
98 * @return bool
99 */
100 private static function looksLikeOwnIdentifier(string $token): bool {
101 return strpos($token, 'ABJ_404_Solution_') === 0 || strpos($token, 'abj404_') === 0;
102 }
103
104 /**
105 * @param string $token a token that has already met the 40-char length bar
106 * @return bool true when the token reads as words joined by separators --
107 * a URL slug -- rather than as generated key material.
108 *
109 * Length alone is not evidence of a secret. The threshold above counts
110 * hyphens and underscores toward its 40 characters, so every ordinary
111 * hyphenated post slug that long was hashed away:
112 * '/2024/06/how-to-configure-your-wordpress-permalinks-correctly/' became
113 * '/2024/06/token-bfe96037/', destroying the one thing a 404 log line
114 * exists to record. Slugs of this length are not exotic; they are what
115 * every SEO-oriented WordPress site produces by default.
116 *
117 * Splitting the token on its separators and re-applying the length test
118 * would be the obvious narrowing and it is not safe: base64url key
119 * material (a JWT signature, for instance) contains '-' and '_' from the
120 * same alphabet as everything else, so its pieces would fall under any
121 * length bar and leak in the clear. The discriminator has to be the
122 * character makeup of the token, not its geometry. Three tests, each of
123 * which a real credential format fails:
124 *
125 * 1. Lowercase letters, digits, and single interior separators only.
126 * One uppercase character is the most reliable evidence a token came
127 * from a generator, and it is what excludes every base64, base64url,
128 * and mixed-case vendor key. A token with no separator at all is a
129 * single opaque run and is likewise excluded, which covers hex
130 * digests and base36 identifiers.
131 * 2. At most MAX_DIGIT_RATIO of the alphanumerics are digits. Hex
132 * digests, numeric account identifiers, and Slack-style tokens are
133 * digit-dense; a slug carries a year or a list count at most.
134 * 3. No consonant run longer than MAX_CONSONANT_RUN. Words alternate
135 * vowels and consonants; random lowercase strings do not. Digits are
136 * transparent to this scan rather than breaking a run, so a token
137 * that laces digits through random letters ('a3f5b2k9m...') cannot
138 * use them to hide its consonant runs, while a product slug like
139 * 'model-number-x1000-included' still reads as words.
140 *
141 * Measured over 40,000 random tokens per alphabet at lengths 40 to 64,
142 * these three tests exempted 0.000% of base64url, base36, hex, and
143 * hex-with-separator tokens, and 0 of 11 real-world credential formats
144 * (JWT header and signature, Stripe, GitHub, Google, Mailgun, Slack, SHA
145 * digest, UUID runs, WooCommerce session values).
146 *
147 * The one shape that survives all three by construction is a wordlist
148 * passphrase ('correct-horse-battery-staple-...'), which no character
149 * test can separate from a slug because it is literally words. That case
150 * is covered a layer earlier: a passphrase reaches this text as the value
151 * of a labelled field, and ABJ_404_Solution_RequestCredentialRedactor
152 * masks it by name before redact() ever runs this pass.
153 */
154 private static function looksLikeReadableSlug(string $token): bool {
155 if (!preg_match('/^[a-z0-9]+(?:[_-][a-z0-9]+)+$/', $token)) {
156 return false;
157 }
158
159 $digitCount = preg_match_all('/[0-9]/', $token);
160 $letterCount = preg_match_all('/[a-z]/', $token);
161
162 // An all-digit token ('1234-5678-9012-...') is an identifier, not
163 // words, so requiring a letter both rejects it and makes the ratio's
164 // denominator below provably non-zero.
165 if ($letterCount < 1) {
166 return false;
167 }
168
169 if (($digitCount / ($digitCount + $letterCount)) > self::MAX_DIGIT_RATIO) {
170 return false;
171 }
172
173 return self::longestConsonantRun($token) <= self::MAX_CONSONANT_RUN;
174 }
175
176 /**
177 * @param string $token a lowercase alphanumeric-and-separator token
178 * @return int the longest run of consecutive consonants, where 'y' counts
179 * as a vowel (it carries one in 'correctly' and 'your'), separators end a
180 * run, and digits are skipped over without ending one.
181 */
182 private static function longestConsonantRun(string $token): int {
183 $longest = 0;
184 $current = 0;
185
186 for ($i = 0, $length = strlen($token); $i < $length; $i++) {
187 $character = $token[$i];
188
189 if ($character >= '0' && $character <= '9') {
190 continue;
191 }
192
193 if ($character >= 'a' && $character <= 'z' && strpos('aeiouy', $character) === false) {
194 $current++;
195 if ($current > $longest) {
196 $longest = $current;
197 }
198 } else {
199 $current = 0;
200 }
201 }
202
203 return $longest;
204 }
205 }
206