Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.23% covered (warning)
69.23%
90 / 130
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Threats
70.31% covered (warning)
70.31%
90 / 128
71.43% covered (warning)
71.43%
5 / 7
154.19
0.00% covered (danger)
0.00%
0 / 1
 format
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 get_site_extensions
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 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_actions
15.91% covered (danger)
15.91%
7 / 44
0.00% covered (danger)
0.00%
0 / 1
309.80
 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 $$next-version$$
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                'actions' => self::get_actions( $site, $type, $slug, $file, $theme ),
61            ) : null,
62        );
63    }
64
65    /**
66     * What threat shaping reads about installed plugins, read once per list.
67     *
68     * @return array Plugins and plugin updates by file, plugin files and WordPress.org entries by slug, and theme updates by slug.
69     */
70    private static function get_site_extensions() {
71        if ( ! function_exists( 'get_plugins' ) ) {
72            require_once ABSPATH . 'wp-admin/includes/plugin.php';
73        }
74
75        $plugins = get_plugins();
76        $files   = array();
77        foreach ( array_keys( $plugins ) as $file ) {
78            $files[ '.' === dirname( $file ) ? basename( $file, '.php' ) : dirname( $file ) ] = $file;
79        }
80
81        $plugin_updates = get_site_transient( 'update_plugins' );
82        $theme_updates  = get_site_transient( 'update_themes' );
83        $directory      = array();
84        foreach ( array_merge( (array) ( $plugin_updates->no_update ?? array() ), (array) ( $plugin_updates->response ?? array() ) ) as $entry ) {
85            $entry = (object) $entry;
86            if ( ! empty( $entry->slug ) ) {
87                $directory[ $entry->slug ] = $entry;
88            }
89        }
90
91        return array(
92            'plugins'        => $plugins,
93            'files'          => $files,
94            'plugin_updates' => (array) ( $plugin_updates->response ?? array() ),
95            'directory'      => $directory,
96            'theme_updates'  => (array) ( $theme_updates->response ?? array() ),
97        );
98    }
99
100    /**
101     * The lines of code around a file threat, as line number and code pairs.
102     *
103     * @param mixed $context Scan's context: code keyed by line number, plus a `marks` entry.
104     * @return array
105     */
106    private static function format_context( $context ) {
107        if ( ! is_array( $context ) && ! is_object( $context ) ) {
108            return array();
109        }
110
111        $lines = array();
112        foreach ( (array) $context as $line => $code ) {
113            if ( is_numeric( $line ) && is_string( $code ) ) {
114                $lines[] = array(
115                    'line' => (int) $line,
116                    'code' => $code,
117                );
118            }
119        }
120        return $lines;
121    }
122
123    /**
124     * Shape the vulnerabilities behind a vulnerable-extension threat.
125     *
126     * @param mixed $vulnerabilities Vulnerability_Model objects, or raw objects with the same properties.
127     * @return array
128     */
129    private static function format_vulnerabilities( $vulnerabilities ) {
130        if ( ! is_array( $vulnerabilities ) ) {
131            return array();
132        }
133
134        $formatted = array();
135        foreach ( $vulnerabilities as $vulnerability ) {
136            $formatted[] = array(
137                'id'     => $vulnerability->id ?? null,
138                'title'  => $vulnerability->title ?? null,
139                'source' => method_exists( $vulnerability, 'get_source' ) ? $vulnerability->get_source() : ( $vulnerability->source ?? null ),
140            );
141        }
142        return $formatted;
143    }
144
145    /**
146     * Admin links that act on the affected plugin, theme or core, for the current user.
147     *
148     * @param array          $site The site's plugins and updates.
149     * @param string|null    $type The plural extension type.
150     * @param string|null    $slug The extension slug.
151     * @param string|null    $file The installed plugin's file, for a plugin.
152     * @param \WP_Theme|null $theme The installed theme, for a theme.
153     * @return array Links keyed `update`, `deactivate`, `delete` and `details`, each only when it applies.
154     */
155    private static function get_actions( $site, $type, $slug, $file, $theme = null ) {
156        $actions = array();
157
158        if ( 'core' === $type ) {
159            if ( current_user_can( 'update_core' ) ) {
160                $actions['update'] = self_admin_url( 'update-core.php' );
161            }
162            return $actions;
163        }
164
165        if ( 'themes' === $type && $slug ) {
166            if ( current_user_can( 'update_themes' ) && isset( $site['theme_updates'][ $slug ] ) ) {
167                $actions['update'] = add_query_arg(
168                    '_wpnonce',
169                    wp_create_nonce( 'upgrade-theme_' . $slug ),
170                    self_admin_url( 'update.php?action=upgrade-theme&theme=' . rawurlencode( $slug ) )
171                );
172            }
173            if ( current_user_can( 'switch_themes' ) && get_stylesheet() === $slug ) {
174                $actions['deactivate'] = self_admin_url( 'themes.php' );
175            }
176            // Core deletes on this link without asking, so the dashboard confirms first. Multisite deletes from Network Admin.
177            if ( ! is_multisite() && current_user_can( 'delete_themes' ) && $theme && ! in_array( $slug, array( get_stylesheet(), get_template() ), true ) ) {
178                $actions['delete'] = add_query_arg(
179                    '_wpnonce',
180                    wp_create_nonce( 'delete-theme_' . $slug ),
181                    admin_url( 'themes.php?action=delete&stylesheet=' . rawurlencode( $slug ) )
182                );
183            }
184            return $actions;
185        }
186
187        if ( ! $file ) {
188            return $actions;
189        }
190
191        if ( current_user_can( 'update_plugins' ) && isset( $site['plugin_updates'][ $file ] ) ) {
192            $actions['update'] = add_query_arg(
193                '_wpnonce',
194                wp_create_nonce( 'upgrade-plugin_' . $file ),
195                self_admin_url( 'update.php?action=upgrade-plugin&plugin=' . rawurlencode( $file ) )
196            );
197        }
198        if ( current_user_can( 'activate_plugins' ) && is_plugin_active( $file ) ) {
199            $actions['deactivate'] = add_query_arg(
200                '_wpnonce',
201                wp_create_nonce( 'deactivate-plugin_' . $file ),
202                self_admin_url( 'plugins.php?action=deactivate&plugin=' . rawurlencode( $file ) )
203            );
204        }
205        // Core asks "Are you sure?" on this link. On multisite, another site may still use the plugin.
206        if ( ! is_multisite() && current_user_can( 'delete_plugins' ) && ! is_plugin_active( $file ) ) {
207            $actions['delete'] = add_query_arg(
208                '_wpnonce',
209                wp_create_nonce( 'bulk-plugins' ),
210                self_admin_url( 'plugins.php?action=delete-selected&checked[]=' . rawurlencode( $file ) )
211            );
212        }
213        if ( isset( $site['directory'][ $slug ] ) ) {
214            $actions['details'] = 'https://wordpress.org/plugins/' . rawurlencode( $slug ) . '/';
215        }
216        return $actions;
217    }
218
219    /**
220     * The plugin's WordPress.org directory icon, from the update check.
221     *
222     * @param array       $site The site's plugins and updates.
223     * @param string|null $slug The plugin slug.
224     * @return string|null
225     */
226    private static function get_plugin_icon( $site, $slug ) {
227        if ( ! $slug ) {
228            return null;
229        }
230        $icons = (array) ( $site['directory'][ $slug ]->icons ?? array() );
231        foreach ( array( 'svg', '2x', '1x', 'default' ) as $size ) {
232            if ( ! empty( $icons[ $size ] ) ) {
233                return $icons[ $size ];
234            }
235        }
236        return null;
237    }
238
239    /**
240     * Shape a list of threats.
241     *
242     * @param iterable $threats Threats, as an array or a Traversable.
243     * @return array
244     */
245    public static function format_all( $threats ) {
246        $formatted = array();
247        if ( ! is_iterable( $threats ) ) {
248            return $formatted;
249        }
250        $site = self::get_site_extensions();
251        foreach ( $threats as $threat ) {
252            $formatted[] = self::format( $threat, $site );
253        }
254        return $formatted;
255    }
256}