Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.84% covered (warning)
82.84%
169 / 204
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Threats
83.66% covered (warning)
83.66%
169 / 202
63.16% covered (warning)
63.16%
12 / 19
151.16
0.00% covered (danger)
0.00%
0 / 1
 resolve
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 get_resolved
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 get_scanned_at
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 without_resolved
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 prune_resolved
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 prune_on_stored_scan
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 format
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
15
 get_site_extensions
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 format_fixable
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 format_context
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 format_database
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 format_vulnerabilities
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_state
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
13.04
 has_active_plugin
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 get_actions
36.11% covered (danger)
36.11%
13 / 36
0.00% covered (danger)
0.00%
0 / 1
92.37
 get_plugin_files
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 can_delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 get_plugin_icon
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 format_all
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Shapes threats for the `@automattic/jetpack-scan` threat list.
4 *
5 * @package automattic/jetpack-protect
6 */
7
8namespace Automattic\Jetpack\Protect;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Converts Protect threat models into the camelCase shape the JS threat list reads.
16 *
17 * @since 0.1.0
18 */
19class Dashboard_Threats {
20
21    /**
22     * Threats the site resolved itself, as fixed snapshots keyed by id, which count until Scan scans again.
23     *
24     * @var string
25     */
26    const RESOLVED_OPTION = 'jetpack_protect_dashboard_resolved_threats';
27
28    /**
29     * Record a threat as fixed on this site, since Scan has no way to be told.
30     *
31     * @param array $threat The threat, from format().
32     * @return array The fixed snapshot.
33     */
34    public static function resolve( $threat ) {
35        $fixed = array_merge(
36            $threat,
37            array(
38                'status'  => 'fixed',
39                'fixedOn' => gmdate( 'Y-m-d\TH:i:s.000\Z' ),
40            )
41        );
42        // Re-read, since another request may have recorded a fix while this one waited on WordPress.com.
43        wp_cache_delete( self::RESOLVED_OPTION, 'options' );
44        $resolved                           = self::get_resolved();
45        $resolved[ (string) $threat['id'] ] = $fixed;
46        update_option( self::RESOLVED_OPTION, $resolved, false );
47        return $fixed;
48    }
49
50    /**
51     * The threats the site resolved since Scan last scanned.
52     *
53     * A later scan has Scan's own answer: fixed in its history, or still there because the threat came back.
54     *
55     * @param string|null $last_checked When Scan last finished a scan, or null when it hasn't.
56     * @return array Fixed snapshots, keyed by threat id.
57     */
58    public static function get_resolved( $last_checked = null ) {
59        $resolved = get_option( self::RESOLVED_OPTION, array() );
60        if ( ! is_array( $resolved ) ) {
61            return array();
62        }
63        $scanned_at = $last_checked ? strtotime( $last_checked ) : false;
64        if ( false === $scanned_at ) {
65            return $resolved;
66        }
67        return array_filter(
68            $resolved,
69            function ( $threat ) use ( $scanned_at ) {
70                return strtotime( $threat['fixedOn'] ?? '' ) > $scanned_at;
71            }
72        );
73    }
74
75    /**
76     * When Scan last finished a scan, for get_resolved(); null when the most recent one failed, so it can't clear fixes.
77     *
78     * @since $$next-version$$
79     *
80     * @param object|null $status The cached scan status.
81     * @return string|null
82     */
83    public static function get_scanned_at( $status ) {
84        return empty( $status->last_scan_failed ) ? ( $status->last_checked ?? null ) : null;
85    }
86
87    /**
88     * Scan's current threats without the ones the site resolved since Scan last scanned.
89     *
90     * @param iterable    $threats      Scan's current threats.
91     * @param string|null $last_checked When Scan last finished a scan.
92     * @return array The threats still open.
93     */
94    public static function without_resolved( $threats, $last_checked = null ) {
95        // Threat_Count runs on every admin page; with nothing reported there is nothing to hide.
96        if ( ! is_iterable( $threats ) || ( is_array( $threats ) && ! $threats ) ) {
97            return array();
98        }
99
100        $pending = self::prune_resolved( $last_checked );
101
102        $open = array();
103        foreach ( $threats as $threat ) {
104            if ( ! isset( $pending[ (string) ( $threat->id ?? '' ) ] ) ) {
105                $open[] = $threat;
106            }
107        }
108        return $open;
109    }
110
111    /**
112     * Drop the fixes a scan at the given time answers for.
113     *
114     * @param string|null $last_checked When Scan last finished a scan.
115     * @return array The fixes still pending, keyed by threat id.
116     */
117    private static function prune_resolved( $last_checked ) {
118        $all     = self::get_resolved();
119        $pending = self::get_resolved( $last_checked );
120        if ( count( $pending ) !== count( $all ) ) {
121            if ( $pending ) {
122                update_option( self::RESOLVED_OPTION, $pending, false );
123            } else {
124                delete_option( self::RESOLVED_OPTION );
125            }
126        }
127        return $pending;
128    }
129
130    /**
131     * Prune as soon as a completed scan is stored, so a failed one fetched before any page reads it can't strand fixes.
132     *
133     * @since $$next-version$$
134     *
135     * @param mixed $old_value The previous value, or the option name when the option is added.
136     * @param mixed $value     Scan's response, as Scan_Status stores it.
137     * @return void
138     */
139    public static function prune_on_stored_scan( $old_value, $value ) {
140        $scan = maybe_unserialize( $value );
141        if ( is_object( $scan ) && ! empty( $scan->most_recent->timestamp ) && empty( $scan->most_recent->error ) ) {
142            self::prune_resolved( $scan->most_recent->timestamp );
143        }
144    }
145
146    /**
147     * Shape one threat.
148     *
149     * @param object     $threat A Threat_Model, or a raw threat with the same properties.
150     * @param array|null $site   The site's plugins and updates, from get_site_extensions(); read now when null.
151     * @return array
152     */
153    public static function format( $threat, $site = null ) {
154        $site    ??= self::get_site_extensions();
155        $extension = $threat->extension ?? null;
156        // History names extension types in the singular; the threat list reads the plural.
157        $type  = in_array( $extension->type ?? '', array( 'plugin', 'theme' ), true ) ? $extension->type . 's' : ( $extension->type ?? null );
158        $slug  = $extension->slug ?? null;
159        $file  = 'plugins' === $type && $slug ? ( $site['files'][ $slug ] ?? null ) : null;
160        $theme = 'themes' === $type && $slug ? wp_get_theme( $slug ) : null;
161        $theme = $theme && $theme->exists() ? $theme : null;
162
163        return array(
164            'id'              => $threat->id ?? null,
165            'signature'       => $threat->signature ?? null,
166            'title'           => $threat->title ?? null,
167            'description'     => $threat->description ?? null,
168            'status'          => $threat->status ?? null,
169            'severity'        => $threat->severity ?? null,
170            'firstDetected'   => $threat->first_detected ?? null,
171            'fixedIn'         => $threat->fixed_in ?? null,
172            'fixedOn'         => $threat->fixed_on ?? null,
173            'fixable'         => self::format_fixable( $threat->fixable ?? null ),
174            'filename'        => $threat->filename ?? null,
175            'source'          => $threat->source ?? null,
176            'context'         => self::format_context( $threat->context ?? null ),
177            'vulnerabilities' => self::format_vulnerabilities( $threat->vulnerabilities ?? null ),
178            'database'        => self::format_database( $threat ),
179            'extension'       => $extension ? array(
180                'slug'    => $slug,
181                // Scan reports may name a plugin by its slug; the installed copy has its real name.
182                'name'    => ( $file ? $site['plugins'][ $file ]['Name'] : ( $theme ? $theme->get( 'Name' ) : null ) ) ?? $extension->name ?? null,
183                'version' => $extension->version ?? null,
184                'type'    => $type,
185                'icon'    => 'plugins' === $type ? self::get_plugin_icon( $site, $slug ) : ( $theme && $theme->get_screenshot() ? $theme->get_screenshot() : null ),
186                'state'   => self::get_state( $type, $file, $theme ),
187                // A fixed threat no longer calls for deleting what it was in.
188                'actions' => array_diff_key( self::get_actions( $site, $type, $slug, $file, $theme ), 'fixed' === ( $threat->status ?? null ) ? array( 'delete' => true ) : array() ),
189            ) : null,
190        );
191    }
192
193    /**
194     * What threat shaping reads about installed plugins, read once per list.
195     *
196     * @return array Plugins and plugin updates by file, plugin files and WordPress.org entries by slug, and theme updates by slug.
197     */
198    private static function get_site_extensions() {
199        if ( ! function_exists( 'get_plugins' ) ) {
200            require_once ABSPATH . 'wp-admin/includes/plugin.php';
201        }
202
203        $plugins = get_plugins();
204        $files   = self::get_plugin_files( $plugins );
205
206        $plugin_updates = get_site_transient( 'update_plugins' );
207        $theme_updates  = get_site_transient( 'update_themes' );
208        $directory      = array();
209        foreach ( array_merge( (array) ( $plugin_updates->no_update ?? array() ), (array) ( $plugin_updates->response ?? array() ) ) as $entry ) {
210            $entry = (object) $entry;
211            if ( ! empty( $entry->slug ) ) {
212                $directory[ $entry->slug ] = $entry;
213            }
214        }
215
216        return array(
217            'plugins'        => $plugins,
218            'files'          => $files,
219            'plugin_updates' => (array) ( $plugin_updates->response ?? array() ),
220            'directory'      => $directory,
221            'theme_updates'  => (array) ( $theme_updates->response ?? array() ),
222        );
223    }
224
225    /**
226     * Scan's fixer, with the keys of its `extras` in camelCase.
227     *
228     * @param mixed $fixable Scan's fixer, or a falsy value when the threat can't be fixed.
229     * @return object|array|false
230     */
231    private static function format_fixable( $fixable ) {
232        if ( empty( $fixable ) ) {
233            return false;
234        }
235        $extras = ( (array) $fixable )['extras'] ?? null;
236        if ( ! is_array( $extras ) && ! is_object( $extras ) ) {
237            return $fixable;
238        }
239        $camel = array();
240        foreach ( (array) $extras as $key => $value ) {
241            $camel[ lcfirst( str_replace( '_', '', ucwords( $key, '_' ) ) ) ] = $value;
242        }
243        // An object, so empty extras encode as `{}` rather than `[]`.
244        return array_merge( (array) $fixable, array( 'extras' => (object) $camel ) );
245    }
246
247    /**
248     * The lines of code around a file threat, as line number and code pairs.
249     *
250     * @param mixed $context Scan's context: code keyed by line number, plus a `marks` entry.
251     * @return array
252     */
253    private static function format_context( $context ) {
254        if ( ! is_array( $context ) && ! is_object( $context ) ) {
255            return array();
256        }
257
258        $lines = array();
259        foreach ( (array) $context as $line => $code ) {
260            if ( is_numeric( $line ) && is_string( $code ) ) {
261                $lines[] = array(
262                    'line' => (int) $line,
263                    'code' => $code,
264                );
265            }
266        }
267        return $lines;
268    }
269
270    /**
271     * The table and row a database threat was found in.
272     *
273     * @param object $threat A Threat_Model, or a raw threat from Scan history.
274     * @return array|null Null when the threat isn't in the database.
275     */
276    private static function format_database( $threat ) {
277        $table = $threat->table ?? null;
278        if ( ! is_string( $table ) || '' === $table ) {
279            return null;
280        }
281
282        // Threat_Model nests the row under `details`; Scan history sends it flat, with the key as `value`.
283        $row = isset( $threat->details->table ) ? $threat->details : $threat;
284        return array(
285            'table'    => $table,
286            'pkColumn' => $row->pk_column ?? null,
287            'pkValue'  => $row->pk_value ?? $row->value ?? null,
288            'details'  => is_array( $row->details ?? null ) || is_object( $row->details ?? null ) ? (object) $row->details : new \stdClass(),
289        );
290    }
291
292    /**
293     * Shape the vulnerabilities behind a vulnerable-extension threat.
294     *
295     * @param mixed $vulnerabilities Vulnerability_Model objects, or raw objects with the same properties.
296     * @return array
297     */
298    private static function format_vulnerabilities( $vulnerabilities ) {
299        if ( ! is_array( $vulnerabilities ) ) {
300            return array();
301        }
302
303        $formatted = array();
304        foreach ( $vulnerabilities as $vulnerability ) {
305            $formatted[] = array(
306                'id'     => $vulnerability->id ?? null,
307                'title'  => $vulnerability->title ?? null,
308                'source' => method_exists( $vulnerability, 'get_source' ) ? $vulnerability->get_source() : ( $vulnerability->source ?? null ),
309            );
310        }
311        return $formatted;
312    }
313
314    /**
315     * Whether the affected plugin or theme is in use.
316     *
317     * @param string|null    $type  The plural extension type.
318     * @param string|null    $file  The installed plugin's file, for a plugin.
319     * @param \WP_Theme|null $theme The installed theme, for a theme.
320     * @return string|null `active`, `inactive`, or `parent` for the active theme's parent; null when it isn't installed.
321     */
322    private static function get_state( $type, $file, $theme ) {
323        if ( 'plugins' === $type && $file ) {
324            return self::has_active_plugin( $file ) ? 'active' : 'inactive';
325        }
326        if ( 'themes' === $type && $theme ) {
327            if ( get_stylesheet() === $theme->get_stylesheet() ) {
328                return 'active';
329            }
330            return get_template() === $theme->get_stylesheet() ? 'parent' : 'inactive';
331        }
332        return null;
333    }
334
335    /**
336     * Whether the plugin, or another plugin in its folder, is active, since deleting it removes the whole folder.
337     *
338     * @param string $file The plugin's file.
339     * @return bool
340     */
341    private static function has_active_plugin( $file ) {
342        $folder = dirname( $file );
343        $files  = '.' === $folder ? array( $file ) : array_keys( get_plugins( '/' . $folder ) );
344        foreach ( $files as $name ) {
345            if ( is_plugin_active( '.' === $folder ? $name : $folder . '/' . $name ) ) {
346                return true;
347            }
348        }
349        return false;
350    }
351
352    /**
353     * Admin links that act on the affected plugin, theme or core, for the current user.
354     *
355     * @param array          $site The site's plugins and updates.
356     * @param string|null    $type The plural extension type.
357     * @param string|null    $slug The extension slug.
358     * @param string|null    $file The installed plugin's file, for a plugin.
359     * @param \WP_Theme|null $theme The installed theme, for a theme.
360     * @return array Links keyed `update`, `deactivate` and `details`, plus `delete` => true, each only when it applies.
361     */
362    private static function get_actions( $site, $type, $slug, $file, $theme = null ) {
363        $actions = array();
364
365        if ( 'core' === $type ) {
366            if ( current_user_can( 'update_core' ) ) {
367                $actions['update'] = self_admin_url( 'update-core.php' );
368            }
369            return $actions;
370        }
371
372        if ( 'themes' === $type && $slug ) {
373            if ( current_user_can( 'update_themes' ) && isset( $site['theme_updates'][ $slug ] ) ) {
374                $actions['update'] = add_query_arg(
375                    '_wpnonce',
376                    wp_create_nonce( 'upgrade-theme_' . $slug ),
377                    self_admin_url( 'update.php?action=upgrade-theme&theme=' . rawurlencode( $slug ) )
378                );
379            }
380            if ( current_user_can( 'switch_themes' ) && get_stylesheet() === $slug ) {
381                $actions['deactivate'] = self_admin_url( 'themes.php' );
382            }
383            if ( self::can_delete( $type, null, $theme ) ) {
384                $actions['delete'] = true;
385            }
386            return $actions;
387        }
388
389        if ( ! $file ) {
390            return $actions;
391        }
392
393        if ( current_user_can( 'update_plugins' ) && isset( $site['plugin_updates'][ $file ] ) ) {
394            $actions['update'] = add_query_arg(
395                '_wpnonce',
396                wp_create_nonce( 'upgrade-plugin_' . $file ),
397                self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $file ) )
398            );
399        }
400        if ( current_user_can( 'activate_plugins' ) && is_plugin_active( $file ) ) {
401            $actions['deactivate'] = add_query_arg(
402                '_wpnonce',
403                wp_create_nonce( 'deactivate-plugin_' . $file ),
404                self_admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( $file ) )
405            );
406        }
407        if ( self::can_delete( $type, $file ) ) {
408            $actions['delete'] = true;
409        }
410        if ( isset( $site['directory'][ $slug ] ) ) {
411            $actions['details'] = 'https://wordpress.org/plugins/' . rawurlencode( $slug ) . '/';
412        }
413        return $actions;
414    }
415
416    /**
417     * Installed plugin files by slug: the plugin's directory, or a single-file plugin's name.
418     *
419     * @param array $plugins Plugins keyed by file, from get_plugins().
420     * @return array
421     */
422    public static function get_plugin_files( $plugins ) {
423        $files = array();
424        foreach ( array_keys( $plugins ) as $file ) {
425            $files[ '.' === dirname( $file ) ? basename( $file, '.php' ) : dirname( $file ) ] = $file;
426        }
427        return $files;
428    }
429
430    /**
431     * Whether the current user can delete the affected software from the dashboard: an inactive plugin, or a theme the site doesn't use.
432     *
433     * Multisite is left to Network Admin, where another site may still use it.
434     *
435     * @param string|null    $type  The plural extension type.
436     * @param string|null    $file  The installed plugin's file, for a plugin.
437     * @param \WP_Theme|null $theme The installed theme, for a theme.
438     * @return bool
439     */
440    public static function can_delete( $type, $file, $theme = null ) {
441        return ! is_multisite()
442            && 'inactive' === self::get_state( $type, $file, $theme )
443            && current_user_can( 'themes' === $type ? 'delete_themes' : 'delete_plugins' );
444    }
445
446    /**
447     * The plugin's WordPress.org directory icon, from the update check.
448     *
449     * @param array       $site The site's plugins and updates.
450     * @param string|null $slug The plugin slug.
451     * @return string|null
452     */
453    private static function get_plugin_icon( $site, $slug ) {
454        if ( ! $slug ) {
455            return null;
456        }
457        $icons = (array) ( $site['directory'][ $slug ]->icons ?? array() );
458        foreach ( array( 'svg', '2x', '1x', 'default' ) as $size ) {
459            if ( ! empty( $icons[ $size ] ) ) {
460                return $icons[ $size ];
461            }
462        }
463        return null;
464    }
465
466    /**
467     * Shape a list of threats.
468     *
469     * @param iterable $threats Threats, as an array or a Traversable.
470     * @return array
471     */
472    public static function format_all( $threats ) {
473        $formatted = array();
474        if ( ! is_iterable( $threats ) ) {
475            return $formatted;
476        }
477        $site = self::get_site_extensions();
478        foreach ( $threats as $threat ) {
479            $formatted[] = self::format( $threat, $site );
480        }
481        return $formatted;
482    }
483}