PluginProbe
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… / 2.11.7
Vigilant – 100% Free Security Suite: Firewall, 2FA, Login, Headers, Scanner… v2.11.7
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 2.9.4 2.9.3 All 86 releases
vigilante / includes / class-wpconfig-security.php

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

769 lines 27.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WP-Config Security Class
4 *
5 * Manages wp-config.php security constants with MULTIPLE safety checks
6 * Uses comment/uncomment strategy to handle existing constants
7 *
8 * @package Vigilante
9 */
10
11 // Prevent direct access
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Class Vigilante_Wpconfig_Security
18 *
19 * Applies security constants to wp-config.php
20 */
21 class Vigilante_Wpconfig_Security {
22
23 /**
24 * Settings instance
25 *
26 * @var Vigilante_Settings
27 */
28 private $settings;
29
30 /**
31 * Security options
32 *
33 * @var array
34 */
35 private $options;
36
37 /**
38 * Path to wp-config.php
39 *
40 * @var string
41 */
42 private $wpconfig_path;
43
44 /**
45 * Marker for our constants
46 */
47 const MARKER_START = '/* BEGIN Vigilante Security Constants */';
48 const MARKER_END = '/* END Vigilante Security Constants */';
49
50 /**
51 * Marker for commented original lines
52 */
53 const ORIGINAL_MARKER = '// [VIGILANTE_ORIGINAL] ';
54
55 /**
56 * Old plugin markers to clean
57 */
58 const OLD_MARKER_START = '/* BEGIN AyudaWP Security Constants */';
59 const OLD_MARKER_END = '/* END AyudaWP Security Constants */';
60
61 /**
62 * Minimum valid wp-config.php size in bytes
63 */
64 const MIN_CONFIG_SIZE = 1000;
65
66 /**
67 * Constants managed by this plugin
68 *
69 * @var array
70 */
71 private $managed_constants = array(
72 'DISALLOW_FILE_EDIT',
73 'DISALLOW_FILE_MODS',
74 'FORCE_SSL_ADMIN',
75 'FORCE_SSL_LOGIN',
76 'WP_DEBUG',
77 'WP_DEBUG_LOG',
78 'WP_DEBUG_DISPLAY',
79 'SCRIPT_DEBUG',
80 'DISABLE_WP_CRON',
81 );
82
83 /**
84 * Constructor
85 *
86 * @param Vigilante_Settings $settings Settings instance.
87 */
88 public function __construct( $settings ) {
89 $this->settings = $settings;
90 $this->options = $settings->get_section( 'wp_hardening' );
91 $this->wpconfig_path = ABSPATH . 'wp-config.php';
92 }
93
94 /**
95 * Apply security constants to wp-config.php
96 *
97 * @return bool|WP_Error
98 */
99 public function apply_security_constants() {
100 // Safety check 0: on a network this file belongs to the main site
101 if ( ! Vigilante_Settings::can_write_shared_files() ) {
102 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
103 }
104
105 // Safety check 1: File must exist and be writable
106 if ( ! $this->is_wpconfig_writable() ) {
107 return new WP_Error( 'not_writable', __( 'wp-config.php is not writable', 'vigilante' ) );
108 }
109
110 // Safety check 2: the file must look whole BEFORE any modification
111 $check_result = $this->check_before_write();
112 if ( is_wp_error( $check_result ) ) {
113 return $check_result;
114 }
115
116 // First clean up old plugin constants
117 $this->remove_old_constants();
118
119 // Restore any previously commented constants (clean slate for upgrades)
120 // This ensures constants no longer managed by current version get uncommented
121 $this->uncomment_original_constants();
122
123 // Comment out existing managed constants
124 $comment_result = $this->comment_existing_constants();
125 if ( is_wp_error( $comment_result ) ) {
126 return $comment_result;
127 }
128
129 // Generate and write our constants block
130 $constants = $this->generate_constants();
131 $result = $this->write_constants( $constants );
132
133 // Regenerate critical file baseline so the integrity scan does not
134 // flag our own modifications as unauthorized changes.
135 if ( true === $result ) {
136 // Record the block as Vigilant's own first: the baseline refreshed
137 // below leaves out of its hash only the blocks recorded this way.
138 if ( class_exists( 'Vigilante_File_Integrity' ) ) {
139 Vigilante_File_Integrity::remember_owned_block( 'wp-config.php', self::MARKER_START, rtrim( $constants, "\n" ) );
140 }
141
142 /**
143 * Fires after Vigilante successfully writes to wp-config.php.
144 * Used by the file integrity module to update the baseline hash.
145 */
146 do_action( 'vigilante_critical_file_written', 'wp-config.php' );
147 }
148
149 return $result;
150 }
151
152 /**
153 * Check wp-config.php before modifying it
154 *
155 * Until 2.11.6 this also stored the whole file in vigilante_wpconfig_backup,
156 * and with it the database password and the authentication keys and salts.
157 * Nothing ever read that copy back to restore anything: the checks are what
158 * protected the file, and the copy only put its secrets in the options table.
159 *
160 * @return bool|WP_Error
161 */
162 private function check_before_write() {
163 if ( ! file_exists( $this->wpconfig_path ) ) {
164 return new WP_Error( 'no_config', __( 'wp-config.php does not exist', 'vigilante' ) );
165 }
166
167 $content = $this->read_file_directly( $this->wpconfig_path );
168
169 if ( false === $content || strlen( $content ) < self::MIN_CONFIG_SIZE ) {
170 return new WP_Error( 'invalid_config', __( 'wp-config.php appears invalid or too small', 'vigilante' ) );
171 }
172
173 // Validate it looks like a real wp-config.php
174 if ( ! $this->validate_wpconfig_content( $content ) ) {
175 return new WP_Error( 'invalid_config', __( 'wp-config.php does not appear to be a valid WordPress configuration file', 'vigilante' ) );
176 }
177
178 if ( ! self::constants_blocks_are_whole( $content ) ) {
179 return new WP_Error( 'block_incomplete', __( 'A Vigilant block in wp-config.php is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
180 }
181
182 return true;
183 }
184
185 /**
186 * Whether no constants block in wp-config.php is left without its END
187 *
188 * A BEGIN with no END after it, or a second BEGIN before the END, means the
189 * block cannot be found whole. Removing it would still rewrite the file and
190 * uncomment the original constants around it while the block keeps defining
191 * them, and writing a new one would leave the broken block in place for the
192 * next removal to pair with the new END, taking everything in between. So
193 * the file is left as it is, and the block keeps working until someone
194 * removes it by hand. An END with no BEGIN before it is left out of the
195 * question: each BEGIN is paired with the next END and a stray END is left
196 * alone.
197 *
198 * Until 2.11.6 only deactivation asked this, from its own copy of the check.
199 *
200 * @since 2.11.6
201 *
202 * @param string $content wp-config.php content.
203 * @return bool
204 */
205 private static function constants_blocks_are_whole( $content ) {
206 preg_match_all( '/' . preg_quote( self::MARKER_START, '/' ) . '|' . preg_quote( self::MARKER_END, '/' ) . '/', $content, $markers );
207
208 $inside = false;
209 foreach ( $markers[0] as $marker ) {
210 if ( self::MARKER_START === $marker ) {
211 if ( $inside ) {
212 return false;
213 }
214 $inside = true;
215 } else {
216 $inside = false;
217 }
218 }
219
220 return ! $inside;
221 }
222
223 /**
224 * Read file directly without WP_Filesystem (more reliable)
225 *
226 * @param string $path File path.
227 * @return string|false
228 */
229 private function read_file_directly( $path ) {
230 if ( ! file_exists( $path ) || ! is_readable( $path ) ) {
231 return false;
232 }
233 return file_get_contents( $path ); // phpcs:ignore
234 }
235
236 /**
237 * Validate that content looks like a real wp-config.php
238 *
239 * @param string $content File content.
240 * @return bool
241 */
242 private function validate_wpconfig_content( $content ) {
243 // Must contain PHP opening tag
244 if ( strpos( $content, '<?php' ) === false ) {
245 return false;
246 }
247
248 // Must contain database configuration
249 if ( strpos( $content, 'DB_NAME' ) === false ) {
250 return false;
251 }
252
253 if ( strpos( $content, 'DB_USER' ) === false ) {
254 return false;
255 }
256
257 if ( strpos( $content, 'DB_PASSWORD' ) === false ) {
258 return false;
259 }
260
261 // Must contain table prefix
262 if ( strpos( $content, '$table_prefix' ) === false ) {
263 return false;
264 }
265
266 return true;
267 }
268
269 /**
270 * Comment out existing managed constants in wp-config.php
271 *
272 * @return bool|WP_Error
273 */
274 private function comment_existing_constants() {
275 $content = $this->read_file_directly( $this->wpconfig_path );
276
277 if ( false === $content || ! $this->validate_wpconfig_content( $content ) ) {
278 return new WP_Error( 'read_failed', __( 'Could not read wp-config.php', 'vigilante' ) );
279 }
280
281 $modified = false;
282
283 foreach ( $this->managed_constants as $constant ) {
284 // Pattern to match define statements for this constant
285 // Matches: define( 'CONSTANT', value ); or define('CONSTANT', value);
286 // Does NOT match already commented lines (commented lines have // prefix before define)
287 $pattern = '/^(\s*)(define\s*\(\s*[\'"]' . preg_quote( $constant, '/' ) . '[\'"]\s*,\s*[^)]+\)\s*;)/m';
288
289 // Loop to comment ALL occurrences, not just the first
290 // wp-config.php files may have duplicate defines (e.g. multiple WP_DEBUG)
291 $safety = 0;
292 while ( preg_match( $pattern, $content, $matches ) && $safety < 20 ) {
293 $safety++;
294 $full_line = $matches[0];
295
296 // Already commented by us — no more uncommented matches possible
297 if ( strpos( $full_line, self::ORIGINAL_MARKER ) !== false ) {
298 break;
299 }
300
301 // Check if this line is inside our Vigilante block (skip it)
302 $marker_pos = strpos( $content, self::MARKER_START );
303 if ( $marker_pos !== false ) {
304 $line_pos = strpos( $content, $full_line );
305 $end_marker_pos = strpos( $content, self::MARKER_END );
306 if ( $line_pos > $marker_pos && $line_pos < $end_marker_pos ) {
307 break; // Inside our block, stop processing this constant
308 }
309 }
310
311 // Comment out this occurrence
312 $replacement = $matches[1] . self::ORIGINAL_MARKER . $matches[2];
313 $content = preg_replace( $pattern, $replacement, $content, 1 );
314 $modified = true;
315 }
316 }
317
318 if ( $modified ) {
319 // Validate BEFORE writing
320 if ( ! $this->validate_wpconfig_content( $content ) ) {
321 return new WP_Error( 'invalid_after_comment', __( 'wp-config.php would be invalid after commenting constants', 'vigilante' ) );
322 }
323
324 if ( ! $this->write_file_directly( $this->wpconfig_path, $content ) ) {
325 return new WP_Error( 'write_failed', __( 'Could not write to wp-config.php', 'vigilante' ) );
326 }
327 }
328
329 return true;
330 }
331
332 /**
333 * Uncomment original constants that were commented by us
334 *
335 * @return bool
336 */
337 private function uncomment_original_constants() {
338 $content = $this->read_file_directly( $this->wpconfig_path );
339
340 if ( false === $content ) {
341 return false;
342 }
343
344 // Find and uncomment lines marked with our original marker
345 $pattern = '/^(\s*)' . preg_quote( self::ORIGINAL_MARKER, '/' ) . '(.+)$/m';
346
347 if ( preg_match( $pattern, $content ) ) {
348 $content = preg_replace( $pattern, '$1$2', $content );
349
350 // Validate BEFORE writing
351 if ( ! $this->validate_wpconfig_content( $content ) ) {
352 return false;
353 }
354
355 return $this->write_file_directly( $this->wpconfig_path, $content );
356 }
357
358 return true;
359 }
360
361 /**
362 * Remove old Easy Vigilante constants from wp-config.php
363 *
364 * @return bool
365 */
366 public function remove_old_constants() {
367 $content = $this->read_file_directly( $this->wpconfig_path );
368
369 if ( false === $content || ! $this->validate_wpconfig_content( $content ) ) {
370 return false;
371 }
372
373 $modified = false;
374
375 // Remove old AyudaWP Security Constants block
376 $pattern = '/' . preg_quote( self::OLD_MARKER_START, '/' ) . '.*?' . preg_quote( self::OLD_MARKER_END, '/' ) . '\s*/s';
377 if ( preg_match( $pattern, $content ) ) {
378 $content = preg_replace( $pattern, '', $content );
379 $modified = true;
380 }
381
382 if ( $modified ) {
383 // Validate BEFORE writing
384 if ( ! $this->validate_wpconfig_content( $content ) ) {
385 return false;
386 }
387 return $this->write_file_directly( $this->wpconfig_path, $content );
388 }
389
390 return true;
391 }
392
393 /**
394 * Drop the integrity scan's record of the constants block once it is gone
395 *
396 * @since 2.11.5
397 */
398 private function forget_owned_block() {
399 if ( class_exists( 'Vigilante_File_Integrity' ) ) {
400 Vigilante_File_Integrity::forget_owned_blocks( 'wp-config.php', self::MARKER_START );
401 }
402 }
403
404 /**
405 * Remove our security constants from wp-config.php and restore originals
406 *
407 * @return bool|WP_Error
408 */
409 public function remove_constants() {
410 // On a network, a subsite deactivating the plugin must not strip the
411 // constants the main site put there for everyone.
412 if ( ! Vigilante_Settings::can_write_shared_files() ) {
413 return new WP_Error( 'network_not_owner', Vigilante_Settings::get_shared_files_notice() );
414 }
415
416 if ( ! file_exists( $this->wpconfig_path ) ) {
417 return true;
418 }
419
420 $content = $this->read_file_directly( $this->wpconfig_path );
421
422 if ( false === $content ) {
423 return false;
424 }
425
426 // A block that lost a marker cannot come out without cutting or
427 // duplicating what surrounds it, so the file is left as it is.
428 if ( ! self::constants_blocks_are_whole( $content ) ) {
429 return new WP_Error( 'block_incomplete', __( 'A Vigilant block in wp-config.php is missing one of its markers, so the file was left as it is.', 'vigilante' ) );
430 }
431
432 // If our markers don't exist, just try to uncomment originals
433 if ( strpos( $content, self::MARKER_START ) === false ) {
434 $this->forget_owned_block();
435 return $this->uncomment_original_constants();
436 }
437
438 // Validate before modification
439 if ( ! $this->validate_wpconfig_content( $content ) ) {
440 return false;
441 }
442
443 // Remove our section
444 $pattern = '/' . preg_quote( self::MARKER_START, '/' ) . '.*?' . preg_quote( self::MARKER_END, '/' ) . '\s*/s';
445 $new_content = preg_replace( $pattern, '', $content );
446
447 // CRITICAL: Validate result BEFORE writing
448 if ( ! $this->validate_wpconfig_content( $new_content ) ) {
449 // Something went wrong, don't write
450 return false;
451 }
452
453 // Clean up multiple empty lines
454 $new_content = preg_replace( '/\n{3,}/', "\n\n", $new_content );
455
456 // Write the file without our block
457 if ( ! $this->write_file_directly( $this->wpconfig_path, $new_content ) ) {
458 return false;
459 }
460
461 $this->forget_owned_block();
462
463 // Now uncomment the original constants
464 return $this->uncomment_original_constants();
465 }
466
467 /**
468 * Generate security constants block (without conditional checks)
469 *
470 * @return string
471 */
472 public function generate_constants() {
473 $constants = array();
474
475 $constants[] = self::MARKER_START;
476 $constants[] = '// Vigilante for WordPress - v' . VIGILANTE_VERSION;
477 $constants[] = '// Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC';
478 $constants[] = '// Note: Original constants (if any) are commented with [VIGILANTE_ORIGINAL] marker';
479 $constants[] = '// Each define() is wrapped in "if ( ! defined() )" so the block is safe on';
480 $constants[] = '// non-standard setups that pre-define WordPress constants before wp-config.php';
481 $constants[] = '// is parsed (would otherwise trigger a "Constant already defined" fatal).';
482 $constants[] = '';
483
484 // File editing/modification
485 if ( ! empty( $this->options['disallow_file_edit'] ) ) {
486 $constants[] = "// Disable file editing in admin";
487 $constants[] = "if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) { define( 'DISALLOW_FILE_EDIT', true ); }";
488 $constants[] = '';
489 }
490
491 if ( ! empty( $this->options['disallow_file_mods'] ) ) {
492 $constants[] = "// Disable file modifications (plugins/themes install/update)";
493 $constants[] = "if ( ! defined( 'DISALLOW_FILE_MODS' ) ) { define( 'DISALLOW_FILE_MODS', true ); }";
494 $constants[] = '';
495 }
496
497 // SSL settings
498 if ( ! empty( $this->options['force_ssl_admin'] ) ) {
499 $constants[] = "// Force SSL for admin";
500 $constants[] = "if ( ! defined( 'FORCE_SSL_ADMIN' ) ) { define( 'FORCE_SSL_ADMIN', true ); }";
501 $constants[] = '';
502 }
503
504 if ( ! empty( $this->options['force_ssl_login'] ) ) {
505 $constants[] = "// Force SSL for login";
506 $constants[] = "if ( ! defined( 'FORCE_SSL_LOGIN' ) ) { define( 'FORCE_SSL_LOGIN', true ); }";
507 $constants[] = '';
508 }
509
510 // Debug settings - generate when "Hide PHP errors from visitors" is unchecked (development mode)
511 if ( empty( $this->options['wp_debug'] ) ) {
512 $constants[] = "// Debug settings (enabled for development)";
513 $constants[] = "if ( ! defined( 'WP_DEBUG' ) ) { define( 'WP_DEBUG', true ); }";
514 $constants[] = "if ( ! defined( 'WP_DEBUG_LOG' ) ) { define( 'WP_DEBUG_LOG', true ); }";
515 $constants[] = "if ( ! defined( 'WP_DEBUG_DISPLAY' ) ) { define( 'WP_DEBUG_DISPLAY', false ); }";
516 $constants[] = "if ( ! defined( 'SCRIPT_DEBUG' ) ) { define( 'SCRIPT_DEBUG', false ); }";
517 $constants[] = '';
518 } else {
519 $constants[] = "// Debug disabled (production)";
520 $constants[] = "if ( ! defined( 'WP_DEBUG' ) ) { define( 'WP_DEBUG', false ); }";
521 $constants[] = '';
522 }
523
524 // Disable WordPress's built-in pseudo-cron (page-view trigger). Pairs with the
525 // .htaccess block from firewall.protect_wp_cron — this constant alone does NOT
526 // block external HTTP access to wp-cron.php, only the auto-spawn from front-end
527 // page views. Both pieces are needed for full coverage; both require a real
528 // server-side cron job calling wp-cron.php from CLI.
529 if ( ! empty( $this->options['disable_wp_cron'] ) ) {
530 $constants[] = "// Disable WordPress pseudo-cron (use real server-side cron instead)";
531 $constants[] = "if ( ! defined( 'DISABLE_WP_CRON' ) ) { define( 'DISABLE_WP_CRON', true ); }";
532 $constants[] = '';
533 }
534
535 $constants[] = self::MARKER_END;
536 $constants[] = '';
537
538 return implode( "\n", $constants );
539 }
540
541 /**
542 * Write constants to wp-config.php with multiple safety checks
543 *
544 * @param string $constants Constants block to write.
545 * @return bool|WP_Error
546 */
547 private function write_constants( $constants ) {
548 // SAFETY CHECK 1: Read file directly (not via WP_Filesystem which can fail)
549 $content = $this->read_file_directly( $this->wpconfig_path );
550
551 // SAFETY CHECK 2: Verify we got valid content
552 if ( false === $content || strlen( $content ) < self::MIN_CONFIG_SIZE ) {
553 return new WP_Error( 'read_failed', __( 'Could not read wp-config.php or file is too small', 'vigilante' ) );
554 }
555
556 // SAFETY CHECK 3: Validate it's a real wp-config.php
557 if ( ! $this->validate_wpconfig_content( $content ) ) {
558 return new WP_Error( 'invalid_config', __( 'wp-config.php does not appear to be valid', 'vigilante' ) );
559 }
560
561 // Store original for comparison
562 $original_content = $content;
563
564 // Remove existing Vigilante constants block
565 $pattern = '/' . preg_quote( self::MARKER_START, '/' ) . '.*?' . preg_quote( self::MARKER_END, '/' ) . '\s*/s';
566 $content = preg_replace( $pattern, '', $content );
567
568 // Remove old plugin constants block
569 $old_pattern = '/' . preg_quote( self::OLD_MARKER_START, '/' ) . '.*?' . preg_quote( self::OLD_MARKER_END, '/' ) . '\s*/s';
570 $content = preg_replace( $old_pattern, '', $content );
571
572 // Clean up multiple empty lines
573 $content = preg_replace( '/\n{3,}/', "\n\n", $content );
574
575 // SAFETY CHECK 4: Content should still be valid after removal
576 if ( ! $this->validate_wpconfig_content( $content ) ) {
577 return new WP_Error( 'invalid_after_clean', __( 'wp-config.php became invalid after cleanup', 'vigilante' ) );
578 }
579
580 // Find the best place to insert constants
581 $inserted = false;
582
583 // Method 1: Before "That's all, stop editing" comment
584 // This comment may be translated in localized wp-config files, so we use a broad pattern
585 // that matches the block comment immediately before the ABSPATH section.
586 // Known variants: "That's all, stop editing!", "C'est tout, ne touchez plus à ce qui suit",
587 // "Das war's, Schluss mit dem Editieren!", "Ya está. ¡Deja de editar!", etc.
588 $stop_editing_patterns = array(
589 // English (default)
590 "/(\/\*[^*]*That's all,?\s*stop editing[^*]*\*\/)/i",
591 // Broad match: any block comment on its own line(s) immediately before "Absolute path"
592 // This catches translated versions without needing every language
593 '/(\n\/\*[^\n*]{5,80}\*\/)\s*\n+\s*\/\*\*\s*Absolute path/i',
594 );
595
596 foreach ( $stop_editing_patterns as $pattern ) {
597 if ( preg_match( $pattern, $content, $matches ) ) {
598 $content = str_replace(
599 $matches[1],
600 $constants . "\n\n" . $matches[1],
601 $content
602 );
603 $inserted = true;
604 break;
605 }
606 }
607
608 // Method 2: Before "/** Absolute path to the WordPress directory" PHPDoc comment
609 // This is a code comment in wp-config-sample.php and is NOT translatable
610 if ( ! $inserted && preg_match( '/(\/\*\*\s*Absolute path to the WordPress directory)/i', $content, $matches ) ) {
611 $content = str_replace(
612 $matches[1],
613 $constants . "\n\n" . $matches[1],
614 $content
615 );
616 $inserted = true;
617 }
618
619 // Method 3: Before ABSPATH definition (language-independent)
620 if ( ! $inserted && preg_match( '/(if\s*\(\s*!\s*defined\s*\(\s*[\'"]ABSPATH[\'"]\s*\)\s*\))/i', $content, $matches ) ) {
621 $content = str_replace(
622 $matches[1],
623 $constants . "\n\n" . $matches[1],
624 $content
625 );
626 $inserted = true;
627 }
628
629 // Method 4: Before require_once wp-settings.php (language-independent)
630 if ( ! $inserted && preg_match( '/(require[_once\s\(]+[\'"]?.*wp-settings\.php[\'"]?\s*\)?;)/i', $content, $matches ) ) {
631 $content = str_replace(
632 $matches[1],
633 $constants . "\n\n" . $matches[1],
634 $content
635 );
636 $inserted = true;
637 }
638
639 // Method 5: After $table_prefix (safest fallback)
640 if ( ! $inserted && preg_match( '/(\$table_prefix\s*=\s*[\'"][^\'"]+[\'"]\s*;)/i', $content, $matches ) ) {
641 $content = str_replace(
642 $matches[1],
643 $matches[1] . "\n\n" . $constants,
644 $content
645 );
646 $inserted = true;
647 }
648
649 if ( ! $inserted ) {
650 return new WP_Error( 'insert_failed', __( 'Could not find a safe place to insert constants', 'vigilante' ) );
651 }
652
653 // SAFETY CHECK 5: Final content must still be valid
654 if ( ! $this->validate_wpconfig_content( $content ) ) {
655 return new WP_Error( 'invalid_final', __( 'Final wp-config.php would be invalid, aborting', 'vigilante' ) );
656 }
657
658 // SAFETY CHECK 6: Final content should be at least as big as original (minus our old block)
659 if ( strlen( $content ) < strlen( $original_content ) * 0.5 ) {
660 return new WP_Error( 'size_check_failed', __( 'Final wp-config.php would be too small, aborting', 'vigilante' ) );
661 }
662
663 // All checks passed, write the file
664 if ( $this->write_file_directly( $this->wpconfig_path, $content ) ) {
665 return true;
666 }
667
668 return new WP_Error( 'write_failed', __( 'Failed to write wp-config.php', 'vigilante' ) );
669 }
670
671 /**
672 * Write file directly (more reliable than WP_Filesystem)
673 *
674 * @param string $path File path.
675 * @param string $content Content to write.
676 * @return bool
677 */
678 private function write_file_directly( $path, $content ) {
679 return false !== file_put_contents( $path, $content ); // phpcs:ignore
680 }
681
682 /**
683 * Check if wp-config.php is writable
684 *
685 * @return bool
686 */
687 public function is_wpconfig_writable() {
688 if ( ! file_exists( $this->wpconfig_path ) ) {
689 return false;
690 }
691
692 // Initialize WP_Filesystem
693 global $wp_filesystem;
694 if ( ! function_exists( 'WP_Filesystem' ) ) {
695 require_once ABSPATH . 'wp-admin/includes/file.php';
696 }
697 WP_Filesystem();
698
699 if ( ! $wp_filesystem ) {
700 return false;
701 }
702
703 return $wp_filesystem->is_writable( $this->wpconfig_path );
704 }
705
706 /**
707 * Verify if our constants are currently active
708 *
709 * @return bool
710 */
711 public function are_constants_active() {
712 $content = $this->read_file_directly( $this->wpconfig_path );
713 if ( false === $content ) {
714 return false;
715 }
716 return strpos( $content, self::MARKER_START ) !== false;
717 }
718
719 /**
720 * Get current defined constants status
721 *
722 * @return array
723 */
724 public function get_constants_status() {
725 return array(
726 'DISALLOW_FILE_EDIT' => defined( 'DISALLOW_FILE_EDIT' ) ? DISALLOW_FILE_EDIT : null,
727 'DISALLOW_FILE_MODS' => defined( 'DISALLOW_FILE_MODS' ) ? DISALLOW_FILE_MODS : null,
728 'FORCE_SSL_ADMIN' => defined( 'FORCE_SSL_ADMIN' ) ? FORCE_SSL_ADMIN : null,
729 'FORCE_SSL_LOGIN' => defined( 'FORCE_SSL_LOGIN' ) ? FORCE_SSL_LOGIN : null,
730 'WP_DEBUG' => defined( 'WP_DEBUG' ) ? WP_DEBUG : null,
731 'WP_DEBUG_LOG' => defined( 'WP_DEBUG_LOG' ) ? WP_DEBUG_LOG : null,
732 'WP_DEBUG_DISPLAY' => defined( 'WP_DEBUG_DISPLAY' ) ? WP_DEBUG_DISPLAY : null,
733 );
734 }
735
736 /**
737 * Check if there are commented original constants
738 *
739 * @return bool
740 */
741 public function has_commented_originals() {
742 $content = $this->read_file_directly( $this->wpconfig_path );
743 if ( false === $content ) {
744 return false;
745 }
746 return strpos( $content, self::ORIGINAL_MARKER ) !== false;
747 }
748
749 /**
750 * Get list of commented original constants
751 *
752 * @return array
753 */
754 public function get_commented_originals() {
755 $content = $this->read_file_directly( $this->wpconfig_path );
756 if ( false === $content ) {
757 return array();
758 }
759
760 $originals = array();
761 $pattern = '/' . preg_quote( self::ORIGINAL_MARKER, '/' ) . '(.+)$/m';
762
763 if ( preg_match_all( $pattern, $content, $matches ) ) {
764 $originals = $matches[1];
765 }
766
767 return $originals;
768 }
769 }