Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.43% covered (warning)
79.43%
112 / 141
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Threats
80.58% covered (warning)
80.58%
112 / 139
72.73% covered (warning)
72.73%
8 / 11
114.13
0.00% covered (danger)
0.00%
0 / 1
 format
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
16
 get_site_extensions
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 format_context
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     * Shape one threat.
23     *
24     * @param object     $threat A Threat_Model, or a raw threat with the same properties.
25     * @param array|null $site   The site's plugins and updates, from get_site_extensions(); read now when null.
26     * @return array
27     */
28    public static function format( $threat, $site = null ) {
29        $site    ??= self::get_site_extensions();
30        $extension = $threat->extension ?? null;
31        // History names extension types in the singular; the threat list reads the plural.
32        $type  = in_array( $extension->type ?? '', array( 'plugin', 'theme' ), true ) ? $extension->type . 's' : ( $extension->type ?? null );
33        $slug  = $extension->slug ?? null;
34        $file  = 'plugins' === $type && $slug ? ( $site['files'][ $slug ] ?? null ) : null;
35        $theme = 'themes' === $type && $slug ? wp_get_theme( $slug ) : null;
36        $theme = $theme && $theme->exists() ? $theme : null;
37
38        return array(
39            'id'              => $threat->id ?? null,
40            'signature'       => $threat->signature ?? null,
41            'title'           => $threat->title ?? null,
42            'description'     => $threat->description ?? null,
43            'status'          => $threat->status ?? null,
44            'severity'        => $threat->severity ?? null,
45            'firstDetected'   => $threat->first_detected ?? null,
46            'fixedIn'         => $threat->fixed_in ?? null,
47            'fixedOn'         => $threat->fixed_on ?? null,
48            'fixable'         => empty( $threat->fixable ) ? false : $threat->fixable,
49            'filename'        => $threat->filename ?? null,
50            'source'          => $threat->source ?? null,
51            'context'         => self::format_context( $threat->context ?? null ),
52            'vulnerabilities' => self::format_vulnerabilities( $threat->vulnerabilities ?? null ),
53            'extension'       => $extension ? array(
54                'slug'    => $slug,
55                // Scan reports may name a plugin by its slug; the installed copy has its real name.
56                'name'    => ( $file ? $site['plugins'][ $file ]['Name'] : ( $theme ? $theme->get( 'Name' ) : null ) ) ?? $extension->name ?? null,
57                'version' => $extension->version ?? null,
58                'type'    => $type,
59                'icon'    => 'plugins' === $type ? self::get_plugin_icon( $site, $slug ) : ( $theme && $theme->get_screenshot() ? $theme->get_screenshot() : null ),
60                'state'   => self::get_state( $type, $file, $theme ),
61                // A fixed threat no longer calls for deleting what it was in.
62                'actions' => array_diff_key( self::get_actions( $site, $type, $slug, $file, $theme ), 'fixed' === ( $threat->status ?? null ) ? array( 'delete' => true ) : array() ),
63            ) : null,
64        );
65    }
66
67    /**
68     * What threat shaping reads about installed plugins, read once per list.
69     *
70     * @return array Plugins and plugin updates by file, plugin files and WordPress.org entries by slug, and theme updates by slug.
71     */
72    private static function get_site_extensions() {
73        if ( ! function_exists( 'get_plugins' ) ) {
74            require_once ABSPATH . 'wp-admin/includes/plugin.php';
75        }
76
77        $plugins = get_plugins();
78        $files   = self::get_plugin_files( $plugins );
79
80        $plugin_updates = get_site_transient( 'update_plugins' );
81        $theme_updates  = get_site_transient( 'update_themes' );
82        $directory      = array();
83        foreach ( array_merge( (array) ( $plugin_updates->no_update ?? array() ), (array) ( $plugin_updates->response ?? array() ) ) as $entry ) {
84            $entry = (object) $entry;
85            if ( ! empty( $entry->slug ) ) {
86                $directory[ $entry->slug ] = $entry;
87            }
88        }
89
90        return array(
91            'plugins'        => $plugins,
92            'files'          => $files,
93            'plugin_updates' => (array) ( $plugin_updates->response ?? array() ),
94            'directory'      => $directory,
95            'theme_updates'  => (array) ( $theme_updates->response ?? array() ),
96        );
97    }
98
99    /**
100     * The lines of code around a file threat, as line number and code pairs.
101     *
102     * @param mixed $context Scan's context: code keyed by line number, plus a `marks` entry.
103     * @return array
104     */
105    private static function format_context( $context ) {
106        if ( ! is_array( $context ) && ! is_object( $context ) ) {
107            return array();
108        }
109
110        $lines = array();
111        foreach ( (array) $context as $line => $code ) {
112            if ( is_numeric( $line ) && is_string( $code ) ) {
113                $lines[] = array(
114                    'line' => (int) $line,
115                    'code' => $code,
116                );
117            }
118        }
119        return $lines;
120    }
121
122    /**
123     * Shape the vulnerabilities behind a vulnerable-extension threat.
124     *
125     * @param mixed $vulnerabilities Vulnerability_Model objects, or raw objects with the same properties.
126     * @return array
127     */
128    private static function format_vulnerabilities( $vulnerabilities ) {
129        if ( ! is_array( $vulnerabilities ) ) {
130            return array();
131        }
132
133        $formatted = array();
134        foreach ( $vulnerabilities as $vulnerability ) {
135            $formatted[] = array(
136                'id'     => $vulnerability->id ?? null,
137                'title'  => $vulnerability->title ?? null,
138                'source' => method_exists( $vulnerability, 'get_source' ) ? $vulnerability->get_source() : ( $vulnerability->source ?? null ),
139            );
140        }
141        return $formatted;
142    }
143
144    /**
145     * Whether the affected plugin or theme is in use.
146     *
147     * @param string|null    $type  The plural extension type.
148     * @param string|null    $file  The installed plugin's file, for a plugin.
149     * @param \WP_Theme|null $theme The installed theme, for a theme.
150     * @return string|null `active`, `inactive`, or `parent` for the active theme's parent; null when it isn't installed.
151     */
152    private static function get_state( $type, $file, $theme ) {
153        if ( 'plugins' === $type && $file ) {
154            return self::has_active_plugin( $file ) ? 'active' : 'inactive';
155        }
156        if ( 'themes' === $type && $theme ) {
157            if ( get_stylesheet() === $theme->get_stylesheet() ) {
158                return 'active';
159            }
160            return get_template() === $theme->get_stylesheet() ? 'parent' : 'inactive';
161        }
162        return null;
163    }
164
165    /**
166     * Whether the plugin, or another plugin in its folder, is active, since deleting it removes the whole folder.
167     *
168     * @param string $file The plugin's file.
169     * @return bool
170     */
171    private static function has_active_plugin( $file ) {
172        $folder = dirname( $file );
173        $files  = '.' === $folder ? array( $file ) : array_keys( get_plugins( '/' . $folder ) );
174        foreach ( $files as $name ) {
175            if ( is_plugin_active( '.' === $folder ? $name : $folder . '/' . $name ) ) {
176                return true;
177            }
178        }
179        return false;
180    }
181
182    /**
183     * Admin links that act on the affected plugin, theme or core, for the current user.
184     *
185     * @param array          $site The site's plugins and updates.
186     * @param string|null    $type The plural extension type.
187     * @param string|null    $slug The extension slug.
188     * @param string|null    $file The installed plugin's file, for a plugin.
189     * @param \WP_Theme|null $theme The installed theme, for a theme.
190     * @return array Links keyed `update`, `deactivate` and `details`, plus `delete` => true, each only when it applies.
191     */
192    private static function get_actions( $site, $type, $slug, $file, $theme = null ) {
193        $actions = array();
194
195        if ( 'core' === $type ) {
196            if ( current_user_can( 'update_core' ) ) {
197                $actions['update'] = self_admin_url( 'update-core.php' );
198            }
199            return $actions;
200        }
201
202        if ( 'themes' === $type && $slug ) {
203            if ( current_user_can( 'update_themes' ) && isset( $site['theme_updates'][ $slug ] ) ) {
204                $actions['update'] = add_query_arg(
205                    '_wpnonce',
206                    wp_create_nonce( 'upgrade-theme_' . $slug ),
207                    self_admin_url( 'update.php?action=upgrade-theme&theme=' . rawurlencode( $slug ) )
208                );
209            }
210            if ( current_user_can( 'switch_themes' ) && get_stylesheet() === $slug ) {
211                $actions['deactivate'] = self_admin_url( 'themes.php' );
212            }
213            if ( self::can_delete( $type, null, $theme ) ) {
214                $actions['delete'] = true;
215            }
216            return $actions;
217        }
218
219        if ( ! $file ) {
220            return $actions;
221        }
222
223        if ( current_user_can( 'update_plugins' ) && isset( $site['plugin_updates'][ $file ] ) ) {
224            $actions['update'] = add_query_arg(
225                '_wpnonce',
226                wp_create_nonce( 'upgrade-plugin_' . $file ),
227                self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $file ) )
228            );
229        }
230        if ( current_user_can( 'activate_plugins' ) && is_plugin_active( $file ) ) {
231            $actions['deactivate'] = add_query_arg(
232                '_wpnonce',
233                wp_create_nonce( 'deactivate-plugin_' . $file ),
234                self_admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( $file ) )
235            );
236        }
237        if ( self::can_delete( $type, $file ) ) {
238            $actions['delete'] = true;
239        }
240        if ( isset( $site['directory'][ $slug ] ) ) {
241            $actions['details'] = 'https://wordpress.org/plugins/' . rawurlencode( $slug ) . '/';
242        }
243        return $actions;
244    }
245
246    /**
247     * Installed plugin files by slug: the plugin's directory, or a single-file plugin's name.
248     *
249     * @param array $plugins Plugins keyed by file, from get_plugins().
250     * @return array
251     */
252    public static function get_plugin_files( $plugins ) {
253        $files = array();
254        foreach ( array_keys( $plugins ) as $file ) {
255            $files[ '.' === dirname( $file ) ? basename( $file, '.php' ) : dirname( $file ) ] = $file;
256        }
257        return $files;
258    }
259
260    /**
261     * Whether the current user can delete the affected software from the dashboard: an inactive plugin, or a theme the site doesn't use.
262     *
263     * Multisite is left to Network Admin, where another site may still use it.
264     *
265     * @param string|null    $type  The plural extension type.
266     * @param string|null    $file  The installed plugin's file, for a plugin.
267     * @param \WP_Theme|null $theme The installed theme, for a theme.
268     * @return bool
269     */
270    public static function can_delete( $type, $file, $theme = null ) {
271        return ! is_multisite()
272            && 'inactive' === self::get_state( $type, $file, $theme )
273            && current_user_can( 'themes' === $type ? 'delete_themes' : 'delete_plugins' );
274    }
275
276    /**
277     * The plugin's WordPress.org directory icon, from the update check.
278     *
279     * @param array       $site The site's plugins and updates.
280     * @param string|null $slug The plugin slug.
281     * @return string|null
282     */
283    private static function get_plugin_icon( $site, $slug ) {
284        if ( ! $slug ) {
285            return null;
286        }
287        $icons = (array) ( $site['directory'][ $slug ]->icons ?? array() );
288        foreach ( array( 'svg', '2x', '1x', 'default' ) as $size ) {
289            if ( ! empty( $icons[ $size ] ) ) {
290                return $icons[ $size ];
291            }
292        }
293        return null;
294    }
295
296    /**
297     * Shape a list of threats.
298     *
299     * @param iterable $threats Threats, as an array or a Traversable.
300     * @return array
301     */
302    public static function format_all( $threats ) {
303        $formatted = array();
304        if ( ! is_iterable( $threats ) ) {
305            return $formatted;
306        }
307        $site = self::get_site_extensions();
308        foreach ( $threats as $threat ) {
309            $formatted[] = self::format( $threat, $site );
310        }
311        return $formatted;
312    }
313}