settings = $settings; $this->processor = $processor; $this->cache = $cache; $this->logger = $logger; $this->scanner = $scanner; $this->notifier = $notifier; } /** * Register admin action hooks. * * @return void */ public function register_hooks(): void { add_action( 'admin_post_sri_shield_reset_cache', array( $this, 'handle_reset_cache' ) ); add_action( 'admin_post_sri_shield_regenerate_hashes', array( $this, 'handle_regenerate_hashes' ) ); add_action( 'admin_post_sri_shield_regenerate_url', array( $this, 'handle_regenerate_url' ) ); add_action( 'admin_post_sri_shield_exclude_url', array( $this, 'handle_exclude_url' ) ); add_action( 'admin_post_sri_shield_include_url', array( $this, 'handle_include_url' ) ); add_action( 'admin_post_sri_shield_generate_external', array( $this, 'handle_generate_external' ) ); add_action( 'admin_post_sri_shield_clear_logs', array( $this, 'handle_clear_logs' ) ); add_action( 'admin_post_sri_shield_export_logs', array( $this, 'handle_export_logs' ) ); add_action( 'admin_post_sri_shield_run_scan', array( $this, 'handle_run_scan' ) ); add_action( 'admin_post_sri_shield_clear_scans', array( $this, 'handle_clear_scans' ) ); add_action( 'admin_post_sri_shield_send_test_email', array( $this, 'handle_send_test_email' ) ); add_action( 'admin_post_sri_shield_review_changes', array( $this, 'handle_review_changes' ) ); } /** * Approve or dismiss selected pending hash changes. * * Approval replaces a trusted hash, so it requires the review capability, * a valid nonce, an explicit confirmation checkbox, and — per asset — the * exact proposed hash the administrator was shown. The processor then * re-fetches each asset and refuses approval if its content no longer * produces that hash. * * @return void */ public function handle_review_changes(): void { $this->authorize_action( 'sri_shield_review_changes' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $decision = isset( $_POST['review_action'] ) ? sanitize_key( wp_unslash( $_POST['review_action'] ) ) : ''; $selected = isset( $_POST['selected'] ) && is_array( $_POST['selected'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['selected'] ) ) : array(); $proposed = isset( $_POST['proposed'] ) && is_array( $_POST['proposed'] ) ? wp_unslash( $_POST['proposed'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Each value is sanitized below and then compared with hash_equals(). $confirmed = isset( $_POST['confirm_review'] ) && '1' === sanitize_text_field( wp_unslash( $_POST['confirm_review'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! in_array( $decision, array( 'approve', 'dismiss' ), true ) ) { $this->redirect_to_records( 'review_invalid' ); } $keys = array_slice( array_values( array_unique( array_filter( array_map( 'strval', $selected ), array( AssetIdentity::class, 'is_valid_key' ) ) ) ), 0, 500 ); if ( empty( $keys ) ) { $this->redirect_to_records( 'review_none' ); } if ( 'approve' === $decision && ! $confirmed ) { $this->redirect_to_records( 'review_confirm_required' ); } $user_id = get_current_user_id(); $done = 0; $failed = 0; $reason = ''; foreach ( $keys as $cache_key ) { if ( 'approve' === $decision ) { $expected = isset( $proposed[ $cache_key ] ) && is_string( $proposed[ $cache_key ] ) ? sanitize_text_field( $proposed[ $cache_key ] ) : ''; $result = $this->processor->approve_pending_change( $cache_key, $expected, $user_id ); } else { $result = $this->processor->dismiss_pending_change( $cache_key, $user_id ); } if ( ! empty( $result['success'] ) ) { ++$done; continue; } ++$failed; if ( '' === $reason ) { $reason = sanitize_key( (string) ( $result['code'] ?? 'failed' ) ); } } $this->redirect_to_records( 'approve' === $decision ? 'review_approved' : 'review_dismissed', array( 'done' => $done, 'failed' => $failed, 'reason' => $reason, ) ); } /** * Run a full website SRI scan on demand. * * @return void */ public function handle_run_scan(): void { $this->authorize_action( 'sri_shield_run_scan' ); $summary = $this->scanner->run(); // Keep only the most recent runs so the scans table stays bounded. \SRIShield\Includes\Core\Database::prune_scan_runs( (int) apply_filters( 'sri_shield_max_scan_runs', 20 ) ); $this->logger->debug( 'Manual website scan completed', array( 'total' => (int) ( $summary['total'] ?? 0 ), 'missing' => (int) ( $summary['missing'] ?? 0 ), 'malformed' => (int) ( $summary['malformed'] ?? 0 ), 'mismatch' => (int) ( $summary['mismatch'] ?? 0 ), 'crossorigin' => (int) ( $summary['crossorigin'] ?? 0 ), 'unreachable' => (int) ( $summary['unreachable'] ?? 0 ), ) ); $this->redirect_to_page( 'sri-shield-scanner', 'scan_completed', array( 'scanned' => (int) ( $summary['total'] ?? 0 ), 'missing' => (int) ( $summary['missing'] ?? 0 ) + (int) ( $summary['malformed'] ?? 0 ), 'mismatch' => (int) ( $summary['mismatch'] ?? 0 ), 'other' => (int) ( $summary['crossorigin'] ?? 0 ) + (int) ( $summary['unreachable'] ?? 0 ), ) ); } /** * Clear all stored scan results. * * @return void */ public function handle_clear_scans(): void { $this->authorize_action( 'sri_shield_clear_scans' ); \SRIShield\Includes\Core\Database::delete_scans(); $this->redirect_to_page( 'sri-shield-scanner', 'scans_cleared' ); } /** * Send a test tamper-notification email to the configured recipient. * * @return void */ public function handle_send_test_email(): void { $this->authorize_action( 'sri_shield_send_test_email' ); $result = $this->notifier->send_test(); $this->redirect_to_page( 'sri-shield-notifications', ! empty( $result['success'] ) ? 'test_email_sent' : 'test_email_failed' ); } /** * Handle cache reset request. * * @return void */ public function handle_reset_cache(): void { $this->authorize_action( 'sri_shield_reset_cache' ); // Trusted hashes and pending reviews are deliberately kept: wiping them // would let the next page load trust the current content of a changed // asset without any review. $removed = $this->cache->clear_untrusted(); $this->logger->debug( 'Cache reset initiated from settings screen', array( 'removed' => $removed ) ); $this->redirect_with_notice( 'cache_reset', array( 'removed' => $removed ) ); } /** * Handle the "Re-check Hashes" request for every registered asset. * * @return void */ public function handle_regenerate_hashes(): void { $this->authorize_action( 'sri_shield_regenerate_hashes' ); $report = $this->processor->regenerate_known_assets(); $counts = array( 'processed' => (int) ( $report['processed'] ?? 0 ), 'generated' => (int) ( $report['generated'] ?? 0 ), 'unchanged' => (int) ( $report['unchanged'] ?? 0 ), 'changed' => (int) ( $report['changed'] ?? 0 ), 'errors' => (int) ( $report['errors'] ?? 0 ), ); $this->logger->debug( 'Manual hash re-check completed', $counts ); $this->redirect_with_notice( 'regenerated', $counts ); } /** * Re-check the hash of a single stored URL. * * @return void */ public function handle_regenerate_url(): void { $this->authorize_action( 'sri_shield_regenerate_url' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $cache_key = isset( $_POST['cache_key'] ) ? sanitize_text_field( wp_unslash( $_POST['cache_key'] ) ) : ''; $report = $this->processor->recheck_by_cache_key( $cache_key ); $notices = array( 'enrolled' => 'url_regenerated', 'unchanged' => 'url_unchanged', 'changed' => 'url_changed', ); $state = (string) ( $report['state'] ?? 'error' ); $this->redirect_to_records( ! empty( $report['success'] ) && isset( $notices[ $state ] ) ? $notices[ $state ] : 'url_regenerate_failed' ); } /** * Exclude a single URL from SRI processing. * * @return void */ public function handle_exclude_url(): void { $this->authorize_action( 'sri_shield_exclude_url' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; if ( '' !== $url ) { $this->settings->exclude_url( $url ); $this->logger->debug( 'URL excluded from settings screen', array( 'url' => $url ) ); } $this->redirect_to_records( 'url_excluded' ); } /** * Re-include a previously excluded URL. * * @return void */ public function handle_include_url(): void { $this->authorize_action( 'sri_shield_include_url' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; if ( '' !== $url ) { $this->settings->include_url( $url ); $this->logger->debug( 'URL re-included from settings screen', array( 'url' => $url ) ); } $this->redirect_to_records( 'url_included' ); } /** * Generate and store a hash for an external/third-party URL. * * @return void */ public function handle_generate_external(): void { $this->authorize_action( 'sri_shield_generate_external' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $url = isset( $_POST['sri_external_url'] ) ? esc_url_raw( wp_unslash( $_POST['sri_external_url'] ) ) : ''; $algorithm = isset( $_POST['sri_external_algorithm'] ) ? sanitize_key( wp_unslash( $_POST['sri_external_algorithm'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Missing $report = $this->processor->generate_for_external_url( $url, $algorithm ); $notices = array( 'enrolled' => 'external_ok', 'unchanged' => 'external_unchanged', 'changed' => 'external_changed', ); $state = (string) ( $report['state'] ?? 'error' ); $this->redirect_to_records( ! empty( $report['success'] ) && isset( $notices[ $state ] ) ? $notices[ $state ] : 'external_failed' ); } /** * Clear all stored log entries. * * @return void */ public function handle_clear_logs(): void { $this->authorize_action( 'sri_shield_clear_logs' ); \SRIShield\Includes\Core\Database::delete_logs(); $this->redirect_to_page( 'sri-shield-logs', 'logs_cleared' ); } /** * Stream stored logs as a downloadable CSV file. * * @return void */ public function handle_export_logs(): void { $this->authorize_action( 'sri_shield_export_logs' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in authorize_action(). $level = isset( $_POST['level'] ) ? sanitize_key( wp_unslash( $_POST['level'] ) ) : ''; if ( ! in_array( $level, array( 'error', 'warning', 'info', 'debug' ), true ) ) { $level = ''; } $logs = \SRIShield\Includes\Core\Database::query_logs( 100000, $level ); $filename = 'sri-shield-logs-' . ( '' !== $level ? $level . '-' : '' ) . gmdate( 'Ymd-His' ) . '.csv'; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a CSV download to the client, not a filesystem write. $handle = fopen( 'php://output', 'w' ); if ( false === $handle ) { exit; } // UTF-8 BOM so spreadsheet apps detect encoding correctly. // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writing to the output stream, not the filesystem. fwrite( $handle, "\xEF\xBB\xBF" ); fputcsv( $handle, array( 'ID', 'Time', 'Level', 'Message', 'Context' ) ); foreach ( $logs as $log ) { if ( ! is_array( $log ) ) { continue; } fputcsv( $handle, array( $this->csv_safe( (string) ( $log['id'] ?? '' ) ), $this->csv_safe( (string) ( $log['created_at'] ?? '' ) ), $this->csv_safe( (string) ( $log['level'] ?? '' ) ), $this->csv_safe( (string) ( $log['message'] ?? '' ) ), $this->csv_safe( (string) ( $log['context'] ?? '' ) ), ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the output stream. fclose( $handle ); exit; } /** * Neutralize a value before it is written to a CSV cell. * * Spreadsheet applications treat a leading =, +, -, @, tab, or carriage * return as the start of a formula. Log context can contain attacker- * influenced strings (e.g. remote asset URLs), so any such cell is prefixed * with a single quote to force literal text interpretation. * * @param string $value Raw cell value. * @return string */ private function csv_safe( string $value ): string { if ( '' !== $value && 1 === preg_match( '/^[=+\-@\t\r]/', $value ) ) { return "'" . $value; } return $value; } /** * Verify capability and nonce for action. * * @param string $nonce_action Nonce action key. * @return void */ private function authorize_action( string $nonce_action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to perform this action.', 'sri-shield' ) ); } check_admin_referer( $nonce_action ); } /** * Redirect back to plugin settings page with status args. * * @param string $notice Notice code. * @param array $args Additional query args. * @return void */ private function redirect_with_notice( string $notice, array $args = array() ): void { $this->redirect_to_page( 'sri-shield', $notice, $args ); } /** * Redirect back to the hash-records page with a status notice. * * @param string $notice Notice code. * @param array $args Additional query args. * @return void */ private function redirect_to_records( string $notice, array $args = array() ): void { $this->redirect_to_page( 'sri-shield-records', $notice, $args ); } /** * Redirect back to a plugin admin page with status args. * * @param string $page Page slug. * @param string $notice Notice code. * @param array $args Additional query args. * @return void */ private function redirect_to_page( string $page, string $notice, array $args = array() ): void { $query = array_merge( array( 'page' => sanitize_key( $page ), 'sri_shield_notice' => sanitize_key( $notice ), ), $args ); $url = add_query_arg( $query, admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } }