PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 3.0.0
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v3.0.0
3.0.0 2.11.12 2.11.11 2.11.10 2.11.9 2.11.7 2.11.8 2.11.6 2.11.5 2.11.4 2.11.3 2.11.1 2.11.2 2.11.0 2.10.5 2.10.4 2.10.3 2.10.2 2.10.1 2.10.0 2.9.9 2.9.8 2.9.6 2.9.7 2.9.5 All 88 releases
vigilante / includes / class-self-repair.php

class-self-repair.php in Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… 3.0.0, at includes/class-self-repair.php

390 lines 15.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * One click repair of Vigilant's own files.
4 *
5 * Detecting tampering and leaving the person with "download a zip and upload
6 * it" is half a tool, so this reinstalls Vigilant from WordPress.org with the
7 * mechanism WordPress already uses for "Replace current with uploaded":
8 * Plugin_Upgrader::install() with overwrite_package, which empties the plugin
9 * folder (so injected files go with it), copies the clean one, and touches
10 * nothing else. Settings, tables and the activity log are kept, because
11 * uninstall.php never runs here.
12 *
13 * What it deliberately does NOT do:
14 *
15 * - It never installs the version the plugin header claims. Whoever changed
16 * the files can write that header, and a header saying 2.10.2 would turn
17 * this button into a downgrade to a version with public vulnerabilities.
18 * The version comes from WordPress.org and is refused if it is older than
19 * the one anchored for this installation.
20 * - The package address is built here from a fixed host and a version that
21 * matches a strict pattern. Nothing from the request, from the update
22 * transient or from the API response is used as a URL.
23 * - It cannot promise anything if the repair code itself was changed: that is
24 * written in SECURITY.md, and the check that does not depend on this server
25 * is still the one against WordPress.org from outside.
26 *
27 * @package Vigilante
28 * @since 3.0.0
29 */
30
31 // Prevent direct access
32 if ( ! defined( 'ABSPATH' ) ) {
33 exit;
34 }
35
36 /**
37 * Class Vigilante_Self_Repair
38 */
39 class Vigilante_Self_Repair {
40
41 /**
42 * admin-post action, and the nonce that guards it.
43 */
44 const ACTION = 'vigilante_repair_self';
45
46 /**
47 * Slug on WordPress.org. Not the folder name: the folder can be renamed,
48 * and this is what identifies the package to download.
49 */
50 const SLUG = 'vigilante';
51
52 /**
53 * Host that serves the official packages.
54 */
55 const PACKAGE_HOST = 'https://downloads.wordpress.org/plugin/';
56
57 /**
58 * Register the handler. It is an admin-post action and not a hidden admin
59 * page so inventario-superficie.php sees it like any other entry point.
60 */
61 public static function init() {
62 add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle' ) );
63 }
64
65 /**
66 * Whether this person can repair Vigilant on this site.
67 *
68 * update_plugins is what WordPress denies when file changes are turned off
69 * (DISALLOW_FILE_MODS) and, on a network, to everyone but a super
70 * administrator: both are exactly the answer this button needs.
71 *
72 * @return bool
73 */
74 public static function can_repair() {
75 return current_user_can( 'manage_options' )
76 && current_user_can( 'update_plugins' )
77 && self::folder_is_the_distributed_one();
78 }
79
80 /**
81 * Whether this copy lives in the folder the official package installs into.
82 *
83 * Plugin_Upgrader::install() puts the package where the package says: the
84 * destination is WP_PLUGIN_DIR plus the folder name inside the zip
85 * (wp-admin/includes/class-wp-upgrader.php:642), and the zip of
86 * WordPress.org always carries "vigilante". On an installation whose folder
87 * was renamed, repairing would leave a clean copy beside the one that is
88 * running, report success, and change nothing of what was tampered with.
89 * So it is not offered there: the box gives the steps by hand instead.
90 *
91 * @param string|null $basename Plugin basename, read from the constant when null.
92 * @return bool
93 */
94 public static function folder_is_the_distributed_one( $basename = null ) {
95 if ( null === $basename ) {
96 $basename = defined( 'VIGILANTE_PLUGIN_BASENAME' ) ? VIGILANTE_PLUGIN_BASENAME : '';
97 }
98 $basename = (string) $basename;
99 if ( '' === $basename || false === strpos( $basename, '/' ) ) {
100 return false;
101 }
102 return self::SLUG === dirname( $basename );
103 }
104
105 /**
106 * URL of the confirmation screen.
107 *
108 * @return string
109 */
110 public static function action_url() {
111 return wp_nonce_url( admin_url( 'admin-post.php?action=' . self::ACTION ), self::ACTION );
112 }
113
114 /**
115 * Version WordPress.org distributes right now, or an empty string when it
116 * cannot be used (no answer, a version that is not a version, or one older
117 * than what this installation already anchored).
118 *
119 * @return string
120 */
121 public static function target_version() {
122 $latest = self::wporg_version();
123 if ( '' === $latest ) {
124 return '';
125 }
126 $floor = self::floor_version();
127 if ( '' !== $floor && version_compare( $latest, $floor, '<' ) ) {
128 return '';
129 }
130 return $latest;
131 }
132
133 /**
134 * Lowest version this repair may install: whatever this installation has
135 * anchored, or the version on disk when there is no state yet.
136 *
137 * @return string
138 */
139 private static function floor_version() {
140 $state = get_option( Vigilante_Self_Integrity::STATE_OPTION, array() );
141 $anchored = ( is_array( $state ) && ! empty( $state['version'] ) ) ? (string) $state['version'] : '';
142 if ( self::is_version( $anchored ) ) {
143 return $anchored;
144 }
145 return self::is_version( VIGILANTE_VERSION ) ? VIGILANTE_VERSION : '';
146 }
147
148 /**
149 * Ask WordPress.org for the current stable version.
150 *
151 * @return string
152 */
153 private static function wporg_version() {
154 if ( ! function_exists( 'plugins_api' ) ) {
155 require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
156 }
157 $info = plugins_api(
158 'plugin_information',
159 array(
160 'slug' => self::SLUG,
161 'fields' => array(
162 'sections' => false,
163 'screenshots' => false,
164 'versions' => false,
165 'reviews' => false,
166 'banners' => false,
167 'icons' => false,
168 'contributors' => false,
169 ),
170 )
171 );
172 if ( is_wp_error( $info ) || empty( $info->version ) ) {
173 return '';
174 }
175 $version = (string) $info->version;
176 return self::is_version( $version ) ? $version : '';
177 }
178
179 /**
180 * A version is three or four numbers and nothing else. Anything else never
181 * reaches the address of a package.
182 *
183 * @param string $version Candidate.
184 * @return bool
185 */
186 private static function is_version( $version ) {
187 return (bool) preg_match( '/^[0-9]+(\.[0-9]+){1,3}$/', (string) $version );
188 }
189
190 /**
191 * Address of the official package for a version.
192 *
193 * @param string $version Version, already checked by is_version().
194 * @return string
195 */
196 private static function package_url( $version ) {
197 return self::PACKAGE_HOST . self::SLUG . '.' . $version . '.zip';
198 }
199
200 /**
201 * admin-post entry point: a GET shows what is going to happen, and only a
202 * POST does it.
203 */
204 public static function handle() {
205 if ( ! self::can_repair() ) {
206 wp_die(
207 esc_html__( 'You are not allowed to repair Vigilant on this site.', 'vigilante' ),
208 esc_html__( 'Repair Vigilant', 'vigilante' ),
209 array( 'response' => 403 )
210 );
211 }
212 check_admin_referer( self::ACTION );
213
214 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The nonce is checked above, for both methods.
215 $confirmed = ( isset( $_POST['vigilante_repair_confirm'] ) );
216
217 $version = self::target_version();
218 if ( $confirmed ) {
219 self::run( $version );
220 return;
221 }
222 self::confirm_screen( $version );
223 }
224
225 /**
226 * Screen that asks before touching anything.
227 *
228 * @param string $version Version that would be installed, may be empty.
229 */
230 private static function confirm_screen( $version ) {
231 self::screen_start( __( 'Repair Vigilant', 'vigilante' ) );
232
233 if ( '' === $version ) {
234 self::render_unavailable();
235 self::screen_end();
236 return;
237 }
238
239 echo '<p>';
240 printf(
241 /* translators: %s: plugin version, like 3.0.0 */
242 esc_html__( 'This downloads Vigilant %s from WordPress.org and replaces the files of the plugin with that copy.', 'vigilante' ),
243 esc_html( $version )
244 );
245 echo '</p>';
246 echo '<ul style="list-style:disc;margin-left:20px;">';
247 echo '<li>' . esc_html__( 'Your settings, your tables and your activity log are kept: nothing is uninstalled and nothing is deactivated.', 'vigilante' ) . '</li>';
248 echo '<li>' . esc_html__( 'Files that are not part of Vigilant but live in its folder are removed with the rest of the folder.', 'vigilante' ) . '</li>';
249 echo '<li>' . esc_html__( 'If your server needs FTP credentials to change files, WordPress asks for them, as it does for any update.', 'vigilante' ) . '</li>';
250 echo '<li>' . esc_html__( 'When it is done, Vigilant checks the new files against WordPress.org and against its manifest, and reports the result in File Integrity.', 'vigilante' ) . '</li>';
251 echo '</ul>';
252 echo '<p>' . esc_html__( 'If your site is deployed with Git, Composer or a sync tool, repair it there instead: your next deployment would put the changed files back.', 'vigilante' ) . '</p>';
253
254 echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php?action=' . self::ACTION ) ) . '">';
255 wp_nonce_field( self::ACTION );
256 echo '<input type="hidden" name="vigilante_repair_confirm" value="1" />';
257 echo '<p>';
258 echo '<button type="submit" class="button button-primary">' . esc_html__( 'Repair Vigilant now', 'vigilante' ) . '</button> ';
259 echo '<a class="button" href="' . esc_url( self::back_url() ) . '">' . esc_html__( 'Cancel', 'vigilante' ) . '</a>';
260 echo '</p>';
261 echo '</form>';
262
263 self::screen_end();
264 }
265
266 /**
267 * Do it.
268 *
269 * @param string $version Version to install.
270 */
271 private static function run( $version ) {
272 self::screen_start( __( 'Repair Vigilant', 'vigilante' ) );
273
274 if ( '' === $version ) {
275 self::render_unavailable();
276 self::screen_end();
277 return;
278 }
279
280 require_once ABSPATH . 'wp-admin/includes/file.php';
281 require_once ABSPATH . 'wp-admin/includes/misc.php';
282 require_once ABSPATH . 'wp-admin/includes/plugin.php';
283 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
284
285 $basename = defined( 'VIGILANTE_PLUGIN_BASENAME' ) ? VIGILANTE_PLUGIN_BASENAME : '';
286 $was_active = $basename && is_plugin_active( $basename );
287 $was_network = $basename && is_plugin_active_for_network( $basename );
288
289 $skin = new WP_Upgrader_Skin(
290 array(
291 'url' => admin_url( 'admin-post.php?action=' . self::ACTION . '&_wpnonce=' . wp_create_nonce( self::ACTION ) ),
292 'nonce' => self::ACTION,
293 )
294 );
295 $upgrader = new Plugin_Upgrader( $skin );
296 $result = $upgrader->install( self::package_url( $version ), array( 'overwrite_package' => true ) );
297
298 /*
299 * Someone opening the plugins screen during the seconds the folder is
300 * being replaced makes WordPress deactivate a plugin whose file is not
301 * there (validate_active_plugins()). Silent, so the activation routine
302 * does not run again: the plugin was never meant to stop.
303 */
304 if ( $was_active && $basename && ! is_plugin_active( $basename ) ) {
305 activate_plugin( $basename, '', $was_network, true );
306 }
307
308 if ( is_wp_error( $result ) ) {
309 echo '<p><strong>' . esc_html__( 'The repair could not be completed.', 'vigilante' ) . '</strong> ' . esc_html( $result->get_error_message() ) . '</p>';
310 self::render_manual_steps();
311 } elseif ( ! $result ) {
312 echo '<p><strong>' . esc_html__( 'The repair could not be completed.', 'vigilante' ) . '</strong></p>';
313 self::render_manual_steps();
314 } else {
315 echo '<p><strong>' . esc_html__( 'Vigilant files have been replaced with the copy from WordPress.org.', 'vigilante' ) . '</strong> '
316 . esc_html__( 'Vigilant checked them again right away: File Integrity shows the result.', 'vigilante' ) . '</p>';
317 }
318
319 echo '<p><a class="button button-primary" href="' . esc_url( self::back_url() ) . '">' . esc_html__( 'Back to File Integrity', 'vigilante' ) . '</a></p>';
320 self::screen_end();
321 }
322
323 /**
324 * When WordPress.org cannot be asked, or answers with a version older than
325 * the one anchored here, there is nothing safe to install: say why, and
326 * give the steps by hand.
327 */
328 private static function render_unavailable() {
329 echo '<p><strong>' . esc_html__( 'Vigilant cannot repair itself right now.', 'vigilante' ) . '</strong> '
330 . esc_html__( 'WordPress.org did not answer with a version that can be installed on this site, either because your server could not reach it or because the version it offers is older than the one anchored here.', 'vigilante' ) . '</p>';
331 self::render_manual_steps();
332 }
333
334 /**
335 * The steps by hand, from the shared catalogue.
336 */
337 private static function render_manual_steps() {
338 echo '<ol style="margin-left:20px;">';
339 foreach ( Vigilante_Self_Integrity_Guidance::manual_steps( 'general' ) as $step ) {
340 echo '<li>' . esc_html( $step ) . '</li>';
341 }
342 echo '</ol>';
343 }
344
345 /**
346 * Where the buttons of these screens go back to.
347 *
348 * @return string
349 */
350 private static function back_url() {
351 return admin_url( 'admin.php?page=vigilante&tab=file-integrity#vigilante-section-fi-self' );
352 }
353
354 /**
355 * Minimal admin chrome, the same one core uses for the screens that run an
356 * upgrader.
357 *
358 * @param string $title Screen title.
359 */
360 private static function screen_start( $title ) {
361 if ( ! function_exists( 'iframe_header' ) ) {
362 require_once ABSPATH . 'wp-admin/includes/template.php';
363 }
364 /*
365 * admin-post.php leaves $hook_suffix unset, and iframe_header() fires
366 * admin_enqueue_scripts with it: a null there is a fatal error in any
367 * plugin that declares a string parameter for that hook (measured with
368 * Plugin Check installed). Give the screen a name before printing it.
369 */
370 if ( empty( $GLOBALS['hook_suffix'] ) || ! is_string( $GLOBALS['hook_suffix'] ) ) {
371 $GLOBALS['hook_suffix'] = 'vigilante_page_repair';
372 }
373 if ( function_exists( 'set_current_screen' ) ) {
374 set_current_screen( $GLOBALS['hook_suffix'] );
375 }
376 iframe_header( $title );
377 echo '<div class="wrap" style="margin:20px;">';
378 echo '<h1>' . esc_html( $title ) . '</h1>';
379 }
380
381 /**
382 * Close what screen_start() opened.
383 */
384 private static function screen_end() {
385 echo '</div>';
386 iframe_footer();
387 exit;
388 }
389 }
390