| 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 |
|