PluginProbe
CryptX / trunk
CryptX vtrunk
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / Cli.php

Cli.php in CryptX trunk, at classes/Cli.php

427 lines 15.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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