Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
47.97% covered (danger)
47.97%
71 / 148
33.33% covered (danger)
33.33%
2 / 6
CRAP
n/a
0 / 0
pcg_update_guard_cap_for_action
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
pcg_update_guard_check
84.85% covered (warning)
84.85%
28 / 33
0.00% covered (danger)
0.00%
0 / 1
9.28
pcg_update_guard_log_blocked
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
pcg_update_guard_stash_retry_context
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
29.43
pcg_update_guard_render_retry_notice
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
110
pcg_update_guard_scan_for_parse_errors
80.65% covered (warning)
80.65%
25 / 31
0.00% covered (danger)
0.00%
0 / 1
11.88
1<?php
2/**
3 * Update guard — refuses plugin installs / updates when the unpacked
4 * package contains PHP parse errors.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9add_filter( 'upgrader_source_selection', 'pcg_update_guard_check', 99, 4 );
10add_action( 'admin_notices', 'pcg_update_guard_render_retry_notice' );
11
12/**
13 * Wall-clock budget (seconds) for scanning a package for parse errors.
14 * Big packages (looking at you, WooCommerce) can have thousands of PHP
15 * files; we'd rather bail out cleanly than blow the cron / request
16 * timeout.
17 */
18const PCG_UPDATE_GUARD_BUDGET_SECONDS = 8.0;
19
20/**
21 * Capability that gates the update guard for a given upgrader action.
22 * `install` → `install_plugins`; everything else (i.e. `update`) → `update_plugins`.
23 * Stock WP roles bundle both on administrator, but a custom role plugin
24 * could split them — derive the cap from the action so we honor that.
25 *
26 * @param string $action `install` or `update`.
27 * @return string
28 */
29function pcg_update_guard_cap_for_action( $action ) {
30    return 'install' === $action ? 'install_plugins' : 'update_plugins';
31}
32
33/**
34 * Filter callback. Returns a WP_Error (aborts the install/update) when
35 * the extracted source contains any PHP parse errors.
36 *
37 * @param string|WP_Error  $source        Extracted source directory, or error from a prior filter.
38 * @param string           $remote_source Original remote source path (unused).
39 * @param WP_Upgrader|null $upgrader      Upgrader instance (unused).
40 * @param array            $hook_extra    { type, action, plugin?, theme? }.
41 * @return string|WP_Error
42 */
43function pcg_update_guard_check( $source, $remote_source, $upgrader, $hook_extra = array() ) {
44    unset( $remote_source, $upgrader );
45
46    if ( is_wp_error( $source ) ) {
47        return $source;
48    }
49    if ( ! apply_filters( 'pcg_guard_updates', true ) ) {
50        return $source;
51    }
52    $type   = $hook_extra['type'] ?? '';
53    $action = (string) ( $hook_extra['action'] ?? '' );
54    if ( 'plugin' !== $type || ! in_array( $action, array( 'install', 'update' ), true ) ) {
55        return $source;
56    }
57    if ( pcg_force_override_active( pcg_update_guard_cap_for_action( $action ) ) ) {
58        return $source;
59    }
60
61    $scan = pcg_update_guard_scan_for_parse_errors( (string) $source );
62
63    if ( empty( $scan['errors'] ) ) {
64        if ( $scan['budget_exceeded'] ) {
65            // Don't fail-closed on a slow scan; log so we can see how often
66            // this fires and which packages trip it.
67            $slug = (string) ( $hook_extra['plugin'] ?? ( $hook_extra['theme'] ?? '' ) );
68            error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
69                sprintf( 'PCG update guard: scan exceeded %.1fs budget for %s; allowing %s.', PCG_UPDATE_GUARD_BUDGET_SECONDS, $slug, $action )
70            );
71        }
72        return $source;
73    }
74
75    $first = $scan['errors'][0];
76
77    pcg_update_guard_log_blocked( $action, $hook_extra, $scan, (string) $source );
78    pcg_update_guard_stash_retry_context( $action, $hook_extra, (string) $source );
79
80    return new WP_Error(
81        'pcg_update_parse_error',
82        sprintf(
83            /* translators: 1: install or update, 2: file name, 3: line number, 4: PHP parse-error message. */
84            __( 'WordPress.com blocked the %1$s: the package contains a PHP parse error in %2$s (line %3$d): %4$s', 'jetpack-mu-wpcom' ),
85            'update' === $action ? __( 'update', 'jetpack-mu-wpcom' ) : __( 'install', 'jetpack-mu-wpcom' ),
86            basename( $first['file'] ),
87            (int) $first['line'],
88            (string) $first['message']
89        ),
90        array( 'errors' => $scan['errors'] )
91    );
92}
93
94/**
95 * Log a refused install/update to logstash. Best-effort.
96 *
97 * @param string $action     `install` or `update`.
98 * @param array  $hook_extra Hook payload from `upgrader_source_selection`.
99 * @param array  $scan       Result from `pcg_update_guard_scan_for_parse_errors()`.
100 * @param string $source     Extracted package directory (fallback slug source on installs,
101 *                           since `Plugin_Upgrader::install()` doesn't populate `hook_extra['plugin']`).
102 * @return void
103 */
104function pcg_update_guard_log_blocked( $action, array $hook_extra, array $scan, $source = '' ) {
105    $first = $scan['errors'][0];
106
107    $slug = (string) ( $hook_extra['plugin'] ?? ( $hook_extra['theme'] ?? '' ) );
108    if ( '' === $slug && '' !== $source ) {
109        $slug = basename( untrailingslashit( $source ) );
110    }
111
112    pcg_log_event(
113        'Update blocked',
114        array(
115            'action'      => (string) $action,
116            'slug'        => $slug,
117            // Basename only — absolute paths leak install layout.
118            'file'        => basename( (string) $first['file'] ),
119            'line'        => (int) $first['line'],
120            'reason'      => (string) $first['message'],
121            'error_count' => count( $scan['errors'] ),
122        )
123    );
124}
125
126/**
127 * Stash the slug/action of a blocked install or update so the next admin
128 * page render can offer a force-retry button. Only logged-in users with
129 * `update_plugins` get a stash (the only callers who'll see the notice).
130 *
131 * @param string $action     `install` or `update`.
132 * @param array  $hook_extra Hook payload from `upgrader_source_selection`.
133 * @param string $source     Extracted package directory — fallback slug source on installs,
134 *                           since `Plugin_Upgrader::install()` doesn't populate `hook_extra['plugin']`.
135 * @return void
136 */
137function pcg_update_guard_stash_retry_context( $action, array $hook_extra, $source = '' ) {
138    if ( ! is_user_logged_in() || ! current_user_can( pcg_update_guard_cap_for_action( $action ) ) ) {
139        return;
140    }
141    $slug = (string) ( $hook_extra['plugin'] ?? '' );
142    if ( '' === $slug && '' !== $source ) {
143        $slug = basename( untrailingslashit( $source ) );
144    }
145    if ( '' === $slug ) {
146        return;
147    }
148    set_transient(
149        'pcg_update_blocked_' . get_current_user_id(),
150        array(
151            'slug'   => $slug,
152            'action' => (string) $action,
153        ),
154        5 * MINUTE_IN_SECONDS
155    );
156}
157
158/**
159 * Render an admin notice that offers force-retry options after an update
160 * block. The transient is set by the upgrader filter; we consume it here.
161 */
162function pcg_update_guard_render_retry_notice() {
163    if ( ! is_user_logged_in() ) {
164        return;
165    }
166    $key = 'pcg_update_blocked_' . get_current_user_id();
167    $ctx = get_transient( $key );
168    if ( ! is_array( $ctx ) || empty( $ctx['slug'] ) ) {
169        return;
170    }
171    $action = (string) ( $ctx['action'] ?? '' );
172    if ( ! current_user_can( pcg_update_guard_cap_for_action( $action ) ) ) {
173        return;
174    }
175    delete_transient( $key );
176
177    $slug      = (string) $ctx['slug'];
178    $is_update = 'install' !== $action;
179    // One-shot retry only makes sense for updates: the original update
180    // request is replay-safe (the .org zip URL is reproducible). Installs
181    // from an uploaded zip aren't — there's no zip to replay — and even
182    // .org installs would need the user back on the Add Plugin page, so
183    // we only surface the bypass-then-retry path for installs.
184    $retry = $is_update
185        ? wp_nonce_url(
186            add_query_arg(
187                array(
188                    'action'    => 'upgrade-plugin',
189                    'plugin'    => $slug,
190                    'pcg_force' => '1',
191                ),
192                self_admin_url( 'update.php' )
193            ),
194            'upgrade-plugin_' . $slug
195        )
196        : '';
197    ?>
198    <div class="notice notice-warning">
199        <p>
200            <strong>
201                <?php
202                if ( $is_update ) {
203                    esc_html_e( 'WordPress.com blocked the last plugin update because the package failed PCG checks:', 'jetpack-mu-wpcom' );
204                } else {
205                    esc_html_e( 'WordPress.com blocked the last plugin install because the package failed PCG checks:', 'jetpack-mu-wpcom' );
206                }
207                ?>
208            </strong>
209            <code><?php echo esc_html( $slug ); ?></code>.
210            <?php esc_html_e( 'Try one of these overrides:', 'jetpack-mu-wpcom' ); ?>
211        </p>
212        <ul style="list-style:disc;padding-inline-start:24px;margin-block-end:0;">
213            <?php if ( $is_update ) : ?>
214                <?php $plugin_name = function_exists( 'pcg_guard_plugin_display_name' ) ? pcg_guard_plugin_display_name( $slug ) : ''; ?>
215                <li>
216                    <a href="<?php echo esc_url( $retry ); ?>" class="button-link">
217                        <?php
218                        if ( '' !== $plugin_name ) {
219                            printf(
220                                /* translators: %s: plugin display name. */
221                                esc_html__( 'Update %s anyway', 'jetpack-mu-wpcom' ),
222                                esc_html( $plugin_name )
223                            );
224                        } else {
225                            esc_html_e( 'Update anyway', 'jetpack-mu-wpcom' );
226                        }
227                        ?>
228                    </a>
229                </li>
230            <?php endif; ?>
231            <li><?php pcg_force_render_bypass_form(); ?></li>
232        </ul>
233    </div>
234    <?php
235}
236
237/**
238 * Tokenize every `.php` under $dir with TOKEN_PARSE and collect the failures.
239 * Bails out once the wall-clock budget is exceeded.
240 *
241 * @param string $dir Extracted package directory.
242 * @return array{errors:array<int,array{file:string,line:int,message:string}>,budget_exceeded:bool}
243 */
244function pcg_update_guard_scan_for_parse_errors( $dir ) {
245    $result = array(
246        'errors'          => array(),
247        'budget_exceeded' => false,
248    );
249    if ( '' === $dir || ! is_dir( $dir ) ) {
250        return $result;
251    }
252
253    $started_at = microtime( true );
254    $iter       = new RecursiveIteratorIterator(
255        new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS )
256    );
257    foreach ( $iter as $path => $file ) {
258        if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) {
259            continue;
260        }
261        if ( ! is_readable( (string) $path ) ) {
262            continue;
263        }
264        if ( ( microtime( true ) - $started_at ) > PCG_UPDATE_GUARD_BUDGET_SECONDS ) {
265            $result['budget_exceeded'] = true;
266            return $result;
267        }
268        $code = file_get_contents( (string) $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read inside a scan loop; WP_Filesystem is overkill here.
269        if ( false === $code ) {
270            continue;
271        }
272        try {
273            // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown -- called only for the ParseError it throws under TOKEN_PARSE; tokens themselves are unused.
274            token_get_all( $code, TOKEN_PARSE );
275        } catch ( \ParseError $e ) {
276            $result['errors'][] = array(
277                'file'    => (string) $path,
278                'line'    => $e->getLine(),
279                'message' => $e->getMessage(),
280            );
281        } catch ( \Throwable $e ) {
282            unset( $e );
283        }
284    }
285    return $result;
286}