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