| 1 |
<?php |
| 2 |
|
| 3 |
namespace CryptX; |
| 4 |
|
| 5 |
use CryptX\Admin\SettingsSchema; |
| 6 |
use WP_CLI; |
| 7 |
use WP_Query; |
| 8 |
|
| 9 |
/** |
| 10 |
* CryptX on the command line. |
| 11 |
* |
| 12 |
* Two things the settings screen cannot do well. |
| 13 |
* |
| 14 |
* The first is a network. Every site keeps its own settings, deliberately -- |
| 15 |
* see Admin\NetworkDefaults for why -- which means changing one setting across |
| 16 |
* forty sites is forty visits to forty screens. With "--url" every command |
| 17 |
* below applies to one site, and a shell loop does the rest. |
| 18 |
* |
| 19 |
* The second is answering "is anything still readable?" for a whole site rather |
| 20 |
* than for one sample. The settings screen renders one address and judges the |
| 21 |
* result; "wp cryptx scan" does the same to every published post and says which |
| 22 |
* ones come out with an address still in them. That is a question a site owner |
| 23 |
* has after changing a setting, and until now the only way to answer it was to |
| 24 |
* look at pages one at a time. |
| 25 |
* |
| 26 |
* @package CryptX |
| 27 |
* @since 4.2.0 |
| 28 |
*/ |
| 29 |
final class Cli |
| 30 |
{ |
| 31 |
/** |
| 32 |
* How many posts are pulled from the database at once. |
| 33 |
* |
| 34 |
* The whole point of the scan is running on sites with a lot of content, |
| 35 |
* and a site with 50,000 posts must not need 50,000 posts' worth of memory |
| 36 |
* to be told that three of them leak. |
| 37 |
*/ |
| 38 |
private const BATCH = 100; |
| 39 |
|
| 40 |
/** |
| 41 |
* Registers the command, if WP-CLI is what is running. |
| 42 |
* |
| 43 |
* @return void |
| 44 |
*/ |
| 45 |
public static function register(): void |
| 46 |
{ |
| 47 |
if (!defined('WP_CLI') || !WP_CLI) { |
| 48 |
return; |
| 49 |
} |
| 50 |
|
| 51 |
WP_CLI::add_command('cryptx settings', [self::class, 'settings']); |
| 52 |
WP_CLI::add_command('cryptx scan', [self::class, 'scan']); |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Reads and writes CryptX settings. |
| 57 |
* |
| 58 |
* ## OPTIONS |
| 59 |
* |
| 60 |
* [<key>] |
| 61 |
* : The setting to read or write. Left out, every setting is listed. |
| 62 |
* |
| 63 |
* [<value>] |
| 64 |
* : The new value. Left out, the setting is only read. |
| 65 |
* |
| 66 |
* [--force] |
| 67 |
* : Store the value even if it had to be changed to be usable. |
| 68 |
* |
| 69 |
* [--format=<format>] |
| 70 |
* : Output format for the list. |
| 71 |
* --- |
| 72 |
* default: table |
| 73 |
* options: |
| 74 |
* - table |
| 75 |
* - csv |
| 76 |
* - json |
| 77 |
* - yaml |
| 78 |
* --- |
| 79 |
* |
| 80 |
* ## EXAMPLES |
| 81 |
* |
| 82 |
* # Everything, with the current values |
| 83 |
* $ wp cryptx settings |
| 84 |
* |
| 85 |
* # One setting |
| 86 |
* $ wp cryptx settings opt_linktext |
| 87 |
* |
| 88 |
* # Change one, on one site of a network |
| 89 |
* $ wp cryptx settings disable_rss 0 --url=example.org |
| 90 |
* |
| 91 |
* @param array<int, string> $args Positional arguments. |
| 92 |
* @param array<string, string> $assoc Flags. |
| 93 |
* |
| 94 |
* @return void |
| 95 |
*/ |
| 96 |
public static function settings(array $args, array $assoc): void |
| 97 |
{ |
| 98 |
$fields = SettingsSchema::fields(); |
| 99 |
$stored = CryptX::get_instance()->loadCryptXOptionsWithDefaults(); |
| 100 |
|
| 101 |
// No key: list everything. |
| 102 |
if ($args === []) { |
| 103 |
$rows = []; |
| 104 |
|
| 105 |
foreach ($fields as $key => $definition) { |
| 106 |
$rows[] = [ |
| 107 |
'key' => $key, |
| 108 |
'value' => self::asText($stored[$key] ?? $definition['default'] ?? ''), |
| 109 |
'default' => self::asText($definition['default'] ?? ''), |
| 110 |
'label' => $definition['label'] ?? '', |
| 111 |
]; |
| 112 |
} |
| 113 |
|
| 114 |
WP_CLI\Utils\format_items( |
| 115 |
$assoc['format'] ?? 'table', |
| 116 |
$rows, |
| 117 |
['key', 'value', 'default', 'label'] |
| 118 |
); |
| 119 |
|
| 120 |
return; |
| 121 |
} |
| 122 |
|
| 123 |
$key = $args[0]; |
| 124 |
|
| 125 |
if (!isset($fields[$key])) { |
| 126 |
// The list of what IS settable, rather than only what is not: the |
| 127 |
// names are not guessable, and a bare "unknown setting" leaves the |
| 128 |
// reader to open a browser. |
| 129 |
WP_CLI::error(sprintf( |
| 130 |
/* translators: 1: the unknown setting, 2: the known ones */ |
| 131 |
__('Unknown setting "%1$s". Known settings: %2$s', 'cryptx'), |
| 132 |
$key, |
| 133 |
implode(', ', array_keys($fields)) |
| 134 |
)); |
| 135 |
} |
| 136 |
|
| 137 |
// Reading. |
| 138 |
if (!isset($args[1])) { |
| 139 |
WP_CLI::line(self::asText($stored[$key] ?? $fields[$key]['default'] ?? '')); |
| 140 |
|
| 141 |
return; |
| 142 |
} |
| 143 |
|
| 144 |
// Writing. Through the same sanitiser the settings screen uses, so a |
| 145 |
// value the screen would refuse cannot arrive by another door. |
| 146 |
$clean = SettingsSchema::sanitize([$key => $args[1]]); |
| 147 |
|
| 148 |
// The sanitiser repairs, it does not refuse -- which is right for a |
| 149 |
// form, where the browser has already limited what can be sent, and |
| 150 |
// wrong here. "wp cryptx settings excludedIDs eins,zwei" quietly wiped |
| 151 |
// every exclusion and reported success; the next page view then served |
| 152 |
// addresses that had been excluded on purpose. So the CLI compares what |
| 153 |
// came back with what went in and stops when they differ. |
| 154 |
// |
| 155 |
// --force accepts the repaired value, because some differences are |
| 156 |
// wanted: an address list is lower-cased, a colour gains its "#". |
| 157 |
$result = self::asText($clean[$key] ?? ''); |
| 158 |
|
| 159 |
if ($result !== (string) $args[1] && !isset($assoc['force'])) { |
| 160 |
WP_CLI::error(sprintf( |
| 161 |
/* translators: 1: the given value, 2: the setting, 3: what it would become */ |
| 162 |
__('"%1$s" is not a usable value for %2$s -- it would be stored as "%3$s". Pass --force to store that instead.', 'cryptx'), |
| 163 |
$args[1], |
| 164 |
$key, |
| 165 |
$result |
| 166 |
)); |
| 167 |
} |
| 168 |
|
| 169 |
$options = get_option('cryptX', []); |
| 170 |
|
| 171 |
if (!is_array($options)) { |
| 172 |
$options = []; |
| 173 |
} |
| 174 |
|
| 175 |
update_option('cryptX', array_merge($options, $clean)); |
| 176 |
|
| 177 |
WP_CLI::success(sprintf( |
| 178 |
/* translators: 1: the setting, 2: the new value */ |
| 179 |
__('%1$s is now %2$s.', 'cryptx'), |
| 180 |
$key, |
| 181 |
// Same "?? ''" as the comparison above. Unreachable today, because |
| 182 |
// no field is also an internal key -- but writing it one way here |
| 183 |
// and the other way twenty lines up is the asymmetry that turns |
| 184 |
// into a warning the day those two lists ever overlap. |
| 185 |
self::asText($clean[$key] ?? '') |
| 186 |
)); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* Runs published content through CryptX and reports what is still readable. |
| 191 |
* |
| 192 |
* The same judgement the settings screen and the Site Health check use, on |
| 193 |
* real content instead of a sample: each post goes through the filters that |
| 194 |
* render it, and CryptX\Exposure decides whether an address survived. |
| 195 |
* |
| 196 |
* "encoded" is worth reading twice. It means the address is in the page as |
| 197 |
* HTML entities -- invisible to a naive scanner, plain to anything that |
| 198 |
* decodes them, which is most things. It is not "safe". |
| 199 |
* |
| 200 |
* ## OPTIONS |
| 201 |
* |
| 202 |
* [--post_type=<types>] |
| 203 |
* : Comma separated. Defaults to every public type. |
| 204 |
* |
| 205 |
* [--all] |
| 206 |
* : Report every post, not only the ones with something left in them. |
| 207 |
* |
| 208 |
* [--format=<format>] |
| 209 |
* : Output format. |
| 210 |
* --- |
| 211 |
* default: table |
| 212 |
* options: |
| 213 |
* - table |
| 214 |
* - csv |
| 215 |
* - json |
| 216 |
* - yaml |
| 217 |
* - count |
| 218 |
* --- |
| 219 |
* |
| 220 |
* ## EXAMPLES |
| 221 |
* |
| 222 |
* # What is still readable on this site |
| 223 |
* $ wp cryptx scan |
| 224 |
* |
| 225 |
* # Across a network |
| 226 |
* $ wp site list --field=url | xargs -I{} wp cryptx scan --url={} |
| 227 |
* |
| 228 |
* @param array<int, string> $args Positional arguments. |
| 229 |
* @param array<string, string> $assoc Flags. |
| 230 |
* |
| 231 |
* @return void |
| 232 |
*/ |
| 233 |
public static function scan(array $args, array $assoc): void |
| 234 |
{ |
| 235 |
$types = isset($assoc['post_type']) |
| 236 |
? array_filter(array_map('trim', explode(',', (string) $assoc['post_type']))) |
| 237 |
: get_post_types(['public' => true]); |
| 238 |
|
| 239 |
// A type nobody registered would otherwise find nothing and be reported |
| 240 |
// as "nothing readable left" -- a clean bill of health for a run that |
| 241 |
// checked nothing. "--post_type=pages" instead of "page" is one |
| 242 |
// keystroke away. |
| 243 |
foreach ($types as $type) { |
| 244 |
if (!post_type_exists($type)) { |
| 245 |
WP_CLI::error(sprintf( |
| 246 |
/* translators: 1: the unknown post type, 2: the known ones */ |
| 247 |
__('Unknown post type "%1$s". Known types: %2$s', 'cryptx'), |
| 248 |
$type, |
| 249 |
implode(', ', get_post_types(['public' => true])) |
| 250 |
)); |
| 251 |
} |
| 252 |
} |
| 253 |
|
| 254 |
$showAll = isset($assoc['all']); |
| 255 |
$format = $assoc['format'] ?? 'table'; |
| 256 |
|
| 257 |
$rows = []; |
| 258 |
$checked = 0; |
| 259 |
$page = 1; |
| 260 |
|
| 261 |
// Saved and put back by hand. wp_reset_postdata() restores from the |
| 262 |
// main query, and under WP-CLI there is no main query -- so it does |
| 263 |
// nothing at all, and $GLOBALS['post'] is left pointing at whichever |
| 264 |
// post was checked last. Harmless when the command is the whole |
| 265 |
// process, not harmless when something else runs afterwards in the |
| 266 |
// same one, which is exactly what the test suite does. |
| 267 |
$previousPost = $GLOBALS['post'] ?? null; |
| 268 |
|
| 269 |
try { |
| 270 |
do { |
| 271 |
$query = new WP_Query([ |
| 272 |
'post_type' => array_values($types), |
| 273 |
'post_status' => 'publish', |
| 274 |
'posts_per_page' => self::BATCH, |
| 275 |
'paged' => $page, |
| 276 |
'ignore_sticky_posts' => true, |
| 277 |
'no_found_rows' => true, |
| 278 |
'update_post_meta_cache' => false, |
| 279 |
'update_post_term_cache' => false, |
| 280 |
]); |
| 281 |
|
| 282 |
if (!$query->have_posts()) { |
| 283 |
break; |
| 284 |
} |
| 285 |
|
| 286 |
foreach ($query->posts as $post) { |
| 287 |
$checked++; |
| 288 |
|
| 289 |
// The real filter, with the post in place -- the exclusion list |
| 290 |
// and the meta box both depend on which post is being rendered, |
| 291 |
// so judging the content without setting it up would report |
| 292 |
// leaks on posts the site owner had deliberately excluded, and |
| 293 |
// miss the ones that matter. |
| 294 |
$GLOBALS['post'] = $post; |
| 295 |
setup_postdata($post); |
| 296 |
|
| 297 |
$rendered = apply_filters('the_content', $post->post_content); |
| 298 |
|
| 299 |
wp_reset_postdata(); |
| 300 |
|
| 301 |
$verdict = Exposure::of($rendered); |
| 302 |
|
| 303 |
// The title as well, and it is not a nicety. CryptX does |
| 304 |
// not filter titles: a title reaches the document head |
| 305 |
// through wp_get_document_title(), which passes through |
| 306 |
// neither the_content nor render_block. An address in a |
| 307 |
// title is therefore readable -- and a scan that judged |
| 308 |
// only the body reported "nothing readable left" for a page |
| 309 |
// that had one. A clean bill of health on a page with a |
| 310 |
// plain address is worse than no scan at all. |
| 311 |
$inTitle = Exposure::of((string) $post->post_title); |
| 312 |
$where = []; |
| 313 |
|
| 314 |
// Not translated, and that is the point: this is a field |
| 315 |
// value in machine-readable output, next to "exposure", |
| 316 |
// which carries the untranslated Exposure constants. Under |
| 317 |
// a German locale a --format=csv run would otherwise say |
| 318 |
// "Inhalt, Titel", and the column this readme documents |
| 319 |
// would stop being scriptable halfway through a network. |
| 320 |
if ($verdict !== Exposure::NONE) { |
| 321 |
$where[] = 'content'; |
| 322 |
} |
| 323 |
|
| 324 |
if ($inTitle !== Exposure::NONE) { |
| 325 |
$where[] = 'title'; |
| 326 |
|
| 327 |
// The worse of the two wins. An entity-encoded address |
| 328 |
// in the body next to a plain one in the title is a |
| 329 |
// plain leak, and reporting it as "encoded" would file |
| 330 |
// it under the milder heading. |
| 331 |
if ($verdict === Exposure::NONE || $inTitle === Exposure::PLAIN) { |
| 332 |
$verdict = $inTitle; |
| 333 |
} |
| 334 |
} |
| 335 |
|
| 336 |
if (!$showAll && $verdict === Exposure::NONE) { |
| 337 |
continue; |
| 338 |
} |
| 339 |
|
| 340 |
$rows[] = [ |
| 341 |
'ID' => $post->ID, |
| 342 |
'type' => $post->post_type, |
| 343 |
'exposure' => $verdict, |
| 344 |
'where' => implode(', ', $where), |
| 345 |
'title' => $post->post_title, |
| 346 |
'url' => get_permalink($post), |
| 347 |
]; |
| 348 |
} |
| 349 |
|
| 350 |
$page++; |
| 351 |
} while (count($query->posts) === self::BATCH); |
| 352 |
} finally { |
| 353 |
$GLOBALS['post'] = $previousPost; |
| 354 |
} |
| 355 |
|
| 356 |
if ($format === 'count') { |
| 357 |
WP_CLI::line((string) count($rows)); |
| 358 |
|
| 359 |
return; |
| 360 |
} |
| 361 |
|
| 362 |
if ($rows === []) { |
| 363 |
// What was measured, not "everything is fine". The scan reads the |
| 364 |
// body and the title of published posts; widgets, comments, feeds |
| 365 |
// and whatever a theme prints for itself are not in it. A summary |
| 366 |
// that did not say so invited exactly the wrong conclusion. |
| 367 |
WP_CLI::success(sprintf( |
| 368 |
/* translators: %d: number of posts checked */ |
| 369 |
_n( |
| 370 |
'Checked the body and title of %d published post; nothing readable left in it.', |
| 371 |
'Checked the body and title of %d published posts; nothing readable left in them.', |
| 372 |
$checked, |
| 373 |
'cryptx' |
| 374 |
), |
| 375 |
$checked |
| 376 |
)); |
| 377 |
|
| 378 |
return; |
| 379 |
} |
| 380 |
|
| 381 |
WP_CLI\Utils\format_items( |
| 382 |
$format, |
| 383 |
$rows, |
| 384 |
['ID', 'type', 'exposure', 'where', 'title', 'url'] |
| 385 |
); |
| 386 |
|
| 387 |
$plain = count(array_filter( |
| 388 |
$rows, |
| 389 |
static fn(array $row): bool => $row['exposure'] === Exposure::PLAIN |
| 390 |
)); |
| 391 |
|
| 392 |
if ($plain > 0 && !$showAll) { |
| 393 |
// Deliberately phrased so that no noun has to agree with a |
| 394 |
// number. "%1$d of %2$d posts still carry" cannot be pluralised |
| 395 |
// correctly: the verb follows the first number and the noun the |
| 396 |
// second, and at 1 of 1 they disagree. A sentence with two counts |
| 397 |
// in it only works if neither governs a plural. |
| 398 |
WP_CLI::warning(sprintf( |
| 399 |
/* translators: 1: number with a readable address, 2: number checked */ |
| 400 |
__('Still carrying a plainly readable address: %1$d of %2$d checked.', 'cryptx'), |
| 401 |
$plain, |
| 402 |
$checked |
| 403 |
)); |
| 404 |
} |
| 405 |
} |
| 406 |
|
| 407 |
/** |
| 408 |
* A setting as one line of text. |
| 409 |
* |
| 410 |
* @param mixed $value The stored value. |
| 411 |
* |
| 412 |
* @return string Something a shell can read and pipe. |
| 413 |
*/ |
| 414 |
private static function asText($value): string |
| 415 |
{ |
| 416 |
if (is_bool($value)) { |
| 417 |
return $value ? '1' : '0'; |
| 418 |
} |
| 419 |
|
| 420 |
if (is_array($value)) { |
| 421 |
return implode(',', array_map('strval', $value)); |
| 422 |
} |
| 423 |
|
| 424 |
return (string) $value; |
| 425 |
} |
| 426 |
} |
| 427 |
|