commit df0f2fccb8954d392074c4e046dafd75ce1fa90e Author: root Date: Sun Aug 9 13:40:16 2026 +0000 ARGUS WordPress Defence 1.0.0 — first production release Automatic WordPress security: local firewall, malware and file-integrity scanning, vulnerability protection, quarantine, scheduled backups, an optional page cache, and automatic global threat intelligence. See README.md for installation, update, and uninstall instructions. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3f7e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.log +node_modules/ +vendor/ +*.zip +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8acb734 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# ARGUS WordPress Defence + +Automatic WordPress security. A local firewall, malware and file-integrity scanning, +vulnerability protection, and scheduled backups — protecting your site with zero manual +configuration required. + +**Version:** 1.0.0 +**Requires:** WordPress 6.0+, PHP 7.4+ +**License:** GPLv2 or later + +## What it does + +- **Firewall** — blocks malicious requests (SQL injection, cross-site scripting, and more) + before they reach your site. +- **Scanner** — regularly checks WordPress core, plugins, and themes for suspicious files + and integrity changes, and quarantines confirmed threats instead of just reporting them. +- **Vulnerability Protection** — checks your installed plugins, themes, and WordPress core + against known security issues. +- **Backups** — scheduled recovery points for your site's database and files, stored + outside the web root. +- **Cache & Performance** — an optional page cache that can make your site faster, built + to never interfere with the firewall or bans. +- **Global Threat Intelligence** — automatically connected, no setup required. + +Everything works out of the box. There is nothing to configure to get protected. + +## Installation + +1. Download the latest release ZIP (see [Releases](#releases) below). +2. In your WordPress admin: **Plugins → Add New → Upload Plugin**, choose the downloaded + ZIP, then **Install Now**. +3. Click **Activate**. + +That's it — ARGUS Defence begins protecting your site automatically. A new **ARGUS +Defence** menu appears in your wp-admin sidebar with an overview of your site's +protection status. + +## Updating + +Automatic in-dashboard updates require an update channel to be configured for your +deployment (`ARGUS_WPD_UPDATE_MANIFEST_URL` and `ARGUS_WPD_UPDATE_PUBLIC_KEY` in +`wp-config.php`). Without one configured, update the plugin the same way you installed +it: download the new release ZIP and upload it again through **Plugins → Add New → +Upload Plugin** — WordPress will offer to replace the existing version. + +## Uninstalling + +Deactivate and delete the plugin from the **Plugins** page as you would any other +WordPress plugin. All ARGUS Defence data (settings, scan history, quarantined files, +database tables) is removed automatically — nothing is left behind. + +## Releases + +Each release is published as a ZIP with an accompanying SHA-256 checksum. Verify the +download before installing it on a production site: + +``` +sha256sum argus-wordpress-defence-X.Y.Z.zip +``` + +Compare the result against the checksum published alongside that release. + +## Source layout + +``` +argus-wordpress-defence.php Plugin entry point (admin UI, cron, activation) +mu-loader/ Tamper-resistant enforcement core (installed as a Must-Use + plugin, keeps running even if the main plugin is deactivated) +includes/ Firewall, ban/policy engine, scanner, quarantine, backups, + vulnerability intelligence, cache +admin/ wp-admin dashboard and settings pages +bin/ Release packaging and signing tools (not shipped in the + plugin ZIP) +``` + +## Support + +Please open an issue in this repository. + +## License + +GPLv2 or later. See [LICENSE](LICENSE). diff --git a/admin/class-argus-admin.php b/admin/class-argus-admin.php new file mode 100644 index 0000000..1fc0c71 --- /dev/null +++ b/admin/class-argus-admin.php @@ -0,0 +1,628 @@ + md5( $row['ip'] . '|' . $row['created_at'] . '|' . $row['type'] ), + 'time' => Argus_Console_Stats::live_feed_time_label( $row['created_at'] ), + 'desc' => esc_html( $row['desc'] ), + ); + } + + wp_send_json_success( $out ); + } + + public static function register_menu() { + add_menu_page( + __( 'ARGUS Defence', 'argus-wordpress-defence' ), + __( 'ARGUS Defence', 'argus-wordpress-defence' ), + 'manage_options', + 'argus-wpd-dashboard', + array( __CLASS__, 'render_overview' ), + 'dashicons-shield', + 2 + ); + + add_submenu_page( 'argus-wpd-dashboard', __( 'Overview', 'argus-wordpress-defence' ), __( 'Overview', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-dashboard', array( __CLASS__, 'render_overview' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Firewall', 'argus-wordpress-defence' ), __( 'Firewall', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-firewall', array( __CLASS__, 'render_firewall' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Threats', 'argus-wordpress-defence' ), __( 'Threats', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-threats', array( __CLASS__, 'render_threats' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Findings', 'argus-wordpress-defence' ), __( 'Findings', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-findings', array( __CLASS__, 'render_findings' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Scanner', 'argus-wordpress-defence' ), __( 'Scanner', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-scanner', array( __CLASS__, 'render_scanner' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Quarantine', 'argus-wordpress-defence' ), __( 'Quarantine', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-quarantine', array( __CLASS__, 'render_quarantine' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Cache & Performance', 'argus-wordpress-defence' ), __( 'Cache & Performance', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-cache', array( __CLASS__, 'render_cache' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Backups', 'argus-wordpress-defence' ), __( 'Backups', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-backups', array( __CLASS__, 'render_backups' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Vulnerabilities', 'argus-wordpress-defence' ), __( 'Vulnerabilities', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-vulnerabilities', array( __CLASS__, 'render_vulnerabilities' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Intelligence', 'argus-wordpress-defence' ), __( 'Intelligence', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-intelligence', array( __CLASS__, 'render_intelligence' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'ANIS', 'argus-wordpress-defence' ), __( 'ANIS', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-anis', array( __CLASS__, 'render_anis' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Audit Log', 'argus-wordpress-defence' ), __( 'Audit Log', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-audit-log', array( __CLASS__, 'render_audit_log' ) ); + add_submenu_page( 'argus-wpd-dashboard', __( 'Settings', 'argus-wordpress-defence' ), __( 'Settings', 'argus-wordpress-defence' ), 'manage_options', 'argus-wpd-settings', array( __CLASS__, 'render_settings' ) ); + } + + public static function hide_settings_from_sidebar() { + echo ''; + } + + public static function enqueue_assets( $hook ) { + if ( false === strpos( $hook, 'argus-wpd' ) ) { + return; + } + $css_path = ARGUS_WPD_DIR . 'assets/css/admin.css'; + + $version = file_exists( $css_path ) ? filemtime( $css_path ) : ARGUS_WPD_VERSION; + wp_enqueue_style( 'argus-wpd-admin', ARGUS_WPD_URL . 'assets/css/admin.css', array(), $version ); + } + + public static function render_overview() { + $mu_status = Argus_MU_Installer::status(); + $mu_exec_status = Argus_MU_Installer::execution_status(); + $mode = Argus_Settings::mode(); + $counts = Argus_Findings::open_counts(); + + $range = isset( $_GET['range'] ) ? sanitize_key( wp_unslash( $_GET['range'] ) ) : '24h'; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $range, array( '24h', '7d', '30d' ), true ) ) { + $range = '24h'; + } + + $blocked_today = Argus_Console_Stats::blocked_today(); + $malicious_ips = Argus_Console_Stats::malicious_ip_count(); + $blocked_30d = Argus_Console_Stats::blocked_since_days( 30 ); + $security_events = Argus_Console_Stats::security_events_count( 30 ); + $series = Argus_Console_Stats::blocked_series( $range ); + + $malware_open = Argus_Findings::count_open( 'malware' ); + $integrity_open = Argus_Findings::count_open( 'integrity' ); + $anis_status = Argus_ANIS_Client::status(); + + include ARGUS_WPD_DIR . 'admin/views/overview.php'; + } + + public static function render_firewall() { + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $tab, array( 'overview', 'rule-activity', 'manual-block', 'blocked-ips' ), true ) ) { + $tab = 'overview'; + } + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $notice = isset( $_GET['notice'] ) ? sanitize_text_field( wp_unslash( $_GET['notice'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + + $result = Argus_Ban_Engine::paginated( $page ); + $bans = $result['rows']; + $page = $result['page']; + $total_pages = $result['total_pages']; + + $rule_hits = Argus_Console_Stats::firewall_rule_hits( 24 ); + $rules_active = count( Argus_WAF_Rules::corpus() ); + $requests_blocked_24h = Argus_Console_Stats::blocked_today(); + $malicious_ips = Argus_Console_Stats::malicious_ip_count(); + $attack_categories = Argus_Console_Stats::attack_category_totals( 24 ); + $security_actions = Argus_Console_Stats::security_action_totals( 24 ); + + include ARGUS_WPD_DIR . 'admin/views/firewall.php'; + } + + protected static function redirect_firewall_tab( $tab, $notice ) { + wp_safe_redirect( add_query_arg( array( 'page' => 'argus-wpd-firewall', 'tab' => $tab, 'notice' => rawurlencode( $notice ) ), admin_url( 'admin.php' ) ) ); + exit; + } + + public static function render_threats() { + $activity_page = isset( $_GET['activity_paged'] ) ? max( 1, absint( $_GET['activity_paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $activity_result = Argus_Console_Stats::recent_activity_paginated( $activity_page ); + $recent_activity = $activity_result['rows']; + $activity_page = $activity_result['page']; + $activity_total_pages = $activity_result['total_pages']; + + $category_counts = Argus_Findings::category_counts(); + $blocked_per_minute = Argus_Console_Stats::blocked_per_minute_series( 30 ); + $latest_blocked = Argus_Console_Stats::latest_blocked_events( 10 ); + $live_feed_nonce = wp_create_nonce( 'argus_wpd_live_feed' ); + + include ARGUS_WPD_DIR . 'admin/views/threats.php'; + } + + public static function render_findings() { + $view = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : 'active'; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $view, array( 'active', 'needs_attention', 'resolved', 'accepted' ), true ) ) { + $view = 'active'; + } + $finding_id = isset( $_GET['finding'] ) ? absint( $_GET['finding'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $action_result = null; + + if ( $finding_id && isset( $_POST['argus_wpd_status_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_status_nonce'] ) ), 'argus_wpd_set_status_' . $finding_id ) ) { // phpcs:ignore + $new_status = sanitize_key( wp_unslash( $_POST['new_status'] ?? '' ) ); + if ( in_array( $new_status, array( Argus_Findings::STATUS_ACKNOWLEDGED, Argus_Findings::STATUS_RESOLVED, Argus_Findings::STATUS_RISK_ACCEPTED ), true ) ) { + Argus_Findings::set_status( $finding_id, $new_status ); + } + } + + if ( $finding_id && isset( $_POST['argus_wpd_action_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_action_nonce'] ) ), 'argus_wpd_run_action_' . $finding_id ) ) { // phpcs:ignore + $finding = Argus_Findings::get( $finding_id ); + $action = $finding->evidence['action'] ?? null; + if ( $action && ! empty( $action['type'] ) ) { + $action_result = Argus_Actions::run( + $action['type'], + array( 'plugin' => $action['plugin'] ?? '', 'theme' => $action['theme'] ?? '', 'path' => $action['path'] ?? '' ) + ); + if ( $action_result['success'] ) { + Argus_Findings::set_status( $finding_id, Argus_Findings::STATUS_RESOLVED ); + } + } + } + + $findings_notice = ''; + + if ( $finding_id && isset( $_POST['argus_wpd_delete_finding_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_delete_finding_nonce'] ) ), 'argus_wpd_delete_finding_' . $finding_id ) ) { // phpcs:ignore + Argus_Findings::delete( $finding_id ); + $findings_notice = __( 'Finding deleted.', 'argus-wordpress-defence' ); + } + + if ( isset( $_POST['argus_wpd_resolve_all_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_resolve_all_nonce'] ) ), 'argus_wpd_resolve_all_open' ) ) { // phpcs:ignore + $resolved_count = Argus_Findings::resolve_all_open(); + $findings_notice = sprintf( + + _n( '%d open finding marked resolved.', '%d open findings marked resolved.', $resolved_count, 'argus-wordpress-defence' ), + $resolved_count + ); + } + + $result = Argus_Findings::paginated_by_view( $page, $view ); + $findings = $result['rows']; + $page = $result['page']; + $total_pages = $result['total_pages']; + $view_counts = Argus_Findings::view_counts(); + + include ARGUS_WPD_DIR . 'admin/views/findings.php'; + } + + public static function render_scanner() { + $scan_notice = ''; + + if ( isset( $_POST['argus_wpd_scan_nonce'], $_POST['scan_type'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_scan_nonce'] ) ), 'argus_wpd_run_scan' ) ) { // phpcs:ignore + $scan_type = sanitize_key( wp_unslash( $_POST['scan_type'] ) ); + $started = time(); + if ( 'full' === $scan_type ) { + Argus_Integrity::full_scan(); + Argus_Malware_Scanner::incremental_scan(); + Argus_Scan_History::record( Argus_Scan_History::TYPE_FULL, $started, Argus_Integrity::scan_status()['files_scanned'] ); + $scan_notice = __( 'Full scan complete.', 'argus-wordpress-defence' ); + } elseif ( 'quick' === $scan_type ) { + Argus_Integrity::incremental_scan(); + Argus_Malware_Scanner::incremental_scan(); + Argus_Scan_History::record( Argus_Scan_History::TYPE_QUICK, $started ); + $scan_notice = __( 'Quick scan complete.', 'argus-wordpress-defence' ); + } + } + + $scan_status = Argus_Integrity::scan_status(); + $malware_open = Argus_Findings::count_open( 'malware' ); + $integrity_open = Argus_Findings::count_open( 'integrity' ); + $lockdown_status = Argus_Malware_Scanner::lockdown_status(); + $quarantine_review_count = Argus_Quarantine::count_open(); + + $history_page = isset( $_GET['scan_paged'] ) ? max( 1, absint( $_GET['scan_paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $history_result = Argus_Scan_History::paginated( $history_page ); + $scan_history = $history_result['rows']; + $history_page = $history_result['page']; + $history_total_pages = $history_result['total_pages']; + + include ARGUS_WPD_DIR . 'admin/views/scanner.php'; + } + + public static function render_quarantine() { + $notice = ''; + $notice_type = 'success'; + + if ( isset( $_POST['quarantine_id'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification + $id = absint( $_POST['quarantine_id'] ); // phpcs:ignore WordPress.Security.NonceVerification + + if ( isset( $_POST['argus_wpd_quarantine_restore_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_quarantine_restore_nonce'] ) ), 'argus_wpd_quarantine_restore_' . $id ) ) { // phpcs:ignore + $result = Argus_Quarantine::restore( $id ); + $notice = $result['message']; + $notice_type = $result['success'] ? 'success' : 'error'; + } elseif ( isset( $_POST['argus_wpd_quarantine_delete_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_quarantine_delete_nonce'] ) ), 'argus_wpd_quarantine_delete_' . $id ) ) { // phpcs:ignore + $result = Argus_Quarantine::delete( $id ); + $notice = $result['message']; + $notice_type = $result['success'] ? 'success' : 'error'; + } elseif ( isset( $_POST['argus_wpd_quarantine_analyse_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_quarantine_analyse_nonce'] ) ), 'argus_wpd_quarantine_analyse_' . $id ) ) { // phpcs:ignore + $result = Argus_Quarantine::analyse( $id ); + $notice = $result['success'] ? __( 'Analysis complete.', 'argus-wordpress-defence' ) : $result['message']; + $notice_type = $result['success'] ? 'success' : 'error'; + } + } + + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $status_filter = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : Argus_Quarantine::STATUS_QUARANTINED; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $status_filter, array( Argus_Quarantine::STATUS_QUARANTINED, Argus_Quarantine::STATUS_RESTORED, Argus_Quarantine::STATUS_DELETED ), true ) ) { + $status_filter = Argus_Quarantine::STATUS_QUARANTINED; + } + $per_page = isset( $_GET['per_page'] ) ? absint( $_GET['per_page'] ) : Argus_Quarantine::PER_PAGE; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $per_page, array( 10, 25, 50 ), true ) ) { + $per_page = Argus_Quarantine::PER_PAGE; + } + + $result = Argus_Quarantine::paginated( $page, $status_filter, $per_page ); + $items = $result['rows']; + $page = $result['page']; + $total_pages = $result['total_pages']; + $review_count = Argus_Quarantine::count_open(); + $legacy_count = Argus_Quarantine::legacy_count(); + $status_counts = Argus_Quarantine::counts_by_status(); + + include ARGUS_WPD_DIR . 'admin/views/quarantine.php'; + } + + public static function render_cache() { + $notice = ''; + $notice_type = 'success'; + + if ( isset( $_POST['argus_wpd_cache_settings_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_settings_nonce'] ) ), 'argus_wpd_cache_settings' ) ) { // phpcs:ignore + $exclude_raw = sanitize_textarea_field( wp_unslash( $_POST['cache_custom_exclude_paths'] ?? '' ) ); // phpcs:ignore + $exclude_paths = array_values( array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $exclude_raw ) ) ) ); + Argus_Settings::update( + array( + 'static_cache_enabled' => ! empty( $_POST['static_cache_enabled'] ), // phpcs:ignore + 'static_cache_ttl_secs' => max( 60, absint( $_POST['static_cache_ttl_secs'] ?? 3600 ) ), // phpcs:ignore + 'cache_stale_grace_secs' => max( 0, absint( $_POST['cache_stale_grace_secs'] ?? 600 ) ), // phpcs:ignore + 'cache_custom_exclude_paths' => $exclude_paths, + ) + ); + $notice = __( 'Website acceleration settings saved.', 'argus-wordpress-defence' ); + } + + if ( isset( $_POST['argus_wpd_cache_compression_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_compression_nonce'] ) ), 'argus_wpd_cache_compression' ) ) { // phpcs:ignore + Argus_Settings::update( + array( + 'cache_gzip_enabled' => ! empty( $_POST['cache_gzip_enabled'] ), // phpcs:ignore + 'cache_gzip_level' => max( 1, min( 9, absint( $_POST['cache_gzip_level'] ?? 6 ) ) ), // phpcs:ignore + 'cache_brotli_enabled' => ! empty( $_POST['cache_brotli_enabled'] ), // phpcs:ignore + 'cache_brotli_level' => max( 0, min( 11, absint( $_POST['cache_brotli_level'] ?? 5 ) ) ), // phpcs:ignore + 'cache_min_compress_bytes' => max( 0, absint( $_POST['cache_min_compress_bytes'] ?? 1024 ) ), // phpcs:ignore + ) + ); + $notice = __( 'Compression settings saved.', 'argus-wordpress-defence' ); + } + + if ( isset( $_POST['argus_wpd_cache_warming_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_warming_nonce'] ) ), 'argus_wpd_cache_warming' ) ) { // phpcs:ignore + Argus_Settings::update( + array( + 'cache_discovery_enabled' => ! empty( $_POST['cache_discovery_enabled'] ), // phpcs:ignore + 'cache_warming_enabled' => ! empty( $_POST['cache_warming_enabled'] ), // phpcs:ignore + 'cache_warm_concurrency' => max( 1, min( 10, absint( $_POST['cache_warm_concurrency'] ?? 4 ) ) ), // phpcs:ignore + 'cache_warm_batch_size' => max( 1, min( 200, absint( $_POST['cache_warm_batch_size'] ?? 20 ) ) ), // phpcs:ignore + 'cache_warm_min_interval_secs' => max( 0, min( 30, absint( $_POST['cache_warm_min_interval_secs'] ?? 1 ) ) ), // phpcs:ignore + ) + ); + $notice = __( 'Preparation (cache warming) settings saved.', 'argus-wordpress-defence' ); + } + + if ( isset( $_POST['argus_wpd_cache_purge_all_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_purge_all_nonce'] ) ), 'argus_wpd_cache_purge_all' ) ) { // phpcs:ignore + $count = Argus_Static_Cache::purge_all(); + $notice = sprintf( __( 'Cache refreshed -- %d accelerated page(s) cleared.', 'argus-wordpress-defence' ), $count ); + } + + if ( isset( $_POST['argus_wpd_cache_purge_url_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_purge_url_nonce'] ) ), 'argus_wpd_cache_purge_url' ) ) { // phpcs:ignore + $url = esc_url_raw( wp_unslash( $_POST['purge_url'] ?? '' ) ); // phpcs:ignore + if ( $url ) { + Argus_Static_Cache::purge_url( $url ); + $notice = __( 'That page will be re-accelerated on its next visit.', 'argus-wordpress-defence' ); + } + } + + if ( isset( $_POST['argus_wpd_cache_purge_pattern_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_purge_pattern_nonce'] ) ), 'argus_wpd_cache_purge_pattern' ) ) { // phpcs:ignore + $pattern = sanitize_text_field( wp_unslash( $_POST['purge_pattern'] ?? '' ) ); // phpcs:ignore + if ( $pattern ) { + $count = Argus_Static_Cache::purge_pattern( $pattern ); + $notice = sprintf( __( '%d matching page(s) will be re-accelerated on their next visit.', 'argus-wordpress-defence' ), $count ); + } + } + + if ( isset( $_POST['argus_wpd_cache_run_discovery_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_run_discovery_nonce'] ) ), 'argus_wpd_cache_run_discovery' ) ) { // phpcs:ignore + $result = Argus_Cache_Discovery::run_discovery(); + $notice = $result['url_count'] > 0 + ? sprintf( __( 'Found %d page(s) on your website.', 'argus-wordpress-defence' ), $result['url_count'] ) + : __( "ARGUS couldn't find a sitemap for your website yet.", 'argus-wordpress-defence' ); + $notice_type = $result['url_count'] > 0 ? 'success' : 'error'; + } + + if ( isset( $_POST['argus_wpd_cache_run_warming_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_cache_run_warming_nonce'] ) ), 'argus_wpd_cache_run_warming' ) ) { // phpcs:ignore + $result = Argus_Cache_Warmer::process_queue(); + $notice = ! empty( $result['skipped'] ) + ? __( 'ARGUS paused preparation to avoid adding extra load to your website right now -- it will try again automatically.', 'argus-wordpress-defence' ) + : sprintf( __( 'Prepared %d page(s).', 'argus-wordpress-defence' ), $result['ok'] ?? 0 ); + } + + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'overview'; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! in_array( $tab, array( 'overview', 'page-cache', 'discovery', 'rules', 'compression', 'warming', 'purge', 'analytics', 'advanced' ), true ) ) { + $tab = 'overview'; + } + + $settings = Argus_Settings::all(); + $cache_stats = Argus_Static_Cache::stats(); + $log_summary_30d = Argus_Cache_Log::summary( 30 ); + $daily_series = Argus_Cache_Log::daily_series( 14 ); + $discovery_summary = class_exists( 'Argus_Cache_Discovery' ) ? Argus_Cache_Discovery::summary() : array(); + $warmer_status = class_exists( 'Argus_Cache_Warmer' ) ? Argus_Cache_Warmer::status() : array(); + $auto_update = Argus_Auto_Update::status(); + $gzip_available = function_exists( 'gzencode' ); + $brotli_available = function_exists( 'brotli_compress' ); + $compression_stats = Argus_Static_Cache::compression_stats(); + + $discovered_urls = array(); + if ( 'discovery' === $tab && class_exists( 'Argus_Cache_Discovery' ) ) { + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $discovered_urls = Argus_Cache_Discovery::paginated_urls( $page ); + } + + $recent_log = array(); + $log_page = 1; + $log_total_pages = 1; + $cached_urls = array(); + if ( 'analytics' === $tab ) { + $log_status_filter = isset( $_GET['status'] ) ? strtoupper( sanitize_key( wp_unslash( $_GET['status'] ) ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification + $log_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $log_result = Argus_Cache_Log::paginated( $log_page, 10, $log_status_filter ); + $recent_log = $log_result['rows']; + $log_page = $log_result['page']; + $log_total_pages = $log_result['total_pages']; + } + if ( 'purge' === $tab ) { + $cached_urls = Argus_Static_Cache::list_cached( 50 ); + } + + include ARGUS_WPD_DIR . 'admin/views/cache.php'; + } + + public static function render_backups() { + $notice = ''; + $notice_type = 'success'; + + if ( isset( $_POST['argus_wpd_backup_create_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_backup_create_nonce'] ) ), 'argus_wpd_backup_create' ) ) { // phpcs:ignore + $result = Argus_Backup::create( Argus_Backup::TYPE_MANUAL ); + $notice = $result['message']; + $notice_type = $result['success'] ? 'success' : 'error'; + } + + if ( isset( $_POST['argus_wpd_backup_delete_nonce'], $_POST['backup_id'] ) ) { // phpcs:ignore + $backup_id = absint( $_POST['backup_id'] ); // phpcs:ignore WordPress.Security.NonceVerification + if ( wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_backup_delete_nonce'] ) ), 'argus_wpd_backup_delete_' . $backup_id ) ) { + $result = Argus_Backup::delete( $backup_id ); + $notice = $result['message']; + $notice_type = $result['success'] ? 'success' : 'error'; + } + } + + if ( isset( $_POST['argus_wpd_backup_schedule_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_backup_schedule_nonce'] ) ), 'argus_wpd_backup_schedule' ) ) { // phpcs:ignore + $mode = sanitize_key( wp_unslash( $_POST['backup_schedule_mode'] ?? 'automatic' ) ); // phpcs:ignore + if ( ! in_array( $mode, array( 'automatic', 'manual', 'custom' ), true ) ) { + $mode = 'automatic'; + } + Argus_Settings::update( array( 'backup_schedule_mode' => $mode ) ); + if ( 'custom' === $mode ) { + $hours = absint( $_POST['backup_interval_hours'] ?? 24 ); // phpcs:ignore + if ( ! in_array( $hours, array( 1, 6, 12, 24, 168 ), true ) ) { + $hours = 24; + } + Argus_Settings::update( array( 'backup_interval_hours' => $hours ) ); + } elseif ( 'automatic' === $mode ) { + $active_template = Argus_Templates::active(); + $templates = Argus_Templates::all(); + if ( $active_template && isset( $templates[ $active_template ]['settings']['backup_interval_hours'] ) ) { + Argus_Settings::update( array( 'backup_interval_hours' => $templates[ $active_template ]['settings']['backup_interval_hours'] ) ); + } + } + $notice = __( 'Backup schedule updated.', 'argus-wordpress-defence' ); + } + + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $result = Argus_Backup::paginated( $page ); + $backups = $result['rows']; + $page = $result['page']; + $total_pages = $result['total_pages']; + $backup_status = Argus_Backup::status(); + + include ARGUS_WPD_DIR . 'admin/views/backups.php'; + } + + public static function handle_backup_download() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to do this.', 'argus-wordpress-defence' ) ); + } + $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification + if ( ! $id || ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'argus_wpd_backup_download_' . $id ) ) { // phpcs:ignore + wp_die( esc_html__( 'Invalid or expired download link.', 'argus-wordpress-defence' ) ); + } + + $backup = Argus_Backup::get( $id ); + if ( ! $backup || Argus_Backup::STATUS_COMPLETE !== $backup->status ) { + wp_die( esc_html__( 'Backup not found.', 'argus-wordpress-defence' ) ); + } + $path = trailingslashit( Argus_Backup::backup_dir() ) . $backup->filename; + if ( ! file_exists( $path ) ) { + wp_die( esc_html__( 'Backup file not found on disk.', 'argus-wordpress-defence' ) ); + } + + nocache_headers(); + header( 'Content-Type: application/zip' ); + header( 'Content-Disposition: attachment; filename="' . $backup->filename . '"' ); + header( 'Content-Length: ' . filesize( $path ) ); + readfile( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + exit; + } + + public static function render_vulnerabilities() { + $vuln_status = Argus_Vuln_Intel::status(); + + $severity_counts = array( 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0 ); + foreach ( $vuln_status['components'] as $component ) { + if ( Argus_Vuln_Intel::STATUS_VULNERABLE === $component['status'] && isset( $severity_counts[ $component['severity'] ] ) ) { + ++$severity_counts[ $component['severity'] ]; + } + } + + $per_page = 10; + $components_page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + $components_total = count( $vuln_status['components'] ); + $components_total_pages = max( 1, (int) ceil( $components_total / $per_page ) ); + $components_page = min( $components_page, $components_total_pages ); + $vuln_status['components'] = array_slice( $vuln_status['components'], ( $components_page - 1 ) * $per_page, $per_page ); + + include ARGUS_WPD_DIR . 'admin/views/vulnerabilities.php'; + } + + public static function render_intelligence() { + global $wpdb; + + $waf_rule_count = count( Argus_WAF_Rules::corpus() ); + $vuln_table = Argus_DB::table( 'vuln_cache' ); + $vuln_row_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$vuln_table}" ); // phpcs:ignore + $geoip_status = class_exists( 'Argus_GeoIP_RIR' ) ? Argus_GeoIP_RIR::status() : null; + $anis_status = Argus_ANIS_Client::status(); + + include ARGUS_WPD_DIR . 'admin/views/intelligence.php'; + } + + public static function render_anis() { + $anis_status = Argus_ANIS_Client::status(); + include ARGUS_WPD_DIR . 'admin/views/anis.php'; + } + + public static function render_audit_log() { + $page = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification + + $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $severity = isset( $_GET['severity'] ) ? sanitize_key( wp_unslash( $_GET['severity'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + $range = isset( $_GET['range'] ) ? sanitize_key( wp_unslash( $_GET['range'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification + + $range_hours = array( '24h' => 24, '7d' => 24 * 7, '30d' => 24 * 30 ); + $filters = array( + 'search' => $search, + 'severity' => in_array( $severity, array( 'critical', 'high', 'medium', 'low', 'info' ), true ) ? $severity : '', + 'type' => $type, + 'since' => isset( $range_hours[ $range ] ) ? gmdate( 'Y-m-d H:i:s', time() - $range_hours[ $range ] * HOUR_IN_SECONDS ) : '', + ); + + $result = Argus_Events::paginated( $page, $filters ); + $events = $result['rows']; + $page = $result['page']; + $total_pages = $result['total_pages']; + $event_types = Argus_Events::distinct_types(); + + include ARGUS_WPD_DIR . 'admin/views/audit-log.php'; + } + + public static function render_settings() { + $saved = false; + $template_applied = null; + + if ( isset( $_POST['argus_wpd_apply_template_nonce'], $_POST['template'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_apply_template_nonce'] ) ), 'argus_wpd_apply_template' ) ) { // phpcs:ignore + $key = sanitize_key( wp_unslash( $_POST['template'] ) ); // phpcs:ignore + if ( class_exists( 'Argus_Templates' ) && Argus_Templates::apply( $key ) ) { + $templates = Argus_Templates::all(); + $template_applied = $templates[ $key ]['label']; + } + } + + if ( isset( $_POST['argus_wpd_add_exception_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_add_exception_nonce'] ) ), 'argus_wpd_add_exception' ) ) { // phpcs:ignore + $ip = sanitize_text_field( wp_unslash( $_POST['exception_ip'] ?? '' ) ); // phpcs:ignore + if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { + Argus_Settings::add_exception( 'ip', $ip, sanitize_text_field( wp_unslash( $_POST['exception_note'] ?? '' ) ) ); // phpcs:ignore + } + } + + if ( isset( $_POST['argus_wpd_remove_exception_nonce'], $_POST['exception_value'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_remove_exception_nonce'] ) ), 'argus_wpd_remove_exception' ) ) { // phpcs:ignore + Argus_Settings::remove_exception( 'ip', sanitize_text_field( wp_unslash( $_POST['exception_value'] ) ) ); // phpcs:ignore + } + + $update_check_result = null; + if ( isset( $_POST['argus_wpd_check_updates_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_check_updates_nonce'] ) ), 'argus_wpd_check_updates' ) && class_exists( 'Argus_Update_Client' ) ) { // phpcs:ignore + $update_check_result = Argus_Update_Client::check_now(); + if ( empty( $update_check_result['message'] ) ) { + $update_check_result['message'] = ! empty( $update_check_result['update_available'] ) + ? __( 'An update is available.', 'argus-wordpress-defence' ) + : __( 'ARGUS Defence is up to date.', 'argus-wordpress-defence' ); + } + } + + if ( isset( $_POST['argus_wpd_settings_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['argus_wpd_settings_nonce'] ) ), 'argus_wpd_save_settings' ) ) { // phpcs:ignore + Argus_Settings::update( + array( + 'mode' => sanitize_key( wp_unslash( $_POST['mode'] ?? Argus_Settings::MODE_MONITOR ) ), // phpcs:ignore + 'waf_enabled' => ! empty( $_POST['waf_enabled'] ), // phpcs:ignore + 'login_protection_enabled' => ! empty( $_POST['login_protection_enabled'] ), // phpcs:ignore + 'xmlrpc_block_pingback' => ! empty( $_POST['xmlrpc_block_pingback'] ), // phpcs:ignore + 'rest_api_protection_enabled' => ! empty( $_POST['rest_api_protection_enabled'] ), // phpcs:ignore + 'integrity_scan_enabled' => ! empty( $_POST['integrity_scan_enabled'] ), // phpcs:ignore + 'malware_scan_enabled' => ! empty( $_POST['malware_scan_enabled'] ), // phpcs:ignore + 'upload_scan_enabled' => ! empty( $_POST['upload_scan_enabled'] ), // phpcs:ignore + 'vuln_intel_enabled' => ! empty( $_POST['vuln_intel_enabled'] ), // phpcs:ignore + 'geoip_rir_enabled' => ! empty( $_POST['geoip_rir_enabled'] ), // phpcs:ignore + ) + ); + $saved = true; + + $user = wp_get_current_user(); + Argus_Events::record( + 'settings_saved', + 'info', + sprintf( 'Protection settings updated by %s', $user->user_login ), + array( 'actor' => $user->user_login, 'mode' => Argus_Settings::mode() ) + ); + } + + $settings = Argus_Settings::all(); + $geoip_status = class_exists( 'Argus_GeoIP_RIR' ) ? Argus_GeoIP_RIR::status() : null; + $templates = class_exists( 'Argus_Templates' ) ? Argus_Templates::all() : array(); + $active_template = class_exists( 'Argus_Templates' ) ? Argus_Templates::active() : ''; + $recommended_template = class_exists( 'Argus_Templates' ) ? Argus_Templates::recommended() : null; + include ARGUS_WPD_DIR . 'admin/views/settings.php'; + } +} diff --git a/admin/views/anis.php b/admin/views/anis.php new file mode 100644 index 0000000..705d31d --- /dev/null +++ b/admin/views/anis.php @@ -0,0 +1,74 @@ + array( 'label' => __( 'ACTIVE', 'argus-wordpress-defence' ), 'chip' => 'chip-emerald' ), + 'limited' => array( 'label' => __( 'TEMPORARILY LIMITED', 'argus-wordpress-defence' ), 'chip' => 'chip-amber' ), + 'not_connected' => array( 'label' => __( 'CONNECTING...', 'argus-wordpress-defence' ), 'chip' => 'chip-off' ), +); +$state = $protection_display[ $anis_status['protection'] ]; + +$date_format = 'd M Y, H:i'; +$last_sync_display = $anis_status['last_sync'] ? wp_date( $date_format, strtotime( $anis_status['last_sync'] . ' UTC' ) ) : ''; +$next_sync_display = $anis_status['next_sync'] ? wp_date( $date_format, strtotime( $anis_status['next_sync'] . ' UTC' ) ) : ''; +?> + +

+ +
+
+ + +
+
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+

+
+ + diff --git a/admin/views/audit-log.php b/admin/views/audit-log.php new file mode 100644 index 0000000..75abf4d --- /dev/null +++ b/admin/views/audit-log.php @@ -0,0 +1,82 @@ + 'var(--rose)', + 'high' => 'var(--orange)', + 'medium' => 'var(--amber)', + 'low' => 'var(--cyan)', + 'info' => 'var(--faint)', +); +?> + +

+

+ +
+ + + + + + + + + +
+ +
+ +

+ + + + + + + + + + + + payload['actor'] ?? __( 'ARGUS', 'argus-wordpress-defence' ); ?> + + + + + + + +
created_at . ' UTC' ) ) ); ?> + + summary ); ?> +
+ +
+ + diff --git a/admin/views/backups.php b/admin/views/backups.php new file mode 100644 index 0000000..843e3ea --- /dev/null +++ b/admin/views/backups.php @@ -0,0 +1,134 @@ + + +

+

+ + +

+ + + +
+

+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

+
+ + +
+
+ +
+

+
+ + + + + + +
+
+
+ +
+ +
+
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + + + +
created_at . ' UTC' ) ) ); ?>type ? __( 'Manual', 'argus-wordpress-defence' ) : __( 'Scheduled', 'argus-wordpress-defence' ) ); ?>status ? size_format( (int) $backup->size_bytes ) : '—' ); ?> + status ) : ?> + + + + + + status ) : ?> + + +
+ id, 'argus_wpd_backup_delete_nonce' ); ?> + + +
+
+ + +
+ + diff --git a/admin/views/cache.php b/admin/views/cache.php new file mode 100644 index 0000000..3f7a152 --- /dev/null +++ b/admin/views/cache.php @@ -0,0 +1,596 @@ + __( 'Overview', 'argus-wordpress-defence' ), + 'page-cache' => __( 'Page Cache', 'argus-wordpress-defence' ), + 'discovery' => __( 'Page Discovery', 'argus-wordpress-defence' ), + 'rules' => __( 'Cache Rules', 'argus-wordpress-defence' ), + 'compression' => __( 'Compression', 'argus-wordpress-defence' ), + 'warming' => __( 'Preload', 'argus-wordpress-defence' ), + 'purge' => __( 'Purge & Cache', 'argus-wordpress-defence' ), + 'analytics' => __( 'Analytics', 'argus-wordpress-defence' ), + 'advanced' => __( 'Advanced', 'argus-wordpress-defence' ), +); + +$hit_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_HIT ]['count'] ?? 0; +$stale_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_STALE ]['count'] ?? 0; +$miss_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_MISS ]['count'] ?? 0; +$revalidated_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_REVALIDATED ]['count'] ?? 0; +$bypass_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_BYPASS ]['count'] ?? 0; +$excluded_30d = $log_summary_30d[ Argus_Static_Cache::STATUS_EXCLUDED ]['count'] ?? 0; +$accelerated_30d = $hit_30d + $stale_30d; +$origin_reached_30d = $miss_30d + $revalidated_30d; +$total_eligible_30d = $accelerated_30d + $origin_reached_30d; +$dynamic_protected_30d = $bypass_30d + $excluded_30d; + +$requests_reduced_pct = $total_eligible_30d > 0 ? round( ( $accelerated_30d / $total_eligible_30d ) * 100 ) : null; +$cache_efficiency_pct = $total_eligible_30d > 0 ? round( ( $accelerated_30d / max( 1, $total_eligible_30d + $dynamic_protected_30d ) ) * 100, 1 ) : null; + +$performance_status = ! $settings['static_cache_enabled'] + ? array( 'label' => __( 'Not Enabled', 'argus-wordpress-defence' ), 'chip' => 'chip-off' ) + : ( $total_eligible_30d < 20 + ? array( 'label' => __( 'Learning', 'argus-wordpress-defence' ), 'chip' => 'chip-amber' ) + : array( 'label' => __( 'Optimised', 'argus-wordpress-defence' ), 'chip' => 'chip-emerald' ) ); + +$cumulative = 0; +$cumulative_series = array(); +foreach ( $daily_series as $day ) { + $cumulative += (int) $day['hit']; + $cumulative_series[] = array( 'label' => $day['date'], 'value' => $cumulative ); +} + +?> + +

+

+ + +

+ + +
+ $label ) : ?> + + +
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

·

+
+ +

+ + + +
+ + +
+

+

+ +
+ + + + +
+

+

+
+ + + + + + + + + + + + + + + + + + +
+ +

+
+ +

+
+ +

+
+ +
+
+ +
+

+

+
    +
  • +
  • +
  • +
  • +
  • +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

+ + + + + +

+ +

+ +
+ + +
+
+ + +
+

+ + + + + + + + + + +
url ); ?> + __( 'Eligible', 'argus-wordpress-defence' ), + Argus_Cache_Eligibility::BYPASS => __( 'Excluded', 'argus-wordpress-defence' ), + Argus_Cache_Eligibility::FORCE_EXCLUDED => __( 'Excluded', 'argus-wordpress-defence' ), + Argus_Cache_Eligibility::NEEDS_REVIEW => __( 'Needs Review', 'argus-wordpress-defence' ), + ); + echo esc_html( $eligible_labels[ $row->eligibility ] ?? $row->eligibility ); + ?> +
+ +
+ + + + +
+

+

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
  7. +
  8. +
+
+ +
+

+

+ + +

+ + + + + + + + + + + +
+ +
+ + + + 0 || $compression_stats['brotli']['files'] > 0 ) : ?> +
+
+
+
+
+
+
+
+
0 ? esc_html( $compression_stats['gzip']['avg_savings_pct'] . '%' ) : '—'; ?>
+
+ +
+
+
+
+
0 ? esc_html( $compression_stats['brotli']['avg_savings_pct'] . '%' ) : '—'; ?>
+
+ 0 + ? sprintf( + + __( 'Average savings, %1$d page(s), %2$s saved', 'argus-wordpress-defence' ), + $compression_stats['brotli']['files'], + size_format( $compression_stats['brotli']['bytes_saved'] ) + ) + : __( 'Not currently in use', 'argus-wordpress-defence' ) + ); + ?> +
+
+
+ + +
+

+

+
+ + + + + + + + + + + + + + +
+ +

+

+ +
+ +

+

+ +
+ +
+
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +

+ + +
+

+

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + +
+
+ + + +
+

+

+ +

+
+ + +
+
+ +
+

+
+ + + +
+
+ + + +
+
+ +
+

+ +

+ + + + + + + + + + + + + +
+ +
+ +
+ + + +
+

·

+ +

+ + + +
+ +
+ $hit_30d + $stale_30d, + __( 'Loaded from WordPress', 'argus-wordpress-defence' ) => $miss_30d + $revalidated_30d, + __( 'Excluded for Safety', 'argus-wordpress-defence' ) => $dynamic_protected_30d, + ); + ?> + +
+
+ +
+

+
+ + __( 'Served from Cache', 'argus-wordpress-defence' ), 'MISS' => __( 'Loaded from WordPress', 'argus-wordpress-defence' ), 'BYPASS' => __( 'Excluded', 'argus-wordpress-defence' ), 'WARMED' => __( 'Preloaded', 'argus-wordpress-defence' ), 'PURGED' => __( 'Refreshed', 'argus-wordpress-defence' ) ) as $status_key => $status_label ) : ?> + + +
+ +

+ + + + + __( 'Served from Cache', 'argus-wordpress-defence' ), + 'STALE' => __( 'Served from Cache', 'argus-wordpress-defence' ), + 'MISS' => __( 'Loaded from WordPress', 'argus-wordpress-defence' ), + 'REVALIDATED' => __( 'Refreshed', 'argus-wordpress-defence' ), + 'BYPASS' => __( 'Excluded', 'argus-wordpress-defence' ), + 'EXCLUDED' => __( 'Excluded', 'argus-wordpress-defence' ), + 'WARMED' => __( 'Preloaded', 'argus-wordpress-defence' ), + 'PURGED' => __( 'Refreshed', 'argus-wordpress-defence' ), + 'EXPIRED' => __( 'Renewed', 'argus-wordpress-defence' ), + ); + ?> + + + + + + + + +
+ + +
+ + + +
+

+

+ + + + + + + + + + + +
+ +
+ + +

+
+
+ +
+
+ +

+ + + + + + + + +
+
+
+ + + + diff --git a/admin/views/findings.php b/admin/views/findings.php new file mode 100644 index 0000000..25f5ca1 --- /dev/null +++ b/admin/views/findings.php @@ -0,0 +1,98 @@ + __( 'Active', 'argus-wordpress-defence' ), + 'needs_attention' => __( 'Needs Attention', 'argus-wordpress-defence' ), + 'resolved' => __( 'Resolved', 'argus-wordpress-defence' ), + 'accepted' => __( 'Accepted', 'argus-wordpress-defence' ), +); + +$donut_data = array(); +foreach ( $view_labels as $key => $label ) { + if ( ( $view_counts[ $key ] ?? 0 ) > 0 ) { + $donut_data[ $label ] = $view_counts[ $key ]; + } +} +?> + +

+

+ + +
+

+
+ + + +
+

+
+ + + +
+

+
+ + +
+
+ + +
+ $label ) : ?> + () + +
+ +
+
+

+ +
+ + +
+ +
+ +

+ +

+ +
+ + + +
+ +
+ + diff --git a/admin/views/firewall.php b/admin/views/firewall.php new file mode 100644 index 0000000..832c8bf --- /dev/null +++ b/admin/views/firewall.php @@ -0,0 +1,213 @@ + __( 'Block', 'argus-wordpress-defence' ), + 'challenge' => __( 'Challenge', 'argus-wordpress-defence' ), + 'rate_limit' => __( 'Rate Limit', 'argus-wordpress-defence' ), + 'allow' => __( 'Allow', 'argus-wordpress-defence' ), +); + +$tabs = array( + 'overview' => __( 'Overview', 'argus-wordpress-defence' ), + 'rule-activity' => __( 'Rule Activity', 'argus-wordpress-defence' ), + 'manual-block' => __( 'Manual IP Block', 'argus-wordpress-defence' ), + 'blocked-ips' => __( 'Blocked IPs', 'argus-wordpress-defence' ), +); + +$blocked_24h = $security_actions[ __( 'Blocked', 'argus-wordpress-defence' ) ] ?? 0; +$challenged_24h = $security_actions[ __( 'Challenged', 'argus-wordpress-defence' ) ] ?? 0; +$top_categories = array_slice( $attack_categories, 0, 3, true ); +?> + +

+

+ + +

+ + +
+ $label ) : ?> + + +
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

+ +

+ +
+ + +
+ +
+
+

+ +

+ +
+ + +
+ +
+
+ +
+ 0 || $challenged_24h > 0 ) : ?> +

+ 0 + ? sprintf( + + __( 'ARGUS blocked %1$s malicious requests and challenged %2$s suspicious requests during the last 24 hours.', 'argus-wordpress-defence' ), + number_format_i18n( $blocked_24h ), + number_format_i18n( $challenged_24h ) + ) + : sprintf( + + __( 'ARGUS blocked %s malicious requests during the last 24 hours.', 'argus-wordpress-defence' ), + number_format_i18n( $blocked_24h ) + ) + ); + ?> +

+ +

+
    + $count ) : ?> +
  • :
  • + +
+ + +

+ +
+ + + +
+

·

+ +

+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+

+

+
+ + + + +
+
+ + + +
+

+

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ip ) ); ?> ip ); ?>source ); ?>reason ); ?>ban_level ); ?>created_at . ' UTC' ) ) ); ?> expires_at ?: __( 'Permanent', 'argus-wordpress-defence' ) ); ?> +
+ id, 'argus_wpd_unban_nonce' ); ?> + + +
+
+ + +
+ + + + diff --git a/admin/views/intelligence.php b/admin/views/intelligence.php new file mode 100644 index 0000000..f3cf26c --- /dev/null +++ b/admin/views/intelligence.php @@ -0,0 +1,118 @@ + + +

+

+ +
+ +
+

+ +

+

+ + + + + + + +

+

+
+
+ +
+
+
+
+
+
+
+
+ 0 ) : ?> + + + + +
+
+
+
+
+ + + + + +
+
+
+ +
+

+
+ WAF + +
+
+ VULN + + 0 + ? __( 'Vulnerability intelligence -- actively matching installed plugins and themes against known security issues.', 'argus-wordpress-defence' ) + : __( 'Vulnerability intelligence -- WordPress core is checked live against WordPress.org. Plugin and theme results activate automatically as intelligence becomes available.', 'argus-wordpress-defence' ) + ); + ?> + +
+
+ GEOIP + + + +
+
+ ANIS + + + +
+
+ + diff --git a/admin/views/overview.php b/admin/views/overview.php new file mode 100644 index 0000000..3029374 --- /dev/null +++ b/admin/views/overview.php @@ -0,0 +1,116 @@ + '24H', '7d' => '7D', '30d' => '30D' ); + +$score = max( 0, 100 - ( $counts['critical'] * 15 ) - ( $counts['high'] * 8 ) - ( $counts['medium'] * 3 ) - ( $counts['low'] * 1 ) ); +$score_r = 54; +$score_circ = 2 * M_PI * $score_r; +$score_offset = $score_circ * ( 1 - $score / 100 ); +?> + +

+

+ + +
+

+ +

+
+ +
+

+
+ + +
+
+ + + + + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+

·

+
+ $label ) : ?> + + +
+
+ +

+ + + +
+ +
+
+ + FIREWALL + +
+
+ + SCANNER + 0 || $integrity_open > 0 ) ? esc_html__( 'ISSUES FOUND', 'argus-wordpress-defence' ) : esc_html__( 'CLEAN', 'argus-wordpress-defence' ); ?> +
+ array( 'chip-cyan', __( 'CONNECTED', 'argus-wordpress-defence' ) ), + 'limited' => array( 'chip-amber', __( 'LIMITED', 'argus-wordpress-defence' ) ), + 'not_connected' => array( 'chip-off', __( 'CONNECTING…', 'argus-wordpress-defence' ) ), + ); + list( $anis_chip_class, $anis_chip_state ) = $anis_chip_map[ $anis_status['protection'] ] ?? $anis_chip_map['not_connected']; + ?> +
+ + ANIS + +
+
+ + diff --git a/admin/views/partials/finding-card.php b/admin/views/partials/finding-card.php new file mode 100644 index 0000000..320c41b --- /dev/null +++ b/admin/views/partials/finding-card.php @@ -0,0 +1,53 @@ +evidence['action'] ?? null; +$finding_ip = $finding->evidence['ip'] ?? null; +?> +
+
+ severity ) ); ?> + + + + + title ); ?> + + status ) ) ); ?> + last_seen_at . ' UTC' ) ) ); ?> +
+ +
+ +
+ id, 'argus_wpd_action_nonce' ); ?> + +
+ + status ) : ?> +
+ id, 'argus_wpd_status_nonce' ); ?> + + + +
+ +
+ id, 'argus_wpd_delete_finding_nonce' ); ?> + +
+
+ + explain ) ) : ?> +
+ + explain ); // phpcs:ignore WordPress.Security.EscapeOutput ?> +
+ +
diff --git a/admin/views/partials/footer.php b/admin/views/partials/footer.php new file mode 100644 index 0000000..ba7656b --- /dev/null +++ b/admin/views/partials/footer.php @@ -0,0 +1,7 @@ + + + diff --git a/admin/views/partials/header.php b/admin/views/partials/header.php new file mode 100644 index 0000000..da28d79 --- /dev/null +++ b/admin/views/partials/header.php @@ -0,0 +1,58 @@ + 'Monitor', 'block' => 'Block', 'strict' => 'Strict' ); + +$argus_domain = wp_parse_url( home_url(), PHP_URL_HOST ) ?: get_bloginfo( 'name' ); + +$argus_protection_state = Argus_Console_Stats::protection_state(); +$argus_protection_labels = array( + 'protected' => array( 'class' => 'on', 'label' => __( 'Protected', 'argus-wordpress-defence' ) ), + 'action_required' => array( 'class' => 'danger', 'label' => __( 'Action Required', 'argus-wordpress-defence' ) ), + 'checking' => array( 'class' => 'checking', 'label' => __( 'Checking Security', 'argus-wordpress-defence' ) ), +); +$argus_protection_display = $argus_protection_labels[ $argus_protection_state ] ?? $argus_protection_labels['protected']; + +$argus_nav_items = array( + 'overview' => array( __( 'Overview', 'argus-wordpress-defence' ), 'argus-wpd-dashboard' ), + 'firewall' => array( __( 'Firewall', 'argus-wordpress-defence' ), 'argus-wpd-firewall' ), + 'threats' => array( __( 'Threats', 'argus-wordpress-defence' ), 'argus-wpd-threats' ), + 'findings' => array( __( 'Findings', 'argus-wordpress-defence' ), 'argus-wpd-findings' ), + 'scanner' => array( __( 'Scanner', 'argus-wordpress-defence' ), 'argus-wpd-scanner' ), + 'quarantine' => array( __( 'Quarantine', 'argus-wordpress-defence' ), 'argus-wpd-quarantine' ), + 'cache' => array( __( 'Cache & Performance', 'argus-wordpress-defence' ), 'argus-wpd-cache' ), + 'backups' => array( __( 'Backups', 'argus-wordpress-defence' ), 'argus-wpd-backups' ), + 'vulnerabilities' => array( __( 'Vulnerabilities', 'argus-wordpress-defence' ), 'argus-wpd-vulnerabilities' ), + 'intelligence' => array( __( 'Intelligence', 'argus-wordpress-defence' ), 'argus-wpd-intelligence' ), + 'anis' => array( __( 'ANIS', 'argus-wordpress-defence' ), 'argus-wpd-anis' ), + 'audit-log' => array( __( 'Audit Log', 'argus-wordpress-defence' ), 'argus-wpd-audit-log' ), +); +?> +
+
+ +
+ +
+ ARGUS DEFENCE · SECURITY OPERATIONS CENTER + + +
+
+ + + · +
+ +
+ + diff --git a/admin/views/partials/pagination.php b/admin/views/partials/pagination.php new file mode 100644 index 0000000..d3455c7 --- /dev/null +++ b/admin/views/partials/pagination.php @@ -0,0 +1,32 @@ + +
+ 1 ) : ?> + + + + + + + + + + +
diff --git a/admin/views/quarantine.php b/admin/views/quarantine.php new file mode 100644 index 0000000..3abcda8 --- /dev/null +++ b/admin/views/quarantine.php @@ -0,0 +1,156 @@ + __( 'Quarantined', 'argus-wordpress-defence' ), + Argus_Quarantine::STATUS_RESTORED => __( 'Restored', 'argus-wordpress-defence' ), + Argus_Quarantine::STATUS_DELETED => __( 'Deleted', 'argus-wordpress-defence' ), +); +?> + +

+

+ 0 + ? esc_html( sprintf( _n( '%d item requires review.', '%d items require review.', $review_count, 'argus-wordpress-defence' ), $review_count ) ) + : esc_html__( 'No items currently require review.', 'argus-wordpress-defence' ); + ?> +

+ + +

+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + 0 ) : ?> +
+

+ +

+
+ + +
+
+ $label ) : ?> + + +
+
+ + + +
+
+ + +

+ +
+ +
+
+ severity ) ); ?> + original_path ); ?> + status ] ?? ucfirst( $item->status ) ); ?> + quarantined_at . ' UTC' ) ) ); ?> +
+ + status ) : ?> +
+
+ id, 'argus_wpd_quarantine_analyse_nonce' ); ?> + + +
+
+ id, 'argus_wpd_quarantine_restore_nonce' ); ?> + + +
+
+ id, 'argus_wpd_quarantine_delete_nonce' ); ?> + + +
+
+ + +
+ +
+
+
detection_engine ); ?>
+
+
detection_type ) ); ?>
+
+
detection_rule ?: '—' ); ?>
+
+
file_size ) ); ?>
+
+
file_hash ); ?>
+
+
original_mtime ?: '—' ); ?>
+ analysed_at ) : ?> +
+
+ confidence_score, + human_time_diff( strtotime( $item->analysed_at . ' UTC' ) ) + ) + ); + if ( ! empty( $item->matched_rules ) ) { + echo ' -- ' . esc_html__( 'matched:', 'argus-wordpress-defence' ) . ' ' . esc_html( implode( ', ', $item->matched_rules ) ); + } + ?> +
+ +
+
+
+ +
+ + + diff --git a/admin/views/scanner.php b/admin/views/scanner.php new file mode 100644 index 0000000..8cef632 --- /dev/null +++ b/admin/views/scanner.php @@ -0,0 +1,175 @@ + __( 'Quick Scan', 'argus-wordpress-defence' ), + Argus_Scan_History::TYPE_FULL => __( 'Full Scan', 'argus-wordpress-defence' ), + Argus_Scan_History::TYPE_SCHEDULED_INCREMENTAL => __( 'Scheduled (Quick)', 'argus-wordpress-defence' ), + Argus_Scan_History::TYPE_SCHEDULED_FULL => __( 'Scheduled (Full)', 'argus-wordpress-defence' ), +); +?> + +

+

+ + +

+ + +
+
+
+
0 || $integrity_open > 0 ) ? esc_html__( 'Issues', 'argus-wordpress-defence' ) : esc_html__( 'Clean', 'argus-wordpress-defence' ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

+
+ + + +
+
+ +
+

+ +

+ + + + + + + + + + + + + + finished_at . ' UTC' ) - strtotime( $run->started_at . ' UTC' ) ); + ?> + + + + + + + + + +
started_at . ' UTC' ) ) ); ?>scan_type ] ?? ucfirst( $run->scan_type ) ); ?>result ? esc_html__( 'Clean', 'argus-wordpress-defence' ) : esc_html__( 'Issues Found', 'argus-wordpress-defence' ); ?>findings_count ) ); ?>
+ + +
+ +
+
+

+
+ + 0 ? esc_html__( 'ISSUES FOUND', 'argus-wordpress-defence' ) : esc_html__( 'VERIFIED', 'argus-wordpress-defence' ); ?> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+

+
+ + 0 ? esc_html__( 'THREATS FOUND', 'argus-wordpress-defence' ) : esc_html__( 'CLEAN', 'argus-wordpress-defence' ); ?> +
+
+ + +
+
+ + + + + + + + +
+ +

+ +

+ +
+ + +
+

+ + +

+
+
+ + 0 || $integrity_open > 0 ) : ?> +
+

+ + , + +

+
+ + + diff --git a/admin/views/settings.php b/admin/views/settings.php new file mode 100644 index 0000000..9e60f05 --- /dev/null +++ b/admin/views/settings.php @@ -0,0 +1,213 @@ + + +

+

+ + +

+ + + +

+ +

+ + + +
+

+

+
+ $template ) : ?> +
+
+ + + +
+

+
')"> + + + +
+
+ +
+
+ + +
+
+ + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ + +
+

+

+ 0 ) : ?> +

+ +

+ 0 ) : ?> +

+ +

+ +

+ + +

+ +
+ + +
+

+

+

+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ +
+ +

+ +
+ + +
+
+ +
+

+

+ +
+ +
+

+

+ + + +

+ + + +
+ + + +
+ + + +
+
+ + + +
+ + + + +
+
+ + diff --git a/admin/views/threats.php b/admin/views/threats.php new file mode 100644 index 0000000..f994a3a --- /dev/null +++ b/admin/views/threats.php @@ -0,0 +1,198 @@ + __( 'WAF (SQLi/XSS/RCE/etc.)', 'argus-wordpress-defence' ), + 'integrity' => __( 'File Integrity', 'argus-wordpress-defence' ), + 'malware' => __( 'Malware', 'argus-wordpress-defence' ), + 'vulnerability' => __( 'Vulnerability', 'argus-wordpress-defence' ), + 'account' => __( 'Account / Login', 'argus-wordpress-defence' ), + 'correlation' => __( 'Correlated Campaign', 'argus-wordpress-defence' ), + 'rest_inventory' => __( 'REST API Inventory', 'argus-wordpress-defence' ), + 'ajax_inventory' => __( 'AJAX Inventory', 'argus-wordpress-defence' ), +); + +$category_display = array(); +foreach ( $category_counts as $category => $count ) { + $category_display[ $category_labels[ $category ] ?? ucfirst( $category ) ] = $count; +} + +$action_labels = array( + 'block' => __( 'Blocked', 'argus-wordpress-defence' ), + 'challenge' => __( 'Challenged', 'argus-wordpress-defence' ), + 'rate_limit' => __( 'Rate Limited', 'argus-wordpress-defence' ), +); + +?> + +

+

+ + +

+ +
+

+ +

+ +
+ +
+ +
+ +
+ +

+ + +
+ + + BLOCKED +
+ + +
+ +
+

+ +

+ +
+ + +
+ +
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + + +
created_at . ' UTC' ) ) ); ?> ip ) ); ?> ip ); ?>label ); ?>action ] ?? ucfirst( $row->action ) ); ?>
+ + +
+ + + + diff --git a/admin/views/vulnerabilities.php b/admin/views/vulnerabilities.php new file mode 100644 index 0000000..3d149f4 --- /dev/null +++ b/admin/views/vulnerabilities.php @@ -0,0 +1,117 @@ + __( 'Core', 'argus-wordpress-defence' ), + 'plugin' => __( 'Plugin', 'argus-wordpress-defence' ), + 'theme' => __( 'Theme', 'argus-wordpress-defence' ), +); + +$status_display = array( + Argus_Vuln_Intel::STATUS_CLEAN => array( 'icon' => '✓', 'label' => __( 'No Known Vulnerabilities', 'argus-wordpress-defence' ), 'color' => 'var(--emerald)' ), + Argus_Vuln_Intel::STATUS_PENDING => array( 'icon' => '⚠', 'label' => __( 'Check Pending', 'argus-wordpress-defence' ), 'color' => 'var(--amber)' ), + Argus_Vuln_Intel::STATUS_UNAVAILABLE => array( 'icon' => '⚠', 'label' => __( 'Intelligence Temporarily Unavailable', 'argus-wordpress-defence' ), 'color' => 'var(--amber)' ), + Argus_Vuln_Intel::STATUS_VULNERABLE => array( 'icon' => '🔴', 'label' => __( 'Vulnerability Detected', 'argus-wordpress-defence' ), 'color' => 'var(--rose)' ), +); + +$severity_labels = array( + 'critical' => __( 'Critical', 'argus-wordpress-defence' ), + 'high' => __( 'High', 'argus-wordpress-defence' ), + 'medium' => __( 'Medium', 'argus-wordpress-defence' ), + 'low' => __( 'Low', 'argus-wordpress-defence' ), +); + +$date_format = 'd M Y, H:i'; +$last_check_display = $vuln_status['last_check'] ? wp_date( $date_format, strtotime( $vuln_status['last_check'] . ' UTC' ) ) : ''; +$next_check_display = $vuln_status['next_check'] ? wp_date( $date_format, strtotime( $vuln_status['next_check'] . ' UTC' ) ) : ''; +?> + +

+ +
+
+ + +
+
+ +

+ +
+ $label ) : ?> +
+
+
+
+ +
+ +
+

+ + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ +
+
+ +
+ +
+ +
+ +
+ +

.

+ + diff --git a/argus-wordpress-defence.php b/argus-wordpress-defence.php new file mode 100644 index 0000000..44db28a --- /dev/null +++ b/argus-wordpress-defence.php @@ -0,0 +1,46 @@ +boot(); +} +add_action( 'plugins_loaded', 'argus_wpd_init', 0 ); diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..8f55796 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,428 @@ +/* ARGUS WordPress Defence -- admin dashboard + * Same token palette as argus-appliance's own Reporting Center HTML + * output (report_html_helpers.go) -- deliberate, so this reads as part + * of the ARGUS family rather than a generic plugin skin (ADR-0053 §5's + * differentiation goal extends to the UI itself, not just detection). + */ + +.argus-wpd-shell { + --bg: #0a0e17; + --panel: #10151f; + --panel-2: #141b28; + --border: rgba(148,163,184,.14); + --border-strong: rgba(148,163,184,.24); + --text: #e5ecf5; + --muted: #93a3b8; + --faint: #5c6b82; + --cyan: #00d4ff; + --cyan-dim: rgba(0,212,255,.12); + --violet: #a78bfa; + --violet-dim: rgba(167,139,250,.12); + --rose: #ff3d3d; + --rose-dim: rgba(255,61,61,.12); + --amber: #ffaa00; + --amber-dim: rgba(255,170,0,.12); + --orange: #ff8c1a; + --emerald: #00ff88; + --emerald-dim: rgba(0,255,136,.12); + --mono: ui-monospace, "SF Mono", "Cascadia Code", Consolas, monospace; + --radius: 12px; + + /* Full-bleed within WP's #wpbody-content padding */ + margin: 0 -20px; + padding: 0; + background: var(--bg); + background-image: + radial-gradient(ellipse 900px 500px at 15% -10%, rgba(0,212,255,.08), transparent 60%), + radial-gradient(ellipse 700px 500px at 100% 10%, rgba(167,139,250,.06), transparent 55%); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + min-height: calc(100vh - 100px); +} +.argus-wpd-shell * { box-sizing: border-box; } +.argus-wpd-inner { max-width: 1280px; margin: 0 auto; padding: 28px 32px 60px; } + +/* ---------- Top header ---------- */ +.argus-wpd-topbar { + display: flex; align-items: center; gap: 16px; padding-bottom: 20px; margin-bottom: 8px; + border-bottom: 1px solid var(--border); flex-wrap: wrap; +} +.argus-wpd-logo { + width: 40px; height: 40px; border-radius: 10px; flex-shrink: 0; + background: linear-gradient(135deg, var(--cyan-dim), var(--violet-dim)); + border: 1px solid var(--border-strong); + display: flex; align-items: center; justify-content: center; font-size: 20px; + box-shadow: 0 0 24px rgba(0,212,255,.15); +} +.argus-wpd-brand { display: flex; flex-direction: column; gap: 2px; } +.argus-wpd-eyebrow { + font: 700 10.5px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--cyan); + display: flex; align-items: center; gap: 7px; +} +.argus-wpd-eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px var(--cyan); animation: argus-pulse 2s ease-in-out infinite; } +.argus-wpd-title { font-size: 19px; font-weight: 800; letter-spacing: -.01em; color: var(--text); font-variant-numeric: tabular-nums; } +.argus-wpd-domain-caption { font-size: 11.5px; color: var(--faint); } +@keyframes argus-pulse { 0%,100%{opacity:1} 50%{opacity:.35} } + +.argus-wpd-status-badge { + margin-left: auto; display: flex; align-items: center; gap: 8px; + font: 700 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; + padding: 8px 14px; border-radius: 99px; border: 1px solid var(--border-strong); background: var(--panel); +} +.argus-wpd-status-badge.on { color: var(--emerald); border-color: rgba(0,255,136,.3); background: var(--emerald-dim); } +.argus-wpd-status-badge.on .dot { background: var(--emerald); box-shadow: 0 0 8px var(--emerald); } +.argus-wpd-status-badge.danger { color: var(--rose); border-color: rgba(230,103,103,.35); background: var(--rose-dim); } +.argus-wpd-status-badge.danger .dot { background: var(--rose); box-shadow: 0 0 8px var(--rose); } +.argus-wpd-status-badge.checking { color: var(--amber); border-color: rgba(255,170,0,.35); background: var(--amber-dim); } +.argus-wpd-status-badge.checking .dot { background: var(--amber); box-shadow: 0 0 8px var(--amber); } +.argus-wpd-status-badge .dot { width: 6px; height: 6px; border-radius: 50%; animation: argus-pulse 2s ease-in-out infinite; } + +/* ---------- Nav tabs ---------- */ +.argus-wpd-nav { display: flex; gap: 4px; margin: 18px 0 28px; flex-wrap: wrap; } +.argus-wpd-nav a { + font: 600 13px/1 -apple-system, sans-serif; color: var(--muted); text-decoration: none; + padding: 9px 16px; border-radius: 8px; border: 1px solid transparent; transition: all .15s; +} +.argus-wpd-nav a:hover { color: var(--text); background: var(--panel); } +.argus-wpd-nav a.active { color: var(--cyan); background: var(--cyan-dim); border-color: rgba(0,212,255,.3); } + +/* ---------- Cards / grid ---------- */ +.argus-wpd-grid { display: grid; grid-template-columns: 220px repeat(4, 1fr); gap: 16px; margin-bottom: 28px; } +@media (max-width: 1100px) { .argus-wpd-grid { grid-template-columns: 1fr 1fr; } } +.argus-wpd-card { + background: linear-gradient(180deg, var(--panel-2), var(--panel)); + border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; + position: relative; overflow: hidden; +} +.argus-wpd-card::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; + background: linear-gradient(90deg, var(--cyan), transparent); +} +.argus-wpd-card.k-crit::before { background: linear-gradient(90deg, var(--rose), transparent); } +.argus-wpd-card.k-warn::before { background: linear-gradient(90deg, var(--amber), transparent); } +.argus-wpd-card.k-ok::before { background: linear-gradient(90deg, var(--emerald), transparent); } + +/* ---------- Score gauge ---------- */ +.argus-wpd-score { grid-row: span 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 6px; } +.argus-wpd-score svg { width: 128px; height: 128px; } +.argus-wpd-score-num { font: 800 30px/1 var(--mono); fill: var(--text); } +.argus-wpd-score-track { fill: none; stroke: var(--border); stroke-width: 8; } +.argus-wpd-score-arc { fill: none; stroke: var(--cyan); stroke-width: 8; stroke-linecap: round; filter: drop-shadow(0 0 6px rgba(0,212,255,.6)); transition: stroke-dashoffset .6s ease; } +.argus-wpd-score-label { font: 700 10.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--faint); margin-top: 4px; } + +.argus-wpd-stat-label { font: 700 10.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--faint); margin-bottom: 10px; } +.argus-wpd-stat-value { font: 800 30px/1 var(--mono); color: var(--text); font-variant-numeric: tabular-nums; } +.argus-wpd-card.k-crit .argus-wpd-stat-value { color: var(--rose); } +.argus-wpd-card.k-ok .argus-wpd-stat-value { color: var(--emerald); } +.argus-wpd-stat-sub { font-size: 11.5px; color: var(--faint); margin-top: 6px; } + +/* ---------- Panels ---------- */ +.argus-wpd-columns { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; align-items: start; } +@media (max-width: 960px) { .argus-wpd-columns { grid-template-columns: 1fr; } } +.argus-wpd-panel { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); + padding: 22px; margin-bottom: 18px; +} +.argus-wpd-panel h2, .argus-wpd-panel h3 { + font-size: 13px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); + margin: 0 0 16px; display: flex; align-items: center; gap: 8px; +} +.argus-wpd-empty { color: var(--faint); font-size: 13px; font-style: italic; padding: 20px 0; text-align: center; } + +/* ---------- Findings ---------- */ +.argus-wpd-finding-list { display: flex; flex-direction: column; gap: 10px; } + +.argus-wpd-finding-card { + border: 1px solid var(--border); border-left: 3px solid var(--faint); border-radius: 9px; + padding: 14px 16px; background: var(--panel-2); +} +.argus-wpd-finding-summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.argus-wpd-finding-title { + font-size: 13.5px; color: var(--text); font-weight: 650; flex: 1; + display: flex; align-items: center; gap: 7px; min-width: 200px; +} +.argus-wpd-finding-time { font: 500 11px/1 var(--mono); color: var(--faint); white-space: nowrap; } +.argus-wpd-flag { font-size: 15px; line-height: 1; flex-shrink: 0; } + +.argus-wpd-finding-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; } + +.argus-wpd-finding-details { margin-top: 12px; } +.argus-wpd-finding-details summary { + cursor: pointer; font: 700 11px/1 var(--mono); letter-spacing: .04em; color: var(--cyan); + list-style: none; padding: 4px 0; +} +.argus-wpd-finding-details summary::-webkit-details-marker { display: none; } +.argus-wpd-finding-details summary::before { content: "\25B8 "; } +.argus-wpd-finding-details[open] summary::before { content: "\25BE "; } +.argus-wpd-finding-details[open] summary { margin-bottom: 2px; } + +.argus-wpd-sev-badge { + font: 800 9.5px/1 var(--mono); letter-spacing: .06em; padding: 3px 8px; border-radius: 5px; flex-shrink: 0; +} +.argus-wpd-sev-critical { border-left-color: var(--rose) !important; } +.argus-wpd-sev-critical .argus-wpd-sev-badge { background: var(--rose-dim); color: var(--rose); border: 1px solid rgba(255,61,61,.3); } +.argus-wpd-sev-high { border-left-color: var(--orange) !important; } +.argus-wpd-sev-high .argus-wpd-sev-badge { background: rgba(255,140,26,.12); color: var(--orange); border: 1px solid rgba(255,140,26,.3); } +.argus-wpd-sev-medium { border-left-color: var(--amber) !important; } +.argus-wpd-sev-medium .argus-wpd-sev-badge { background: var(--amber-dim); color: var(--amber); border: 1px solid rgba(255,170,0,.3); } +.argus-wpd-sev-low, .argus-wpd-sev-info { border-left-color: var(--cyan) !important; } +.argus-wpd-sev-low .argus-wpd-sev-badge, .argus-wpd-sev-info .argus-wpd-sev-badge { background: var(--cyan-dim); color: var(--cyan); border: 1px solid rgba(0,212,255,.3); } + +.argus-wpd-status-pill { font: 700 10px/1 var(--mono); padding: 3px 9px; border-radius: 99px; background: var(--panel); border: 1px solid var(--border-strong); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } +.argus-wpd-status-open { color: var(--amber); border-color: rgba(255,170,0,.3); background: var(--amber-dim); } + +/* explainability template */ +.argus-explain { margin: 14px 0 0; display: grid; gap: 10px; } +.argus-explain dt { font: 700 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); margin-bottom: 3px; } +.argus-explain dd { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.6; } +.argus-explain dd p { margin: 0; } + +/* ---------- Buttons ---------- */ +.argus-wpd-shell .button, +.argus-wpd-shell button.button, +.argus-wpd-shell .argus-wpd-finding-actions button { + font: 700 12px/1 -apple-system, sans-serif !important; padding: 9px 16px !important; height: auto !important; + border-radius: 8px !important; border: 1px solid var(--border-strong) !important; + background: var(--panel-2) !important; color: var(--text) !important; text-shadow: none !important; box-shadow: none !important; +} +.argus-wpd-shell .button:hover { border-color: var(--cyan) !important; color: var(--cyan) !important; } +.argus-wpd-shell .button-primary, +.argus-wpd-shell input[type="submit"].button-primary { + background: var(--cyan) !important; border-color: var(--cyan) !important; color: #001820 !important; +} +.argus-wpd-shell .button-primary:hover { background: #33ddff !important; } + +/* ---------- Tables ---------- */ +.argus-wpd-shell table.widefat { + background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; + border-collapse: separate; border-spacing: 0; +} +.argus-wpd-shell table.widefat th { + background: var(--panel-2); color: var(--faint); font: 700 10.5px/1 var(--mono); text-transform: uppercase; letter-spacing: .06em; + border-bottom: 1px solid var(--border) !important; padding: 12px 16px; +} +.argus-wpd-shell table.widefat td { color: var(--muted); border-bottom: 1px solid var(--border) !important; padding: 12px 16px; font-size: 12.5px; } +.argus-wpd-shell table.widefat tr:last-child td { border-bottom: none !important; } +.argus-wpd-shell table.widefat code { background: var(--panel-2); color: var(--cyan); padding: 2px 6px; border-radius: 4px; font-size: 11.5px; } +.argus-wpd-shell table.widefat.striped > tbody > tr:nth-child(odd) { background: var(--panel-2); } + +/* ---------- Settings form ---------- */ +.argus-wpd-shell .form-table th { color: var(--text); font-weight: 650; padding: 18px 16px 18px 0; } +.argus-wpd-shell .form-table td { padding: 18px 0; } +.argus-wpd-shell .form-table label { color: var(--muted); font-size: 13px; } +.argus-wpd-shell select, .argus-wpd-shell input[type="checkbox"] { accent-color: var(--cyan); } +.argus-wpd-shell select { + background: var(--panel-2); color: var(--text); border: 1px solid var(--border-strong); border-radius: 7px; padding: 7px 10px; +} +.argus-wpd-shell .description { color: var(--faint); } +.argus-wpd-shell .notice, +.argus-wpd-shell .notice p, +.argus-wpd-shell .notice-warning, +.argus-wpd-shell .notice-warning p, +.argus-wpd-shell .notice-success, +.argus-wpd-shell .notice-success p, +.argus-wpd-shell .notice-error, +.argus-wpd-shell .notice-error p { + color: var(--text) !important; +} +.argus-wpd-shell .notice { background: var(--emerald-dim); border-left: 3px solid var(--emerald); border-radius: 6px; border-width: 0 0 0 3px; } +.argus-wpd-shell .notice-warning { background: var(--amber-dim); border-left-color: var(--amber); } +.argus-wpd-shell .notice-error { background: var(--rose-dim); border-left-color: var(--rose); } + +/* ---------- Ban / recent tables spacing ---------- */ +.argus-wpd-shell h1.argus-wpd-page-title { color: var(--text); font-size: 22px; font-weight: 800; margin: 0 0 4px; } + +/* ---------- Pagination ---------- */ +.argus-wpd-pagination { display: flex; align-items: center; justify-content: center; gap: 18px; margin: 24px 0 8px; } +.argus-wpd-page-link { color: var(--cyan); font: 700 12.5px/1 -apple-system, sans-serif; text-decoration: none; padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--panel); } +.argus-wpd-page-link:hover { border-color: var(--cyan); background: var(--cyan-dim); } +.argus-wpd-page-info { font: 600 12px/1 var(--mono); color: var(--faint); } + +/* ---------- Inline forms (block IP, whitelist add) ---------- */ +.argus-wpd-inline-form { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } +.argus-wpd-shell .argus-wpd-inline-form input[type="text"] { + background: var(--panel-2); color: var(--text); border: 1px solid var(--border-strong); border-radius: 7px; + padding: 9px 12px; font-size: 13px; min-width: 200px; +} +.argus-wpd-shell .argus-wpd-inline-form input[type="text"]:focus { border-color: var(--cyan); outline: none; } + +/* ---------- Site-type templates ---------- */ +.argus-wpd-template-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } +@media (max-width: 1200px) { .argus-wpd-template-grid { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 640px) { .argus-wpd-template-grid { grid-template-columns: 1fr; } } +.argus-wpd-template-card { + background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 18px; + display: flex; flex-direction: column; gap: 10px; +} +.argus-wpd-template-card.active { border-color: rgba(0,212,255,.4); background: var(--cyan-dim); } +.argus-wpd-template-title { font: 700 14px/1.3 -apple-system, sans-serif; color: var(--text); display: flex; flex-direction: column; gap: 6px; align-items: flex-start; } +.argus-wpd-template-badge { + font: 700 9.5px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase; padding: 3px 8px; border-radius: 5px; + background: var(--violet-dim); color: var(--violet); border: 1px solid rgba(167,139,250,.3); +} +.argus-wpd-template-badge.active { background: var(--cyan-dim); color: var(--cyan); border-color: rgba(0,212,255,.3); } +.argus-wpd-template-desc { font-size: 12px; color: var(--muted); line-height: 1.55; flex: 1; margin: 0; } + +/* ---------- Whitelist / exceptions list ---------- */ +.argus-wpd-exception-row { + display: flex; align-items: center; gap: 14px; padding: 11px 0; border-bottom: 1px solid var(--border); +} +.argus-wpd-exception-row:last-child { border-bottom: none; } +.argus-wpd-exception-row code { background: var(--panel-2); color: var(--cyan); padding: 3px 8px; border-radius: 5px; font-size: 12px; } +.argus-wpd-exception-row .note { flex: 1; color: var(--faint); font-size: 12px; } +.argus-wpd-exception-row .added { font: 500 11px/1 var(--mono); color: var(--faint); } + +/* ---------- Donut stats (Statistics page) ---------- */ +.argus-wpd-donut-row { display: flex; align-items: center; gap: 28px; flex-wrap: wrap; } +.argus-wpd-donut-svg { width: 168px; height: 168px; flex-shrink: 0; } +.argus-wpd-donut-svg circle { transition: stroke-dasharray .5s ease; } +.argus-wpd-donut-total { font: 800 22px/1 var(--mono); fill: var(--text); font-variant-numeric: tabular-nums; } +.argus-wpd-donut-legend { display: flex; flex-direction: column; gap: 10px; flex: 1; min-width: 180px; } +.argus-wpd-donut-legend-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; } +.argus-wpd-donut-swatch { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; } +.argus-wpd-donut-legend-label { color: var(--text); flex: 1; } +.argus-wpd-donut-legend-value { font: 700 12px/1 var(--mono); color: var(--muted); font-variant-numeric: tabular-nums; } +.argus-wpd-donut-legend-value em { color: var(--faint); font-style: normal; margin-left: 3px; } + +/* ---------- Console: gear icon (Settings, off primary nav) ---------- */ +.argus-wpd-gear { + margin-left: 10px; width: 38px; height: 38px; border-radius: 9px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; font-size: 16px; + background: var(--panel); border: 1px solid var(--border-strong); color: var(--muted); + text-decoration: none; transition: all .15s; +} +.argus-wpd-gear:hover, .argus-wpd-gear.active { color: var(--cyan); border-color: rgba(0,212,255,.35); background: var(--cyan-dim); } + +/* ---------- Console: metric cards w/ trend ---------- */ +.argus-wpd-metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } +@media (max-width: 1100px) { .argus-wpd-metric-grid { grid-template-columns: 1fr 1fr; } } +@media (max-width: 640px) { .argus-wpd-metric-grid { grid-template-columns: 1fr; } } +.argus-wpd-metric-card { + background: linear-gradient(180deg, var(--panel-2), var(--panel)); + border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; + position: relative; overflow: hidden; +} +.argus-wpd-metric-card::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; + background: linear-gradient(90deg, var(--cyan), transparent); +} +.argus-wpd-metric-label { font: 700 10.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase; color: var(--faint); margin-bottom: 12px; } +.argus-wpd-metric-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.argus-wpd-metric-value { font: 800 28px/1 var(--mono); color: var(--text); font-variant-numeric: tabular-nums; } +.argus-wpd-metric-sub { font-size: 11px; color: var(--faint); margin-top: 8px; } +.argus-wpd-trend { font: 700 11.5px/1 var(--mono); display: inline-flex; align-items: center; gap: 3px; padding: 4px 8px; border-radius: 99px; white-space: nowrap; } +.argus-wpd-trend.up { color: var(--rose); background: var(--rose-dim); } +.argus-wpd-trend.down { color: var(--emerald); background: var(--emerald-dim); } +.argus-wpd-trend.flat { color: var(--faint); background: var(--panel); } + +/* ---------- Console: activity area chart ---------- */ +.argus-wpd-area-card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; margin-bottom: 20px; } +.argus-wpd-area-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; } +.argus-wpd-range-tabs { display: flex; gap: 4px; } +.argus-wpd-range-tabs a { + font: 700 11px/1 var(--mono); letter-spacing: .04em; color: var(--faint); text-decoration: none; + padding: 7px 12px; border-radius: 7px; border: 1px solid var(--border); transition: all .15s; +} +.argus-wpd-range-tabs a:hover { color: var(--text); } +.argus-wpd-range-tabs a.active { color: var(--cyan); background: var(--cyan-dim); border-color: rgba(0,212,255,.3); } +.argus-wpd-area-card h2 { font: 700 13px/1 -apple-system, sans-serif; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0; } +.argus-wpd-area-svg { width: 100%; height: 160px; display: block; } +.argus-wpd-area-empty { color: var(--faint); font-size: 13px; font-style: italic; padding: 40px 0; text-align: center; } + +/* ---------- Console: status badge row ---------- */ +.argus-wpd-status-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; } +.argus-wpd-status-chip { + display: flex; align-items: center; gap: 8px; font: 700 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; + padding: 9px 14px; border-radius: 9px; border: 1px solid var(--border-strong); background: var(--panel); +} +.argus-wpd-status-chip .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } +.argus-wpd-status-chip .label { color: var(--faint); } +.argus-wpd-status-chip .state { color: var(--text); } +.argus-wpd-status-chip.chip-cyan { border-color: rgba(0,212,255,.3); background: var(--cyan-dim); } +.argus-wpd-status-chip.chip-cyan .dot { background: var(--cyan); box-shadow: 0 0 8px var(--cyan); } +.argus-wpd-status-chip.chip-cyan .state { color: var(--cyan); } +.argus-wpd-status-chip.chip-emerald { border-color: rgba(0,255,136,.3); background: var(--emerald-dim); } +.argus-wpd-status-chip.chip-emerald .dot { background: var(--emerald); box-shadow: 0 0 8px var(--emerald); } +.argus-wpd-status-chip.chip-emerald .state { color: var(--emerald); } +.argus-wpd-status-chip.chip-amber { border-color: rgba(255,170,0,.3); background: var(--amber-dim); } +.argus-wpd-status-chip.chip-amber .dot { background: var(--amber); box-shadow: 0 0 8px var(--amber); } +.argus-wpd-status-chip.chip-amber .state { color: var(--amber); } +.argus-wpd-status-chip.chip-rose { border-color: rgba(255,61,61,.3); background: var(--rose-dim); } +.argus-wpd-status-chip.chip-rose .dot { background: var(--rose); box-shadow: 0 0 8px var(--rose); } +.argus-wpd-status-chip.chip-rose .state { color: var(--rose); } +.argus-wpd-status-chip.chip-violet { border-color: rgba(167,139,250,.3); background: var(--violet-dim); } +.argus-wpd-status-chip.chip-violet .dot { background: var(--violet); box-shadow: 0 0 8px var(--violet); } +.argus-wpd-status-chip.chip-violet .state { color: var(--violet); } +.argus-wpd-status-chip.chip-off .dot { background: var(--faint); } +.argus-wpd-status-chip.chip-off .state { color: var(--faint); } + +/* ---------- Console: stat tiles (Firewall / Scanner) ---------- */ +.argus-wpd-tile-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; } +@media (max-width: 960px) { .argus-wpd-tile-row { grid-template-columns: 1fr 1fr; } } +.argus-wpd-tile { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; } +.argus-wpd-tile-label { font: 700 10px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); margin-bottom: 8px; } +.argus-wpd-tile-value { font: 800 22px/1 var(--mono); color: var(--text); font-variant-numeric: tabular-nums; } + +/* ---------- Console: Intelligence IOC rows + not-connected banner ---------- */ +.argus-wpd-not-connected { + display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px; margin-bottom: 20px; + background: var(--panel-2); border: 1px solid var(--border-strong); border-left: 3px solid var(--violet); + border-radius: var(--radius); +} +.argus-wpd-not-connected .icon { font-size: 20px; line-height: 1; flex-shrink: 0; } +.argus-wpd-not-connected h3 { margin: 0 0 6px; font-size: 13.5px; color: var(--text); font-weight: 700; } +.argus-wpd-not-connected p { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.6; } +.argus-wpd-ioc-row { + display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); +} +.argus-wpd-ioc-row:last-child { border-bottom: none; } +.argus-wpd-ioc-type { + font: 700 9.5px/1 var(--mono); letter-spacing: .05em; text-transform: uppercase; padding: 4px 9px; border-radius: 5px; + background: var(--violet-dim); color: var(--violet); border: 1px solid rgba(167,139,250,.3); flex-shrink: 0; min-width: 66px; text-align: center; +} +.argus-wpd-ioc-desc { flex: 1; font-size: 12.5px; color: var(--text); } +.argus-wpd-ioc-meta { font: 500 11px/1 var(--mono); color: var(--faint); white-space: nowrap; } + +/* ---------- Console: scanner engine cards ---------- */ +.argus-wpd-scan-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; align-items: start; } +@media (max-width: 960px) { .argus-wpd-scan-grid { grid-template-columns: 1fr; } } +.argus-wpd-scan-fact-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 12.5px; } +.argus-wpd-scan-fact-row:last-child { border-bottom: none; } +.argus-wpd-scan-fact-row .k { color: var(--faint); font: 700 10.5px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; } +.argus-wpd-scan-fact-row .v { color: var(--text); font-weight: 650; } + +/* ---------- Threats: mockup-matched Live Threat Activity + feed ---------- */ +.argus-wpd-subtle-label { font: 600 10.5px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); margin-left: 4px; } + +.argus-wpd-live-chart-panel { margin-bottom: 0; padding-bottom: 14px; } +.argus-wpd-live-chart-panel .argus-wpd-chart-wrap { line-height: 0; } + +/* Deliberately not wrapped in .argus-wpd-panel -- mirrors the reference + mockup's own composition, where the activity list flows directly + below the chart panel rather than sitting in its own bordered box. */ +.argus-wpd-live-feed-rows { display: flex; flex-direction: column; margin: 0 0 24px; } +.argus-wpd-live-feed-row { + display: flex; align-items: center; gap: 18px; + padding: 14px 4px; border-bottom: 1px solid var(--border); font-size: 13.5px; + border-radius: 6px; transition: background .15s; +} +.argus-wpd-live-feed-row:hover { background: var(--panel-2); } +.argus-wpd-live-feed-row:last-child { border-bottom: none; } +.argus-wpd-live-feed-row.is-entering { animation: argusWpdRowEnter .5s ease-out; } +@keyframes argusWpdRowEnter { + from { opacity: 0; transform: translateY(-6px); background: var(--cyan-dim); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { .argus-wpd-live-feed-row.is-entering { animation: none; } } +.argus-wpd-live-feed-time { font: 600 12.5px/1 var(--mono); color: var(--faint); font-variant-numeric: tabular-nums; flex-shrink: 0; width: 96px; white-space: nowrap; } +.argus-wpd-live-feed-desc { color: var(--text); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.argus-wpd-live-feed-action { + font: 800 10px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; + padding: 5px 12px; border-radius: 6px; flex-shrink: 0; + background: var(--rose-dim); color: var(--rose); border: 1px solid rgba(255,61,61,.3); +} +@media (max-width: 640px) { + .argus-wpd-live-feed-row { flex-wrap: wrap; } + .argus-wpd-live-feed-time { width: 100%; order: -1; } + .argus-wpd-live-feed-desc { white-space: normal; } +} diff --git a/bin/build-release.sh b/bin/build-release.sh new file mode 100755 index 0000000..803bbda --- /dev/null +++ b/bin/build-release.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Production release packaging -- docs/design/production-distribution-and-updates.md +# sections 1-3, 16, 18. Builds the public download ZIP from a clean git +# checkout (git archive -- never the working tree, so uncommitted local +# files can never leak into a release), strips comments/whitespace +# (not obfuscation -- see strip-comments.php's own header), and audits +# the result against an explicit allowlist before packaging, failing +# closed on anything unexpected rather than silently shipping it. +# +# Usage: bin/build-release.sh [git-ref] (defaults to HEAD) +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REF="${1:-HEAD}" +BUILD_DIR="$(mktemp -d)" +STAGE_DIR="${BUILD_DIR}/argus-wordpress-defence" +OUT_DIR="${REPO_ROOT}/dist" + +cleanup() { rm -rf "${BUILD_DIR}"; } +trap cleanup EXIT + +echo "==> Packaging ${REF} from a clean checkout (git archive, not the working tree)" +mkdir -p "${STAGE_DIR}" +git -C "${REPO_ROOT}" archive "${REF}" | tar -x -C "${STAGE_DIR}" + +echo "==> Removing dev-only paths not meant for the production artifact" +rm -rf \ + "${STAGE_DIR}/docs" \ + "${STAGE_DIR}/tests" \ + "${STAGE_DIR}/README.md" \ + "${STAGE_DIR}/ARGUS_WORDPRESS_SECURITY_ARCHITECTURE.md" \ + "${STAGE_DIR}/.gitignore" \ + "${STAGE_DIR}/bin" + +echo "==> Allowlist audit -- fail closed on anything unexpected" +UNEXPECTED=0 +while IFS= read -r -d '' item; do + rel="${item#"${STAGE_DIR}"/}" + case "$rel" in + argus-wordpress-defence.php|uninstall.php|readme.txt) ;; + admin|admin/*|includes|includes/*|mu-loader|mu-loader/*|assets|assets/*|languages|languages/*) ;; + *) + if [ -f "$item" ]; then + echo " UNEXPECTED FILE: ${rel}" + UNEXPECTED=1 + fi + ;; + esac +done < <(find "${STAGE_DIR}" -print0) + +# Reject known-forbidden patterns explicitly, even inside allowlisted dirs. +if find "${STAGE_DIR}" \( -name "*.key" -o -name "*.pem" -o -name ".env*" -o -name "*.map" \) | grep -q .; then + echo " FORBIDDEN FILE TYPE found (key/pem/.env/source map)" + UNEXPECTED=1 +fi + +if [ "${UNEXPECTED}" -ne 0 ]; then + echo "==> BUILD FAILED: allowlist audit found unexpected content. Nothing was packaged." + exit 1 +fi +echo " clean -- only allowlisted paths present" + +# Read the version BEFORE stripping -- it lives inside the plugin +# header's own DocBlock comment, which php_strip_whitespace() below +# would otherwise remove before this ever got a chance to read it. +VERSION="$(grep -oP '(?<=Version:)\s*\K\S+' "${STAGE_DIR}/argus-wordpress-defence.php" | head -1)" +if [ -z "${VERSION}" ]; then + echo "==> BUILD FAILED: could not read plugin version from argus-wordpress-defence.php" + exit 1 +fi +echo "==> Packaging version ${VERSION}" + +echo "==> Stripping comments/whitespace (not obfuscation -- see bin/strip-comments.php)" +php "${REPO_ROOT}/bin/strip-comments.php" "${STAGE_DIR}" + +mkdir -p "${OUT_DIR}" +ZIP_PATH="${OUT_DIR}/argus-wordpress-defence-${VERSION}.zip" +rm -f "${ZIP_PATH}" +# Prefer the `zip` CLI when present (most CI runners have it); fall +# back to PHP's ZipArchive (bin/zip-directory.php) for a host that +# only has the PHP extension -- either is a real, complete archive, no +# feature difference between the two paths. +if command -v zip >/dev/null 2>&1; then + ( cd "${BUILD_DIR}" && zip -rq "${ZIP_PATH}" "argus-wordpress-defence" ) +else + php "${REPO_ROOT}/bin/zip-directory.php" "${STAGE_DIR}" "${ZIP_PATH}" "argus-wordpress-defence" +fi + +SHA256="$(sha256sum "${ZIP_PATH}" | cut -d' ' -f1)" +echo "${SHA256} $(basename "${ZIP_PATH}")" > "${ZIP_PATH}.sha256" + +echo "==> Done" +echo " ${ZIP_PATH}" +echo " SHA-256: ${SHA256}" +echo " Size: $(du -h "${ZIP_PATH}" | cut -f1)" diff --git a/bin/generate-manifest.php b/bin/generate-manifest.php new file mode 100644 index 0000000..36af785 --- /dev/null +++ b/bin/generate-manifest.php @@ -0,0 +1,82 @@ + php generate-manifest.php [--critical]\n" ); + exit( 1 ); +} + +$secret_b64 = getenv( 'ARGUS_RELEASE_SECRET_KEY' ); +if ( ! $secret_b64 ) { + fwrite( STDERR, "ARGUS_RELEASE_SECRET_KEY environment variable is not set.\n" ); + exit( 1 ); +} +$secret_key = base64_decode( $secret_b64, true ); +if ( false === $secret_key || SODIUM_CRYPTO_SIGN_SECRETKEYBYTES !== strlen( $secret_key ) ) { + fwrite( STDERR, "ARGUS_RELEASE_SECRET_KEY is not a valid base64-encoded Ed25519 secret key.\n" ); + exit( 1 ); +} + +$zip = new ZipArchive(); +if ( true !== $zip->open( $zip_path ) ) { + fwrite( STDERR, "Could not open {$zip_path}\n" ); + exit( 1 ); +} +$header = null; +for ( $i = 0; $i < $zip->numFiles; $i++ ) { + $name = $zip->getNameIndex( $i ); + if ( preg_match( '#(^|/)argus-wordpress-defence\.php$#', $name ) ) { + $header = $zip->getFromIndex( $i ); + break; + } +} +$zip->close(); +if ( ! $header ) { + fwrite( STDERR, "Could not find argus-wordpress-defence.php inside the archive.\n" ); + exit( 1 ); +} + +preg_match( '/Version:\s*([^\r\n]+)/', $header, $m_version ); +preg_match( '/Requires at least:\s*([^\r\n]+)/', $header, $m_wp ); +preg_match( '/Requires PHP:\s*([^\r\n]+)/', $header, $m_php ); + +$version = trim( $m_version[1] ?? '' ); +$min_wp = trim( $m_wp[1] ?? '0' ); +$min_php = trim( $m_php[1] ?? '0' ); + +if ( '' === $version ) { + fwrite( STDERR, "Could not read Version from the archive's plugin header.\n" ); + exit( 1 ); +} + +$sha256 = hash_file( 'sha256', $zip_path ); + +$payload = array( + 'version' => $version, + 'released_at' => gmdate( 'Y-m-d\TH:i:s\Z' ), + 'package_url' => $package_url, + 'sha256' => $sha256, + 'min_php' => $min_php, + 'min_wp' => $min_wp, + 'critical' => $critical, +); + +$canonical = wp_json_encode_stable( $payload ); +$signature = sodium_crypto_sign_detached( $canonical, $secret_key ); + +$manifest = $payload; +$manifest['signature'] = base64_encode( $signature ); + +echo json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; + +function wp_json_encode_stable( array $payload ) { + $ordered = array(); + foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) { + $ordered[ $key ] = $payload[ $key ]; + } + return json_encode( $ordered, JSON_UNESCAPED_SLASHES ); +} diff --git a/bin/strip-comments.php b/bin/strip-comments.php new file mode 100644 index 0000000..83a7b65 --- /dev/null +++ b/bin/strip-comments.php @@ -0,0 +1,33 @@ +\n" ); + exit( 1 ); +} + +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) +); + +$count = 0; +$skipped = 0; +foreach ( $iterator as $file ) { + if ( 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + if ( 'argus-wordpress-defence.php' === $file->getFilename() ) { + $skipped++; + continue; + } + $path = $file->getPathname(); + $stripped = php_strip_whitespace( $path ); + if ( '' === trim( (string) $stripped ) ) { + fwrite( STDERR, "WARNING: stripping produced empty output for {$path}, leaving original untouched\n" ); + continue; + } + file_put_contents( $path, $stripped ); + $count++; +} + +echo "Stripped comments/whitespace from {$count} PHP files ({$skipped} skipped: plugin header file).\n"; diff --git a/bin/strip-dev-comments.php b/bin/strip-dev-comments.php new file mode 100644 index 0000000..7f60b42 --- /dev/null +++ b/bin/strip-dev-comments.php @@ -0,0 +1,90 @@ + [ ...] + */ + +function argus_should_keep_comment( string $text, bool $is_first_token_in_bootstrap_file ): bool { + if ( $is_first_token_in_bootstrap_file && false !== strpos( $text, 'Plugin Name:' ) ) { + return true; + } + $trimmed = ltrim( $text, "/ \t" ); + return 0 === stripos( $trimmed, 'phpcs:' ); +} + +function argus_strip_file( string $path ): void { + $source = file_get_contents( $path ); + $tokens = token_get_all( $source ); + $is_bootstrap = ( 'argus-wordpress-defence.php' === basename( $path ) ); + + $out = ''; + $seen_first_comment = false; + foreach ( $tokens as $token ) { + if ( is_array( $token ) ) { + list( $id, $text ) = $token; + if ( T_COMMENT === $id || T_DOC_COMMENT === $id ) { + $is_first = $is_bootstrap && ! $seen_first_comment; + $seen_first_comment = true; + if ( argus_should_keep_comment( $text, $is_first ) ) { + $out .= $text; + } + // else: drop the comment text entirely (whitespace/newlines + // around it are separate whitespace tokens, left untouched). + continue; + } + $out .= $text; + } else { + $out .= $token; + } + } + + // Collapse runs of 3+ blank lines left behind by removed comment + // blocks down to a single blank line, purely cosmetic. + $out = preg_replace( "/\n{3,}/", "\n\n", $out ); + + file_put_contents( $path, $out ); +} + +function argus_collect_php_files( array $paths ): array { + $files = array(); + foreach ( $paths as $p ) { + if ( is_dir( $p ) ) { + $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $p, FilesystemIterator::SKIP_DOTS ) ); + foreach ( $it as $f ) { + if ( 'php' === strtolower( $f->getExtension() ) ) { + $files[] = $f->getPathname(); + } + } + } elseif ( is_file( $p ) ) { + $files[] = $p; + } + } + return $files; +} + +$args = array_slice( $argv, 1 ); +if ( empty( $args ) ) { + fwrite( STDERR, "Usage: php bin/strip-dev-comments.php [...]\n" ); + exit( 1 ); +} + +$files = argus_collect_php_files( $args ); +$count = 0; +foreach ( $files as $file ) { + argus_strip_file( $file ); + $count++; +} +echo "Stripped development comments from {$count} PHP files.\n"; diff --git a/bin/zip-directory.php b/bin/zip-directory.php new file mode 100755 index 0000000..0950fd3 --- /dev/null +++ b/bin/zip-directory.php @@ -0,0 +1,37 @@ + \n" ); + exit( 1 ); +} +if ( ! class_exists( 'ZipArchive' ) ) { + fwrite( STDERR, "PHP's zip extension is not available on this host -- cannot build the release archive here.\n" ); + exit( 1 ); +} + +$zip = new ZipArchive(); +if ( true !== $zip->open( $dest_zip, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { + fwrite( STDERR, "Could not create {$dest_zip}\n" ); + exit( 1 ); +} + +$source_dir = rtrim( $source_dir, '/' ); +$iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST +); + +foreach ( $iterator as $item ) { + $relative = $root_name . '/' . ltrim( str_replace( $source_dir, '', $item->getPathname() ), '/' ); + if ( $item->isDir() ) { + $zip->addEmptyDir( $relative ); + } else { + $zip->addFile( $item->getPathname(), $relative ); + } +} + +$zip->close(); +echo "Wrote {$dest_zip}\n"; diff --git a/includes/class-argus-actions.php b/includes/class-argus-actions.php new file mode 100644 index 0000000..ec0765f --- /dev/null +++ b/includes/class-argus-actions.php @@ -0,0 +1,133 @@ + false, 'message' => __( 'You do not have permission to do this.', 'argus-wordpress-defence' ) ); + } + + switch ( $type ) { + case 'update_core': + return self::update_core(); + case 'update_plugin': + return self::update_plugin( $params['plugin'] ?? '' ); + case 'update_theme': + return self::update_theme( $params['theme'] ?? '' ); + case 'restore_quarantined_file': + return self::restore_quarantined_file( $params['path'] ?? '' ); + default: + return array( 'success' => false, 'message' => __( 'Unknown action.', 'argus-wordpress-defence' ) ); + } + } + + protected static function restore_quarantined_file( $rel_path ) { + if ( '' === $rel_path || Argus_Malware_Scanner::QUARANTINE_SUFFIX !== substr( $rel_path, -strlen( Argus_Malware_Scanner::QUARANTINE_SUFFIX ) ) ) { + return array( 'success' => false, 'message' => __( 'Not a quarantined file.', 'argus-wordpress-defence' ) ); + } + + $uploads = wp_get_upload_dir(); + $base = wp_normalize_path( trailingslashit( ABSPATH ) ); + $abs = wp_normalize_path( ABSPATH . ltrim( $rel_path, '/' ) ); + $uploads_base = wp_normalize_path( trailingslashit( $uploads['basedir'] ?? '' ) ); + + if ( '' === $uploads_base || 0 !== strpos( $abs, $uploads_base ) || false !== strpos( $rel_path, '..' ) ) { + return array( 'success' => false, 'message' => __( 'Refusing to restore a path outside the uploads directory.', 'argus-wordpress-defence' ) ); + } + if ( ! file_exists( $abs ) ) { + return array( 'success' => false, 'message' => __( 'That file no longer exists.', 'argus-wordpress-defence' ) ); + } + + $restored_abs = substr( $abs, 0, -strlen( Argus_Malware_Scanner::QUARANTINE_SUFFIX ) ); + if ( file_exists( $restored_abs ) || ! @rename( $abs, $restored_abs ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors + return array( 'success' => false, 'message' => __( 'Could not restore the file (a file already exists at the original name, or the rename failed).', 'argus-wordpress-defence' ) ); + } + + $restored_rel = str_replace( $base, '', $restored_abs ); + + Argus_Malware_Scanner::mark_restored_trusted( $restored_rel, hash_file( 'sha256', $restored_abs ) ); + + Argus_Events::record( 'action_completed', 'info', 'Restored quarantined file: ' . $restored_rel, array( 'type' => 'restore_quarantined_file', 'path' => $restored_rel ) ); + return array( 'success' => true, 'message' => sprintf( __( 'Restored %s to its original name.', 'argus-wordpress-defence' ), $restored_rel ) ); + } + + protected static function update_core() { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/update.php'; + + wp_version_check(); + $updates = get_core_updates(); + if ( empty( $updates ) || 'upgrade' !== ( $updates[0]->response ?? '' ) ) { + return array( 'success' => true, 'message' => __( 'WordPress core is already up to date.', 'argus-wordpress-defence' ) ); + } + + $upgrader = new Core_Upgrader(); + $result = $upgrader->upgrade( $updates[0] ); + + if ( is_wp_error( $result ) ) { + Argus_Events::record( 'action_failed', 'medium', 'Core update failed: ' . $result->get_error_message(), array( 'type' => 'update_core' ) ); + return array( 'success' => false, 'message' => $result->get_error_message() ); + } + + Argus_Events::record( 'action_completed', 'info', 'Updated WordPress core to ' . $updates[0]->version, array( 'type' => 'update_core', 'version' => $updates[0]->version ) ); + return array( 'success' => true, 'message' => sprintf( __( 'Updated WordPress core to %s.', 'argus-wordpress-defence' ), $updates[0]->version ) ); + } + + protected static function update_plugin( $plugin_file ) { + if ( '' === $plugin_file ) { + return array( 'success' => false, 'message' => __( 'Missing plugin.', 'argus-wordpress-defence' ) ); + } + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/update.php'; + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + + wp_update_plugins(); + + $upgrader = new Plugin_Upgrader(); + $result = $upgrader->upgrade( $plugin_file ); + + if ( is_wp_error( $result ) || false === $result ) { + $message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Plugin update failed.', 'argus-wordpress-defence' ); + Argus_Events::record( 'action_failed', 'medium', 'Plugin update failed: ' . $message, array( 'type' => 'update_plugin', 'plugin' => $plugin_file ) ); + return array( 'success' => false, 'message' => $message ); + } + + Argus_Events::record( 'action_completed', 'info', 'Updated plugin ' . $plugin_file, array( 'type' => 'update_plugin', 'plugin' => $plugin_file ) ); + return array( 'success' => true, 'message' => __( 'Plugin updated.', 'argus-wordpress-defence' ) ); + } + + protected static function update_theme( $stylesheet ) { + if ( '' === $stylesheet ) { + return array( 'success' => false, 'message' => __( 'Missing theme.', 'argus-wordpress-defence' ) ); + } + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/misc.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/update.php'; + + wp_update_themes(); + + $upgrader = new Theme_Upgrader(); + $result = $upgrader->upgrade( $stylesheet ); + + if ( is_wp_error( $result ) || false === $result ) { + $message = is_wp_error( $result ) ? $result->get_error_message() : __( 'Theme update failed.', 'argus-wordpress-defence' ); + Argus_Events::record( 'action_failed', 'medium', 'Theme update failed: ' . $message, array( 'type' => 'update_theme', 'theme' => $stylesheet ) ); + return array( 'success' => false, 'message' => $message ); + } + + Argus_Events::record( 'action_completed', 'info', 'Updated theme ' . $stylesheet, array( 'type' => 'update_theme', 'theme' => $stylesheet ) ); + return array( 'success' => true, 'message' => __( 'Theme updated.', 'argus-wordpress-defence' ) ); + } +} diff --git a/includes/class-argus-activator.php b/includes/class-argus-activator.php new file mode 100644 index 0000000..91581ae --- /dev/null +++ b/includes/class-argus-activator.php @@ -0,0 +1,55 @@ + $hook_obj ) { + if ( 0 !== strpos( (string) $hook_name, 'wp_ajax_nopriv_' ) ) { + continue; + } + + $action = substr( (string) $hook_name, strlen( 'wp_ajax_nopriv_' ) ); + $plugin = self::owning_plugin_for_hook( $hook_obj ); + + $nopriv_actions[] = array( 'action' => $action, 'plugin' => $plugin ); + } + + update_option( 'argus_wpd_ajax_inventory', array( 'checked_at' => current_time( 'mysql', true ), 'nopriv_actions' => $nopriv_actions ), false ); + + if ( ! empty( $nopriv_actions ) ) { + Argus_Findings::record( + 'ajax_inventory', + 'info', + array( + + 'what_happened' => 'Unauthenticated-reachable AJAX actions inventoried', + 'why_it_matters' => 'wp_ajax_nopriv_* handlers are callable by anyone, logged in or not -- this is a completely normal, widely-used WordPress mechanism (contact forms, AJAX search, and many other legitimate features all use it), not inherently a problem.', + 'what_argus_found' => sprintf( '%d action(s) found: %s', count( $nopriv_actions ), implode( ', ', array_map( function ( $a ) { return $a['action'] . ( $a['plugin'] ? ' (' . $a['plugin'] . ')' : '' ); }, $nopriv_actions ) ) ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'This is an inventory, not a detection -- shown so you know your site\'s full unauthenticated attack surface in one place.', + 'what_could_be_affected' => 'Depends on what each handler does -- review any you don\'t recognize.', + 'what_should_you_do' => 'No action needed unless you see an action name you don\'t recognize from a plugin you don\'t remember installing.', + ), + array( 'nopriv_actions' => $nopriv_actions ) + ); + } + + return $nopriv_actions; + } + + protected static function owning_plugin_for_hook( $hook_obj ) { + if ( ! isset( $hook_obj->callbacks ) || ! is_array( $hook_obj->callbacks ) ) { + return null; + } + + foreach ( $hook_obj->callbacks as $priority_group ) { + foreach ( $priority_group as $cb ) { + $callback = $cb['function'] ?? null; + $plugin = Argus_REST_Inventory::owning_plugin( $callback ); + if ( $plugin ) { + return $plugin; + } + } + } + + return null; + } +} diff --git a/includes/class-argus-anis-client.php b/includes/class-argus-anis-client.php new file mode 100644 index 0000000..bed09b8 --- /dev/null +++ b/includes/class-argus-anis-client.php @@ -0,0 +1,585 @@ + 'closed', 'failures' => 0, 'opened_at' => 0 ) + ); + } + + protected static function circuit_allows_request() { + $circuit = self::circuit_state(); + + if ( 'closed' === $circuit['state'] ) { + return true; + } + + if ( 'open' === $circuit['state'] ) { + if ( time() - (int) $circuit['opened_at'] >= self::CIRCUIT_COOLDOWN_SECS ) { + + $circuit['state'] = 'half_open'; + update_option( self::CIRCUIT_OPTION, $circuit, false ); + return true; + } + return false; + } + + return true; + } + + protected static function circuit_record_success() { + update_option( self::CIRCUIT_OPTION, array( 'state' => 'closed', 'failures' => 0, 'opened_at' => 0 ), false ); + } + + protected static function circuit_record_failure() { + $circuit = self::circuit_state(); + $failures = (int) $circuit['failures'] + 1; + + if ( 'half_open' === $circuit['state'] || $failures >= self::CIRCUIT_FAILURE_THRESHOLD ) { + if ( 'open' !== $circuit['state'] ) { + Argus_Events::record( 'anis_circuit_open', 'medium', 'ANIS appears unreachable -- pausing outbound requests temporarily.', array( 'failures' => $failures ) ); + } + update_option( self::CIRCUIT_OPTION, array( 'state' => 'open', 'failures' => $failures, 'opened_at' => time() ), false ); + return; + } + + update_option( self::CIRCUIT_OPTION, array( 'state' => 'closed', 'failures' => $failures, 'opened_at' => 0 ), false ); + } + + public static function circuit_is_open() { + return 'open' === self::circuit_state()['state']; + } + + protected static function base_url() { + if ( defined( 'ARGUS_WPD_ANIS_BASE_URL' ) && ARGUS_WPD_ANIS_BASE_URL ) { + return untrailingslashit( ARGUS_WPD_ANIS_BASE_URL ); + } + return untrailingslashit( (string) Argus_Settings::get( 'anis_base_url', '' ) ); + } + + protected static function license_key() { + if ( defined( 'ARGUS_WPD_ANIS_LICENSE_KEY' ) && ARGUS_WPD_ANIS_LICENSE_KEY ) { + return trim( ARGUS_WPD_ANIS_LICENSE_KEY ); + } + return trim( (string) Argus_Settings::get( 'anis_license_key', '' ) ); + } + + const INSTALL_SECRET_OPTION = 'argus_wpd_anis_install_secret'; + + public static function instance_hash() { + $secret = get_option( self::INSTALL_SECRET_OPTION ); + if ( ! $secret ) { + $secret = bin2hex( random_bytes( 24 ) ); + update_option( self::INSTALL_SECRET_OPTION, $secret, false ); + } + return substr( hash( 'sha256', $secret ), 0, 16 ); + } + + public static function register() { + if ( ! self::is_configured() ) { + return array( 'success' => false, 'message' => __( 'Set an ANIS server address first.', 'argus-wordpress-defence' ) ); + } + if ( ! self::circuit_allows_request() ) { + return array( 'success' => false, 'message' => __( 'ANIS is temporarily unreachable -- will retry automatically.', 'argus-wordpress-defence' ) ); + } + + $license_key = self::license_key(); + $body = array( 'instance_hash' => self::instance_hash(), 'version' => ARGUS_WPD_VERSION ); + if ( '' !== $license_key ) { + $body['license_key'] = $license_key; + } + + $response = wp_remote_post( + self::base_url() . '/api/v1/instances/register', + array( + 'timeout' => 15, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + self::circuit_record_failure(); + self::record_error( $response->get_error_message() ); + return array( 'success' => false, 'message' => $response->get_error_message() ); + } + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + self::circuit_record_failure(); + $message = self::extract_error_message( $response ); + self::record_error( $message ); + return array( 'success' => false, 'message' => $message ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $data ) || empty( $data['status'] ) ) { + self::circuit_record_failure(); + self::record_error( __( 'ANIS returned an unexpected response.', 'argus-wordpress-defence' ) ); + return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected response.', 'argus-wordpress-defence' ) ); + } + + self::circuit_record_success(); + update_option( self::STATUS_OPTION, 'registered', false ); + update_option( self::TIER_OPTION, $data['tier'] ?? 'community', false ); + update_option( self::LAST_REGISTER_OPTION, current_time( 'mysql', true ), false ); + update_option( self::LAST_ERROR_OPTION, '', false ); + if ( isset( $data['features'] ) && is_array( $data['features'] ) ) { + update_option( self::FEATURES_OPTION, $data['features'], false ); + } + + Argus_Events::record( 'anis_registered', 'info', sprintf( 'Connected to ANIS (%s tier)', $data['tier'] ?? 'community' ), array( 'tier' => $data['tier'] ?? 'community' ) ); + + return array( 'success' => true, 'message' => sprintf( __( 'Connected -- %s tier.', 'argus-wordpress-defence' ), $data['tier'] ?? 'community' ) ); + } + + protected static function record_error( $message ) { + update_option( self::STATUS_OPTION, 'error', false ); + update_option( self::LAST_ERROR_OPTION, $message, false ); + } + + protected static function extract_error_message( $response ) { + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( is_array( $body ) && ! empty( $body['message'] ) ) { + return sprintf( 'ANIS returned %d: %s', $code, $body['message'] ); + } + return sprintf( 'ANIS returned HTTP %d.', $code ); + } + + public static function sync() { + if ( ! self::is_connected() ) { + return array( 'success' => false, 'message' => __( 'Not connected to ANIS.', 'argus-wordpress-defence' ) ); + } + + $watermark = get_option( self::SYNC_WATERMARK_OPTION, '' ); + $last_full = get_option( self::LAST_SYNC_OPTION, '' ); + + $needs_snapshot = '' === $watermark + || ( '' !== $watermark && strtotime( $watermark . ' UTC' ) < strtotime( '-' . self::SAFETY_MARGIN_DAYS . ' days', current_time( 'timestamp', true ) ) ) + || ( '' !== $last_full && strtotime( $last_full . ' UTC' ) < strtotime( '-' . self::DRIFT_CORRECTION_HOURS . ' hours', current_time( 'timestamp', true ) ) ); + + if ( $needs_snapshot ) { + return self::sync_snapshot(); + } + + $result = self::sync_delta(); + if ( ! $result['success'] ) { + return self::sync_snapshot(); + } + return $result; + } + + const RETRY_DELAYS_MINUTES = array( 5, 5, 15, 30 ); + + public static function scheduled_sync() { + $result = self::sync(); + if ( $result['success'] ) { + self::clear_retry_ladder(); + } else { + self::start_retry_ladder(); + } + return $result; + } + + protected static function start_retry_ladder() { + update_option( self::RETRY_STAGE_OPTION, 0, false ); + update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[0] * MINUTE_IN_SECONDS ), false ); + } + + protected static function clear_retry_ladder() { + delete_option( self::RETRY_STAGE_OPTION ); + delete_option( self::NEXT_RETRY_AT_OPTION ); + } + + public static function maybe_retry_sync() { + $stage = get_option( self::RETRY_STAGE_OPTION, null ); + if ( null === $stage ) { + return; + } + if ( time() < (int) get_option( self::NEXT_RETRY_AT_OPTION, 0 ) ) { + return; + } + + $result = self::sync(); + if ( $result['success'] ) { + self::clear_retry_ladder(); + return; + } + + $next_stage = (int) $stage + 1; + if ( ! isset( self::RETRY_DELAYS_MINUTES[ $next_stage ] ) ) { + self::clear_retry_ladder(); + return; + } + update_option( self::RETRY_STAGE_OPTION, $next_stage, false ); + update_option( self::NEXT_RETRY_AT_OPTION, time() + ( self::RETRY_DELAYS_MINUTES[ $next_stage ] * MINUTE_IN_SECONDS ), false ); + } + + public static function sync_snapshot() { + $response = self::get( '/api/v1/intelligence/decisions' ); + if ( is_wp_error( $response ) ) { + return array( 'success' => false, 'message' => $response->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $data ) || ! isset( $data['decisions'] ) ) { + return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected decisions response.', 'argus-wordpress-defence' ) ); + } + + global $wpdb; + $table = Argus_DB::table( 'anis_reputation' ); + + $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore + + foreach ( (array) $data['decisions'] as $decision ) { + self::upsert_decision( $decision ); + } + + self::enforce_reputation_cap(); + + $now = current_time( 'mysql', true ); + update_option( self::LAST_SYNC_OPTION, $now, false ); + update_option( self::LAST_SUCCESS_OPTION, $now, false ); + update_option( self::SYNC_WATERMARK_OPTION, gmdate( self::SINCE_FORMAT ), false ); + + return array( 'success' => true, 'message' => sprintf( __( 'Synced %d decision(s) from ANIS (full snapshot).', 'argus-wordpress-defence' ), count( $data['decisions'] ) ) ); + } + + public static function sync_delta() { + $since = get_option( self::SYNC_WATERMARK_OPTION, '' ); + if ( '' === $since ) { + return array( 'success' => false, 'message' => 'no watermark' ); + } + + $response = self::get( '/api/v1/intelligence/decisions/delta?since=' . rawurlencode( $since ) ); + if ( is_wp_error( $response ) ) { + return array( 'success' => false, 'message' => $response->get_error_message() ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $data ) || ! isset( $data['events'] ) ) { + return array( 'success' => false, 'message' => __( 'ANIS returned an unexpected delta response.', 'argus-wordpress-defence' ) ); + } + + global $wpdb; + $table = Argus_DB::table( 'anis_reputation' ); + + foreach ( (array) $data['events'] as $event ) { + if ( 'remove' === ( $event['event'] ?? '' ) ) { + $wpdb->delete( $table, array( 'ip' => $event['ip'] ?? '' ), array( '%s' ) ); + continue; + } + self::upsert_decision( $event ); + } + + update_option( self::SYNC_WATERMARK_OPTION, gmdate( self::SINCE_FORMAT ), false ); + update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false ); + self::enforce_reputation_cap(); + + return array( 'success' => true, 'message' => sprintf( __( 'Synced %d change(s) from ANIS (incremental).', 'argus-wordpress-defence' ), count( $data['events'] ) ) ); + } + + protected static function enforce_reputation_cap() { + global $wpdb; + $table = Argus_DB::table( 'anis_reputation' ); + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + if ( $total <= self::MAX_LOCAL_REPUTATION_ROWS ) { + return; + } + + $overflow = $total - self::MAX_LOCAL_REPUTATION_ROWS; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table} ORDER BY confidence ASC, cached_at ASC LIMIT %d", // phpcs:ignore + $overflow + ) + ); + } + + protected static function upsert_decision( array $decision ) { + if ( empty( $decision['ip'] ) ) { + return; + } + global $wpdb; + $table = Argus_DB::table( 'anis_reputation' ); + $now = current_time( 'mysql', true ); + + $decided_at = ! empty( $decision['created_at'] ) ? "'" . esc_sql( gmdate( 'Y-m-d H:i:s', strtotime( $decision['created_at'] ) ) ) . "'" : 'NULL'; + $expires_at = ! empty( $decision['expires_at'] ) ? "'" . esc_sql( gmdate( 'Y-m-d H:i:s', strtotime( $decision['expires_at'] ) ) ) . "'" : 'NULL'; + + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$table} (ip, action, confidence, reports, source, reason, decided_at, expires_at, cached_at) + VALUES (%s, %s, %d, %d, %s, %s, {$decided_at}, {$expires_at}, %s) + ON DUPLICATE KEY UPDATE action = VALUES(action), confidence = VALUES(confidence), reports = VALUES(reports), source = VALUES(source), reason = VALUES(reason), decided_at = VALUES(decided_at), expires_at = VALUES(expires_at), cached_at = VALUES(cached_at)", // phpcs:ignore + $decision['ip'], + $decision['action'] ?? 'allow', + (int) ( $decision['confidence'] ?? 0 ), + (int) ( $decision['reports'] ?? 0 ), + $decision['source'] ?? '', + $decision['reason'] ?? '', + $now + ) + ); + } + + public static function cached_reputation( $ip ) { + global $wpdb; + $table = Argus_DB::table( 'anis_reputation' ); + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE ip = %s", $ip ) ); // phpcs:ignore + } + + public static function maybe_enforce( $ip ) { + if ( ! self::is_enabled() ) { + return false; + } + $row = self::cached_reputation( $ip ); + if ( ! $row || 'ban' !== $row->action || (int) $row->confidence < self::AUTO_BAN_CONFIDENCE_THRESHOLD ) { + return false; + } + if ( $row->expires_at && strtotime( $row->expires_at . ' UTC' ) < time() ) { + return false; + } + + Argus_Ban_Engine::ban( + $ip, + Argus_Ban_Engine::SOURCE_ARGUS_CLOUD, + sprintf( 'Flagged by ANIS community threat intelligence (confidence %d, %d report(s))', $row->confidence, $row->reports ), + array( 'anis_reason' => $row->reason, 'anis_source' => $row->source ), + Argus_Ban_Engine::LEVEL_EXTENDED + ); + + return true; + } + + public static function maybe_report( $ip, $source, $reason, $level ) { + if ( ! self::is_enabled() ) { + return; + } + $scenario = self::scenario_for_source( $source ); + if ( ! $scenario ) { + return; + } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return; + } + + self::report_async( $ip, $scenario ); + } + + protected static function scenario_for_source( $source ) { + $map = array( + Argus_Ban_Engine::SOURCE_LOCAL_WAF => 'waf_block', + Argus_Ban_Engine::SOURCE_BRUTE_FORCE => 'brute_force', + ); + return $map[ $source ] ?? null; + } + + public static function report_async( $ip, $scenario, $confidence = null, $country = null ) { + if ( ! in_array( $scenario, self::REPORT_SCENARIOS, true ) ) { + return; + } + if ( self::circuit_is_open() ) { + return; + } + $body = array( + 'instance_hash' => self::instance_hash(), + 'ip' => $ip, + 'scenario' => $scenario, + ); + if ( null !== $confidence ) { + $body['confidence'] = max( 1, min( 100, (int) $confidence ) ); + } + if ( $country ) { + $body['country'] = $country; + } + + wp_remote_post( + self::base_url() . '/api/v1/intelligence/report', + array( + 'timeout' => 5, + 'blocking' => false, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $body ), + ) + ); + } + + public static function check_ip_live( $ip ) { + if ( ! self::is_enabled() || ! self::circuit_allows_request() ) { + return null; + } + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { + return null; + } + + $instance = self::instance_hash(); + $args = array( 'timeout' => 2 ); + if ( $instance ) { + $args['headers'] = array( 'X-ANIS-Instance' => $instance ); + } + + $response = wp_remote_get( self::base_url() . '/api/v1/intelligence/check/' . rawurlencode( $ip ), $args ); + + if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500 ) { + self::circuit_record_failure(); + return null; + } + self::circuit_record_success(); + + if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $data ) || ! isset( $data['action'] ) ) { + return null; + } + + return array( 'action' => $data['action'], 'score' => (int) ( $data['score'] ?? 0 ) ); + } + + protected static function get( $path ) { + if ( ! self::circuit_allows_request() ) { + return new WP_Error( 'argus_anis_circuit_open', __( 'ANIS is temporarily unreachable.', 'argus-wordpress-defence' ) ); + } + + $args = array( 'timeout' => 15 ); + $instance = self::instance_hash(); + if ( $instance ) { + $args['headers'] = array( 'X-ANIS-Instance' => $instance ); + } + $response = wp_remote_get( self::base_url() . $path, $args ); + + if ( is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500 ) { + + self::circuit_record_failure(); + } else { + self::circuit_record_success(); + } + + return $response; + } + + public static function stats() { + if ( ! self::is_connected() ) { + return null; + } + $response = self::get( '/api/v1/intelligence/stats' ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + return json_decode( wp_remote_retrieve_body( $response ), true ); + } + + public static function feeds_status() { + if ( ! self::is_connected() ) { + return null; + } + $response = self::get( '/api/v1/feeds/status' ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + return null; + } + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + return is_array( $data ) ? ( $data['feeds'] ?? array() ) : array(); + } + + public static function local_reputation_count() { + global $wpdb; + return (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'anis_reputation' ) ); // phpcs:ignore + } + + public static function blocked_count() { + global $wpdb; + return (int) $wpdb->get_var( + $wpdb->prepare( + 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'bans' ) . ' WHERE source = %s', // phpcs:ignore + Argus_Ban_Engine::SOURCE_ARGUS_CLOUD + ) + ); + } + + const SYNC_INTERVAL_SECS = HOUR_IN_SECONDS; + + public static function status() { + $last_success = get_option( self::LAST_SUCCESS_OPTION, '' ); + + return array( + 'configured' => self::is_configured(), + 'enabled' => (bool) Argus_Settings::get( 'anis_enabled', true ), + 'connected' => self::is_connected(), + 'status' => get_option( self::STATUS_OPTION, 'unconfigured' ), + 'last_error' => get_option( self::LAST_ERROR_OPTION, '' ), + + 'last_sync' => $last_success, + 'next_sync' => $last_success ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + self::SYNC_INTERVAL_SECS ) : '', + 'local_count' => self::local_reputation_count(), + 'blocked_count' => self::blocked_count(), + 'circuit_open' => self::circuit_is_open(), + + 'protection' => self::protection_state(), + ); + } + + protected static function protection_state() { + if ( ! self::is_enabled() ) { + return 'not_connected'; + } + if ( self::circuit_is_open() && self::is_connected() ) { + return 'limited'; + } + return self::is_connected() ? 'active' : 'not_connected'; + } +} diff --git a/includes/class-argus-api-guard.php b/includes/class-argus-api-guard.php new file mode 100644 index 0000000..0285960 --- /dev/null +++ b/includes/class-argus-api-guard.php @@ -0,0 +1,95 @@ +get_route(); + if ( 0 !== strpos( $route, '/wp/v2/users' ) ) { + return $result; + } + + $ip = Argus_Request_Inputs::client_ip(); + + if ( Argus_Ban_Engine::is_banned( $ip ) ) { + Argus_Policy_Engine::deny_already_banned( $ip ); + return $result; + } + + $recent = self::recent_user_enumeration_hits( $ip ); + + if ( $recent >= 10 ) { + + $decision = Argus_Policy_Engine::evaluate( $ip, 'rest_abuse', array( 'route' => $route, 'recent_hits' => $recent ) ); + + Argus_Findings::record( + 'account', + 'high', + array( + + 'what_happened' => sprintf( 'Unauthenticated user-enumeration probing from %s targeting %s', $ip, $route ), + 'why_it_matters' => 'Repeated unauthenticated requests to the users endpoint are a well-known reconnaissance step -- enumerating valid usernames to fuel a subsequent brute-force run.', + 'what_argus_found' => sprintf( '%d unauthenticated requests to %s from %s. Action taken: %s (%s).', $recent, $route, $ip, $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'A real client has no reason to request this endpoint repeatedly without authenticating -- this pattern is consistent with an automated recon/scanning tool.', + 'what_could_be_affected' => 'Enumerated usernames are commonly fed into a follow-up brute-force or credential-stuffing attack against wp-login.php.', + 'what_should_you_do' => $decision['observation_only'] + ? 'ARGUS is in MONITOR mode and did not block this. Review recent activity and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.' + : 'No action needed -- ARGUS already blocked this IP.', + ), + array( 'ip' => $ip, 'route' => $route, 'recent_hits' => $recent, 'decision' => $decision ) + ); + + Argus_Policy_Engine::enforce_decision( $ip, 'rest_abuse', $decision, array( 'route' => $route, 'recent_hits' => $recent ) ); + } else { + Argus_Events::record( 'rest_user_enum_probe', 'low', 'Unauthenticated request to ' . $route, array( 'route' => $route ), $ip ); + } + + return $result; + }, + 10, + 3 + ); + } + + protected static function recent_user_enumeration_hits( $ip ) { + global $wpdb; + $table = Argus_DB::table( 'events' ); + $since = gmdate( 'Y-m-d H:i:s', time() - 300 ); + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE event_type = %s AND ip = %s AND created_at >= %s", // phpcs:ignore + 'rest_user_enum_probe', + $ip, + $since + ) + ); + } +} diff --git a/includes/class-argus-auto-update.php b/includes/class-argus-auto-update.php new file mode 100644 index 0000000..4c569a2 --- /dev/null +++ b/includes/class-argus-auto-update.php @@ -0,0 +1,45 @@ +plugin ) && ARGUS_WPD_BASENAME === $item->plugin ) { + return true; + } + return $update; + } + + public static function ensure_in_list( $list ) { + $list = is_array( $list ) ? $list : array(); + if ( ! in_array( ARGUS_WPD_BASENAME, $list, true ) ) { + $list[] = ARGUS_WPD_BASENAME; + } + return $list; + } + + public static function lock_admin_toggle( $html, $plugin_file, $plugin_data ) { + if ( ARGUS_WPD_BASENAME !== $plugin_file ) { + return $html; + } + + return '● ' . esc_html__( 'Automatic security updates: Enabled and protected', 'argus-wordpress-defence' ) . ''; + } + + public static function status() { + return array( + 'label' => __( 'Enabled and Protected', 'argus-wordpress-defence' ), + 'detail' => __( 'ARGUS Defence automatically installs important security, protection, stability, and compatibility updates to help keep your website protected. This setting is managed automatically and cannot be disabled from the plugin.', 'argus-wordpress-defence' ), + ); + } +} diff --git a/includes/class-argus-autoloader.php b/includes/class-argus-autoloader.php new file mode 100644 index 0000000..e3e536c --- /dev/null +++ b/includes/class-argus-autoloader.php @@ -0,0 +1,31 @@ +open( $path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { + @unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions + return self::record_failure( $type, __( 'Could not create the backup archive.', 'argus-wordpress-defence' ) ); + } + + $zip->addFile( $sql_tmp, 'database.sql' ); + self::add_directory_to_zip( $zip, WP_CONTENT_DIR, 'wp-content', array( + wp_normalize_path( self::backup_dir() ), + wp_normalize_path( WP_CONTENT_DIR . '/uploads/argus-wpd-data/html-cache' ), + ) ); + $zip->close(); + @unlink( $sql_tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions + + if ( ! file_exists( $path ) ) { + return self::record_failure( $type, __( 'The backup archive could not be written to disk.', 'argus-wordpress-defence' ) ); + } + + global $wpdb; + $wpdb->insert( + Argus_DB::table( 'backups' ), + array( + 'filename' => $filename, + 'type' => $type, + 'size_bytes' => filesize( $path ), + 'status' => self::STATUS_COMPLETE, + 'created_at' => current_time( 'mysql', true ), + ) + ); + + update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false ); + Argus_Events::record( 'backup_created', 'info', sprintf( 'Backup created (%s).', $type ), array( 'filename' => $filename, 'type' => $type ) ); + + self::enforce_retention(); + + return array( 'success' => true, 'message' => __( 'Backup created successfully.', 'argus-wordpress-defence' ) ); + } + + protected static function record_failure( $type, $message ) { + global $wpdb; + $wpdb->insert( + Argus_DB::table( 'backups' ), + array( + 'filename' => 'failed-' . gmdate( 'Y-m-d-His' ) . '-' . bin2hex( random_bytes( 4 ) ), + 'type' => $type, + 'size_bytes' => 0, + 'status' => self::STATUS_FAILED, + 'error_message' => $message, + 'created_at' => current_time( 'mysql', true ), + ) + ); + Argus_Events::record( 'backup_failed', 'medium', $message, array( 'type' => $type ) ); + return array( 'success' => false, 'message' => $message ); + } + + protected static function dump_database( $dest_path ) { + global $wpdb; + $handle = fopen( $dest_path, 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( ! $handle ) { + return false; + } + + fwrite( $handle, "-- ARGUS Defence database backup\n-- Generated: " . current_time( 'mysql', true ) . " UTC\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fwrite( $handle, "SET FOREIGN_KEY_CHECKS=0;\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + $tables = $wpdb->get_col( 'SHOW TABLES' ); // phpcs:ignore + foreach ( $tables as $table ) { + $create = $wpdb->get_row( "SHOW CREATE TABLE `{$table}`", ARRAY_N ); // phpcs:ignore + if ( ! $create ) { + continue; + } + fwrite( $handle, "DROP TABLE IF EXISTS `{$table}`;\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + fwrite( $handle, $create[1] . ";\n\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore + $offset = 0; + while ( $offset < $total ) { + $rows = $wpdb->get_results( "SELECT * FROM `{$table}` LIMIT " . self::DB_DUMP_BATCH_SIZE . " OFFSET {$offset}", ARRAY_A ); // phpcs:ignore + foreach ( $rows as $row ) { + $columns = array_map( function ( $c ) { return '`' . $c . '`'; }, array_keys( $row ) ); + $values = array_map( function ( $v ) use ( $wpdb ) { + return null === $v ? 'NULL' : "'" . esc_sql( $v ) . "'"; + }, array_values( $row ) ); + fwrite( $handle, "INSERT INTO `{$table}` (" . implode( ',', $columns ) . ') VALUES (' . implode( ',', $values ) . ");\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + $offset += self::DB_DUMP_BATCH_SIZE; + } + fwrite( $handle, "\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return true; + } + + protected static function add_directory_to_zip( ZipArchive $zip, $source_dir, $zip_root, array $exclude_prefixes ) { + $source_dir = wp_normalize_path( $source_dir ); + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $source_dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ( $iterator as $item ) { + $item_path = wp_normalize_path( $item->getPathname() ); + foreach ( $exclude_prefixes as $prefix ) { + if ( 0 === strpos( $item_path, $prefix ) ) { + continue 2; + } + } + $relative = $zip_root . '/' . ltrim( str_replace( $source_dir, '', $item_path ), '/' ); + if ( $item->isDir() ) { + $zip->addEmptyDir( $relative ); + } else { + $zip->addFile( $item_path, $relative ); + } + } + } + + public static function paginated( $page = 1, $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'backups' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore + ); + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + public static function get( $id ) { + global $wpdb; + return $wpdb->get_row( + $wpdb->prepare( 'SELECT * FROM ' . Argus_DB::table( 'backups' ) . ' WHERE id = %d', $id ) // phpcs:ignore + ); + } + + public static function delete( $id ) { + $row = self::get( $id ); + if ( ! $row ) { + return array( 'success' => false, 'message' => __( 'Backup not found.', 'argus-wordpress-defence' ) ); + } + + $path = trailingslashit( self::backup_dir() ) . $row->filename; + if ( file_exists( $path ) ) { + wp_delete_file( $path ); + } + + global $wpdb; + $wpdb->delete( Argus_DB::table( 'backups' ), array( 'id' => $id ), array( '%d' ) ); + + Argus_Events::record( 'backup_deleted', 'info', 'Backup deleted.', array( 'filename' => $row->filename ) ); + + return array( 'success' => true, 'message' => __( 'Backup deleted.', 'argus-wordpress-defence' ) ); + } + + protected static function enforce_retention() { + $keep = max( 1, (int) Argus_Settings::get( 'backup_retention_count', 5 ) ); + + global $wpdb; + $table = Argus_DB::table( 'backups' ); + $ids = $wpdb->get_col( + $wpdb->prepare( "SELECT id FROM {$table} WHERE status = %s ORDER BY created_at DESC", self::STATUS_COMPLETE ) // phpcs:ignore + ); + + foreach ( array_slice( $ids, $keep ) as $id ) { + self::delete( (int) $id ); + } + } + + public static function status() { + $last_success = get_option( self::LAST_SUCCESS_OPTION, '' ); + $interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS; + + global $wpdb; + $available = (int) $wpdb->get_var( + $wpdb->prepare( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'backups' ) . ' WHERE status = %s', self::STATUS_COMPLETE ) // phpcs:ignore + ); + + return array( + 'last_backup' => $last_success, + 'next_backup' => ( 'manual' !== Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) && $last_success ) + ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + $interval_secs ) + : '', + 'available' => $available, + ); + } + + public static function maybe_run_scheduled() { + if ( 'manual' === Argus_Settings::get( 'backup_schedule_mode', 'automatic' ) ) { + return; + } + + $last_success = get_option( self::LAST_SUCCESS_OPTION, '' ); + $interval_secs = max( 1, (int) Argus_Settings::get( 'backup_interval_hours', 24 ) ) * HOUR_IN_SECONDS; + + if ( $last_success && ( strtotime( $last_success . ' UTC' ) + $interval_secs ) > time() ) { + return; + } + + self::create( self::TYPE_SCHEDULED ); + } +} diff --git a/includes/class-argus-ban-engine.php b/includes/class-argus-ban-engine.php new file mode 100644 index 0000000..c2956e9 --- /dev/null +++ b/includes/class-argus-ban-engine.php @@ -0,0 +1,229 @@ +ban_level, $level ); + $wpdb->update( + Argus_DB::table( 'bans' ), + array( + 'source' => $source, + 'reason' => $reason, + 'evidence' => wp_json_encode( $evidence ), + 'ban_level' => $new_level, + 'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null, + 'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + self::ttl_for_level( $new_level ) ) : null, + ), + array( 'id' => $existing->id ), + array( '%s', '%s', '%s', '%s', '%s', '%s' ), + array( '%d' ) + ); + $id = (int) $existing->id; + } else { + $wpdb->insert( + Argus_DB::table( 'bans' ), + array( + 'ip' => $ip, + 'source' => $source, + 'reason' => $reason, + 'evidence' => wp_json_encode( $evidence ), + 'ban_level' => $level, + 'created_at' => $now, + 'expires_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null, + 'recovery_eligible_at' => $ttl ? gmdate( 'Y-m-d H:i:s', strtotime( $now ) + $ttl ) : null, + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + $id = (int) $wpdb->insert_id; + } + + self::invalidate_cache( $ip ); + + Argus_Events::record( + 'ban', + 'high', + sprintf( 'Banned %s (%s)', $ip, $reason ), + array( 'ip' => $ip, 'source' => $source, 'reason' => $reason, 'evidence' => $evidence, 'level' => $level ), + $ip + ); + + do_action( 'argus_wpd_ip_banned', $ip, $source, $reason, $level ); + + return $id; + } + + public static function is_banned( $ip ) { + if ( array_key_exists( $ip, self::$request_cache ) ) { + return self::$request_cache[ $ip ]; + } + + $cache_key = self::cache_key( $ip ); + $cached = wp_cache_get( $cache_key, self::CACHE_GROUP ); + if ( false !== $cached ) { + self::$request_cache[ $ip ] = (bool) $cached; + return self::$request_cache[ $ip ]; + } + + $result = null !== self::active_ban( $ip ); + self::$request_cache[ $ip ] = $result; + + wp_cache_set( $cache_key, $result ? 'yes' : 'no', self::CACHE_GROUP, self::CACHE_TTL ); + + return $result; + } + + protected static function cache_key( $ip ) { + return 'is_banned_' . md5( $ip ); + } + + protected static function invalidate_cache( $ip ) { + unset( self::$request_cache[ $ip ] ); + wp_cache_delete( self::cache_key( $ip ), self::CACHE_GROUP ); + } + + public static function active_ban( $ip ) { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + $now = current_time( 'mysql', true ); + + return $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE ip = %s AND lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT 1", // phpcs:ignore + $ip, + $now + ) + ); + } + + public static function lift( $ban_id ) { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + + $ip = $wpdb->get_var( $wpdb->prepare( "SELECT ip FROM {$table} WHERE id = %d", $ban_id ) ); // phpcs:ignore + + $result = $wpdb->update( + $table, + array( 'lifted_at' => current_time( 'mysql', true ) ), + array( 'id' => $ban_id ), + array( '%s' ), + array( '%d' ) + ); + + if ( $ip ) { + self::invalidate_cache( $ip ); + } + + return $result; + } + + public static function sweep_expired() { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + $now = current_time( 'mysql', true ); + + $expired = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, ip FROM {$table} WHERE lifted_at IS NULL AND expires_at IS NOT NULL AND expires_at <= %s", // phpcs:ignore + $now + ) + ); + + foreach ( $expired as $row ) { + self::lift( $row->id ); + Argus_Events::record( 'ban_expired', 'info', sprintf( 'Ban on %s expired and was lifted', $row->ip ), array( 'ip' => $row->ip ), $row->ip ); + } + + return count( $expired ); + } + + public static function recent( $limit = 50 ) { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) ); // phpcs:ignore + foreach ( $rows as &$row ) { + $row->evidence = json_decode( $row->evidence, true ); + } + return $rows; + } + + const PER_PAGE = 10; + + public static function paginated( $page = 1, $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + $now = current_time( 'mysql', true ); + + $total = (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s)", $now ) // phpcs:ignore + ); + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore + $now, + $per_page, + $offset + ) + ); + foreach ( $rows as &$row ) { + $row->evidence = json_decode( $row->evidence, true ); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + public static function manual_ban( $ip, $reason ) { + return self::ban( $ip, self::SOURCE_MANUAL, $reason ?: 'Manually banned by administrator', array(), self::LEVEL_EXTENDED ); + } + + protected static function ttl_for_level( $level ) { + switch ( $level ) { + case self::LEVEL_TEMPORARY: + return 15 * MINUTE_IN_SECONDS; + case self::LEVEL_EXTENDED: + return DAY_IN_SECONDS; + case self::LEVEL_PERMANENT: + return 0; + default: + return 15 * MINUTE_IN_SECONDS; + } + } + + protected static function escalate( $current, $incoming ) { + $rank = array( self::LEVEL_TEMPORARY => 1, self::LEVEL_EXTENDED => 2, self::LEVEL_PERMANENT => 3 ); + return ( $rank[ $incoming ] ?? 1 ) > ( $rank[ $current ] ?? 1 ) ? $incoming : $current; + } +} diff --git a/includes/class-argus-cache-discovery.php b/includes/class-argus-cache-discovery.php new file mode 100644 index 0000000..f3739d0 --- /dev/null +++ b/includes/class-argus-cache-discovery.php @@ -0,0 +1,199 @@ + count( $urls ), 'sources' => $sources ); + } + + protected static function sitemaps_from_robots() { + $response = wp_remote_get( home_url( '/robots.txt' ), array( 'timeout' => 10 ) ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return array(); + } + $body = wp_remote_retrieve_body( $response ); + $out = array(); + foreach ( preg_split( '/\r\n|\r|\n/', $body ) as $line ) { + if ( 0 === stripos( trim( $line ), 'Sitemap:' ) ) { + $out[] = trim( substr( trim( $line ), strlen( 'Sitemap:' ) ) ); + } + } + return array_slice( $out, 0, 10 ); + } + + protected static function fetch_sitemap( $url, $depth = 0 ) { + $response = wp_remote_get( $url, array( 'timeout' => 15 ) ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + $body = wp_remote_retrieve_body( $response ); + if ( '' === trim( $body ) ) { + return false; + } + + $needs_legacy_guard = PHP_VERSION_ID < 80000; + $previous = $needs_legacy_guard ? libxml_disable_entity_loader( true ) : null; // phpcs:ignore + libxml_use_internal_errors( true ); + $xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NONET ); + libxml_clear_errors(); + if ( $needs_legacy_guard ) { + libxml_disable_entity_loader( $previous ); // phpcs:ignore + } + + if ( false === $xml ) { + return false; + } + + $urls = array(); + + if ( isset( $xml->sitemap ) && $depth < 1 ) { + $i = 0; + foreach ( $xml->sitemap as $child ) { + if ( ++$i > self::MAX_SITEMAP_FILES ) { + break; + } + $loc = (string) ( $child->loc ?? '' ); + if ( $loc ) { + $child_urls = self::fetch_sitemap( $loc, $depth + 1 ); + if ( is_array( $child_urls ) ) { + $urls = array_merge( $urls, $child_urls ); + } + } + if ( count( $urls ) >= self::MAX_URLS ) { + break; + } + } + return $urls; + } + + if ( isset( $xml->url ) ) { + foreach ( $xml->url as $entry ) { + $loc = (string) ( $entry->loc ?? '' ); + if ( $loc ) { + $urls[] = $loc; + } + if ( count( $urls ) >= self::MAX_URLS ) { + break; + } + } + return $urls; + } + + return false; + } + + protected static function store_discovered( array $urls, array $sources ) { + global $wpdb; + $table = Argus_DB::table( 'cache_discovered_urls' ); + $now = current_time( 'mysql', true ); + + foreach ( $urls as $url ) { + $url = esc_url_raw( $url ); + if ( ! $url ) { + continue; + } + $classification = Argus_Cache_Eligibility::classify_url( $url ); + $source = ''; + foreach ( $sources as $s ) { + if ( false !== strpos( $url, wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { + $source = $s; + break; + } + } + + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$table} (url, source, eligibility, eligibility_reason, discovered_at, last_checked_at) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE eligibility = VALUES(eligibility), eligibility_reason = VALUES(eligibility_reason), last_checked_at = VALUES(last_checked_at)", // phpcs:ignore + $url, + $source ?: 'sitemap', + $classification['status'], + $classification['reason'], + $now, + $now + ) + ); + } + } + + public static function summary() { + global $wpdb; + $table = Argus_DB::table( 'cache_discovered_urls' ); + + $rows = $wpdb->get_results( "SELECT eligibility, COUNT(*) AS cnt FROM {$table} GROUP BY eligibility" ); // phpcs:ignore + $by_status = array(); + foreach ( $rows as $row ) { + $by_status[ $row->eligibility ] = (int) $row->cnt; + } + + return array( + 'total' => array_sum( $by_status ), + 'eligible' => $by_status[ Argus_Cache_Eligibility::SAFE_TO_CACHE ] ?? 0, + 'excluded' => ( $by_status[ Argus_Cache_Eligibility::FORCE_EXCLUDED ] ?? 0 ) + ( $by_status[ Argus_Cache_Eligibility::BYPASS ] ?? 0 ), + 'needs_review' => $by_status[ Argus_Cache_Eligibility::NEEDS_REVIEW ] ?? 0, + 'last_run' => get_option( self::LAST_RUN_OPTION, '' ), + 'last_error' => get_option( self::LAST_ERROR_OPTION, '' ), + ); + } + + public static function paginated_urls( $page = 1, $eligibility = null, $per_page = 20 ) { + global $wpdb; + $table = Argus_DB::table( 'cache_discovered_urls' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + $where = $eligibility ? $wpdb->prepare( 'WHERE eligibility = %s', $eligibility ) : ''; // phpcs:ignore + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} {$where}" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} {$where} ORDER BY id DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore + ); + + return array( + 'rows' => $rows, + 'total' => $total, + 'page' => $page, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + ); + } +} diff --git a/includes/class-argus-cache-eligibility.php b/includes/class-argus-cache-eligibility.php new file mode 100644 index 0000000..32d7152 --- /dev/null +++ b/includes/class-argus-cache-eligibility.php @@ -0,0 +1,185 @@ + $cookie_value ) { + $hit = self::match_cookie_pattern( $cookie_name ); + if ( $hit ) { + return self::result( self::BYPASS, 'Session/personalization cookie present: ' . $hit ); + } + } + + $query = (array) ( $context['query'] ?? array() ); + $meaningful_query = array_diff_key( $query, array_flip( self::IGNORABLE_QUERY_PARAMS ) ); + if ( ! empty( $meaningful_query ) ) { + return self::result( self::NEEDS_REVIEW, 'Request has query parameters beyond known tracking params: ' . implode( ', ', array_keys( $meaningful_query ) ) ); + } + + return self::result( self::SAFE_TO_CACHE, 'GET request, anonymous, no protected path, no session cookies, no meaningful query string.' ); + } + + public static function classify_response( array $context ) { + $status = (int) ( $context['status_code'] ?? 0 ); + if ( 200 !== $status ) { + return self::result( self::FORCE_EXCLUDED, 'Response status ' . $status . ' is not 200 (redirects/errors are never cached).' ); + } + + $content_type = (string) ( $context['content_type'] ?? '' ); + if ( '' !== $content_type && false === stripos( $content_type, 'text/html' ) ) { + return self::result( self::BYPASS, 'Response content-type is not text/html: ' . $content_type ); + } + + $headers = array_change_key_case( (array) ( $context['headers'] ?? array() ), CASE_LOWER ); + + $cache_control = strtolower( (string) ( $headers['cache-control'] ?? '' ) ); + if ( $cache_control ) { + foreach ( array( 'no-store', 'private', 'no-cache' ) as $directive ) { + if ( false !== strpos( $cache_control, $directive ) ) { + return self::result( self::BYPASS, 'Origin response sent Cache-Control: ' . $directive ); + } + } + } + + if ( ! empty( $headers['pragma'] ) && false !== stripos( (string) $headers['pragma'], 'no-cache' ) ) { + return self::result( self::BYPASS, 'Origin response sent Pragma: no-cache.' ); + } + + if ( ! empty( $headers['set-cookie'] ) ) { + return self::result( self::BYPASS, 'Origin response set a new cookie during this request -- likely personalized (session/cart/comment identity).' ); + } + + foreach ( (array) ( $context['new_cookies_set'] ?? array() ) as $cookie_name ) { + if ( self::match_cookie_pattern( $cookie_name ) ) { + return self::result( self::BYPASS, 'Origin response set a session/personalization cookie: ' . $cookie_name ); + } + } + + return self::result( self::SAFE_TO_CACHE, 'Response is a plain 200 text/html page with no private/session signal.' ); + } + + protected static function match_cookie_pattern( $cookie_name ) { + foreach ( self::cookie_patterns() as $pattern ) { + if ( self::wildcard_match( $pattern, $cookie_name ) ) { + return $pattern; + } + } + return false; + } + + protected static function wildcard_match( $pattern, $subject ) { + $regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i'; + return (bool) preg_match( $regex, $subject ); + } + + public static function cookie_patterns() { + $defaults = array( + + 'wordpress_logged_in_*', + 'wordpress_sec_*', + 'wordpress_*', + 'wp-settings-*', + 'wp-settings-time-*', + 'comment_author_*', + 'comment_author_email_*', + 'comment_author_url_*', + + 'woocommerce_items_in_cart', + 'woocommerce_cart_hash', + 'wp_woocommerce_session_*', + ); + + return apply_filters( 'argus_wpd_cache_bypass_cookie_patterns', $defaults ); + } + + public static function protected_paths() { + $defaults = array( + '/wp-admin', + '/wp-login.php', + '/wp-json', + '/wp-cron.php', + '/xmlrpc.php', + ); + if ( class_exists( 'WooCommerce' ) ) { + $defaults = array_merge( $defaults, self::woocommerce_paths() ); + } + return apply_filters( 'argus_wpd_cache_protected_paths', $defaults ); + } + + protected static function woocommerce_paths() { + $paths = array(); + $page_options = array( + 'woocommerce_cart_page_id' => '/cart', + 'woocommerce_checkout_page_id' => '/checkout', + 'woocommerce_myaccount_page_id' => '/my-account', + ); + foreach ( $page_options as $option => $fallback ) { + $page_id = (int) get_option( $option ); + $path = $page_id ? wp_parse_url( get_permalink( $page_id ), PHP_URL_PATH ) : null; + $paths[] = $path ? untrailingslashit( $path ) : $fallback; + } + return $paths; + } + + public static function classify_url( $url ) { + $parts = wp_parse_url( $url ); + $query = array(); + if ( ! empty( $parts['query'] ) ) { + parse_str( $parts['query'], $query ); + } + return self::classify_request( + array( + 'method' => 'GET', + 'path' => $parts['path'] ?? '/', + 'query' => $query, + 'is_admin' => false, + 'is_logged_in' => false, + 'cookies' => array(), + ) + ); + } + + protected static function result( $status, $reason ) { + return array( 'status' => $status, 'reason' => $reason ); + } +} diff --git a/includes/class-argus-cache-log.php b/includes/class-argus-cache-log.php new file mode 100644 index 0000000..37f9d07 --- /dev/null +++ b/includes/class-argus-cache-log.php @@ -0,0 +1,192 @@ + microtime( true ), + 's' => $status, + 'u' => mb_substr( (string) $url, 0, 500 ), + 'r' => mb_substr( (string) $reason, 0, 255 ), + 'ms' => null === $response_time_ms ? null : (int) $response_time_ms, + 'rule' => $rule, + ) + ); + + $file = trailingslashit( $dir ) . gmdate( 'Y-m-d' ) . '.log'; + + @file_put_contents( $file, $line . "\n", FILE_APPEND | LOCK_EX ); // phpcs:ignore + } + + public static function recent( $limit = 200, $status_filter = null ) { + $dir = self::log_dir(); + if ( ! is_dir( $dir ) ) { + return array(); + } + + $files = glob( trailingslashit( $dir ) . '*.log' ) ?: array(); + rsort( $files ); + + $rows = array(); + foreach ( array_slice( $files, 0, self::RAW_RETENTION_DAYS ) as $file ) { + $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ) ?: array(); + for ( $i = count( $lines ) - 1; $i >= 0 && count( $rows ) < $limit * 4; $i-- ) { + $row = json_decode( $lines[ $i ], true ); + if ( ! is_array( $row ) ) { + continue; + } + if ( $status_filter && ( $row['s'] ?? '' ) !== $status_filter ) { + continue; + } + $rows[] = $row; + if ( count( $rows ) >= $limit ) { + break 2; + } + } + } + + return $rows; + } + + public static function paginated( $page = 1, $per_page = 10, $status_filter = null ) { + $page = max( 1, (int) $page ); + $all = self::recent( ( $page * $per_page ) + 1, $status_filter ); + + $has_next = count( $all ) > ( $page - 1 ) * $per_page + $per_page; + $rows = array_slice( $all, ( $page - 1 ) * $per_page, $per_page ); + + return array( + 'rows' => $rows, + 'page' => $page, + 'total_pages' => $page + ( $has_next ? 1 : 0 ), + ); + } + + public static function rollup_and_prune() { + global $wpdb; + $dir = self::log_dir(); + if ( ! is_dir( $dir ) ) { + return; + } + + $table = Argus_DB::table( 'cache_stats_daily' ); + $files = glob( trailingslashit( $dir ) . '*.log' ) ?: array(); + + foreach ( $files as $file ) { + $basename = basename( $file, '.log' ); + if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $basename ) ) { + continue; + } + + $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ) ?: array(); + $by_status = array(); + foreach ( $lines as $line ) { + $row = json_decode( $line, true ); + if ( ! is_array( $row ) || empty( $row['s'] ) ) { + continue; + } + $status = $row['s']; + if ( ! isset( $by_status[ $status ] ) ) { + $by_status[ $status ] = array( 'count' => 0, 'ms' => 0 ); + } + ++$by_status[ $status ]['count']; + $by_status[ $status ]['ms'] += (int) ( $row['ms'] ?? 0 ); + } + + foreach ( $by_status as $status => $agg ) { + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$table} (stat_date, status, request_count, total_response_time_ms, total_bytes) + VALUES (%s, %s, %d, %d, 0) + ON DUPLICATE KEY UPDATE request_count = VALUES(request_count), total_response_time_ms = VALUES(total_response_time_ms)", // phpcs:ignore + $basename, + $status, + $agg['count'], + $agg['ms'] + ) + ); + } + } + + $cutoff = gmdate( 'Y-m-d', strtotime( '-' . self::RAW_RETENTION_DAYS . ' days' ) ); + foreach ( $files as $file ) { + $basename = basename( $file, '.log' ); + if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $basename ) && $basename < $cutoff ) { + @unlink( $file ); // phpcs:ignore + } + } + } + + public static function summary( $days = 7 ) { + global $wpdb; + $table = Argus_DB::table( 'cache_stats_daily' ); + $since = gmdate( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT status, SUM(request_count) AS cnt, SUM(total_response_time_ms) AS ms + FROM {$table} WHERE stat_date >= %s GROUP BY status", // phpcs:ignore + $since + ) + ); + + $out = array(); + foreach ( $rows as $row ) { + $cnt = (int) $row->cnt; + $out[ $row->status ] = array( + 'count' => $cnt, + 'avg_ms' => $cnt > 0 ? (int) round( $row->ms / $cnt ) : null, + ); + } + return $out; + } + + public static function daily_series( $days = 14 ) { + global $wpdb; + $table = Argus_DB::table( 'cache_stats_daily' ); + $since = gmdate( 'Y-m-d', strtotime( '-' . (int) $days . ' days' ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT stat_date, status, request_count FROM {$table} WHERE stat_date >= %s", $since ) // phpcs:ignore + ); + + $by_date = array(); + for ( $i = $days - 1; $i >= 0; $i-- ) { + $d = gmdate( 'Y-m-d', strtotime( "-{$i} days" ) ); + $by_date[ $d ] = array( 'date' => $d, 'hit' => 0, 'miss' => 0, 'bypass' => 0 ); + } + foreach ( $rows as $row ) { + if ( ! isset( $by_date[ $row->stat_date ] ) ) { + continue; + } + $key = strtolower( $row->status ); + if ( isset( $by_date[ $row->stat_date ][ $key ] ) ) { + $by_date[ $row->stat_date ][ $key ] += (int) $row->request_count; + } + } + + return array_values( $by_date ); + } +} diff --git a/includes/class-argus-cache-warmer.php b/includes/class-argus-cache-warmer.php new file mode 100644 index 0000000..1a4b148 --- /dev/null +++ b/includes/class-argus-cache-warmer.php @@ -0,0 +1,213 @@ + 0, 'next_allowed_ts' => 0 ) ); + if ( time() < (int) ( $backoff['next_allowed_ts'] ?? 0 ) ) { + return array( 'skipped' => true, 'reason' => 'Backing off -- origin was slow on a recent warming pass.' ); + } + + if ( self::origin_looks_unhealthy() ) { + self::apply_backoff( $backoff ); + return array( 'skipped' => true, 'reason' => 'Origin average response time is elevated; pausing warming this cycle.' ); + } + + self::refill_queue(); + + $batch_size = max( 1, (int) Argus_Settings::get( 'cache_warm_batch_size', 20 ) ); + $batch = self::claim_batch( $batch_size ); + if ( empty( $batch ) ) { + return array( 'processed' => 0 ); + } + + $concurrency = max( 1, min( 10, (int) Argus_Settings::get( 'cache_warm_concurrency', 4 ) ) ); + $min_interval = max( 0, (int) Argus_Settings::get( 'cache_warm_min_interval_secs', 1 ) ); + + $results = array( 'ok' => 0, 'failed' => 0 ); + foreach ( array_chunk( $batch, $concurrency ) as $chunk ) { + $chunk_results = self::fetch_concurrently( wp_list_pluck( $chunk, 'url' ) ); + foreach ( $chunk as $item ) { + $ok = ! empty( $chunk_results[ $item->url ] ); + self::record_result( $item, $ok ); + $ok ? ++$results['ok'] : ++$results['failed']; + } + if ( $min_interval > 0 ) { + sleep( $min_interval ); + } + } + + if ( $results['failed'] > $results['ok'] ) { + $backoff['consecutive_slow'] = (int) ( $backoff['consecutive_slow'] ?? 0 ) + 1; + self::apply_backoff( $backoff ); + } else { + update_option( self::BACKOFF_OPTION, array( 'consecutive_slow' => 0, 'next_allowed_ts' => 0 ), false ); + } + + return array( 'processed' => count( $batch ) ) + $results; + } + + protected static function origin_looks_unhealthy() { + $summary = Argus_Cache_Log::summary( 1 ); + $miss_ms = $summary[ Argus_Static_Cache::STATUS_MISS ]['avg_ms'] ?? null; + return null !== $miss_ms && $miss_ms > self::SLOW_ORIGIN_MS; + } + + protected static function apply_backoff( array $backoff ) { + $consecutive = max( 1, (int) ( $backoff['consecutive_slow'] ?? 1 ) ); + $delay = min( HOUR_IN_SECONDS, 60 * ( 2 ** $consecutive ) ); + update_option( + self::BACKOFF_OPTION, + array( 'consecutive_slow' => $consecutive, 'next_allowed_ts' => time() + $delay ), + false + ); + } + + protected static function refill_queue() { + global $wpdb; + $discovered = Argus_DB::table( 'cache_discovered_urls' ); + $queue = Argus_DB::table( 'cache_warm_queue' ); + $home = home_url( '/' ); + $now = current_time( 'mysql', true ); + + $candidates = $wpdb->get_results( + $wpdb->prepare( + "SELECT url, lastmod FROM {$discovered} + WHERE eligibility = %s + AND ( last_warmed_at IS NULL OR last_warmed_at < %s ) + AND NOT EXISTS ( SELECT 1 FROM {$queue} q WHERE q.url = {$discovered}.url AND q.status = 'queued' ) + ORDER BY (url = %s) DESC, lastmod DESC + LIMIT %d", // phpcs:ignore + Argus_Cache_Eligibility::SAFE_TO_CACHE, + gmdate( 'Y-m-d H:i:s', strtotime( '-1 hour' ) ), + $home, + self::REFILL_LIMIT + ) + ); + + foreach ( $candidates as $i => $row ) { + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$queue} (url, priority, status, queued_at) VALUES (%s, %d, 'queued', %s)", // phpcs:ignore + $row->url, + $row->url === $home ? 0 : ( 100 + $i ), + $now + ) + ); + } + } + + protected static function claim_batch( $limit ) { + global $wpdb; + $table = Argus_DB::table( 'cache_warm_queue' ); + + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE status = 'queued' ORDER BY priority ASC, id ASC LIMIT %d", $limit ) // phpcs:ignore + ); + if ( empty( $rows ) ) { + return array(); + } + + $ids = wp_list_pluck( $rows, 'id' ); + $wpdb->query( "UPDATE {$table} SET status = 'processing' WHERE id IN (" . implode( ',', array_map( 'absint', $ids ) ) . ')' ); // phpcs:ignore + + return $rows; + } + + protected static function record_result( $item, $ok ) { + global $wpdb; + $queue = Argus_DB::table( 'cache_warm_queue' ); + $discovered = Argus_DB::table( 'cache_discovered_urls' ); + $now = current_time( 'mysql', true ); + + if ( $ok ) { + $wpdb->update( $queue, array( 'status' => 'done', 'last_attempt_at' => $now, 'last_result' => 'warmed' ), array( 'id' => $item->id ) ); + $wpdb->update( $discovered, array( 'last_warmed_at' => $now ), array( 'url' => $item->url ) ); + Argus_Cache_Log::record( Argus_Static_Cache::STATUS_WARMED, $item->url, 'Preloaded by cache warmer.' ); + return; + } + + $attempts = (int) $item->attempts + 1; + if ( $attempts >= self::MAX_ATTEMPTS ) { + $wpdb->update( $queue, array( 'status' => 'failed', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'gave up after ' . $attempts . ' attempts' ), array( 'id' => $item->id ) ); + } else { + $wpdb->update( $queue, array( 'status' => 'queued', 'attempts' => $attempts, 'last_attempt_at' => $now, 'last_result' => 'retrying' ), array( 'id' => $item->id ) ); + } + } + + protected static function fetch_concurrently( array $urls ) { + if ( ! function_exists( 'curl_multi_init' ) ) { + $out = array(); + foreach ( $urls as $url ) { + $response = wp_remote_get( $url, array( 'timeout' => 20, 'user-agent' => self::user_agent() ) ); + $out[ $url ] = ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) < 500; + } + return $out; + } + + $mh = curl_multi_init(); // phpcs:ignore WordPress.WP.AlternativeFunctions + $handles = array(); + + foreach ( $urls as $url ) { + $ch = curl_init( $url ); // phpcs:ignore WordPress.WP.AlternativeFunctions + curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions + $ch, + array( + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_USERAGENT => self::user_agent(), + CURLOPT_NOBODY => false, + CURLOPT_SSL_VERIFYPEER => true, + ) + ); + curl_multi_add_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $handles[ $url ] = $ch; + } + + $running = null; + do { + curl_multi_exec( $mh, $running ); // phpcs:ignore WordPress.WP.AlternativeFunctions + curl_multi_select( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } while ( $running > 0 ); + + $out = array(); + foreach ( $handles as $url => $ch ) { + $code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $out[ $url ] = $code > 0 && $code < 500; + curl_multi_remove_handle( $mh, $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions + curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + curl_multi_close( $mh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + return $out; + } + + protected static function user_agent() { + return 'ARGUS-Defence-CacheWarmer/1.0 (+' . home_url( '/' ) . ')'; + } + + public static function status() { + global $wpdb; + $table = Argus_DB::table( 'cache_warm_queue' ); + $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore + + $out = array( 'queued' => 0, 'processing' => 0, 'done' => 0, 'failed' => 0 ); + foreach ( $rows as $row ) { + if ( isset( $out[ $row->status ] ) ) { + $out[ $row->status ] = (int) $row->cnt; + } + } + $backoff = get_option( self::BACKOFF_OPTION, array() ); + $out['paused_until'] = ( ! empty( $backoff['next_allowed_ts'] ) && $backoff['next_allowed_ts'] > time() ) ? $backoff['next_allowed_ts'] : null; + return $out; + } +} diff --git a/includes/class-argus-challenge.php b/includes/class-argus-challenge.php new file mode 100644 index 0000000..dbae5e9 --- /dev/null +++ b/includes/class-argus-challenge.php @@ -0,0 +1,146 @@ + $ip, + 'prefix' => $prefix, + 'difficulty' => $difficulty, + 'issued_at' => time(), + ), + self::TTL_SECONDS + ); + + return array( + 'challenge_id' => $challenge_id, + 'prefix' => $prefix, + 'difficulty' => $difficulty, + ); + } + + public static function verify( $challenge_id, $counter, $ip ) { + $data = get_transient( self::TRANSIENT_PREFIX . $challenge_id ); + if ( ! $data || $data['ip'] !== $ip ) { + return false; + } + + $hash = hash( 'sha256', $data['prefix'] . $counter ); + $ok = 0 === strncmp( $hash, str_repeat( '0', $data['difficulty'] ), $data['difficulty'] ); + + if ( $ok ) { + + delete_transient( self::TRANSIENT_PREFIX . $challenge_id ); + self::issue_pass_cookie( $ip ); + } + + return $ok; + } + + protected static function issue_pass_cookie( $ip ) { + $token = wp_generate_password( 32, false ); + set_transient( 'argus_wpd_pass_' . $token, $ip, self::PASS_TTL_SECONDS ); + setcookie( self::COOKIE_NAME, $token, time() + self::PASS_TTL_SECONDS, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true ); + } + + public static function has_valid_pass( $ip ) { + if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) { // phpcs:ignore + return false; + } + $token = sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ); // phpcs:ignore + $stored_ip = get_transient( 'argus_wpd_pass_' . $token ); + return $stored_ip && hash_equals( (string) $stored_ip, $ip ); + } + + public static function render_and_exit( $ip ) { + $challenge = self::issue( $ip ); + nocache_headers(); + status_header( 429 ); + header( 'Content-Type: text/html; charset=utf-8' ); + ?> + + + + +Verifying your browser… + + + + +
+
+

Verifying your browser

+

This site is protected by ARGUS WordPress Defence. This should only take a moment.

+
+ + + + $value ) { + if ( $value <= 0 ) { + continue; + } + $len = ( $value / $total ) * $circumference; + $color = self::PALETTE[ $i % count( self::PALETTE ) ]; + + $segments .= sprintf( + '%8$s: %9$s', + esc_attr( $c ), + esc_attr( $r ), + esc_attr( $color ), + esc_attr( $stroke ), + esc_attr( round( $len, 3 ) ), + esc_attr( round( $circumference - $len, 3 ) ), + esc_attr( round( -$offset, 3 ) ), + esc_html( $label ), + esc_html( number_format_i18n( $value ) ) + ); + $offset += $len; + $i++; + } + + return sprintf( + '' + . '' + . '%6$s' + . '%7$s' + . '', + $size, + esc_attr__( 'Distribution chart', 'argus-wordpress-defence' ), + esc_attr( $c ), + esc_attr( $r ), + esc_attr( $stroke ), + $segments, + esc_html( number_format_i18n( $total ) ) + ); + } + + public static function legend( array $data ) { + $total = array_sum( $data ); + if ( $total <= 0 ) { + return ''; + } + + $html = '
'; + $i = 0; + foreach ( $data as $label => $value ) { + if ( $value <= 0 ) { + continue; + } + $color = self::PALETTE[ $i % count( self::PALETTE ) ]; + $pct = round( $value / $total * 100 ); + + $html .= sprintf( + '
%2$s%3$s (%4$s%%)
', + esc_attr( $color ), + esc_html( $label ), + esc_html( number_format_i18n( $value ) ), + esc_html( $pct ) + ); + $i++; + } + $html .= '
'; + return $html; + } + + public static function area_chart( array $series, $width = 640, $height = 160 ) { + $count = count( $series ); + if ( $count < 2 ) { + return ''; + } + + $values = wp_list_pluck( $series, 'value' ); + $max = max( 1, max( $values ) ); + $pad_x = 8; + $pad_y = 10; + $plot_w = $width - ( $pad_x * 2 ); + $plot_h = $height - ( $pad_y * 2 ); + $step = $plot_w / ( $count - 1 ); + + $points = array(); + foreach ( array_values( $values ) as $i => $value ) { + $x = $pad_x + ( $i * $step ); + $y = $pad_y + $plot_h - ( ( $value / $max ) * $plot_h ); + $points[] = array( round( $x, 2 ), round( $y, 2 ) ); + } + + $line_path = 'M ' . implode( ' L ', array_map( function ( $p ) { + return $p[0] . ' ' . $p[1]; + }, $points ) ); + + $fill_path = $line_path + . sprintf( ' L %s %s', round( $pad_x + $plot_w, 2 ), round( $pad_y + $plot_h, 2 ) ) + . sprintf( ' L %s %s Z', $pad_x, round( $pad_y + $plot_h, 2 ) ); + + $last = end( $points ); + + return sprintf( + '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '', + $width, + $height, + esc_attr__( 'Blocked requests over time', 'argus-wordpress-defence' ), + esc_attr( $fill_path ), + esc_attr( $line_path ), + esc_attr( $last[0] ), + esc_attr( $last[1] ) + ); + } +} diff --git a/includes/class-argus-console-stats.php b/includes/class-argus-console-stats.php new file mode 100644 index 0000000..79793ec --- /dev/null +++ b/includes/class-argus-console-stats.php @@ -0,0 +1,410 @@ + 'Already-banned IP retry', + 'brute-force-threshold' => 'Brute-force login threshold', + 'login-elevated' => 'Elevated login failure rate', + 'exception-ip' => 'Allowlisted IP', + 'xmlrpc_abuse' => 'XML-RPC abuse', + 'rest_abuse' => 'REST API user enumeration', + 'waf-no-hits' => 'No WAF signal', + 'waf-low-severity' => 'Low-severity WAF signal', + 'no-match' => 'No policy rule matched', + ); + + const ACCOUNT_TRIGGERS = array( 'active-ban', 'brute-force-threshold', 'login-elevated', 'exception-ip' ); + const API_TRIGGERS = array( 'xmlrpc_abuse', 'rest_abuse' ); + + public static function protection_state() { + if ( ! Argus_Settings::get( 'waf_enabled', true ) ) { + return 'action_required'; + } + + $open = Argus_Findings::open_counts(); + if ( ( $open['critical'] ?? 0 ) > 0 || ( $open['high'] ?? 0 ) > 0 ) { + return 'action_required'; + } + + if ( class_exists( 'Argus_MU_Installer' ) ) { + $activated_at = get_option( 'argus_wpd_activated_at' ); + $just_activated = $activated_at && ( time() - strtotime( $activated_at . ' UTC' ) ) < 5 * MINUTE_IN_SECONDS; + if ( $just_activated && 'never' === Argus_MU_Installer::execution_status() ) { + return 'checking'; + } + } + + return 'protected'; + } + + public static function blocked_today() { + $since = gmdate( 'Y-m-d 00:00:00' ); + return self::count_actions_since( $since, 'block' ); + } + + public static function blocked_since_days( $days ) { + $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $days * DAY_IN_SECONDS ) ); + return self::count_actions_since( $since, 'block' ); + } + + protected static function count_actions_since( $since_mysql, $action = null ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + + if ( $action ) { + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE action = %s AND created_at >= %s", $action, $since_mysql ) // phpcs:ignore + ); + } + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s", $since_mysql ) // phpcs:ignore + ); + } + + public static function malicious_ip_count() { + global $wpdb; + $table = Argus_DB::table( 'bans' ); + $now = current_time( 'mysql', true ); + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(DISTINCT ip) FROM {$table} WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > %s)", // phpcs:ignore + $now + ) + ); + } + + public static function security_events_count( $days = 30 ) { + global $wpdb; + $table = Argus_DB::table( 'events' ); + $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $days * DAY_IN_SECONDS ) ); + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE created_at >= %s AND severity IN ('critical','high','medium')", // phpcs:ignore + $since + ) + ); + } + + public static function blocked_series( $range ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + + switch ( $range ) { + case '30d': + $buckets = 30; + $bucket_secs = DAY_IN_SECONDS; + $fmt = 'M j'; + break; + case '7d': + $buckets = 7; + $bucket_secs = DAY_IN_SECONDS; + $fmt = 'D'; + break; + case '24h': + default: + $buckets = 24; + $bucket_secs = HOUR_IN_SECONDS; + $fmt = 'ga'; + break; + } + + $now = (int) current_time( 'timestamp', true ); + $since_epoch = $now - ( $buckets * $bucket_secs ); + $since_mysql = gmdate( 'Y-m-d H:i:s', $since_epoch ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT created_at FROM {$table} WHERE action = %s AND created_at >= %s", // phpcs:ignore + 'block', + $since_mysql + ) + ); + + $series = array(); + for ( $i = $buckets - 1; $i >= 0; $i-- ) { + $bucket_start = $now - ( $i * $bucket_secs ); + $key = (int) floor( $bucket_start / $bucket_secs ); + $series[ $key ] = array( + 'label' => gmdate( $fmt, $bucket_start ), + 'value' => 0, + ); + } + + foreach ( $rows as $row ) { + $ts = strtotime( $row->created_at . ' UTC' ); + $key = (int) floor( $ts / $bucket_secs ); + if ( isset( $series[ $key ] ) ) { + $series[ $key ]['value']++; + } + } + + return array_values( $series ); + } + + public static function blocked_per_minute_series( $minutes = 30 ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + + $now = (int) current_time( 'timestamp', true ); + $since_epoch = $now - ( $minutes * MINUTE_IN_SECONDS ); + $since_mysql = gmdate( 'Y-m-d H:i:s', $since_epoch ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT created_at FROM {$table} WHERE action = %s AND created_at >= %s", // phpcs:ignore + 'block', + $since_mysql + ) + ); + + $series = array(); + for ( $i = $minutes - 1; $i >= 0; $i-- ) { + $bucket_start = $now - ( $i * MINUTE_IN_SECONDS ); + $key = (int) floor( $bucket_start / MINUTE_IN_SECONDS ); + $series[ $key ] = array( + 'label' => gmdate( 'H:i', $bucket_start ), + 'value' => 0, + ); + } + + foreach ( $rows as $row ) { + $ts = strtotime( $row->created_at . ' UTC' ); + $key = (int) floor( $ts / MINUTE_IN_SECONDS ); + if ( isset( $series[ $key ] ) ) { + $series[ $key ]['value']++; + } + } + + return array_values( $series ); + } + + public static function live_feed_time_label( $created_at_utc_mysql ) { + $ts = strtotime( $created_at_utc_mysql . ' UTC' ); + if ( wp_date( 'Y-m-d', $ts ) === wp_date( 'Y-m-d' ) ) { + return wp_date( 'H:i:s', $ts ); + } + return wp_date( 'M j, H:i', $ts ); + } + + public static function live_feed_description( $flag, $ip, $type ) { + return sprintf( + + __( 'Blocked %3$s from %1$s %2$s', 'argus-wordpress-defence' ), + $flag, + $ip, + $type + ); + } + + public static function latest_blocked_events( $limit = 10 ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ip, rule_name, created_at FROM {$table} WHERE action = 'block' ORDER BY created_at DESC LIMIT %d", // phpcs:ignore + $limit + ) + ); + + $out = array(); + foreach ( $rows as $row ) { + $type = self::rule_label( self::first_rule_id( $row->rule_name ) ); + $out[] = array( + 'ip' => $row->ip, + 'flag' => Argus_GeoIP::icon( $row->ip ), + 'origin' => Argus_GeoIP::label( $row->ip ), + 'type' => $type, + 'desc' => self::live_feed_description( Argus_GeoIP::icon( $row->ip ), $row->ip, $type ), + 'created_at' => $row->created_at, + ); + } + return $out; + } + + const ACTIVITY_PER_PAGE = 10; + + public static function recent_activity_paginated( $page = 1, $per_page = self::ACTIVITY_PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE action != 'allow'" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT ip, trigger_type, rule_name, action, created_at FROM {$table} WHERE action != 'allow' ORDER BY created_at DESC LIMIT %d OFFSET %d", // phpcs:ignore + $per_page, + $offset + ) + ); + + foreach ( $rows as &$row ) { + $row->label = self::rule_label( self::first_rule_id( $row->rule_name ) ); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + protected static function first_rule_id( $rule_name ) { + $parts = explode( ',', (string) $rule_name ); + return trim( $parts[0] ); + } + + public static function firewall_rule_hits( $hours = 24 ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT rule_name, action, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' AND rule_name IS NOT NULL AND rule_name != '' GROUP BY rule_name, action", // phpcs:ignore + $since + ) + ); + + $tally = array(); + foreach ( $rows as $row ) { + foreach ( explode( ',', $row->rule_name ) as $rule_id ) { + $rule_id = trim( $rule_id ); + if ( '' === $rule_id ) { + continue; + } + $key = $rule_id . '|' . $row->action; + $tally[ $key ] = ( $tally[ $key ] ?? 0 ) + (int) $row->cnt; + } + } + + arsort( $tally ); + $tally = array_slice( $tally, 0, 10, true ); + + $out = array(); + foreach ( $tally as $key => $cnt ) { + list( $rule_id, $action ) = explode( '|', $key, 2 ); + $out[] = array( + 'rule_id' => $rule_id, + 'label' => self::rule_label( $rule_id ), + 'category' => self::rule_category_label( $rule_id ), + 'action' => $action, + 'hits' => $cnt, + ); + } + + return $out; + } + + public static function attack_category_totals( $hours = 24 ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT rule_name, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' AND rule_name IS NOT NULL AND rule_name != '' GROUP BY rule_name", // phpcs:ignore + $since + ) + ); + + $totals = array(); + foreach ( $rows as $row ) { + foreach ( explode( ',', $row->rule_name ) as $rule_id ) { + $rule_id = trim( $rule_id ); + if ( '' === $rule_id ) { + continue; + } + $label = self::rule_category_label( $rule_id ); + $totals[ $label ] = ( $totals[ $label ] ?? 0 ) + (int) $row->cnt; + } + } + + arsort( $totals ); + return $totals; + } + + public static function security_action_totals( $hours = 24 ) { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + $since = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp', true ) - ( (int) $hours * HOUR_IN_SECONDS ) ); + + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT action, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s AND action != 'allow' GROUP BY action", // phpcs:ignore + $since + ) + ); + + $labels = array( + 'block' => __( 'Blocked', 'argus-wordpress-defence' ), + 'challenge' => __( 'Challenged', 'argus-wordpress-defence' ), + 'rate_limit' => __( 'Rate Limited', 'argus-wordpress-defence' ), + ); + + $totals = array(); + foreach ( $rows as $row ) { + $label = $labels[ $row->action ] ?? ucfirst( $row->action ); + $totals[ $label ] = ( $totals[ $label ] ?? 0 ) + (int) $row->cnt; + } + + arsort( $totals ); + return $totals; + } + + protected static function waf_rule_map() { + static $map = null; + if ( null === $map ) { + $map = array(); + foreach ( Argus_WAF_Rules::corpus() as $rule ) { + $map[ $rule['id'] ] = $rule; + } + } + return $map; + } + + public static function rule_label( $rule_id ) { + if ( isset( self::waf_rule_map()[ $rule_id ] ) || isset( self::NON_WAF_LABELS[ $rule_id ] ) ) { + return self::NON_WAF_LABELS[ $rule_id ] ?? self::humanize_rule_id( $rule_id ); + } + return self::humanize_rule_id( $rule_id ); + } + + protected static function humanize_rule_id( $rule_id ) { + return ucwords( str_replace( '-', ' ', $rule_id ) ); + } + + public static function rule_category_label( $rule_id ) { + $map = self::waf_rule_map(); + if ( isset( $map[ $rule_id ] ) ) { + return self::waf_category_label( $map[ $rule_id ]['category'] ); + } + if ( in_array( $rule_id, self::ACCOUNT_TRIGGERS, true ) ) { + return __( 'Account', 'argus-wordpress-defence' ); + } + if ( in_array( $rule_id, self::API_TRIGGERS, true ) ) { + return __( 'API Abuse', 'argus-wordpress-defence' ); + } + return __( 'Policy', 'argus-wordpress-defence' ); + } + + public static function waf_category_label( $category ) { + $labels = array( + 'sql_injection' => __( 'SQL Injection', 'argus-wordpress-defence' ), + 'xss' => __( 'Cross-Site Scripting', 'argus-wordpress-defence' ), + 'rce' => __( 'Remote Code Execution', 'argus-wordpress-defence' ), + 'file_access' => __( 'Path Traversal / LFI', 'argus-wordpress-defence' ), + 'protocol_anomaly' => __( 'Protocol Anomaly', 'argus-wordpress-defence' ), + ); + return $labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) ); + } +} diff --git a/includes/class-argus-correlation.php b/includes/class-argus-correlation.php new file mode 100644 index 0000000..daf8929 --- /dev/null +++ b/includes/class-argus-correlation.php @@ -0,0 +1,124 @@ +get_results( + $wpdb->prepare( + "SELECT * FROM {$events_table} WHERE event_type IN ('ban','policy_observed','login_failed') AND severity IN ('high','critical') AND ip IS NOT NULL AND created_at >= %s ORDER BY created_at ASC", // phpcs:ignore + $since + ) + ); + + if ( empty( $suspicious ) ) { + update_option( self::WATERMARK_OPTION, $now, false ); + return 0; + } + + $file_events = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$events_table} WHERE event_type IN ('integrity_new_file','integrity_changed_file') AND created_at >= %s ORDER BY created_at ASC", // phpcs:ignore + $since + ) + ); + + $by_ip = array(); + foreach ( $suspicious as $event ) { + $by_ip[ $event->ip ][] = $event; + } + + $correlated = 0; + + foreach ( $by_ip as $ip => $ip_events ) { + $request_event = $ip_events[0]; + $request_time = strtotime( $request_event->created_at . ' UTC' ); + + $best_delta = null; + $best_file = null; + $best_payload = null; + + foreach ( $file_events as $file_event ) { + $payload = json_decode( $file_event->payload, true ); + $file_mtime = isset( $payload['file_mtime'] ) ? strtotime( $payload['file_mtime'] . ' UTC' ) : null; + if ( null === $file_mtime ) { + continue; + } + + $delta = $file_mtime - $request_time; + if ( $delta < 0 || $delta > self::WINDOW_SECONDS ) { + continue; + } + + if ( null === $best_delta || $delta < $best_delta ) { + $best_delta = $delta; + $best_file = $file_event; + $best_payload = $payload; + } + } + + if ( null !== $best_file ) { + self::record_correlated_finding( $request_event, $best_file, $best_payload, $best_delta ); + $correlated++; + } + } + + update_option( self::WATERMARK_OPTION, $now, false ); + return $correlated; + } + + protected static function record_correlated_finding( $request_event, $file_event, array $file_payload, $delta_seconds ) { + Argus_Findings::record( + 'correlation', + 'critical', + array( + 'what_happened' => sprintf( + 'A high-severity request from %s was followed %d seconds later by a %s: %s', + $request_event->ip, + $delta_seconds, + 'integrity_new_file' === $file_event->event_type ? 'new file' : 'changed file', + $file_payload['file_path'] ?? 'unknown' + ), + 'why_it_matters' => 'A suspicious request closely followed by a filesystem change is a much stronger signal than either event alone -- this is the pattern a successful exploit-then-webshell-drop typically produces.', + 'what_argus_found' => sprintf( + 'Request event: %s (%s, severity %s) at %s. File event: %s at %s.', + $request_event->event_type, + $request_event->summary, + $request_event->severity, + $request_event->created_at, + $file_event->summary, + $file_event->created_at + ), + 'when_it_happened' => $file_event->created_at, + 'why_suspicious' => sprintf( 'The file change happened within %d seconds of the suspicious request -- close enough in time to be a plausible cause-and-effect, though ARGUS cannot prove the same actor performed both (a filesystem write carries no IP attribution).', self::WINDOW_SECONDS ), + 'what_could_be_affected' => 'If this represents a successful exploit, the new/changed file may itself be malicious code with the same access as your web application.', + 'what_should_you_do' => 'Review the file named above immediately, and treat the source IP as a confirmed active threat rather than a routine block.', + ), + array( + 'request_event_id' => $request_event->id, + 'file_event_id' => $file_event->id, + 'ip' => $request_event->ip, + 'delta_seconds' => $delta_seconds, + ) + ); + + Argus_Ban_Engine::ban( + $request_event->ip, + Argus_Ban_Engine::SOURCE_POLICY_ENGINE, + 'Correlated with a filesystem change ' . $delta_seconds . 's later', + array( 'request_event_id' => $request_event->id, 'file_event_id' => $file_event->id ), + Argus_Ban_Engine::LEVEL_EXTENDED + ); + } +} diff --git a/includes/class-argus-db.php b/includes/class-argus-db.php new file mode 100644 index 0000000..73028bf --- /dev/null +++ b/includes/class-argus-db.php @@ -0,0 +1,311 @@ +get_var( + $wpdb->prepare( 'SHOW INDEX FROM ' . $table . ' WHERE Key_name = %s', $index ) // phpcs:ignore + ); + if ( $exists ) { + $wpdb->query( 'ALTER TABLE ' . $table . ' DROP INDEX ' . $index ); // phpcs:ignore + } + } + + public static function table( $name ) { + global $wpdb; + return $wpdb->prefix . 'argus_' . $name; + } + + public static function install() { + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset_collate = $wpdb->get_charset_collate(); + + $events = self::table( 'events' ); + $findings = self::table( 'findings' ); + $bans = self::table( 'bans' ); + $policy_decisions = self::table( 'policy_decisions' ); + $integrity_baseline = self::table( 'integrity_baseline' ); + $login_attempts = self::table( 'login_attempts' ); + $vuln_cache = self::table( 'vuln_cache' ); + $quarantine = self::table( 'quarantine' ); + $cache_stats_daily = self::table( 'cache_stats_daily' ); + $cache_discovered_urls = self::table( 'cache_discovered_urls' ); + $cache_warm_queue = self::table( 'cache_warm_queue' ); + $anis_reputation = self::table( 'anis_reputation' ); + $vuln_status = self::table( 'vuln_status' ); + $scan_history = self::table( 'scan_history' ); + $backups = self::table( 'backups' ); + + $sql = "CREATE TABLE {$events} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_type VARCHAR(40) NOT NULL, + severity VARCHAR(20) NOT NULL DEFAULT 'info', + ip VARCHAR(45) NULL, + summary VARCHAR(255) NOT NULL, + payload LONGTEXT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY event_type (event_type), + KEY created_at (created_at), + KEY ip (ip) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$findings} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(40) NOT NULL, + severity VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + detail LONGTEXT NULL, + remediation_hint LONGTEXT NULL, + evidence LONGTEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'open', + first_seen_at DATETIME NOT NULL, + last_seen_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY category (category), + KEY status (status), + KEY severity (severity) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$bans} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL, + source VARCHAR(40) NOT NULL, + reason VARCHAR(255) NOT NULL, + evidence LONGTEXT NULL, + ban_level VARCHAR(20) NOT NULL DEFAULT 'temporary', + created_at DATETIME NOT NULL, + expires_at DATETIME NULL, + recovery_eligible_at DATETIME NULL, + lifted_at DATETIME NULL, + PRIMARY KEY (id), + KEY ip (ip), + KEY expires_at (expires_at) + ) {$charset_collate};"; + dbDelta( $sql ); + self::drop_index_if_exists( $bans, 'ip_active' ); + + $sql = "CREATE TABLE {$policy_decisions} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL, + trigger_type VARCHAR(40) NOT NULL, + action VARCHAR(20) NOT NULL, + rule_name VARCHAR(100) NULL, + reason VARCHAR(255) NOT NULL, + observation_only TINYINT(1) NOT NULL DEFAULT 0, + mode VARCHAR(20) NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY ip (ip), + KEY created_at (created_at) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$integrity_baseline} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + file_path VARCHAR(500) NOT NULL, + file_hash CHAR(64) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL, + file_mtime DATETIME NOT NULL, + category VARCHAR(20) NOT NULL, + first_seen_at DATETIME NOT NULL, + last_checked_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY file_path (file_path(191)), + KEY category (category) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$login_attempts} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL, + username VARCHAR(60) NOT NULL, + success TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY ip_created (ip, created_at), + KEY username_created (username, created_at) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$vuln_cache} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + component_type VARCHAR(20) NOT NULL, + slug VARCHAR(191) NOT NULL, + vulnerable_below VARCHAR(40) NULL, + fixed_in VARCHAR(40) NULL, + severity VARCHAR(20) NOT NULL, + cve VARCHAR(20) NULL, + description LONGTEXT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY component_slug (component_type, slug(120)) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$quarantine} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + original_path VARCHAR(500) NOT NULL, + quarantine_filename VARCHAR(60) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + file_size BIGINT UNSIGNED NOT NULL, + file_hash CHAR(64) NOT NULL, + original_mtime DATETIME NULL, + original_perms VARCHAR(4) NULL, + detection_engine VARCHAR(60) NOT NULL, + detection_rule VARCHAR(100) NULL, + detection_type VARCHAR(20) NOT NULL, + severity VARCHAR(20) NOT NULL, + confidence_score INT NULL, + matched_rules LONGTEXT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'quarantined', + finding_id BIGINT UNSIGNED NULL, + quarantined_at DATETIME NOT NULL, + analysed_at DATETIME NULL, + resolved_at DATETIME NULL, + restore_attempts INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY quarantine_filename (quarantine_filename), + KEY status (status), + KEY finding_id (finding_id) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$cache_stats_daily} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + stat_date DATE NOT NULL, + status VARCHAR(20) NOT NULL, + request_count BIGINT UNSIGNED NOT NULL DEFAULT 0, + total_response_time_ms BIGINT UNSIGNED NOT NULL DEFAULT 0, + total_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY date_status (stat_date, status) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$cache_discovered_urls} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + url VARCHAR(500) NOT NULL, + source VARCHAR(40) NOT NULL, + eligibility VARCHAR(20) NOT NULL DEFAULT 'needs_review', + eligibility_reason VARCHAR(255) NULL, + priority_hint DECIMAL(3,2) NULL, + lastmod DATETIME NULL, + last_checked_at DATETIME NULL, + last_warmed_at DATETIME NULL, + discovered_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY url (url(191)), + KEY eligibility (eligibility) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$cache_warm_queue} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + url VARCHAR(500) NOT NULL, + priority INT NOT NULL DEFAULT 100, + status VARCHAR(20) NOT NULL DEFAULT 'queued', + attempts INT NOT NULL DEFAULT 0, + last_attempt_at DATETIME NULL, + last_result VARCHAR(255) NULL, + queued_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY status_priority (status, priority), + KEY url (url(191)) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$anis_reputation} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ip VARCHAR(45) NOT NULL, + action VARCHAR(20) NOT NULL, + confidence INT NOT NULL DEFAULT 0, + reports INT NOT NULL DEFAULT 0, + source VARCHAR(60) NULL, + reason VARCHAR(255) NULL, + decided_at DATETIME NULL, + expires_at DATETIME NULL, + cached_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY ip (ip), + KEY action (action) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$vuln_status} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + component_type VARCHAR(20) NOT NULL, + slug VARCHAR(191) NOT NULL, + name VARCHAR(255) NOT NULL, + installed_version VARCHAR(40) NULL, + checked_version VARCHAR(40) NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + severity VARCHAR(20) NULL, + cve VARCHAR(20) NULL, + fixed_in VARCHAR(40) NULL, + description LONGTEXT NULL, + last_checked_at DATETIME NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY component (component_type, slug), + KEY status (status) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$scan_history} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + scan_type VARCHAR(30) NOT NULL, + started_at DATETIME NOT NULL, + finished_at DATETIME NOT NULL, + files_scanned INT UNSIGNED NOT NULL DEFAULT 0, + findings_count INT UNSIGNED NOT NULL DEFAULT 0, + result VARCHAR(20) NOT NULL, + PRIMARY KEY (id), + KEY started_at (started_at) + ) {$charset_collate};"; + dbDelta( $sql ); + + $sql = "CREATE TABLE {$backups} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + filename VARCHAR(191) NOT NULL, + type VARCHAR(20) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'complete', + error_message TEXT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY filename (filename), + KEY created_at (created_at) + ) {$charset_collate};"; + dbDelta( $sql ); + + update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false ); + } + + public static function maybe_upgrade() { + if ( get_option( self::SCHEMA_OPTION ) !== self::SCHEMA_VERSION ) { + self::install(); + } + } + + public static function uninstall() { + global $wpdb; + foreach ( array( 'events', 'findings', 'bans', 'policy_decisions', 'integrity_baseline', 'login_attempts', 'vuln_cache', 'quarantine', 'cache_stats_daily', 'cache_discovered_urls', 'cache_warm_queue', 'anis_reputation', 'vuln_status', 'scan_history', 'backups' ) as $t ) { + $wpdb->query( 'DROP TABLE IF EXISTS ' . self::table( $t ) ); // phpcs:ignore + } + delete_option( self::SCHEMA_OPTION ); + } +} diff --git a/includes/class-argus-deactivator.php b/includes/class-argus-deactivator.php new file mode 100644 index 0000000..419e4fa --- /dev/null +++ b/includes/class-argus-deactivator.php @@ -0,0 +1,14 @@ +insert( + Argus_DB::table( 'events' ), + array( + 'event_type' => $type, + 'severity' => $severity, + 'ip' => $ip, + 'summary' => $summary, + 'payload' => wp_json_encode( $payload ), + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + public static function recent( $limit = 50, $type = null ) { + global $wpdb; + $table = Argus_DB::table( 'events' ); + + if ( $type ) { + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE event_type = %s ORDER BY created_at DESC LIMIT %d", $type, $limit ) // phpcs:ignore + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) // phpcs:ignore + ); + } + + foreach ( $rows as &$row ) { + $row->payload = json_decode( $row->payload, true ); + } + + return $rows; + } + + public static function counts_since( $since_mysql ) { + global $wpdb; + $table = Argus_DB::table( 'events' ); + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT event_type, severity, COUNT(*) AS cnt FROM {$table} WHERE created_at >= %s GROUP BY event_type, severity", // phpcs:ignore + $since_mysql + ) + ); + } + + const PER_PAGE = 10; + + public static function paginated( $page = 1, array $filters = array(), $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'events' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + $where = array( '1=1' ); + $args = array(); + + if ( ! empty( $filters['search'] ) ) { + $where[] = 'summary LIKE %s'; + $args[] = '%' . $wpdb->esc_like( $filters['search'] ) . '%'; + } + if ( ! empty( $filters['severity'] ) ) { + $where[] = 'severity = %s'; + $args[] = $filters['severity']; + } + if ( ! empty( $filters['type'] ) ) { + $where[] = 'event_type = %s'; + $args[] = $filters['type']; + } + if ( ! empty( $filters['since'] ) ) { + $where[] = 'created_at >= %s'; + $args[] = $filters['since']; + } + + $where_sql = implode( ' AND ', $where ); + + $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; // phpcs:ignore + $total = (int) ( $args ? $wpdb->get_var( $wpdb->prepare( $count_sql, $args ) ) : $wpdb->get_var( $count_sql ) ); // phpcs:ignore + + $list_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; // phpcs:ignore + $list_args = array_merge( $args, array( $per_page, $offset ) ); + $rows = $wpdb->get_results( $wpdb->prepare( $list_sql, $list_args ) ); // phpcs:ignore + + foreach ( $rows as &$row ) { + $row->payload = json_decode( $row->payload, true ); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + public static function distinct_types() { + global $wpdb; + $table = Argus_DB::table( 'events' ); + return $wpdb->get_col( "SELECT DISTINCT event_type FROM {$table} ORDER BY event_type ASC" ); // phpcs:ignore + } + + const RETENTION_DAYS_BY_SEVERITY = array( + 'info' => 30, + 'low' => 90, + 'medium' => 365, + 'high' => 365, + 'critical' => 365, + ); + + public static function prune() { + global $wpdb; + $table = Argus_DB::table( 'events' ); + + $retention = apply_filters( 'argus_wpd_event_retention_days', self::RETENTION_DAYS_BY_SEVERITY ); + $deleted = 0; + foreach ( $retention as $severity => $days ) { + $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( (int) $days * DAY_IN_SECONDS ) ); + $deleted += (int) $wpdb->query( + $wpdb->prepare( "DELETE FROM {$table} WHERE severity = %s AND created_at < %s", $severity, $cutoff ) // phpcs:ignore + ); + } + + $max_rows = (int) apply_filters( 'argus_wpd_max_event_rows', self::DEFAULT_MAX_ROWS ); + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + if ( $total <= $max_rows ) { + return $deleted; + } + + $overflow = $total - $max_rows; + $deleted += (int) $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$table} ORDER BY created_at ASC LIMIT %d", // phpcs:ignore + $overflow + ) + ); + + return $deleted; + } +} diff --git a/includes/class-argus-explain.php b/includes/class-argus-explain.php new file mode 100644 index 0000000..ac28645 --- /dev/null +++ b/includes/class-argus-explain.php @@ -0,0 +1,54 @@ + __( 'What happened', 'argus-wordpress-defence' ), + 'why_it_matters' => __( 'Why it matters', 'argus-wordpress-defence' ), + 'what_argus_found' => __( 'What ARGUS found', 'argus-wordpress-defence' ), + 'when_it_happened' => __( 'When it happened', 'argus-wordpress-defence' ), + 'why_suspicious' => __( 'Why ARGUS considers this suspicious', 'argus-wordpress-defence' ), + 'what_could_be_affected' => __( 'What could be affected', 'argus-wordpress-defence' ), + 'what_should_you_do' => __( 'What should you do', 'argus-wordpress-defence' ), + ); + } + + public static function render( array $explain ) { + $labels = self::labels(); + ob_start(); + ?> +
+ +
+
+ +
+ get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE category = %s AND title = %s AND status = %s LIMIT 1", // phpcs:ignore + $category, + $explain['what_happened'], + self::STATUS_OPEN + ) + ); + + if ( $existing_id ) { + $wpdb->update( + $table, + array( + 'last_seen_at' => $now, + 'evidence' => wp_json_encode( $evidence ), + 'detail' => wp_json_encode( $explain ), + ), + array( 'id' => $existing_id ), + array( '%s', '%s', '%s' ), + array( '%d' ) + ); + return (int) $existing_id; + } + + $wpdb->insert( + $table, + array( + 'category' => $category, + 'severity' => $severity, + 'title' => $explain['what_happened'], + 'detail' => wp_json_encode( $explain ), + 'remediation_hint' => $explain['what_should_you_do'], + 'evidence' => wp_json_encode( $evidence ), + 'status' => $initial_status, + 'first_seen_at' => $now, + 'last_seen_at' => $now, + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + public static function set_status( $id, $status ) { + global $wpdb; + return $wpdb->update( + Argus_DB::table( 'findings' ), + array( 'status' => $status ), + array( 'id' => $id ), + array( '%s' ), + array( '%d' ) + ); + } + + public static function category_counts() { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + $rows = $wpdb->get_results( "SELECT category, COUNT(*) AS cnt FROM {$table} GROUP BY category ORDER BY cnt DESC" ); // phpcs:ignore + $out = array(); + foreach ( $rows as $row ) { + $out[ $row->category ] = (int) $row->cnt; + } + return $out; + } + + public static function count_open( $category ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE category = %s AND status = %s", $category, self::STATUS_OPEN ) // phpcs:ignore + ); + } + + public static function open_counts() { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT severity, COUNT(*) AS cnt FROM {$table} WHERE status = %s GROUP BY severity", self::STATUS_OPEN ) // phpcs:ignore + ); + + $counts = array( + 'critical' => 0, + 'high' => 0, + 'medium' => 0, + 'low' => 0, + 'info' => 0, + ); + foreach ( $rows as $row ) { + if ( isset( $counts[ $row->severity ] ) ) { + $counts[ $row->severity ] = (int) $row->cnt; + } + } + return $counts; + } + + public static function recent( $limit = 25, $status = null ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + if ( $status ) { + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY last_seen_at DESC LIMIT %d", $status, $limit ) // phpcs:ignore + ); + } else { + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY last_seen_at DESC LIMIT %d", $limit ) // phpcs:ignore + ); + } + + foreach ( $rows as &$row ) { + $row->explain = json_decode( $row->detail, true ); + $row->evidence = json_decode( $row->evidence, true ); + } + + return $rows; + } + + const PER_PAGE = 10; + + public static function paginated( $page = 1, $status = null, $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + if ( $status ) { + $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", $status, $per_page, $offset ) // phpcs:ignore + ); + } else { + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore + ); + } + + foreach ( $rows as &$row ) { + $row->explain = json_decode( $row->detail, true ); + $row->evidence = json_decode( $row->evidence, true ); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + const NEEDS_ATTENTION_SEVERITIES = array( 'critical', 'high' ); + + public static function paginated_by_view( $page = 1, $view = 'active', $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + list( $where, $params ) = self::view_where( $view ); + + $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where}", $params ) ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where} ORDER BY last_seen_at DESC LIMIT %d OFFSET %d", array_merge( $params, array( $per_page, $offset ) ) ) // phpcs:ignore + ); + + foreach ( $rows as &$row ) { + $row->explain = json_decode( $row->detail, true ); + $row->evidence = json_decode( $row->evidence, true ); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + public static function view_counts() { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + $out = array(); + foreach ( array( 'active', 'needs_attention', 'resolved', 'accepted' ) as $view ) { + list( $where, $params ) = self::view_where( $view ); + $out[ $view ] = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where}", $params ) ); // phpcs:ignore + } + return $out; + } + + protected static function view_where( $view ) { + $severity_placeholders = implode( ',', array_fill( 0, count( self::NEEDS_ATTENTION_SEVERITIES ), '%s' ) ); + switch ( $view ) { + case 'needs_attention': + return array( + "status IN (%s,%s) AND severity IN ({$severity_placeholders})", + array_merge( array( self::STATUS_OPEN, self::STATUS_ACKNOWLEDGED ), self::NEEDS_ATTENTION_SEVERITIES ), + ); + case 'resolved': + return array( 'status = %s', array( self::STATUS_RESOLVED ) ); + case 'accepted': + return array( 'status = %s', array( self::STATUS_RISK_ACCEPTED ) ); + case 'active': + default: + return array( + "status IN (%s,%s) AND severity NOT IN ({$severity_placeholders})", + array_merge( array( self::STATUS_OPEN, self::STATUS_ACKNOWLEDGED ), self::NEEDS_ATTENTION_SEVERITIES ), + ); + } + } + + public static function delete( $id ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + $row = self::get( $id ); + if ( ! $row ) { + return false; + } + + Argus_Events::record( + 'finding_deleted', + 'info', + sprintf( 'Finding deleted: %s', $row->title ), + array( 'finding_id' => $id, 'category' => $row->category, 'severity' => $row->severity ) + ); + + return (bool) $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) ); + } + + public static function resolve_all_open() { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + + $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::STATUS_OPEN ) ); // phpcs:ignore + if ( 0 === $count ) { + return 0; + } + + $wpdb->update( $table, array( 'status' => self::STATUS_RESOLVED ), array( 'status' => self::STATUS_OPEN ), array( '%s' ), array( '%s' ) ); + + Argus_Events::record( 'findings_bulk_resolved', 'info', sprintf( '%d open finding(s) bulk-resolved by an administrator', $count ), array( 'count' => $count ) ); + + return $count; + } + + public static function get( $id ) { + global $wpdb; + $table = Argus_DB::table( 'findings' ); + $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore + if ( $row ) { + $row->explain = json_decode( $row->detail, true ); + $row->evidence = json_decode( $row->evidence, true ); + } + return $row; + } +} diff --git a/includes/class-argus-geoip-rir.php b/includes/class-argus-geoip-rir.php new file mode 100644 index 0000000..65d2b43 --- /dev/null +++ b/includes/class-argus-geoip-rir.php @@ -0,0 +1,381 @@ + 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest', + 'ripencc' => 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest', + 'apnic' => 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest', + 'lacnic' => 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest', + 'afrinic' => 'https://ftp.afrinic.net/pub/stats/afrinic/delegated-afrinic-extended-latest', + ); + + const TIME_BUDGET_SECONDS = 40; + + const CURSOR_OPTION = 'argus_wpd_geoip_cursor'; + const BUILD_PATH_OPTION = 'argus_wpd_geoip_build_path'; + const LAST_REFRESH_OPTION = 'argus_wpd_geoip_last_refresh'; + const RANGE_COUNT_OPTION = 'argus_wpd_geoip_range_count'; + const LAST_ERROR_OPTION = 'argus_wpd_geoip_last_error'; + const CORRUPT_OPTION = 'argus_wpd_geoip_corrupt_detected'; + + const HEADER_MAGIC = 'AWG1'; + const HEADER_LEN = 16; + const FORMAT_VERSION = 1; + + protected static $file_cache = null; + + public static function init() { + add_filter( 'argus_wpd_resolve_country', array( __CLASS__, 'resolve' ), 10, 2 ); + } + + public static function resolve( $result, $ip ) { + if ( null !== $result ) { + return $result; + } + if ( ! Argus_Settings::get( 'geoip_rir_enabled', true ) ) { + return null; + } + $code = self::lookup_ipv4( $ip ); + if ( ! $code ) { + return null; + } + return array( 'label' => self::country_name( $code ), 'code' => $code ); + } + + public static function lookup_ipv4( $ip ) { + $n = self::ipv4_to_uint( $ip ); + if ( false === $n ) { + return null; + } + + if ( null === self::$file_cache ) { + self::$file_cache = self::read_and_verify(); + } + if ( '' === self::$file_cache ) { + return null; + } + + $count = (int) ( strlen( self::$file_cache ) / 10 ); + $lo = 0; + $hi = $count - 1; + + while ( $lo <= $hi ) { + $mid = intdiv( $lo + $hi, 2 ); + $rec = unpack( 'Nstart/Nend/a2cc', substr( self::$file_cache, $mid * 10, 10 ) ); + if ( $n < $rec['start'] ) { + $hi = $mid - 1; + } elseif ( $n > $rec['end'] ) { + $lo = $mid + 1; + } else { + return $rec['cc']; + } + } + return null; + } + + protected static function read_and_verify() { + $path = self::compiled_file(); + if ( ! file_exists( $path ) ) { + return ''; + } + $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $raw || '' === $raw ) { + return ''; + } + + if ( strlen( $raw ) < self::HEADER_LEN || self::HEADER_MAGIC !== substr( $raw, 0, 4 ) ) { + return $raw; + } + + $header = unpack( 'a4magic/Cversion/Ncount/Ncrc', substr( $raw, 0, self::HEADER_LEN ) ); + $records = substr( $raw, self::HEADER_LEN ); + + if ( (int) $header['count'] !== (int) ( strlen( $records ) / 10 ) || (int) $header['crc'] !== crc32( $records ) ) { + + if ( ! get_option( self::CORRUPT_OPTION ) ) { + update_option( self::CORRUPT_OPTION, true, false ); + Argus_Events::record( 'geoip_corrupt', 'medium', 'GeoIP database file failed its integrity check and was ignored until the next scheduled refresh.', array() ); + } + return ''; + } + + if ( get_option( self::CORRUPT_OPTION ) ) { + delete_option( self::CORRUPT_OPTION ); + } + + return $records; + } + + public static function run_daily_refresh() { + $started = microtime( true ); + $sources = array_keys( self::SOURCES ); + $cursor = (int) get_option( self::CURSOR_OPTION, 0 ); + + $build_path = get_option( self::BUILD_PATH_OPTION, '' ); + if ( 0 === $cursor || '' === $build_path || ! file_exists( $build_path ) ) { + + foreach ( glob( trailingslashit( self::data_dir() ) . 'geoip-build-*.raw' ) ?: array() as $stray ) { + @unlink( $stray ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + $build_path = trailingslashit( self::data_dir() ) . 'geoip-build-' . wp_generate_password( 8, false ) . '.raw'; + update_option( self::BUILD_PATH_OPTION, $build_path, false ); + $cursor = 0; + } + + $handle = fopen( $build_path, 'ab' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( ! $handle ) { + self::record_outcome( false, __( 'Could not open a working file in uploads to build the GeoIP database.', 'argus-wordpress-defence' ) ); + return; + } + + $any_failure = false; + while ( $cursor < count( $sources ) ) { + if ( microtime( true ) - $started > self::TIME_BUDGET_SECONDS ) { + break; + } + $key = $sources[ $cursor ]; + if ( ! self::fetch_and_append( $key, self::SOURCES[ $key ], $handle ) ) { + $any_failure = true; + } + $cursor++; + update_option( self::CURSOR_OPTION, $cursor, false ); + } + + fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + if ( $cursor < count( $sources ) ) { + return; + } + + $swap_result = self::compile_and_swap( $build_path, $any_failure ); + @unlink( $build_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + + update_option( self::BUILD_PATH_OPTION, '', false ); + update_option( self::CURSOR_OPTION, 0, false ); + update_option( self::RANGE_COUNT_OPTION, $swap_result['count'], false ); + + if ( $swap_result['swapped'] ) { + + update_option( self::LAST_REFRESH_OPTION, current_time( 'mysql', true ), false ); + } + + if ( $any_failure && ! $swap_result['swapped'] ) { + + self::record_outcome( + false, + sprintf( + + __( 'One or more RIR sources could not be reached this cycle. Kept the existing %d-range database rather than replacing it with an incomplete one; will retry on the next scheduled refresh.', 'argus-wordpress-defence' ), + $swap_result['count'] + ) + ); + } else { + self::record_outcome( + ! $any_failure, + $any_failure + ? sprintf( + + __( 'GeoIP database refreshed with %d IPv4 ranges, but one or more RIR sources could not be reached this cycle -- those ranges are missing until a future refresh succeeds.', 'argus-wordpress-defence' ), + $swap_result['count'] + ) + : sprintf( + + __( 'GeoIP database refreshed: %d IPv4 ranges loaded from 5 RIR sources.', 'argus-wordpress-defence' ), + $swap_result['count'] + ) + ); + } + + self::$file_cache = null; + } + + protected static function fetch_and_append( $source_key, $url, $handle ) { + + $tmp = tempnam( sys_get_temp_dir(), 'argus-geoip-' . $source_key . '-' ); + if ( ! $tmp ) { + return false; + } + + $response = wp_remote_get( $url, array( 'timeout' => 30, 'stream' => true, 'filename' => $tmp ) ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + return false; + } + + $fh = fopen( $tmp, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( ! $fh ) { + @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + return false; + } + + while ( false !== ( $line = fgets( $fh ) ) ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition + $line = trim( $line ); + if ( '' === $line || '#' === $line[0] ) { + continue; + } + + $parts = explode( '|', $line ); + if ( count( $parts ) < 7 ) { + continue; + } + if ( 'ipv4' !== $parts[2] ) { + continue; + } + if ( ! in_array( $parts[6], array( 'allocated', 'assigned' ), true ) ) { + continue; + } + $cc = strtoupper( $parts[1] ); + if ( 2 !== strlen( $cc ) || ! ctype_alpha( $cc ) ) { + continue; + } + $start = self::ipv4_to_uint( $parts[3] ); + $count = (int) $parts[4]; + if ( false === $start || $count <= 0 ) { + continue; + } + $end = min( $start + $count - 1, 4294967295 ); + + fwrite( $handle, pack( 'NNa2', $start, $end, $cc ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + @unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + return true; + } + + protected static function compile_and_swap( $build_path, $any_failure = false ) { + $data = file_get_contents( $build_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $data || '' === $data ) { + return array( 'count' => self::count_from_file( self::compiled_file() ), 'swapped' => false ); + } + + $existing_file = self::compiled_file(); + if ( $any_failure && file_exists( $existing_file ) ) { + return array( 'count' => self::count_from_file( $existing_file ), 'swapped' => false ); + } + + $records = str_split( $data, 10 ); + usort( + $records, + function ( $a, $b ) { + return substr_compare( $a, $b, 0, 4 ); + } + ); + + $record_bytes = implode( '', $records ); + + $header = pack( 'a4CNNa3', self::HEADER_MAGIC, self::FORMAT_VERSION, count( $records ), crc32( $record_bytes ), '' ); + + $tmp_final = trailingslashit( self::data_dir() ) . 'geoip-v4.bin.tmp'; + file_put_contents( $tmp_final, $header . $record_bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions + rename( $tmp_final, $existing_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + delete_option( self::CORRUPT_OPTION ); + + return array( 'count' => count( $records ), 'swapped' => true ); + } + + protected static function count_from_file( $path ) { + if ( ! file_exists( $path ) ) { + return 0; + } + $head = file_get_contents( $path, false, null, 0, self::HEADER_LEN ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( is_string( $head ) && strlen( $head ) === self::HEADER_LEN && self::HEADER_MAGIC === substr( $head, 0, 4 ) ) { + $header = unpack( 'a4magic/Cversion/Ncount', $head ); + return (int) $header['count']; + } + return (int) ( filesize( $path ) / 10 ); + } + + protected static function record_outcome( $success, $message ) { + update_option( self::LAST_ERROR_OPTION, $success ? '' : $message, false ); + Argus_Events::record( 'geoip_refresh', $success ? 'info' : 'medium', $message, array( 'success' => $success ) ); + } + + protected static function data_dir() { + $uploads = wp_get_upload_dir(); + $dir = trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data'; + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + return $dir; + } + + protected static function compiled_file() { + return trailingslashit( self::data_dir() ) . 'geoip-v4.bin'; + } + + public static function ipv4_to_uint( $ip ) { + if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) { + return false; + } + $parts = explode( '.', $ip ); + if ( 4 !== count( $parts ) ) { + return false; + } + return ( (int) $parts[0] << 24 ) + ( (int) $parts[1] << 16 ) + ( (int) $parts[2] << 8 ) + (int) $parts[3]; + } + + public static function status() { + return array( + 'last_refresh' => get_option( self::LAST_REFRESH_OPTION, '' ) ?: null, + 'range_count' => (int) get_option( self::RANGE_COUNT_OPTION, 0 ), + 'last_error' => get_option( self::LAST_ERROR_OPTION, '' ), + 'cursor' => (int) get_option( self::CURSOR_OPTION, 0 ), + ); + } + + public static function country_name( $code ) { + static $names = null; + if ( null === $names ) { + $names = array( + 'AL' => 'Albania', 'AD' => 'Andorra', 'AT' => 'Austria', 'BY' => 'Belarus', 'BE' => 'Belgium', + 'BA' => 'Bosnia and Herzegovina', 'BG' => 'Bulgaria', 'HR' => 'Croatia', 'CY' => 'Cyprus', 'CZ' => 'Czech Republic', + 'DK' => 'Denmark', 'EE' => 'Estonia', 'FI' => 'Finland', 'FR' => 'France', 'DE' => 'Germany', + 'GR' => 'Greece', 'HU' => 'Hungary', 'IS' => 'Iceland', 'IE' => 'Ireland', 'IT' => 'Italy', + 'XK' => 'Kosovo', 'LV' => 'Latvia', 'LI' => 'Liechtenstein', 'LT' => 'Lithuania', 'LU' => 'Luxembourg', + 'MT' => 'Malta', 'MD' => 'Moldova', 'MC' => 'Monaco', 'ME' => 'Montenegro', 'NL' => 'Netherlands', + 'MK' => 'North Macedonia', 'NO' => 'Norway', 'PL' => 'Poland', 'PT' => 'Portugal', 'RO' => 'Romania', + 'RU' => 'Russia', 'SM' => 'San Marino', 'RS' => 'Serbia', 'SK' => 'Slovakia', 'SI' => 'Slovenia', + 'ES' => 'Spain', 'SE' => 'Sweden', 'CH' => 'Switzerland', 'UA' => 'Ukraine', 'GB' => 'United Kingdom', + 'VA' => 'Vatican City', 'AF' => 'Afghanistan', 'AM' => 'Armenia', 'AZ' => 'Azerbaijan', 'BH' => 'Bahrain', + 'BD' => 'Bangladesh', 'BT' => 'Bhutan', 'BN' => 'Brunei', 'KH' => 'Cambodia', 'CN' => 'China', + 'GE' => 'Georgia', 'IN' => 'India', 'ID' => 'Indonesia', 'IR' => 'Iran', 'IQ' => 'Iraq', + 'IL' => 'Israel', 'JP' => 'Japan', 'JO' => 'Jordan', 'KZ' => 'Kazakhstan', 'KW' => 'Kuwait', + 'KG' => 'Kyrgyzstan', 'LA' => 'Laos', 'LB' => 'Lebanon', 'MY' => 'Malaysia', 'MV' => 'Maldives', + 'MN' => 'Mongolia', 'MM' => 'Myanmar', 'NP' => 'Nepal', 'KP' => 'North Korea', 'OM' => 'Oman', + 'PK' => 'Pakistan', 'PS' => 'Palestine', 'PH' => 'Philippines', 'QA' => 'Qatar', 'SA' => 'Saudi Arabia', + 'SG' => 'Singapore', 'KR' => 'South Korea', 'LK' => 'Sri Lanka', 'SY' => 'Syria', 'TW' => 'Taiwan', + 'TJ' => 'Tajikistan', 'TH' => 'Thailand', 'TL' => 'Timor-Leste', 'TR' => 'Turkey', 'TM' => 'Turkmenistan', + 'AE' => 'United Arab Emirates', 'UZ' => 'Uzbekistan', 'VN' => 'Vietnam', 'YE' => 'Yemen', 'AG' => 'Antigua and Barbuda', + 'AR' => 'Argentina', 'BS' => 'Bahamas', 'BB' => 'Barbados', 'BZ' => 'Belize', 'BO' => 'Bolivia', + 'BR' => 'Brazil', 'CA' => 'Canada', 'CL' => 'Chile', 'CO' => 'Colombia', 'CR' => 'Costa Rica', + 'CU' => 'Cuba', 'DM' => 'Dominica', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'SV' => 'El Salvador', + 'GD' => 'Grenada', 'GT' => 'Guatemala', 'GY' => 'Guyana', 'HT' => 'Haiti', 'HN' => 'Honduras', + 'JM' => 'Jamaica', 'MX' => 'Mexico', 'NI' => 'Nicaragua', 'PA' => 'Panama', 'PY' => 'Paraguay', + 'PE' => 'Peru', 'KN' => 'Saint Kitts and Nevis', 'LC' => 'Saint Lucia', 'VC' => 'Saint Vincent and the Grenadines', 'SR' => 'Suriname', + 'TT' => 'Trinidad and Tobago', 'US' => 'United States', 'UY' => 'Uruguay', 'VE' => 'Venezuela', 'DZ' => 'Algeria', + 'AO' => 'Angola', 'BJ' => 'Benin', 'BW' => 'Botswana', 'BF' => 'Burkina Faso', 'BI' => 'Burundi', + 'CM' => 'Cameroon', 'CV' => 'Cape Verde', 'CF' => 'Central African Republic', 'TD' => 'Chad', 'KM' => 'Comoros', + 'CG' => 'Republic of the Congo', 'CD' => 'Democratic Republic of the Congo', 'CI' => 'Ivory Coast', 'DJ' => 'Djibouti', 'EG' => 'Egypt', + 'GQ' => 'Equatorial Guinea', 'ER' => 'Eritrea', 'ET' => 'Ethiopia', 'GA' => 'Gabon', 'GM' => 'Gambia', + 'GH' => 'Ghana', 'GN' => 'Guinea', 'GW' => 'Guinea-Bissau', 'KE' => 'Kenya', 'LS' => 'Lesotho', + 'LR' => 'Liberia', 'LY' => 'Libya', 'MG' => 'Madagascar', 'MW' => 'Malawi', 'ML' => 'Mali', + 'MR' => 'Mauritania', 'MU' => 'Mauritius', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'NA' => 'Namibia', + 'NE' => 'Niger', 'NG' => 'Nigeria', 'RW' => 'Rwanda', 'ST' => 'Sao Tome and Principe', 'SN' => 'Senegal', + 'SL' => 'Sierra Leone', 'SO' => 'Somalia', 'ZA' => 'South Africa', 'SS' => 'South Sudan', 'SD' => 'Sudan', + 'SZ' => 'Eswatini', 'TZ' => 'Tanzania', 'TG' => 'Togo', 'TN' => 'Tunisia', 'UG' => 'Uganda', + 'ZM' => 'Zambia', 'ZW' => 'Zimbabwe', 'AU' => 'Australia', 'FJ' => 'Fiji', 'KI' => 'Kiribati', + 'MH' => 'Marshall Islands', 'FM' => 'Micronesia', 'NR' => 'Nauru', 'NZ' => 'New Zealand', 'PW' => 'Palau', + 'PG' => 'Papua New Guinea', 'WS' => 'Samoa', 'SB' => 'Solomon Islands', 'TO' => 'Tonga', 'TV' => 'Tuvalu', + 'VU' => 'Vanuatu', + ); + } + return $names[ $code ] ?? $code; + } +} diff --git a/includes/class-argus-geoip.php b/includes/class-argus-geoip.php new file mode 100644 index 0000000..67573ed --- /dev/null +++ b/includes/class-argus-geoip.php @@ -0,0 +1,44 @@ + self::LOCAL, 'code' => null ); + } + + $result = apply_filters( 'argus_wpd_resolve_country', null, $ip ); + + if ( is_array( $result ) && ! empty( $result['label'] ) ) { + return array( 'label' => $result['label'], 'code' => $result['code'] ?? null ); + } + if ( is_string( $result ) && '' !== $result ) { + return array( 'label' => $result, 'code' => null ); + } + return array( 'label' => self::UNKNOWN, 'code' => null ); + } + + public static function label( $ip ) { + return self::classify( $ip )['label']; + } + + public static function icon( $ip ) { + $c = self::classify( $ip ); + if ( $c['code'] && preg_match( '/^[a-zA-Z]{2}$/', $c['code'] ) ) { + $code = strtoupper( $c['code'] ); + return mb_chr( 0x1F1E6 + ( ord( $code[0] ) - 65 ) ) . mb_chr( 0x1F1E6 + ( ord( $code[1] ) - 65 ) ); + } + if ( self::LOCAL === $c['label'] ) { + return '🏠'; + } + return '🌐'; + } + +} diff --git a/includes/class-argus-integrity.php b/includes/class-argus-integrity.php new file mode 100644 index 0000000..f23c002 --- /dev/null +++ b/includes/class-argus-integrity.php @@ -0,0 +1,332 @@ + $until ) { + if ( $until < $now ) { + continue; + } + $pruned[ $prefix ] = $until; + if ( ! $trusted && 0 === strpos( $rel_path, $prefix ) ) { + $trusted = true; + } + } + + if ( count( $pruned ) !== count( $windows ) ) { + update_option( self::TRUSTED_WINDOWS_OPTION, $pruned, false ); + } + + return $trusted; + } + + public static function full_scan() { + $file_count = self::scan( null ); + self::check_core_checksums(); + update_option( self::WATERMARK_OPTION, time(), false ); + update_option( self::LAST_FULL_SCAN_OPTION, time(), false ); + update_option( self::LAST_FULL_SCAN_FILES_OPTION, $file_count, false ); + } + + public static function incremental_scan() { + $since = (int) get_option( self::WATERMARK_OPTION, 0 ); + self::scan( $since ); + update_option( self::WATERMARK_OPTION, time(), false ); + } + + public static function scan_status() { + $next = wp_next_scheduled( 'argus_wpd_daily' ); + return array( + 'last_full_scan' => (int) get_option( self::LAST_FULL_SCAN_OPTION, 0 ), + 'files_scanned' => (int) get_option( self::LAST_FULL_SCAN_FILES_OPTION, 0 ), + 'next_scan' => $next ? (int) $next : null, + ); + } + + protected static function scan( $mtime_since ) { + global $wpdb; + + $targets = array( + self::CATEGORY_CORE => array( ABSPATH . 'wp-admin', ABSPATH . WPINC ), + self::CATEGORY_PLUGIN => array( WP_PLUGIN_DIR ), + self::CATEGORY_THEME => array( get_theme_root() ), + ); + + $seen_paths = array(); + + foreach ( $targets as $category => $dirs ) { + foreach ( $dirs as $dir ) { + if ( ! is_dir( $dir ) ) { + continue; + } + foreach ( self::walk_php_files( $dir ) as $path ) { + $mtime = filemtime( $path ); + if ( null !== $mtime_since && $mtime < $mtime_since ) { + + $seen_paths[] = $path; + continue; + } + self::check_file( $path, $category ); + $seen_paths[] = $path; + } + } + } + + if ( file_exists( ABSPATH . 'wp-config.php' ) ) { + self::check_file( ABSPATH . 'wp-config.php', self::CATEGORY_CONFIG ); + $seen_paths[] = ABSPATH . 'wp-config.php'; + } + + if ( null === $mtime_since ) { + self::check_deletions( $seen_paths ); + } + + return count( $seen_paths ); + } + + protected static function walk_php_files( $dir ) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && 'php' === strtolower( $file->getExtension() ) ) { + yield $file->getPathname(); + } + } + } + + protected static function check_file( $path, $category ) { + global $wpdb; + $table = Argus_DB::table( 'integrity_baseline' ); + + $hash = hash_file( 'sha256', $path ); + $size = filesize( $path ); + $mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) ); + $now = current_time( 'mysql', true ); + $rel = self::relative_path( $path ); + + $existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE file_path = %s", $rel ) ); // phpcs:ignore + + if ( ! $existing ) { + $wpdb->insert( + $table, + array( + 'file_path' => $rel, + 'file_hash' => $hash, + 'file_size' => $size, + 'file_mtime' => $mtime, + 'category' => $category, + 'first_seen_at' => $now, + 'last_checked_at' => $now, + ), + array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ) + ); + + if ( self::CATEGORY_CORE === $category ) { + self::record_finding( + 'New file appeared inside WordPress core: ' . $rel, + 'high', + $rel, + 'WordPress core (wp-admin/wp-includes) does not normally gain new files outside of an actual WordPress update.', + 'This can indicate a webshell or backdoor was planted directly into core.', + 'Compare this file against a fresh WordPress download of your exact version. If you did not just update WordPress, treat this as a likely compromise and investigate immediately.', + $mtime + ); + } + + Argus_Events::record( 'integrity_new_file', 'low', 'New file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) ); + return; + } + + if ( $existing->file_hash !== $hash ) { + $wpdb->update( + $table, + array( 'file_hash' => $hash, 'file_size' => $size, 'file_mtime' => $mtime, 'last_checked_at' => $now ), + array( 'id' => $existing->id ), + array( '%s', '%d', '%s', '%s' ), + array( '%d' ) + ); + + Argus_Events::record( 'integrity_changed_file', 'low', 'Changed file: ' . $rel, array( 'file_path' => $rel, 'category' => $category, 'file_mtime' => $mtime ) ); + + $trusted = self::is_trusted_change( $rel ); + + self::record_finding( + sprintf( '%s file changed: %s', ucfirst( $category ), $rel ), + self::CATEGORY_CORE === $category ? 'high' : 'medium', + $rel, + self::CATEGORY_CORE === $category + ? 'WordPress core files should only change during an official WordPress update.' + : 'Unexpected changes to plugin/theme files can indicate a compromise, a manual edit that will be lost on the next update, or supply-chain tampering.', + 'The file\'s content hash no longer matches what ARGUS last recorded for it.', + 'If this file is part of an application (theme/plugin), changes here can affect every visitor and every other user of the site.', + $trusted + ? 'No action needed -- this change was recorded during a WordPress-initiated update of this exact file (core/plugin/theme updater), so ARGUS resolved it automatically.' + : 'If you made this change deliberately (a manual edit, a WordPress/plugin update), no action is needed. Otherwise, compare it against the original source and investigate.', + null, + $trusted + ); + } else { + $wpdb->update( $table, array( 'last_checked_at' => $now ), array( 'id' => $existing->id ), array( '%s' ), array( '%d' ) ); + } + } + + protected static function check_deletions( array $seen_paths ) { + global $wpdb; + $table = Argus_DB::table( 'integrity_baseline' ); + $seen_rel = array_map( array( __CLASS__, 'relative_path' ), $seen_paths ); + + $known = $wpdb->get_results( "SELECT id, file_path, category FROM {$table}" ); // phpcs:ignore + + foreach ( $known as $row ) { + if ( in_array( $row->file_path, $seen_rel, true ) ) { + continue; + } + + $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); + + if ( self::CATEGORY_CORE === $row->category ) { + self::record_finding( + 'WordPress core file was deleted: ' . $row->file_path, + 'high', + $row->file_path, + 'A missing core file can break site functionality or be a sign of tampering.', + 'This file was previously recorded and is no longer present.', + 'Restore this file from a fresh WordPress download of your exact version, or reinstall WordPress core files.' + ); + } + } + } + + protected static function check_core_checksums() { + global $wp_version; + $response = wp_remote_get( + sprintf( 'https://api.wordpress.org/core/checksums/1.0/?version=%s&locale=en_US', rawurlencode( $wp_version ) ), + array( 'timeout' => 8 ) + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $checksums = $body['checksums'][ $wp_version ] ?? null; + if ( ! is_array( $checksums ) ) { + return; + } + + foreach ( $checksums as $rel_path => $official_hash ) { + + $abs = ABSPATH . $rel_path; + if ( ! file_exists( $abs ) || 0 === strpos( $rel_path, 'wp-content/' ) ) { + continue; + } + if ( md5_file( $abs ) !== $official_hash ) { + self::record_finding( + 'Core file does not match the official WordPress.org checksum: ' . $rel_path, + 'critical', + $rel_path, + 'This file differs from the exact bytes WordPress.org publishes for your installed version.', + sprintf( 'Cross-checked against api.wordpress.org\'s official checksum feed for WordPress %s.', $wp_version ), + 'This is a strong tampering signal, not just a local drift. Restore this file from a fresh, official WordPress download immediately.' + ); + } + } + } + + protected static function record_finding( $what_happened, $severity, $path, $why_it_matters, $what_argus_found, $what_should_you_do, $mtime = null, $auto_resolved = false ) { + Argus_Findings::record( + 'integrity', + $severity, + array( + 'what_happened' => $what_happened, + 'why_it_matters' => $why_it_matters, + 'what_argus_found' => $what_argus_found, + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'ARGUS maintains its own baseline of every core, plugin, and theme file and flags anything that changes outside of a WordPress-initiated update.', + 'what_could_be_affected' => 'Any visitor or user interacting with the affected file\'s functionality.', + 'what_should_you_do' => $what_should_you_do, + ), + array_filter( array( 'file_path' => $path, 'file_mtime' => $mtime ) ), + $auto_resolved ? Argus_Findings::STATUS_RESOLVED : Argus_Findings::STATUS_OPEN + ); + } + + protected static function relative_path( $abs_path ) { + return str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $abs_path ) ); + } +} diff --git a/includes/class-argus-login-guard.php b/includes/class-argus-login-guard.php new file mode 100644 index 0000000..196dd96 --- /dev/null +++ b/includes/class-argus-login-guard.php @@ -0,0 +1,186 @@ + $failures ) ); + + if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) { + self::record_finding( $ip, (string) $_POST['log'], $failures, $decision ); // phpcs:ignore WordPress.Security.NonceVerification + } + + Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) ); + + return $user; + } + + public static function on_failure( $username, $error = null ) { + global $wpdb; + $ip = Argus_Request_Inputs::client_ip(); + + if ( Argus_Ban_Engine::is_banned( $ip ) ) { + Argus_Policy_Engine::deny_already_banned( $ip ); + return; + } + + $wpdb->insert( + Argus_DB::table( 'login_attempts' ), + array( + 'ip' => $ip, + 'username' => mb_substr( (string) $username, 0, 60 ), + 'success' => 0, + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%d', '%s' ) + ); + + $failures = self::recent_failures( $ip ); + Argus_Events::record( 'login_failed', $failures >= Argus_Settings::get( 'login_attempt_threshold', 5 ) ? 'high' : 'medium', sprintf( 'Failed login for "%s" from %s (%d recent failures)', $username, $ip, $failures ), array( 'username' => $username, 'failures' => $failures ), $ip ); + + self::maybe_check_anis_reputation( $ip, $failures ); + + $decision = Argus_Policy_Engine::evaluate( $ip, 'login_failure', array( 'recent_failures' => $failures ) ); + + if ( in_array( $decision['action'], array( Argus_Policy_Engine::ACTION_BLOCK, Argus_Policy_Engine::ACTION_CHALLENGE ), true ) ) { + self::record_finding( $ip, $username, $failures, $decision ); + } + + Argus_Policy_Engine::enforce_decision( $ip, 'login_failure', $decision, array( 'recent_failures' => $failures ) ); + } + + protected static function record_finding( $ip, $username, $failures, array $decision ) { + $blocked = Argus_Policy_Engine::ACTION_BLOCK === $decision['action']; + + Argus_Findings::record( + 'account', + $blocked ? 'high' : 'medium', + array( + + 'what_happened' => sprintf( 'Brute-force login attempts from %s', $ip ), + 'why_it_matters' => 'Repeated failed logins from one source in a short window is the classic pattern of an automated credential-guessing attack against wp-login.php.', + 'what_argus_found' => sprintf( '%d failed login attempts from %s. Action taken: %s (%s).', $failures, $ip, $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'A real person very rarely fails to log in this many times in a row -- this volume is consistent with automated password guessing, not a forgetful admin.', + 'what_could_be_affected' => 'If a guessed password succeeds, the attacker gains full access under that account\'s role -- often an administrator.', + 'what_should_you_do' => $decision['observation_only'] + ? 'ARGUS is in MONITOR mode and did not block this. Review recent login activity and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.' + : ( $blocked + ? 'No action needed -- ARGUS already banned this IP.' + : 'ARGUS is challenging further attempts from this IP with a proof-of-work check before allowing another login try. If attempts continue, it will be banned outright.' ), + ), + array( 'ip' => $ip, 'username' => $username, 'failures' => $failures, 'decision' => $decision ) + ); + } + + protected static function maybe_check_anis_reputation( $ip, $failures ) { + if ( ! class_exists( 'Argus_ANIS_Client' ) || ! Argus_ANIS_Client::is_enabled() ) { + return; + } + $threshold = (int) Argus_Settings::get( 'login_attempt_threshold', 5 ); + if ( $failures < max( 1, $threshold - 2 ) || $failures >= $threshold ) { + return; + } + if ( Argus_Ban_Engine::is_banned( $ip ) ) { + return; + } + + $reputation = Argus_ANIS_Client::check_ip_live( $ip ); + if ( $reputation && 'ban' === $reputation['action'] && $reputation['score'] >= Argus_ANIS_Client::AUTO_BAN_CONFIDENCE_THRESHOLD ) { + Argus_Ban_Engine::ban( + $ip, + Argus_Ban_Engine::SOURCE_ARGUS_CLOUD, + sprintf( 'Flagged by ANIS community threat intelligence during an active login attempt (score %d)', $reputation['score'] ), + array( 'anis_score' => $reputation['score'], 'login_failures' => $failures ), + Argus_Ban_Engine::LEVEL_EXTENDED + ); + } + } + + public static function on_success( $username, $user ) { + global $wpdb; + $ip = Argus_Request_Inputs::client_ip(); + + $wpdb->insert( + Argus_DB::table( 'login_attempts' ), + array( + 'ip' => $ip, + 'username' => mb_substr( (string) $username, 0, 60 ), + 'success' => 1, + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%d', '%s' ) + ); + + if ( $user instanceof WP_User && in_array( 'administrator', (array) $user->roles, true ) ) { + $account_age_days = ( time() - strtotime( $user->user_registered . ' UTC' ) ) / DAY_IN_SECONDS; + if ( $account_age_days < 1 ) { + Argus_Findings::record( + 'account', + 'medium', + array( + 'what_happened' => sprintf( 'Administrator "%s" logged in for the first time within a day of the account being created', $username ), + 'why_it_matters' => 'A brand-new administrator account is a common outcome of a successful privilege-escalation attack, not just a legitimate new team member.', + 'what_argus_found' => sprintf( 'Account created %s, first login %s, from %s.', $user->user_registered, current_time( 'mysql' ), $ip ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'New administrator accounts created shortly before their first login are worth a quick manual confirmation, especially if you did not create this account yourself.', + 'what_could_be_affected' => 'A rogue administrator account has full control of the site: content, users, plugins, and themes.', + 'what_should_you_do' => 'If you created this account intentionally, you can dismiss this finding. If not, remove the account immediately and review recent file/plugin changes.', + ), + array( 'username' => $username, 'user_id' => $user->ID, 'ip' => $ip ) + ); + } + } + } + + const ATTEMPTS_RETENTION_SECS = 7 * DAY_IN_SECONDS; + + public static function prune_attempts() { + global $wpdb; + $table = Argus_DB::table( 'login_attempts' ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - self::ATTEMPTS_RETENTION_SECS ); + + return $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore + } + + public static function recent_failures( $ip ) { + global $wpdb; + $table = Argus_DB::table( 'login_attempts' ); + $window = (int) Argus_Settings::get( 'login_attempt_window_secs', 600 ); + $since = gmdate( 'Y-m-d H:i:s', time() - $window ); + + return (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE ip = %s AND success = 0 AND created_at >= %s", // phpcs:ignore + $ip, + $since + ) + ); + } +} diff --git a/includes/class-argus-malware-scanner.php b/includes/class-argus-malware-scanner.php new file mode 100644 index 0000000..29b87ec --- /dev/null +++ b/includes/class-argus-malware-scanner.php @@ -0,0 +1,290 @@ + 'eval-dynamic-decode', 'weight' => 40, + 'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|convert_uudecode)\s*\(/i', + 'label' => 'eval() wrapping a decode/decompress function -- the single most common webshell obfuscation pattern' ), + array( 'id' => 'superglobal-into-exec', 'weight' => 40, + 'pattern' => '/\b(eval|assert|system|exec|shell_exec|passthru|proc_open)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)/i', + 'label' => 'A request superglobal ($_POST/$_GET/etc.) passed directly into a code-execution function' ), + array( 'id' => 'assert-as-eval', 'weight' => 25, + 'pattern' => '/\bassert\s*\(\s*[\'"]/i', + 'label' => 'assert() called with a string argument -- a legacy PHP trick equivalent to eval()' ), + array( 'id' => 'dangerous-function-present', 'weight' => 15, + 'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i', + 'label' => 'A shell-execution function is present in the file' ), + array( 'id' => 'variable-variable-obfuscation', 'weight' => 15, + 'pattern' => '/\$\{\s*[\'"]\w+[\'"]\s*\}|\$\$\w+/', + 'label' => 'Variable-variable syntax -- a common obfuscation technique to hide function/variable names from simple text search' ), + array( 'id' => 'large-base64-blob', 'weight' => 20, + 'pattern' => '/[A-Za-z0-9+\/]{800,}={0,2}/', + 'label' => 'A very large base64-like blob is embedded in the file' ), + array( 'id' => 'create-function', 'weight' => 15, + 'pattern' => '/\bcreate_function\s*\(/i', + 'label' => 'create_function() -- deprecated since PHP 7.2, rarely used except to obscure dynamically-generated code' ), + array( 'id' => 'file-write-from-request', 'weight' => 20, + 'pattern' => '/\b(file_put_contents|fwrite)\s*\([^,]+,\s*\$_(POST|GET|REQUEST)/i', + 'label' => 'Writes request data directly to a file -- consistent with a dropper/uploader shell' ), + ); + } + + const THRESHOLD_HIGH = 55; + const THRESHOLD_MEDIUM = 30; + + const QUARANTINE_SUFFIX = '.argus-quarantined'; + const HTACCESS_MARKER = 'ARGUS WordPress Defence -- deny PHP execution (ADR-0053 §9.1)'; + const RESTORED_TRUST_OPTION = 'argus_wpd_malware_restored_trust'; + + const SCAN_WATERMARK_OPTION = 'argus_wpd_malware_watermark'; + + const LOCKDOWN_STATUS_OPTION = 'argus_wpd_uploads_lockdown_status'; + + public static function mark_restored_trusted( $rel_path, $hash ) { + $trust = get_option( self::RESTORED_TRUST_OPTION, array() ); + if ( ! is_array( $trust ) ) { + $trust = array(); + } + $trust[ $rel_path ] = $hash; + update_option( self::RESTORED_TRUST_OPTION, $trust, false ); + } + + protected static function is_restored_trusted( $rel_path, $hash ) { + $trust = get_option( self::RESTORED_TRUST_OPTION, array() ); + return is_array( $trust ) && isset( $trust[ $rel_path ] ) && hash_equals( $trust[ $rel_path ], $hash ); + } + + public static function incremental_scan() { + self::ensure_uploads_lockdown(); + self::scan_uploads_for_php(); + self::scan_recently_changed(); + self::rescan_restored_trust(); + } + + public static function ensure_uploads_lockdown() { + $uploads = wp_get_upload_dir(); + $base = $uploads['basedir'] ?? null; + if ( ! $base || ! is_dir( $base ) || ! is_writable( $base ) ) { + update_option( self::LOCKDOWN_STATUS_OPTION, 'not_writable', false ); + return false; + } + + $path = trailingslashit( $base ) . '.htaccess'; + + $rule = "# " . self::HTACCESS_MARKER . "\n" + . "\n\tphp_flag engine off\n\n" + . "\n\tphp_flag engine off\n\n" + . "\n\tRequire all denied\n\n"; + + $existing = file_exists( $path ) ? file_get_contents( $path ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions + + $healthy = is_string( $existing ) + && false !== strpos( $existing, self::HTACCESS_MARKER ) + && false !== strpos( $existing, '\\.ph(p[3457]?|t|tml)"' ); + + if ( ! $healthy ) { + + $preserved = is_string( $existing ) ? $existing : ''; + $preserved = (string) preg_replace( + '/# ' . preg_quote( self::HTACCESS_MARKER, '/' ) . '.*?<\/FilesMatch>\n?/s', + '', + $preserved + ); + + $written = file_put_contents( $path, $rule . $preserved ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $written ) { + update_option( self::LOCKDOWN_STATUS_OPTION, 'failed_write', false ); + return false; + } + } + + update_option( self::LOCKDOWN_STATUS_OPTION, 'active', false ); + return true; + } + + public static function lockdown_status() { + return get_option( self::LOCKDOWN_STATUS_OPTION, 'unknown' ); + } + + protected static function scan_uploads_for_php() { + $uploads = wp_get_upload_dir(); + $base = $uploads['basedir'] ?? null; + if ( ! $base || ! is_dir( $base ) ) { + return; + } + + $data_dir = wp_normalize_path( trailingslashit( $base ) . 'argus-wpd-data' ); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ( $iterator as $file ) { + if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + $path = $file->getPathname(); + + if ( 0 === strpos( wp_normalize_path( $path ), $data_dir ) ) { + continue; + } + + $rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) ); + + if ( self::is_restored_trusted( $rel, hash_file( 'sha256', $path ) ) ) { + continue; + } + + $size = filesize( $path ); + $mtime = gmdate( 'Y-m-d H:i:s', filemtime( $path ) ); + + $result = Argus_Quarantine::quarantine_file( + $path, + $rel, + array( + 'engine' => 'Argus_Malware_Scanner', + 'rule' => 'php-in-uploads', + 'type' => 'signature', + 'severity' => 'critical', + ) + ); + $neutralized = false !== $result; + + $finding_id = Argus_Findings::record( + 'malware', + 'critical', + array( + 'what_happened' => 'A PHP file was found inside the uploads directory: ' . $rel, + 'why_it_matters' => 'WordPress does not execute PHP inside wp-content/uploads by design -- a legitimate install should never have PHP files there.', + 'what_argus_found' => sprintf( 'File: %s, size %s bytes, last modified %s.', $rel, $size, $mtime ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'This is one of the most common webshell placement techniques -- uploads directories are usually writable by the web application, unlike wp-admin/wp-includes.', + 'what_could_be_affected' => 'A PHP file here can be executed directly by visiting its URL, potentially giving an attacker full code execution on your server.', + 'what_should_you_do' => $neutralized + ? 'This file has been quarantined -- moved to a protected, non-web-accessible store. Review it on the Quarantine page and decide whether to restore or permanently delete it.' + : 'ARGUS detected this file but could not quarantine it (the uploads directory is not writable by PHP on this host, or it is a symlink ARGUS refuses to move automatically). Remove it manually and investigate how it got there.', + ), + array( + 'quarantine_id' => $neutralized ? $result['id'] : null, + 'file_path' => $rel, + 'file_mtime' => $mtime, + 'neutralized' => $neutralized, + ), + Argus_Findings::STATUS_OPEN + ); + + if ( $neutralized ) { + global $wpdb; + $wpdb->update( + Argus_DB::table( 'quarantine' ), + array( 'finding_id' => $finding_id ), + array( 'id' => $result['id'] ), + array( '%d' ), + array( '%d' ) + ); + } + } + } + + protected static function scan_recently_changed() { + $since = (int) get_option( self::SCAN_WATERMARK_OPTION, time() - HOUR_IN_SECONDS ); + + foreach ( array( WP_PLUGIN_DIR, get_theme_root(), ABSPATH . 'wp-admin', ABSPATH . WPINC ) as $dir ) { + if ( ! is_dir( $dir ) ) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ( $iterator as $file ) { + if ( ! $file->isFile() || 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + if ( $file->getMTime() < $since ) { + continue; + } + self::scan_file( $file->getPathname() ); + } + } + + update_option( self::SCAN_WATERMARK_OPTION, time(), false ); + } + + protected static function rescan_restored_trust() { + $trust = get_option( self::RESTORED_TRUST_OPTION, array() ); + if ( ! is_array( $trust ) || empty( $trust ) ) { + return; + } + + foreach ( $trust as $rel_path => $known_hash ) { + $abs = ABSPATH . ltrim( $rel_path, '/' ); + if ( ! is_readable( $abs ) ) { + continue; + } + if ( hash_file( 'sha256', $abs ) === $known_hash ) { + continue; + } + self::scan_file( $abs ); + } + } + + public static function score_content( $content ) { + $score = 0; + $matched = array(); + + foreach ( self::heuristics() as $rule ) { + if ( preg_match( $rule['pattern'], $content ) ) { + $score += $rule['weight']; + $matched[] = $rule; + } + } + + return array( $score, $matched ); + } + + public static function scan_file( $path ) { + if ( ! is_readable( $path ) || filesize( $path ) > self::MAX_FILE_BYTES ) { + return; + } + + $content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( false === $content ) { + return; + } + + list( $score, $matched ) = self::score_content( $content ); + + if ( $score < self::THRESHOLD_MEDIUM ) { + return; + } + + $severity = $score >= self::THRESHOLD_HIGH ? 'critical' : 'medium'; + $rel = str_replace( wp_normalize_path( ABSPATH ), '', wp_normalize_path( $path ) ); + $labels = wp_list_pluck( $matched, 'label' ); + + Argus_Findings::record( + 'malware', + $severity, + array( + 'what_happened' => sprintf( 'Suspicious PHP file detected: %s (heuristic score %d)', $rel, $score ), + 'why_it_matters' => 'This file contains patterns strongly associated with webshells or backdoors -- code designed to give an attacker persistent, hidden access to your server.', + 'what_argus_found' => implode( '; ', $labels ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'These are static-analysis heuristics, not a claim that this file is definitely malicious -- some are also occasionally present in legitimate advanced plugins. ARGUS shows you exactly what matched so you can judge it yourself.', + 'what_could_be_affected' => 'If this file is genuinely malicious, an attacker could have code execution on your server through it.', + 'what_should_you_do' => 'Review the file manually. If you don\'t recognize it or can\'t explain why it exists, remove it and investigate how it got there (check recent file changes, plugin/theme sources, and user activity).', + ), + array( 'file_path' => $rel, 'score' => $score, 'matched_rules' => wp_list_pluck( $matched, 'id' ), 'file_mtime' => gmdate( 'Y-m-d H:i:s', filemtime( $path ) ) ) + ); + } +} diff --git a/includes/class-argus-mu-installer.php b/includes/class-argus-mu-installer.php new file mode 100644 index 0000000..917c139 --- /dev/null +++ b/includes/class-argus-mu-installer.php @@ -0,0 +1,93 @@ + $target ) ); + self::install(); + return; + } + + if ( md5_file( $target ) !== md5_file( self::source_path() ) ) { + self::maybe_log_self_heal( 'medium', 'MU enforcement core was out of date -- refreshed', array( 'path' => $target ) ); + self::install(); + } + } + + protected static function maybe_log_self_heal( $severity, $message, array $context ) { + $last = (int) get_option( self::SELF_HEAL_LOG_COOLDOWN_OPTION, 0 ); + if ( time() - $last < self::SELF_HEAL_LOG_COOLDOWN_SECS ) { + return; + } + update_option( self::SELF_HEAL_LOG_COOLDOWN_OPTION, time(), false ); + Argus_Events::record( 'self_heal', $severity, $message, $context ); + } + + public static function status() { + return get_option( 'argus_wpd_mu_install_status', 'unknown' ); + } + + const HEARTBEAT_OPTION = 'argus_wpd_mu_core_heartbeat'; + const HEARTBEAT_INTERVAL_SECS = 300; + const HEARTBEAT_STALE_AFTER_SECS = 900; + + public static function execution_status() { + $last = (int) get_option( self::HEARTBEAT_OPTION, 0 ); + if ( 0 === $last ) { + + return 'never'; + } + return ( time() - $last ) <= self::HEARTBEAT_STALE_AFTER_SECS ? 'healthy' : 'stale'; + } + + public static function uninstall() { + $target = self::target_path(); + if ( file_exists( $target ) ) { + wp_delete_file( $target ); + } + delete_option( 'argus_wpd_mu_install_status' ); + } +} diff --git a/includes/class-argus-plugin.php b/includes/class-argus-plugin.php new file mode 100644 index 0000000..788683d --- /dev/null +++ b/includes/class-argus-plugin.php @@ -0,0 +1,126 @@ + 5 * MINUTE_IN_SECONDS, + 'display' => __( 'Every 5 Minutes (ARGUS Defence)', 'argus-wordpress-defence' ), + ); + return $schedules; + } + + public static function run_daily() { + if ( class_exists( 'Argus_Integrity' ) ) { + $scan_started = time(); + Argus_Integrity::full_scan(); + if ( class_exists( 'Argus_Scan_History' ) ) { + Argus_Scan_History::record( Argus_Scan_History::TYPE_SCHEDULED_FULL, $scan_started, Argus_Integrity::scan_status()['files_scanned'] ); + } + } + if ( class_exists( 'Argus_GeoIP_RIR' ) && Argus_Settings::get( 'geoip_rir_enabled', true ) ) { + Argus_GeoIP_RIR::run_daily_refresh(); + } + if ( class_exists( 'Argus_Cache_Discovery' ) && Argus_Settings::get( 'cache_discovery_enabled', true ) && Argus_Settings::get( 'static_cache_enabled', false ) ) { + Argus_Cache_Discovery::run_discovery(); + } + if ( class_exists( 'Argus_ANIS_Client' ) && Argus_ANIS_Client::is_enabled() ) { + + Argus_ANIS_Client::register(); + } + } +} diff --git a/includes/class-argus-policy-engine.php b/includes/class-argus-policy-engine.php new file mode 100644 index 0000000..c0e2fb1 --- /dev/null +++ b/includes/class-argus-policy-engine.php @@ -0,0 +1,162 @@ += $threshold ) { + return self::decision( self::ACTION_BLOCK, 'brute-force-threshold', sprintf( '%d failed login attempts in the tracked window', $attempts ), $mode, 'login_failure' ); + } + if ( $attempts >= max( 1, (int) ceil( $threshold / 2 ) ) ) { + return self::decision( self::ACTION_CHALLENGE, 'login-elevated', sprintf( '%d failed login attempts -- challenging before further tries', $attempts ), $mode, 'login_failure' ); + } + + return self::decision( self::ACTION_ALLOW, 'login-under-threshold', 'Failure count below challenge threshold', $mode, 'login_failure' ); + } + + protected static function decision( $action, $rule_name, $reason, $mode, $trigger ) { + return array( + 'action' => $action, + 'rule_name' => $rule_name, + 'reason' => $reason, + + 'observation_only' => Argus_Settings::MODE_MONITOR === $mode, + 'mode' => $mode, + 'trigger' => $trigger, + ); + } + + public static function evaluate_and_enforce( $ip, $trigger, array $context = array() ) { + $decision = self::evaluate( $ip, $trigger, $context ); + self::enforce_decision( $ip, $trigger, $decision, $context ); + return $decision; + } + + public static function enforce_decision( $ip, $trigger, array $decision, array $context = array() ) { + global $wpdb; + $wpdb->insert( + Argus_DB::table( 'policy_decisions' ), + array( + 'ip' => $ip, + 'trigger_type' => $trigger, + 'action' => $decision['action'], + 'rule_name' => $decision['rule_name'], + 'reason' => $decision['reason'], + 'observation_only' => $decision['observation_only'] ? 1 : 0, + 'mode' => $decision['mode'], + 'created_at' => current_time( 'mysql', true ), + ), + array( '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + if ( self::ACTION_ALLOW === $decision['action'] || $decision['observation_only'] ) { + if ( self::ACTION_ALLOW !== $decision['action'] ) { + Argus_Events::record( 'policy_observed', 'low', 'Would have ' . $decision['action'] . ': ' . $decision['reason'], $decision, $ip ); + } + return; + } + + self::enforce( $decision, $ip, $context ); + } + + public static function deny_already_banned( $ip ) { + self::deny_and_exit( $ip, 'IP is currently banned' ); + } + + protected static function enforce( array $decision, $ip, array $context ) { + switch ( $decision['action'] ) { + case self::ACTION_BLOCK: + Argus_Ban_Engine::ban( + $ip, + 'waf_match' === $decision['trigger'] ? Argus_Ban_Engine::SOURCE_LOCAL_WAF : Argus_Ban_Engine::SOURCE_POLICY_ENGINE, + $decision['reason'], + array( 'rule_name' => $decision['rule_name'], 'context' => $context ) + ); + self::deny_and_exit( $ip, $decision['reason'] ); + break; + + case self::ACTION_CHALLENGE: + if ( ! Argus_Challenge::has_valid_pass( $ip ) ) { + Argus_Events::record( 'challenge_issued', 'medium', 'Challenge issued: ' . $decision['reason'], $decision, $ip ); + Argus_Challenge::render_and_exit( $ip ); + } + break; + + case self::ACTION_RATE_LIMIT: + self::deny_and_exit( $ip, $decision['reason'], 429 ); + break; + } + } + + protected static function deny_and_exit( $ip, $reason, $status = 403 ) { + nocache_headers(); + status_header( $status ); + header( 'Content-Type: text/plain; charset=utf-8' ); + echo 'Forbidden.'; + exit; + } + + const RETENTION_DAYS = 90; + + public static function prune() { + global $wpdb; + $table = Argus_DB::table( 'policy_decisions' ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) ); + + return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) ); // phpcs:ignore + } +} diff --git a/includes/class-argus-quarantine.php b/includes/class-argus-quarantine.php new file mode 100644 index 0000000..682e02e --- /dev/null +++ b/includes/class-argus-quarantine.php @@ -0,0 +1,348 @@ +insert( + Argus_DB::table( 'quarantine' ), + array( + 'original_path' => $rel_path, + 'quarantine_filename' => $filename, + 'original_filename' => basename( $rel_path ), + 'file_size' => $size, + 'file_hash' => $hash, + 'original_mtime' => $mtime, + 'original_perms' => $perms, + 'detection_engine' => $detection['engine'], + 'detection_rule' => $detection['rule'] ?? null, + 'detection_type' => $detection['type'], + 'severity' => $detection['severity'], + 'status' => self::STATUS_QUARANTINED, + 'quarantined_at' => $now, + ), + array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) + ); + + $id = (int) $wpdb->insert_id; + + Argus_Events::record( + 'quarantine_added', + 'high', + sprintf( 'Quarantined %s (%s)', $rel_path, $detection['engine'] ), + array( 'quarantine_id' => $id, 'original_path' => $rel_path, 'detection_type' => $detection['type'] ) + ); + + return array( 'id' => $id, 'hash' => $hash, 'quarantine_filename' => $filename ); + } + + protected static function store_path( $row ) { + return trailingslashit( self::quarantine_dir() ) . $row->quarantine_filename; + } + + public static function restore( $id ) { + $row = self::get( $id ); + if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) { + return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) ); + } + + $store_path = self::store_path( $row ); + if ( ! file_exists( $store_path ) ) { + return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) ); + } + + $content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content ); + + global $wpdb; + + if ( $score >= Argus_Malware_Scanner::THRESHOLD_MEDIUM ) { + $wpdb->update( + Argus_DB::table( 'quarantine' ), + array( 'restore_attempts' => (int) $row->restore_attempts + 1 ), + array( 'id' => $row->id ), + array( '%d' ), + array( '%d' ) + ); + return array( + 'success' => false, + + 'message' => __( 'Restore cancelled -- the restored file still triggers the security engine.', 'argus-wordpress-defence' ), + ); + } + + $abs_original = ABSPATH . ltrim( $row->original_path, '/' ); + if ( file_exists( $abs_original ) ) { + return array( 'success' => false, 'message' => __( 'A file already exists at the original location.', 'argus-wordpress-defence' ) ); + } + + $moved = @rename( $store_path, $abs_original ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + if ( ! $moved ) { + return array( 'success' => false, 'message' => __( 'Could not move the file back to its original location.', 'argus-wordpress-defence' ) ); + } + + if ( $row->original_perms ) { + @chmod( $abs_original, octdec( $row->original_perms ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.PHP.NoSilencedErrors + } + + $now = current_time( 'mysql', true ); + $wpdb->update( + Argus_DB::table( 'quarantine' ), + array( 'status' => self::STATUS_RESTORED, 'resolved_at' => $now, 'restore_attempts' => (int) $row->restore_attempts + 1 ), + array( 'id' => $row->id ), + array( '%s', '%s', '%d' ), + array( '%d' ) + ); + + Argus_Malware_Scanner::mark_restored_trusted( $row->original_path, hash_file( 'sha256', $abs_original ) ); + + if ( $row->finding_id ) { + Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED ); + } + + Argus_Events::record( + 'quarantine_restored', + 'info', + sprintf( 'Restored %s from quarantine', $row->original_path ), + array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path, 'score' => $score ) + ); + + return array( + 'success' => true, + + 'message' => __( 'Restored -- ARGUS\'s static-analysis engine found no suspicious patterns in the current content. This is not a guarantee the file is safe, only that it did not match ARGUS\'s known indicators.', 'argus-wordpress-defence' ), + ); + } + + public static function delete( $id ) { + global $wpdb; + + $row = self::get( $id ); + if ( ! $row || self::STATUS_QUARANTINED !== $row->status ) { + return array( 'success' => false, 'message' => __( 'That item is not currently quarantined.', 'argus-wordpress-defence' ) ); + } + + $store_path = self::store_path( $row ); + if ( file_exists( $store_path ) && ! @unlink( $store_path ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors + return array( 'success' => false, 'message' => __( 'Could not delete the quarantined file.', 'argus-wordpress-defence' ) ); + } + + $now = current_time( 'mysql', true ); + $wpdb->update( + Argus_DB::table( 'quarantine' ), + array( 'status' => self::STATUS_DELETED, 'resolved_at' => $now ), + array( 'id' => $row->id ), + array( '%s', '%s' ), + array( '%d' ) + ); + + if ( $row->finding_id ) { + Argus_Findings::set_status( $row->finding_id, Argus_Findings::STATUS_RESOLVED ); + } + + Argus_Events::record( + 'quarantine_deleted', + 'info', + sprintf( 'Permanently deleted quarantined file: %s', $row->original_path ), + array( 'quarantine_id' => $row->id, 'original_path' => $row->original_path ) + ); + + return array( 'success' => true, 'message' => __( 'Permanently deleted.', 'argus-wordpress-defence' ) ); + } + + public static function analyse( $id ) { + global $wpdb; + + $row = self::get( $id ); + if ( ! $row ) { + return array( 'success' => false, 'message' => __( 'Quarantine item not found.', 'argus-wordpress-defence' ) ); + } + + $store_path = self::store_path( $row ); + if ( ! file_exists( $store_path ) ) { + return array( 'success' => false, 'message' => __( 'The quarantined file is missing from the quarantine store.', 'argus-wordpress-defence' ) ); + } + + $content = file_get_contents( $store_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions + list( $score, $matched ) = Argus_Malware_Scanner::score_content( (string) $content ); + + $wpdb->update( + Argus_DB::table( 'quarantine' ), + array( + 'confidence_score' => $score, + 'matched_rules' => wp_json_encode( wp_list_pluck( $matched, 'id' ) ), + 'analysed_at' => current_time( 'mysql', true ), + ), + array( 'id' => $row->id ), + array( '%d', '%s', '%s' ), + array( '%d' ) + ); + + return array( 'success' => true, 'score' => $score, 'matched' => $matched ); + } + + public static function get( $id ) { + global $wpdb; + $table = Argus_DB::table( 'quarantine' ); + return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) ); // phpcs:ignore + } + + const PER_PAGE = 10; + + public static function paginated( $page = 1, $status = null, $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'quarantine' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + if ( $status ) { + $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", $status ) ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE status = %s ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $status, $per_page, $offset ) // phpcs:ignore + ); + } else { + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY quarantined_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore + ); + } + + foreach ( $rows as &$row ) { + $row->matched_rules = $row->matched_rules ? json_decode( $row->matched_rules, true ) : array(); + } + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } + + public static function count_open() { + global $wpdb; + $table = Argus_DB::table( 'quarantine' ); + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", self::STATUS_QUARANTINED ) // phpcs:ignore + ); + } + + public static function counts_by_status() { + global $wpdb; + $table = Argus_DB::table( 'quarantine' ); + $rows = $wpdb->get_results( "SELECT status, COUNT(*) AS cnt FROM {$table} GROUP BY status" ); // phpcs:ignore + + $out = array( self::STATUS_QUARANTINED => 0, self::STATUS_RESTORED => 0, self::STATUS_DELETED => 0 ); + foreach ( $rows as $row ) { + if ( isset( $out[ $row->status ] ) ) { + $out[ $row->status ] = (int) $row->cnt; + } + } + return $out; + } + + public static function legacy_count() { + $uploads = wp_get_upload_dir(); + $dir = $uploads['basedir'] ?? ''; + if ( ! $dir || ! is_dir( $dir ) ) { + return 0; + } + + $count = 0; + $suffix = Argus_Malware_Scanner::QUARANTINE_SUFFIX; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), + RecursiveIteratorIterator::LEAVES_ONLY + ); + foreach ( $iterator as $file ) { + if ( $file->isFile() && $suffix === substr( $file->getFilename(), -strlen( $suffix ) ) ) { + $count++; + } + } + return $count; + } +} diff --git a/includes/class-argus-request-inputs.php b/includes/class-argus-request-inputs.php new file mode 100644 index 0000000..4ecf0c1 --- /dev/null +++ b/includes/class-argus-request-inputs.php @@ -0,0 +1,53 @@ + $value ) { // phpcs:ignore WordPress.Security.NonceVerification + self::flatten( 'GET:' . $key, $value, $inputs ); + } + foreach ( (array) $_POST as $key => $value ) { // phpcs:ignore WordPress.Security.NonceVerification + self::flatten( 'POST:' . $key, $value, $inputs ); + } + + $raw_body = file_get_contents( 'php://input' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( is_string( $raw_body ) && '' !== $raw_body ) { + + $inputs['BODY'] = mb_substr( $raw_body, 0, 8192 ); + } + + foreach ( array( 'HTTP_USER_AGENT', 'HTTP_REFERER', 'HTTP_X_FORWARDED_FOR', 'REQUEST_URI' ) as $server_key ) { + if ( ! empty( $_SERVER[ $server_key ] ) ) { + $inputs[ 'HEADER:' . $server_key ] = stripslashes( (string) $_SERVER[ $server_key ] ); // phpcs:ignore + } + } + + return $inputs; + } + + protected static function flatten( $prefix, $value, array &$out, $depth = 0 ) { + if ( $depth > 3 ) { + return; + } + if ( is_array( $value ) ) { + foreach ( $value as $k => $v ) { + self::flatten( $prefix . '.' . $k, $v, $out, $depth + 1 ); + } + return; + } + if ( is_string( $value ) ) { + $out[ $prefix ] = stripslashes( $value ); + } + } + + public static function client_ip() { + + return isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : ''; // phpcs:ignore + } +} + +} diff --git a/includes/class-argus-rest-inventory.php b/includes/class-argus-rest-inventory.php new file mode 100644 index 0000000..1ecee16 --- /dev/null +++ b/includes/class-argus-rest-inventory.php @@ -0,0 +1,86 @@ +get_routes(); + + $flagged = array(); + + foreach ( $routes as $route => $handlers ) { + + if ( 0 === strpos( $route, '/wp/v2' ) || 0 === strpos( $route, '/oembed' ) || 0 === strpos( $route, '/batch' ) ) { + continue; + } + + foreach ( $handlers as $handler ) { + $permission = $handler['permission_callback'] ?? null; + $methods = is_array( $handler['methods'] ?? null ) ? array_keys( array_filter( $handler['methods'] ) ) : (array) ( $handler['methods'] ?? array() ); + + $is_wide_open = ( null === $permission ) || '__return_true' === $permission; + $accepts_write = (bool) array_intersect( array( 'POST', 'PUT', 'PATCH', 'DELETE' ), $methods ); + + if ( $is_wide_open && $accepts_write ) { + $flagged[] = array( + 'route' => $route, + 'methods' => $methods, + 'plugin' => self::owning_plugin( $handler['callback'] ?? null ), + ); + } + } + } + + update_option( 'argus_wpd_rest_inventory', array( 'checked_at' => current_time( 'mysql', true ), 'flagged' => $flagged, 'total_routes' => count( $routes ) ), false ); + + foreach ( $flagged as $route_info ) { + Argus_Findings::record( + 'rest_inventory', + 'low', + array( + 'what_happened' => sprintf( 'Unauthenticated, state-changing REST route found: %s (%s)', $route_info['route'], implode( '/', $route_info['methods'] ) ), + 'why_it_matters' => 'A route that accepts POST/PUT/PATCH/DELETE with no permission check is reachable by anyone, including automated scanners -- this is often intentional (e.g. a public contact-form endpoint) but worth a quick review.', + 'what_argus_found' => sprintf( 'Route registered by: %s.', $route_info['plugin'] ?: 'unknown' ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'This is an inventory finding, not a detected attack -- ARGUS does not know what this endpoint actually does, only that nothing stops an unauthenticated caller from reaching it.', + 'what_could_be_affected' => 'Depends entirely on what the endpoint does -- review its plugin\'s documentation or source.', + 'what_should_you_do' => 'If this endpoint is meant to be public, no action is needed. If not, check whether the owning plugin has a setting to require authentication, or contact its developer.', + ), + array( 'route' => $route_info['route'], 'methods' => $route_info['methods'], 'plugin' => $route_info['plugin'] ) + ); + } + + return $flagged; + } + + public static function owning_plugin( $callback ) { + try { + if ( is_array( $callback ) && is_object( $callback[0] ?? null ) ) { + $ref = new ReflectionClass( $callback[0] ); + } elseif ( is_string( $callback ) && function_exists( $callback ) ) { + $ref = new ReflectionFunction( $callback ); + } elseif ( $callback instanceof Closure ) { + $ref = new ReflectionFunction( $callback ); + } else { + return null; + } + + $file = $ref->getFileName(); + if ( ! $file ) { + return null; + } + $rel = str_replace( wp_normalize_path( WP_PLUGIN_DIR ) . '/', '', wp_normalize_path( $file ) ); + return explode( '/', $rel )[0] ?? null; + } catch ( ReflectionException $e ) { + return null; + } + } +} diff --git a/includes/class-argus-scan-history.php b/includes/class-argus-scan-history.php new file mode 100644 index 0000000..002d283 --- /dev/null +++ b/includes/class-argus-scan-history.php @@ -0,0 +1,52 @@ +insert( + Argus_DB::table( 'scan_history' ), + array( + 'scan_type' => $type, + 'started_at' => gmdate( 'Y-m-d H:i:s', $started_at ), + 'finished_at' => current_time( 'mysql', true ), + 'files_scanned' => (int) $files_scanned, + 'findings_count' => $findings_count, + 'result' => $findings_count > 0 ? 'issues' : 'clean', + ) + ); + } + + public static function paginated( $page = 1, $per_page = self::PER_PAGE ) { + global $wpdb; + $table = Argus_DB::table( 'scan_history' ); + $page = max( 1, (int) $page ); + $offset = ( $page - 1 ) * $per_page; + + $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY started_at DESC LIMIT %d OFFSET %d", $per_page, $offset ) // phpcs:ignore + ); + + return array( + 'rows' => $rows, + 'total' => $total, + 'total_pages' => max( 1, (int) ceil( $total / $per_page ) ), + 'page' => $page, + ); + } +} diff --git a/includes/class-argus-settings.php b/includes/class-argus-settings.php new file mode 100644 index 0000000..25aadf0 --- /dev/null +++ b/includes/class-argus-settings.php @@ -0,0 +1,120 @@ + self::MODE_MONITOR, + 'waf_enabled' => true, + 'login_protection_enabled' => true, + 'xmlrpc_block_pingback' => true, + 'rest_api_protection_enabled'=> true, + 'integrity_scan_enabled' => true, + 'malware_scan_enabled' => true, + 'upload_scan_enabled' => true, + 'vuln_intel_enabled' => true, + 'geoip_rir_enabled' => true, + 'static_cache_enabled' => false, + 'static_cache_ttl_secs' => 3600, + 'cache_stale_grace_secs' => 600, + 'cache_custom_exclude_paths' => array(), + 'cache_gzip_enabled' => true, + 'cache_gzip_level' => 6, + 'cache_brotli_enabled' => true, + 'cache_brotli_level' => 5, + 'cache_min_compress_bytes' => 1024, + 'cache_discovery_enabled' => true, + 'cache_warming_enabled' => false, + 'cache_warm_concurrency' => 4, + 'cache_warm_batch_size' => 20, + 'cache_warm_min_interval_secs' => 1, + 'challenge_enabled' => true, + 'login_attempt_threshold' => 5, + 'login_attempt_window_secs' => 600, + 'login_lockout_secs' => 900, + 'exceptions' => array(), + 'cloud_connected' => false, + + 'anis_enabled' => true, + 'anis_base_url' => 'https://anis.weboria.eu', + 'anis_license_key' => '', + + 'backup_schedule_mode' => 'automatic', + 'backup_interval_hours' => 24, + 'backup_retention_count' => 5, + ); + + public static function all() { + $stored = get_option( self::OPTION, array() ); + return wp_parse_args( $stored, self::$defaults ); + } + + public static function get( $key, $fallback = null ) { + $all = self::all(); + return array_key_exists( $key, $all ) ? $all[ $key ] : $fallback; + } + + public static function update( array $partial ) { + $all = array_merge( self::all(), $partial ); + + update_option( self::OPTION, $all, true ); + return $all; + } + + public static function mode() { + return self::get( 'mode', self::MODE_MONITOR ); + } + + public static function is_monitor_only() { + return self::mode() === self::MODE_MONITOR; + } + + public static function is_strict() { + return self::mode() === self::MODE_STRICT; + } + + public static function has_exception( $type, $value ) { + foreach ( self::get( 'exceptions', array() ) as $exception ) { + if ( ( $exception['type'] ?? '' ) === $type && ( $exception['value'] ?? '' ) === $value ) { + return true; + } + } + return false; + } + + public static function add_exception( $type, $value, $note = '' ) { + if ( self::has_exception( $type, $value ) ) { + return self::all(); + } + $exceptions = self::get( 'exceptions', array() ); + $exceptions[] = array( + 'type' => $type, + 'value' => $value, + 'note' => $note, + 'added_at' => current_time( 'mysql', true ), + ); + return self::update( array( 'exceptions' => $exceptions ) ); + } + + public static function remove_exception( $type, $value ) { + $exceptions = array_values( + array_filter( + self::get( 'exceptions', array() ), + function ( $e ) use ( $type, $value ) { + return ! ( ( $e['type'] ?? '' ) === $type && ( $e['value'] ?? '' ) === $value ); + } + ) + ); + return self::update( array( 'exceptions' => $exceptions ) ); + } +} diff --git a/includes/class-argus-static-cache.php b/includes/class-argus-static-cache.php new file mode 100644 index 0000000..11d19f5 --- /dev/null +++ b/includes/class-argus-static-cache.php @@ -0,0 +1,495 @@ + http_response_code(), + 'headers' => self::response_headers(), + 'new_cookies_set' => self::response_cookie_names(), + 'content_type' => self::response_content_type(), + ); + + $classification = Argus_Cache_Eligibility::classify_response( $response_context ); + + if ( Argus_Cache_Eligibility::SAFE_TO_CACHE !== $classification['status'] || '' === $html ) { + $log_status = '' === $html ? self::STATUS_BYPASS : ( Argus_Cache_Eligibility::FORCE_EXCLUDED === $classification['status'] ? self::STATUS_EXCLUDED : self::STATUS_BYPASS ); + Argus_Cache_Log::record( $log_status, $url, '' === $html ? 'Empty response body.' : $classification['reason'], $response_ms ); + self::release_lock( $lock ); + return $html; + } + + self::write_cache_entry( $paths, $html, $url ); + Argus_Cache_Log::record( $status, $url, 'Stored.', $response_ms ); + self::release_lock( $lock ); + + return $html; + } + + protected static function write_cache_entry( array $paths, $html, $url ) { + wp_mkdir_p( dirname( $paths['html'] ) ); + file_put_contents( $paths['html'], $html ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + $min_bytes = (int) Argus_Settings::get( 'cache_min_compress_bytes', 1024 ); + if ( strlen( $html ) >= $min_bytes ) { + if ( Argus_Settings::get( 'cache_gzip_enabled', true ) && function_exists( 'gzencode' ) ) { + $level = (int) Argus_Settings::get( 'cache_gzip_level', 6 ); + file_put_contents( $paths['gz'], gzencode( $html, max( 1, min( 9, $level ) ) ) ); // phpcs:ignore + } else { + @unlink( $paths['gz'] ); // phpcs:ignore + } + if ( Argus_Settings::get( 'cache_brotli_enabled', true ) && function_exists( 'brotli_compress' ) ) { + $level = (int) Argus_Settings::get( 'cache_brotli_level', 5 ); + file_put_contents( $paths['br'], brotli_compress( $html, max( 0, min( 11, $level ) ) ) ); // phpcs:ignore + } else { + @unlink( $paths['br'] ); // phpcs:ignore + } + } else { + @unlink( $paths['gz'] ); // phpcs:ignore + @unlink( $paths['br'] ); // phpcs:ignore + } + + file_put_contents( // phpcs:ignore + $paths['meta'], + wp_json_encode( + array( + 'url' => $url, + 'bytes' => strlen( $html ), + 'created_at' => current_time( 'mysql', true ), + ) + ) + ); + } + + protected static function serve_hit( array $paths, $url, $status, $start ) { + $encoding = self::negotiate_encoding(); + $source = $paths['html']; + $header_encoding = ''; + + if ( 'br' === $encoding && file_exists( $paths['br'] ) ) { + $source = $paths['br']; + $header_encoding = 'br'; + } elseif ( 'gzip' === $encoding && file_exists( $paths['gz'] ) ) { + $source = $paths['gz']; + $header_encoding = 'gzip'; + } + + header( 'X-Argus-Cache: ' . $status ); + header( 'X-Argus-Cache-Age: ' . ( time() - filemtime( $paths['html'] ) ) ); + header( 'Vary: Accept-Encoding' ); + if ( $header_encoding ) { + header( 'Content-Encoding: ' . $header_encoding ); + } + header( 'Content-Type: text/html; charset=UTF-8' ); + readfile( $source ); // phpcs:ignore WordPress.WP.AlternativeFunctions + + Argus_Cache_Log::record( $status, $url, 'Served from cache.', (int) round( ( microtime( true ) - $start ) * 1000 ) ); + exit; + } + + protected static function negotiate_encoding() { + $accept = strtolower( (string) ( $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + if ( false !== strpos( $accept, 'br' ) ) { + return 'br'; + } + if ( false !== strpos( $accept, 'gzip' ) ) { + return 'gzip'; + } + return ''; + } + + protected static function request_context() { + $cookies = array(); + foreach ( (array) $_COOKIE as $name => $value ) { // phpcs:ignore WordPress.Security.NonceVerification + $cookies[ (string) $name ] = ''; + } + + return array( + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + 'path' => (string) wp_parse_url( self::current_url(), PHP_URL_PATH ), + 'query' => $_GET, // phpcs:ignore WordPress.Security.NonceVerification + 'is_admin' => is_admin(), + 'is_logged_in' => is_user_logged_in(), + 'cookies' => $cookies, + 'has_authorization_header' => ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) || ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ), // phpcs:ignore + 'is_ajax' => wp_doing_ajax(), + 'is_rest' => defined( 'REST_REQUEST' ) && REST_REQUEST, + 'is_cron' => defined( 'DOING_CRON' ) && DOING_CRON, + ) + self::conditional_tag_excludes(); + } + + protected static function conditional_tag_excludes() { + if ( is_feed() || is_trackback() || is_preview() || is_search() || is_404() ) { + return array( 'is_admin' => true ); + } + return array(); + } + + protected static function response_headers() { + $out = array(); + foreach ( headers_list() as $header ) { + $parts = explode( ':', $header, 2 ); + if ( 2 !== count( $parts ) ) { + continue; + } + $name = strtolower( trim( $parts[0] ) ); + if ( isset( $out[ $name ] ) ) { + $out[ $name ] .= ', ' . trim( $parts[1] ); + } else { + $out[ $name ] = trim( $parts[1] ); + } + } + return $out; + } + + protected static function response_cookie_names() { + $names = array(); + foreach ( headers_list() as $header ) { + if ( 0 === stripos( $header, 'Set-Cookie:' ) && preg_match( '/^Set-Cookie:\s*([^=]+)=/i', $header, $m ) ) { + $names[] = trim( $m[1] ); + } + } + return $names; + } + + protected static function response_content_type() { + foreach ( headers_list() as $header ) { + if ( 0 === stripos( $header, 'Content-Type:' ) ) { + return trim( substr( $header, strlen( 'Content-Type:' ) ) ); + } + } + return ''; + } + + protected static function current_url() { + $scheme = is_ssl() ? 'https' : 'http'; + $host = (string) ( $_SERVER['HTTP_HOST'] ?? 'default' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + $uri = (string) ( $_SERVER['REQUEST_URI'] ?? '/' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + return $scheme . '://' . $host . $uri; + } + + public static function cache_key( $url ) { + $parts = wp_parse_url( $url ); + $path = $parts['path'] ?? '/'; + $query = array(); + if ( ! empty( $parts['query'] ) ) { + parse_str( $parts['query'], $query ); + $query = array_diff_key( $query, array_flip( Argus_Cache_Eligibility::IGNORABLE_QUERY_PARAMS ) ); + ksort( $query ); + } + $host = (string) ( $parts['host'] ?? 'default' ); + return md5( $host . '|' . $path . '|' . http_build_query( $query ) ); + } + + protected static function cache_dir() { + $uploads = wp_get_upload_dir(); + return trailingslashit( $uploads['basedir'] ) . 'argus-wpd-data/html-cache'; + } + + public static function cache_paths( $key ) { + $base = trailingslashit( self::cache_dir() ) . $key; + return array( + 'html' => $base . '.html', + 'gz' => $base . '.html.gz', + 'br' => $base . '.html.br', + 'meta' => $base . '.meta.json', + ); + } + + protected static function lock_dir() { + return trailingslashit( self::cache_dir() ) . 'locks'; + } + + protected static function acquire_lock( $key ) { + $dir = self::lock_dir(); + if ( ! is_dir( $dir ) ) { + wp_mkdir_p( $dir ); + } + $path = trailingslashit( $dir ) . $key . '.lock'; + $fh = @fopen( $path, 'c' ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors + if ( ! $fh ) { + return false; + } + if ( ! flock( $fh, LOCK_EX | LOCK_NB ) ) { + fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return false; + } + return $fh; + } + + protected static function release_lock( $fh ) { + if ( $fh ) { + flock( $fh, LOCK_UN ); + fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + } + + public static function purge_all() { + $dir = self::cache_dir(); + if ( ! is_dir( $dir ) ) { + return 0; + } + $count = 0; + foreach ( array( '*.html', '*.html.gz', '*.html.br', '*.meta.json' ) as $glob ) { + foreach ( glob( trailingslashit( $dir ) . $glob ) ?: array() as $file ) { + @unlink( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + ++$count; + } + } + Argus_Cache_Log::record( self::STATUS_PURGED, '*', 'Purge everything.' ); + return $count; + } + + public static function purge_url( $url ) { + $key = self::cache_key( $url ); + foreach ( self::cache_paths( $key ) as $path ) { + @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + Argus_Cache_Log::record( self::STATUS_PURGED, $url, 'Purge single URL.' ); + } + + public static function purge_pattern( $pattern ) { + $dir = self::cache_dir(); + if ( ! is_dir( $dir ) ) { + return 0; + } + $regex = '/^' . str_replace( '\*', '.*', preg_quote( $pattern, '/' ) ) . '$/i'; + $count = 0; + foreach ( glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array() as $meta_file ) { + $meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore + if ( ! is_array( $meta ) || empty( $meta['url'] ) || ! preg_match( $regex, $meta['url'] ) ) { + continue; + } + $key = basename( $meta_file, '.meta.json' ); + foreach ( self::cache_paths( $key ) as $path ) { + @unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors + } + ++$count; + } + Argus_Cache_Log::record( self::STATUS_PURGED, $pattern, 'Purge by pattern (' . $count . ' entries).' ); + return $count; + } + + public static function purge_related_to_post( $post_id ) { + if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { + return; + } + $permalink = get_permalink( $post_id ); + if ( $permalink ) { + self::purge_url( $permalink ); + } + self::purge_url( home_url( '/' ) ); + $page_for_posts = (int) get_option( 'page_for_posts' ); + if ( $page_for_posts ) { + self::purge_url( get_permalink( $page_for_posts ) ); + } + } + + public static function purge_related_to_comment( $comment_id ) { + $comment = get_comment( $comment_id ); + if ( $comment && $comment->comment_post_ID ) { + self::purge_related_to_post( (int) $comment->comment_post_ID ); + } + } + + public static function stats() { + $dir = self::cache_dir(); + if ( ! is_dir( $dir ) ) { + return array( 'file_count' => 0, 'total_bytes' => 0 ); + } + $files = glob( trailingslashit( $dir ) . '*.html' ) ?: array(); + $bytes = 0; + foreach ( $files as $file ) { + $bytes += filesize( $file ); + foreach ( array( '.gz', '.br' ) as $ext ) { + $variant = $file . $ext; + if ( file_exists( $variant ) ) { + $bytes += filesize( $variant ); + } + } + } + return array( 'file_count' => count( $files ), 'total_bytes' => $bytes ); + } + + public static function compression_stats() { + $dir = self::cache_dir(); + $out = array( + 'gzip' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ), + 'brotli' => array( 'files' => 0, 'avg_savings_pct' => 0.0, 'bytes_saved' => 0 ), + 'total_bytes_saved' => 0, + ); + if ( ! is_dir( $dir ) ) { + return $out; + } + + $files = glob( trailingslashit( $dir ) . '*.html' ) ?: array(); + $pct_sums = array( 'gzip' => 0.0, 'brotli' => 0.0 ); + + foreach ( $files as $file ) { + $plain_size = filesize( $file ); + if ( ! $plain_size ) { + continue; + } + foreach ( array( 'gzip' => '.gz', 'brotli' => '.br' ) as $format => $ext ) { + $variant = $file . $ext; + if ( ! file_exists( $variant ) ) { + continue; + } + $variant_size = filesize( $variant ); + if ( ! $variant_size || $variant_size >= $plain_size ) { + continue; + } + $saved = $plain_size - $variant_size; + ++$out[ $format ]['files']; + $out[ $format ]['bytes_saved'] += $saved; + $out['total_bytes_saved'] += $saved; + $pct_sums[ $format ] += ( $saved / $plain_size ) * 100; + } + } + + foreach ( array( 'gzip', 'brotli' ) as $format ) { + if ( $out[ $format ]['files'] > 0 ) { + $out[ $format ]['avg_savings_pct'] = round( $pct_sums[ $format ] / $out[ $format ]['files'], 1 ); + } + } + + return $out; + } + + public static function list_cached( $limit = 100 ) { + $dir = self::cache_dir(); + if ( ! is_dir( $dir ) ) { + return array(); + } + $meta_files = glob( trailingslashit( $dir ) . '*.meta.json' ) ?: array(); + usort( + $meta_files, + function ( $a, $b ) { + return filemtime( $b ) <=> filemtime( $a ); + } + ); + + $out = array(); + foreach ( array_slice( $meta_files, 0, $limit ) as $meta_file ) { + $meta = json_decode( (string) file_get_contents( $meta_file ), true ); // phpcs:ignore + if ( ! is_array( $meta ) ) { + continue; + } + $key = basename( $meta_file, '.meta.json' ); + $paths = self::cache_paths( $key ); + $out[] = array( + 'url' => $meta['url'] ?? '', + 'bytes' => (int) ( $meta['bytes'] ?? 0 ), + 'created_at' => $meta['created_at'] ?? '', + 'age_secs' => file_exists( $paths['html'] ) ? ( time() - filemtime( $paths['html'] ) ) : null, + 'has_gzip' => file_exists( $paths['gz'] ), + 'has_brotli' => file_exists( $paths['br'] ), + ); + } + return $out; + } +} diff --git a/includes/class-argus-templates.php b/includes/class-argus-templates.php new file mode 100644 index 0000000..22a5de3 --- /dev/null +++ b/includes/class-argus-templates.php @@ -0,0 +1,133 @@ + array( + 'label' => __( 'Personal / Blog', 'argus-wordpress-defence' ), + 'description' => __( 'A personal site or small blog -- real protection without getting in the way of the one person who actually logs in.', 'argus-wordpress-defence' ), + 'settings' => array( + 'mode' => Argus_Settings::MODE_BLOCK, + 'waf_enabled' => true, + 'login_protection_enabled' => true, + 'xmlrpc_block_pingback' => true, + 'rest_api_protection_enabled' => true, + 'integrity_scan_enabled' => true, + 'malware_scan_enabled' => true, + 'vuln_intel_enabled' => true, + 'geoip_rir_enabled' => true, + 'challenge_enabled' => true, + 'login_attempt_threshold' => 5, + 'login_attempt_window_secs' => 600, + 'login_lockout_secs' => 900, + 'backup_interval_hours' => 168, + 'backup_retention_count' => 4, + ), + ), + 'business' => array( + 'label' => __( 'Business', 'argus-wordpress-defence' ), + 'description' => __( 'A company site with several editors/admins -- tighter login tolerance than a personal blog, everything else fully on.', 'argus-wordpress-defence' ), + 'settings' => array( + 'mode' => Argus_Settings::MODE_BLOCK, + 'waf_enabled' => true, + 'login_protection_enabled' => true, + 'xmlrpc_block_pingback' => true, + 'rest_api_protection_enabled' => true, + 'integrity_scan_enabled' => true, + 'malware_scan_enabled' => true, + 'vuln_intel_enabled' => true, + 'geoip_rir_enabled' => true, + 'challenge_enabled' => true, + 'login_attempt_threshold' => 4, + 'login_attempt_window_secs' => 600, + 'login_lockout_secs' => 1800, + 'backup_interval_hours' => 24, + 'backup_retention_count' => 7, + ), + ), + 'enterprise' => array( + 'label' => __( 'Enterprise', 'argus-wordpress-defence' ), + 'description' => __( 'A larger organization -- many admins/editors across more content and integrations, a bigger and more attractive attack surface than a single-team business site. STRICT mode, tight login tolerance.', 'argus-wordpress-defence' ), + 'settings' => array( + 'mode' => Argus_Settings::MODE_STRICT, + 'waf_enabled' => true, + 'login_protection_enabled' => true, + 'xmlrpc_block_pingback' => true, + 'rest_api_protection_enabled' => true, + 'integrity_scan_enabled' => true, + 'malware_scan_enabled' => true, + 'vuln_intel_enabled' => true, + 'geoip_rir_enabled' => true, + 'challenge_enabled' => true, + 'login_attempt_threshold' => 3, + 'login_attempt_window_secs' => 600, + 'login_lockout_secs' => 3600, + 'backup_interval_hours' => 24, + 'backup_retention_count' => 14, + ), + ), + 'ecommerce' => array( + 'label' => __( 'Online Store (WooCommerce, Easy Digital Downloads, or other)', 'argus-wordpress-defence' ), + 'description' => __( 'Real customer accounts, payment/order data, and a REST API third parties call -- the highest-value target of the four, so STRICT mode, the tightest login tolerance, and the longest lockout (payment fraud is a stronger incentive to keep retrying than most other attacks).', 'argus-wordpress-defence' ), + 'settings' => array( + 'mode' => Argus_Settings::MODE_STRICT, + 'waf_enabled' => true, + 'login_protection_enabled' => true, + 'xmlrpc_block_pingback' => true, + 'rest_api_protection_enabled' => true, + 'integrity_scan_enabled' => true, + 'malware_scan_enabled' => true, + 'vuln_intel_enabled' => true, + 'geoip_rir_enabled' => true, + 'challenge_enabled' => true, + 'login_attempt_threshold' => 3, + 'login_attempt_window_secs' => 600, + 'login_lockout_secs' => 7200, + 'backup_interval_hours' => 12, + 'backup_retention_count' => 10, + ), + ), + ); + } + + public static function recommended() { + return self::is_ecommerce_site() ? 'ecommerce' : null; + } + + public static function is_ecommerce_site() { + if ( class_exists( 'WooCommerce' ) ) { + return true; + } + if ( function_exists( 'EDD' ) ) { + return true; + } + return (bool) apply_filters( 'argus_wpd_is_ecommerce_site', false ); + } + + public static function apply( $key ) { + $templates = self::all(); + if ( ! isset( $templates[ $key ] ) ) { + return false; + } + Argus_Settings::update( $templates[ $key ]['settings'] ); + update_option( self::ACTIVE_OPTION, $key, false ); + Argus_Events::record( + 'template_applied', + 'info', + sprintf( 'Applied the "%s" protection template', $templates[ $key ]['label'] ), + array( 'template' => $key ) + ); + return true; + } + + public static function active() { + return get_option( self::ACTIVE_OPTION, '' ); + } +} diff --git a/includes/class-argus-update-client.php b/includes/class-argus-update-client.php new file mode 100644 index 0000000..09017d6 --- /dev/null +++ b/includes/class-argus-update-client.php @@ -0,0 +1,272 @@ + time() ) { + return; + } + + if ( false !== get_transient( self::LOCK_TRANSIENT ) ) { + return; + } + set_transient( self::LOCK_TRANSIENT, 1, 4 * MINUTE_IN_SECONDS ); + + self::check_now(); + + delete_transient( self::LOCK_TRANSIENT ); + } + + public static function check_now() { + if ( ! self::is_configured() ) { + return array( 'success' => false, 'message' => __( 'No update source is configured.', 'argus-wordpress-defence' ), 'update_available' => false ); + } + + update_option( self::LAST_CHECK_OPTION, time(), false ); + + $args = array( 'timeout' => 15 ); + $etag = get_option( self::MANIFEST_ETAG_OPTION, '' ); + if ( $etag ) { + $args['headers'] = array( 'If-None-Match' => $etag ); + } + + $response = wp_remote_get( self::manifest_url(), $args ); + if ( is_wp_error( $response ) ) { + return self::record_failure( __( 'Could not reach the update server.', 'argus-wordpress-defence' ) ); + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( 304 === $code ) { + + return array( 'success' => true, 'message' => '', 'update_available' => false ); + } + if ( 200 !== $code ) { + return self::record_failure( sprintf( 'Update server returned HTTP %d.', $code ) ); + } + + $new_etag = wp_remote_retrieve_header( $response, 'etag' ); + if ( $new_etag ) { + update_option( self::MANIFEST_ETAG_OPTION, $new_etag, false ); + } + + $body = wp_remote_retrieve_body( $response ); + $manifest = json_decode( $body, true ); + if ( ! is_array( $manifest ) ) { + return self::record_failure( __( 'Update manifest was not valid JSON.', 'argus-wordpress-defence' ) ); + } + + return self::process_manifest( $manifest ); + } + + public static function process_manifest( array $manifest ) { + + if ( ! self::verify_signature( $manifest ) ) { + return self::record_failure( __( 'Update manifest failed signature verification.', 'argus-wordpress-defence' ) ); + } + + $version = $manifest['version'] ?? ''; + if ( '' === $version ) { + return self::record_failure( __( 'Update manifest is missing a version.', 'argus-wordpress-defence' ) ); + } + + if ( version_compare( $version, ARGUS_WPD_VERSION, '<=' ) ) { + return array( 'success' => true, 'message' => '', 'update_available' => false ); + } + + $last_installed = get_option( self::LAST_SUCCESS_OPTION, array() ); + if ( ! empty( $last_installed['version'] ) && version_compare( $version, $last_installed['version'], '<=' ) ) { + return array( 'success' => true, 'message' => '', 'update_available' => false ); + } + + global $wp_version; + if ( ! empty( $manifest['min_php'] ) && version_compare( PHP_VERSION, $manifest['min_php'], '<' ) ) { + return self::record_failure( sprintf( 'Update %s requires PHP %s or newer.', $version, $manifest['min_php'] ) ); + } + if ( ! empty( $manifest['min_wp'] ) && version_compare( $wp_version, $manifest['min_wp'], '<' ) ) { + return self::record_failure( sprintf( 'Update %s requires WordPress %s or newer.', $version, $manifest['min_wp'] ) ); + } + + update_option( self::PENDING_MANIFEST_OPTION, $manifest, false ); + + if ( ! empty( $manifest['critical'] ) ) { + + return self::install_update( $manifest ); + } + + return array( 'success' => true, 'message' => '', 'update_available' => true ); + } + + public static function verify_signature( array $manifest ) { + if ( empty( $manifest['signature'] ) ) { + return false; + } + $signature = base64_decode( $manifest['signature'], true ); + if ( false === $signature || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $signature ) ) { + return false; + } + $public_key_b64 = self::public_key(); + if ( '' === $public_key_b64 ) { + return false; + } + $public_key = base64_decode( $public_key_b64, true ); + if ( false === $public_key ) { + return false; + } + + $canonical = self::canonical_payload( $manifest ); + return sodium_crypto_sign_verify_detached( $signature, $canonical, $public_key ); + } + + protected static function canonical_payload( array $manifest ) { + $ordered = array(); + foreach ( array( 'version', 'released_at', 'package_url', 'sha256', 'min_php', 'min_wp', 'critical' ) as $key ) { + $ordered[ $key ] = $manifest[ $key ] ?? null; + } + return wp_json_encode( $ordered, JSON_UNESCAPED_SLASHES ); + } + + public static function install_update( array $manifest, $target_dir = null ) { + $target_dir = $target_dir ?: ( WP_PLUGIN_DIR . '/argus-wordpress-defence' ); + $version = $manifest['version']; + + $upgrade_dir = trailingslashit( wp_get_upload_dir()['basedir'] ) . 'argus-wpd-data/update-staging'; + if ( ! is_dir( $upgrade_dir ) && ! wp_mkdir_p( $upgrade_dir ) ) { + return self::record_failure( __( 'Could not prepare the update staging directory.', 'argus-wordpress-defence' ) ); + } + + $package_path = trailingslashit( $upgrade_dir ) . 'package-' . $version . '-' . bin2hex( random_bytes( 4 ) ) . '.zip'; + $response = wp_remote_get( $manifest['package_url'], array( 'timeout' => 120, 'stream' => true, 'filename' => $package_path ) ); + if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { + @unlink( $package_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions + return self::record_failure( __( 'Could not download the update package.', 'argus-wordpress-defence' ) ); + } + + if ( ! hash_equals( $manifest['sha256'], hash_file( 'sha256', $package_path ) ) ) { + wp_delete_file( $package_path ); + return self::record_failure( __( 'Downloaded package hash did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) ); + } + + $extract_root = trailingslashit( $upgrade_dir ) . 'extract-' . $version . '-' . bin2hex( random_bytes( 4 ) ); + if ( ! class_exists( 'ZipArchive' ) ) { + wp_delete_file( $package_path ); + return self::record_failure( __( 'The PHP zip extension is not available -- cannot install the update.', 'argus-wordpress-defence' ) ); + } + $zip = new ZipArchive(); + if ( true !== $zip->open( $package_path ) || true !== $zip->extractTo( $extract_root ) ) { + wp_delete_file( $package_path ); + return self::record_failure( __( 'Could not extract the update package.', 'argus-wordpress-defence' ) ); + } + $zip->close(); + wp_delete_file( $package_path ); + + $extracted_plugin_dir = trailingslashit( $extract_root ) . 'argus-wordpress-defence'; + $new_main_file = trailingslashit( $extracted_plugin_dir ) . 'argus-wordpress-defence.php'; + if ( ! file_exists( $new_main_file ) ) { + self::rrmdir( $extract_root ); + return self::record_failure( __( 'Extracted package did not contain the expected plugin file.', 'argus-wordpress-defence' ) ); + } + + $header_contents = file_get_contents( $new_main_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( ! preg_match( '/Version:\s*([^\r\n]+)/', $header_contents, $m ) || trim( $m[1] ) !== $version ) { + self::rrmdir( $extract_root ); + return self::record_failure( __( 'Extracted package version did not match the signed manifest -- rejected.', 'argus-wordpress-defence' ) ); + } + + $backup_dir = $target_dir . '-previous-' . time(); + $had_previous = is_dir( $target_dir ); + if ( $had_previous && ! rename( $target_dir, $backup_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions + self::rrmdir( $extract_root ); + return self::record_failure( __( 'Could not move the current plugin version aside for the update.', 'argus-wordpress-defence' ) ); + } + if ( ! rename( $extracted_plugin_dir, $target_dir ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions + + if ( $had_previous ) { + rename( $backup_dir, $target_dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + self::rrmdir( $extract_root ); + return self::record_failure( __( 'Could not activate the downloaded update -- rolled back to the previous version.', 'argus-wordpress-defence' ) ); + } + + self::rrmdir( $extract_root ); + if ( $had_previous ) { + self::rrmdir( $backup_dir ); + } + + delete_option( self::PENDING_MANIFEST_OPTION ); + update_option( + self::LAST_SUCCESS_OPTION, + array( 'version' => $version, 'installed_at' => current_time( 'mysql', true ) ), + false + ); + + return array( + 'success' => true, + 'message' => ! empty( $manifest['critical'] ) + ? sprintf( __( 'A critical ARGUS security update was installed (%s).', 'argus-wordpress-defence' ), $version ) + : sprintf( __( 'ARGUS Defence was updated to version %s.', 'argus-wordpress-defence' ), $version ), + 'update_available' => false, + ); + } + + protected static function record_failure( $message ) { + update_option( + self::LAST_FAILURE_OPTION, + array( 'message' => $message, 'at' => current_time( 'mysql', true ) ), + false + ); + Argus_Events::record( 'update_check_failed', 'medium', $message, array() ); + return array( 'success' => false, 'message' => $message, 'update_available' => false ); + } + + protected static function rrmdir( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + $items = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ), RecursiveIteratorIterator::CHILD_FIRST ); + foreach ( $items as $item ) { + $item->isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + public static function status() { + return array( + 'current_version' => ARGUS_WPD_VERSION, + 'configured' => self::is_configured(), + 'last_check' => get_option( self::LAST_CHECK_OPTION, 0 ), + 'last_success' => get_option( self::LAST_SUCCESS_OPTION, array() ), + 'last_failure' => get_option( self::LAST_FAILURE_OPTION, array() ), + 'pending' => get_option( self::PENDING_MANIFEST_OPTION, array() ), + ); + } +} diff --git a/includes/class-argus-upload-guard.php b/includes/class-argus-upload-guard.php new file mode 100644 index 0000000..da9ff62 --- /dev/null +++ b/includes/class-argus-upload-guard.php @@ -0,0 +1,102 @@ + self::MAX_FILE_SIZE_TO_SCAN ) { + return $file; + } + + $content = self::read_head_and_tail( $tmp, $size ); + list( $score, $matched ) = Argus_Malware_Scanner::score_content( $content ); + + if ( $score >= Argus_Malware_Scanner::THRESHOLD_HIGH ) { + $file['error'] = __( 'For your website\'s security, ARGUS Defence blocked this upload: its content matched known malicious patterns.', 'argus-wordpress-defence' ); + self::record_block( $name, 'content-heuristic', 'critical', $score, $matched ); + } + + return $file; + } + + protected static function read_head_and_tail( $path, $size ) { + $fh = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions + if ( ! $fh ) { + return ''; + } + + $head = fread( $fh, min( self::SCAN_CHUNK_BYTES, $size ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $tail = ''; + if ( $size > self::SCAN_CHUNK_BYTES * 2 ) { + fseek( $fh, -self::SCAN_CHUNK_BYTES, SEEK_END ); + $tail = fread( $fh, self::SCAN_CHUNK_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } elseif ( $size > self::SCAN_CHUNK_BYTES ) { + $tail = fread( $fh, $size - self::SCAN_CHUNK_BYTES ); // phpcs:ignore WordPress.WP.AlternativeFunctions + } + + fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions + return $head . $tail; + } + + protected static function record_block( $filename, $rule, $severity, $score = null, array $matched = array() ) { + $user = wp_get_current_user(); + $who = $user && $user->exists() ? $user->user_login : 'unknown'; + + Argus_Findings::record( + 'malware', + $severity, + array( + 'what_happened' => sprintf( 'Blocked a malicious file upload before it reached your website: %s', $filename ), + 'why_it_matters' => 'A file matching this pattern could execute code on your server if it had been allowed through.', + 'what_argus_found' => 'executable-extension' === $rule + ? sprintf( 'The filename "%s" has a PHP-executable extension, which is never allowed as a media upload.', $filename ) + : sprintf( 'The file content scored %d on ARGUS\'s heuristic scanner (matched: %s), consistent with a webshell or obfuscated payload.', (int) $score, implode( ', ', wp_list_pluck( $matched, 'id' ) ) ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'Legitimate media uploads (images, documents, video) never contain executable code or PHP-executable extensions.', + 'what_could_be_affected' => 'If this upload had succeeded, it could have given an attacker code execution on your server.', + 'what_should_you_do' => sprintf( 'No action needed -- ARGUS already blocked this upload before it reached your website. Uploaded by: %s.', $who ), + ), + array( 'filename' => $filename, 'rule' => $rule, 'score' => $score, 'matched_rules' => $matched, 'uploaded_by' => $who ) + ); + + Argus_Events::record( + 'upload_blocked', + $severity, + sprintf( 'Blocked upload "%s" (%s)', $filename, $rule ), + array( 'filename' => $filename, 'rule' => $rule, 'uploaded_by' => $who ) + ); + } +} diff --git a/includes/class-argus-vuln-intel.php b/includes/class-argus-vuln-intel.php new file mode 100644 index 0000000..149c69f --- /dev/null +++ b/includes/class-argus-vuln-intel.php @@ -0,0 +1,383 @@ + 15 ) ); + if ( is_wp_error( $ping ) || (int) wp_remote_retrieve_response_code( $ping ) >= 500 ) { + return false; + } + + wp_version_check(); + $updates = get_core_updates(); + + global $wp_version; + self::upsert_status( 'core', 'core', 'WordPress Core', $wp_version, self::STATUS_CLEAN ); + + update_option( self::LAST_SUCCESS_OPTION, current_time( 'mysql', true ), false ); + + if ( empty( $updates ) || ! is_array( $updates ) || 'upgrade' !== ( $updates[0]->response ?? '' ) ) { + return true; + } + + $update = $updates[0]; + Argus_Findings::record( + 'vulnerability', + 'medium', + array( + 'what_happened' => sprintf( 'WordPress core is out of date: running %s, %s is available', $wp_version, $update->version ), + 'why_it_matters' => 'Older WordPress core releases can be missing security fixes, even when no specific vulnerability is separately confirmed for this exact version.', + 'what_argus_found' => sprintf( 'Checked against WordPress.org\'s own update API. Current: %s. Available: %s.', $wp_version, $update->version ), + 'when_it_happened' => current_time( 'mysql' ), + + 'why_suspicious' => 'This is a currency check, not a confirmed exploit -- ARGUS flags any available core update for visibility, not only ones known to fix a specific CVE.', + 'what_could_be_affected' => 'The entire site, since WordPress core underlies every plugin and theme.', + 'what_should_you_do' => 'Review the WordPress release notes for ' . $update->version . ' and update when convenient. If you have a reason to defer (compatibility testing, staging validation), that is a reasonable choice -- just don\'t defer indefinitely.', + ), + array( + 'current_version' => $wp_version, + 'available_version' => $update->version, + 'action' => array( + 'type' => 'update_core', + 'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $update->version ), + ), + ) + ); + + return true; + } + + public static function inventory() { + global $wp_version; + $items = array( array( 'type' => 'core', 'slug' => 'core', 'name' => 'WordPress Core', 'version' => $wp_version ) ); + + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + foreach ( get_plugins() as $plugin_file => $plugin_data ) { + $items[] = array( + 'type' => 'plugin', + 'slug' => self::slug_from_plugin_file( $plugin_file ), + 'name' => $plugin_data['Name'] ?? $plugin_file, + 'version' => $plugin_data['Version'] ?? '', + ); + } + + foreach ( wp_get_themes() as $stylesheet => $theme ) { + $items[] = array( + 'type' => 'theme', + 'slug' => $stylesheet, + 'name' => $theme->get( 'Name' ) ?: $stylesheet, + 'version' => $theme->get( 'Version' ) ?: '', + ); + } + + return $items; + } + + public static function resolve_inventory() { + global $wpdb; + $vuln_cache_total = (int) $wpdb->get_var( 'SELECT COUNT(*) FROM ' . Argus_DB::table( 'vuln_cache' ) ); // phpcs:ignore + + foreach ( self::inventory() as $item ) { + if ( 'core' === $item['type'] ) { + continue; + } + self::resolve_component( $item['type'], $item['slug'], $item['name'], $item['version'], $vuln_cache_total ); + } + + self::prune_removed_components(); + } + + protected static function resolve_component( $type, $slug, $name, $version, $vuln_cache_total ) { + global $wpdb; + + if ( 0 === $vuln_cache_total ) { + + self::upsert_status( $type, $slug, $name, $version, self::STATUS_PENDING ); + return; + } + + $table = Argus_DB::table( 'vuln_cache' ); + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore + ); + + $flagged = null; + foreach ( $rows as $row ) { + if ( '' === $version || ! $row->vulnerable_below ) { + continue; + } + if ( version_compare( $version, $row->vulnerable_below, '<' ) ) { + $flagged = $row; + break; + } + } + + if ( $flagged ) { + self::upsert_status( $type, $slug, $name, $version, self::STATUS_VULNERABLE, $flagged ); + self::record_vulnerability_finding( $type, $slug, $name, $version, $flagged ); + return; + } + + self::upsert_status( $type, $slug, $name, $version, self::STATUS_CLEAN ); + } + + protected static function upsert_status( $type, $slug, $name, $version, $status, $vuln_row = null ) { + global $wpdb; + $table = Argus_DB::table( 'vuln_status' ); + $now = current_time( 'mysql', true ); + + $data = array( + 'component_type' => $type, + 'slug' => $slug, + 'name' => $name, + 'installed_version' => $version, + 'checked_version' => $version, + 'status' => $status, + 'severity' => $vuln_row->severity ?? null, + 'cve' => $vuln_row->cve ?? null, + 'fixed_in' => $vuln_row->fixed_in ?? null, + 'description' => $vuln_row->description ?? null, + 'last_checked_at' => $now, + 'updated_at' => $now, + ); + + $existing_id = $wpdb->get_var( + $wpdb->prepare( "SELECT id FROM {$table} WHERE component_type = %s AND slug = %s", $type, $slug ) // phpcs:ignore + ); + if ( $existing_id ) { + $wpdb->update( $table, $data, array( 'id' => $existing_id ) ); + } else { + $wpdb->insert( $table, $data ); + } + } + + protected static function prune_removed_components() { + global $wpdb; + $table = Argus_DB::table( 'vuln_status' ); + + $current = array(); + foreach ( self::inventory() as $item ) { + $current[] = $item['type'] . '|' . $item['slug']; + } + + $existing = $wpdb->get_results( "SELECT id, component_type, slug FROM {$table}" ); // phpcs:ignore + foreach ( $existing as $row ) { + if ( ! in_array( $row->component_type . '|' . $row->slug, $current, true ) ) { + $wpdb->delete( $table, array( 'id' => $row->id ), array( '%d' ) ); + } + } + } + + protected static function record_vulnerability_finding( $type, $slug, $name, $version, $vuln_row ) { + Argus_Findings::record( + 'vulnerability', + $vuln_row->severity, + array( + 'what_happened' => sprintf( '%s "%s" (v%s) has a known vulnerability%s', ucfirst( $type ), $name, $version, $vuln_row->cve ? ' (' . $vuln_row->cve . ')' : '' ), + 'why_it_matters' => $vuln_row->description ?: 'This version is affected by a publicly documented vulnerability.', + 'what_argus_found' => sprintf( 'Installed: %s. Vulnerable below: %s. Fixed in: %s.', $version, $vuln_row->vulnerable_below, $vuln_row->fixed_in ?: 'unknown' ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'This is a version-matching result against ARGUS\'s vulnerability intelligence, not a detected exploitation attempt.', + 'what_could_be_affected' => 'Depends on the specific vulnerability -- see the description above.', + 'what_should_you_do' => $vuln_row->fixed_in + ? sprintf( 'Update to version %s or later.', $vuln_row->fixed_in ) + : 'No fixed version is currently known -- consider deactivating this component until one is available, or accept the risk deliberately if it\'s not exposed.', + ), + array( + 'component_type' => $type, + 'slug' => $slug, + 'installed_version' => $version, + 'cve' => $vuln_row->cve, + 'action' => $vuln_row->fixed_in ? array( + 'type' => 'plugin' === $type ? 'update_plugin' : 'update_theme', + 'label' => sprintf( __( 'Update to %s', 'argus-wordpress-defence' ), $vuln_row->fixed_in ), + 'plugin' => 'plugin' === $type ? self::plugin_file_for_slug( $slug ) : null, + 'theme' => 'theme' === $type ? $slug : null, + ) : null, + ) + ); + } + + protected static function plugin_file_for_slug( $slug ) { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + foreach ( array_keys( get_plugins() ) as $plugin_file ) { + if ( self::slug_from_plugin_file( $plugin_file ) === $slug ) { + return $plugin_file; + } + } + return null; + } + + public static function import_feed( array $records ) { + global $wpdb; + $table = Argus_DB::table( 'vuln_cache' ); + $now = current_time( 'mysql', true ); + $count = 0; + + foreach ( $records as $r ) { + if ( empty( $r['component_type'] ) || empty( $r['slug'] ) ) { + continue; + } + + $existing_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE component_type = %s AND slug = %s AND cve <=> %s", // phpcs:ignore + $r['component_type'], + $r['slug'], + $r['cve'] ?? null + ) + ); + + $data = array( + 'component_type' => $r['component_type'], + 'slug' => $r['slug'], + 'vulnerable_below' => $r['vulnerable_below'] ?? null, + 'fixed_in' => $r['fixed_in'] ?? null, + 'severity' => $r['severity'] ?? 'medium', + 'cve' => $r['cve'] ?? null, + 'description' => $r['description'] ?? '', + 'updated_at' => $now, + ); + + if ( $existing_id ) { + $wpdb->update( $table, $data, array( 'id' => $existing_id ) ); + } else { + $wpdb->insert( $table, $data ); + } + $count++; + } + + if ( $count > 0 ) { + self::resolve_inventory(); + } + + return $count; + } + + protected static function slug_from_plugin_file( $plugin_file ) { + $parts = explode( '/', $plugin_file ); + return $parts[0] ?? $plugin_file; + } + + public static function status() { + $last_success = get_option( self::LAST_SUCCESS_OPTION, '' ); + + global $wpdb; + $rows = $wpdb->get_results( 'SELECT * FROM ' . Argus_DB::table( 'vuln_status' ) . ' ORDER BY component_type = "core" DESC, name ASC' ); // phpcs:ignore + + $components = array(); + foreach ( $rows as $row ) { + $status = $row->status; + + if ( 'core' === $row->component_type && $last_success && strtotime( $last_success . ' UTC' ) < ( time() - self::UNAVAILABLE_AFTER_SECS ) ) { + $status = self::STATUS_UNAVAILABLE; + } + $components[] = array( + 'type' => $row->component_type, + 'slug' => $row->slug, + 'name' => $row->name, + 'installed_version' => $row->installed_version, + 'status' => $status, + 'severity' => $row->severity, + 'cve' => $row->cve, + 'fixed_in' => $row->fixed_in, + ); + } + + return array( + + 'protection' => $last_success ? 'active' : 'starting', + 'last_check' => $last_success, + 'next_check' => $last_success ? gmdate( 'Y-m-d H:i:s', strtotime( $last_success . ' UTC' ) + self::CHECK_INTERVAL_SECS ) : '', + 'components' => $components, + ); + } +} diff --git a/includes/class-argus-waf-rules.php b/includes/class-argus-waf-rules.php new file mode 100644 index 0000000..4499823 --- /dev/null +++ b/includes/class-argus-waf-rules.php @@ -0,0 +1,122 @@ + 'sqli-union-select', 'category' => 'sql_injection', 'severity' => 'critical', + 'pattern' => '/\bunion\b[^\w]{1,20}\bselect\b/i' ), + array( 'id' => 'sqli-classic-tautology', 'category' => 'sql_injection', 'severity' => 'critical', + + 'pattern' => '/(\%27|\'|\%22|")\s*(or|and)\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?\s*=\s*(\%27|\'|\%22|")?[\d\w]+(\%27|\'|\%22|")?/i' ), + array( 'id' => 'sqli-information-schema', 'category' => 'sql_injection', 'severity' => 'critical', + 'pattern' => '/information_schema|sysobjects|sysdatabases/i' ), + array( 'id' => 'sqli-time-based', 'category' => 'sql_injection', 'severity' => 'high', + 'pattern' => '/\b(sleep|benchmark|pg_sleep|waitfor\s+delay)\s*\(/i' ), + array( 'id' => 'sqli-stacked-comment', 'category' => 'sql_injection', 'severity' => 'high', + 'pattern' => '/;\s*(drop|delete|update|insert)\s+(table|from|into)/i' ), + + array( 'id' => 'xss-script-tag', 'category' => 'xss', 'severity' => 'high', + 'pattern' => '/<\s*script[\s>\/]/i' ), + + array( 'id' => 'xss-event-handler', 'category' => 'xss', 'severity' => 'high', + 'pattern' => '/\bon(error|load|mouseover|click|focus)\s*=\s*["\']?[^"\'>]*[\(\{]/i' ), + array( 'id' => 'xss-javascript-uri', 'category' => 'xss', 'severity' => 'high', + 'pattern' => '/javascript\s*:\s*[^\s]/i' ), + array( 'id' => 'xss-svg-onload', 'category' => 'xss', 'severity' => 'high', + 'pattern' => '/<\s*svg[^>]*onload/i' ), + + array( 'id' => 'cmdi-shell-metachars', 'category' => 'rce', 'severity' => 'critical', + 'pattern' => '/;\s*(cat|ls|whoami|id|uname|wget|curl)\s/i' ), + array( 'id' => 'cmdi-backtick-subshell', 'category' => 'rce', 'severity' => 'critical', + 'pattern' => '/`[^`]{1,80}`|\$\([^\)]{1,80}\)/' ), + array( 'id' => 'phpi-eval-base64', 'category' => 'rce', 'severity' => 'critical', + 'pattern' => '/\beval\s*\(\s*(base64_decode|gzinflate|str_rot13)\s*\(/i' ), + array( 'id' => 'phpi-dangerous-function', 'category' => 'rce', 'severity' => 'critical', + 'pattern' => '/\b(system|exec|shell_exec|passthru|proc_open|popen)\s*\(/i' ), + array( 'id' => 'phpi-tag-in-input', 'category' => 'rce', 'severity' => 'high', + 'pattern' => '/<\?php|<\?=/i' ), + + array( 'id' => 'lfi-dot-dot-slash', 'category' => 'file_access', 'severity' => 'high', + 'pattern' => '/(\.\.\/|\.\.\\\\|%2e%2e%2f|%252e%252e%252f)/i' ), + array( 'id' => 'lfi-sensitive-file', 'category' => 'file_access', 'severity' => 'high', + 'pattern' => '/\/etc\/(passwd|shadow|hosts)\b|wp-config\.php/i' ), + array( 'id' => 'lfi-php-wrapper', 'category' => 'file_access', 'severity' => 'high', + 'pattern' => '/php:\/\/(filter|input|data)/i' ), + array( 'id' => 'rfi-remote-scheme', 'category' => 'file_access', 'severity' => 'high', + 'pattern' => '/^(https?|ftp):\/\/.+\.(php|txt)(\?|$)/i' ), + + array( 'id' => 'proto-null-byte', 'category' => 'protocol_anomaly', 'severity' => 'medium', + 'pattern' => '/%00/' ), + array( 'id' => 'proto-double-encoding', 'category' => 'protocol_anomaly', 'severity' => 'low', + 'pattern' => '/%25(2e|2f|5c)/i' ), + ); + + return $rules; + } + + public static function scan( array $inputs ) { + $hits = array(); + + foreach ( $inputs as $source => $value ) { + if ( ! is_string( $value ) || '' === $value ) { + continue; + } + + $decoded = rawurldecode( $value ); + + foreach ( self::corpus() as $rule ) { + $matched = preg_match( $rule['pattern'], $decoded, $m ) ? $m : ( preg_match( $rule['pattern'], $value, $m ) ? $m : null ); + if ( null === $matched ) { + continue; + } + if ( 'rfi-remote-scheme' === $rule['id'] && ! self::is_cross_origin( $matched[0] ) ) { + + continue; + } + $hits[] = array_merge( + $rule, + array( + 'source' => $source, + 'matched_value' => mb_substr( $value, 0, 200 ), + ) + ); + } + } + + return $hits; + } + + protected static function is_cross_origin( $url ) { + $target_host = strtolower( (string) parse_url( $url, PHP_URL_HOST ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions + $own_host = strtolower( (string) ( $_SERVER['HTTP_HOST'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput + + if ( '' === $target_host || '' === $own_host ) { + return true; + } + + $own_host = preg_replace( '/:\d+$/', '', $own_host ); + return $target_host !== $own_host; + } + + public static function highest_severity( array $hits ) { + $order = array( 'critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1, 'info' => 0 ); + $best = 'info'; + foreach ( $hits as $hit ) { + if ( ( $order[ $hit['severity'] ] ?? 0 ) > ( $order[ $best ] ?? 0 ) ) { + $best = $hit['severity']; + } + } + return $best; + } +} + +} diff --git a/includes/class-argus-waf.php b/includes/class-argus-waf.php new file mode 100644 index 0000000..563ccc8 --- /dev/null +++ b/includes/class-argus-waf.php @@ -0,0 +1,76 @@ + $hits ) ); + + self::record_finding( $ip, $hits, $decision ); + + Argus_Policy_Engine::enforce_decision( $ip, 'waf_match', $decision, array( 'hits' => $hits ) ); + } + + protected static function record_finding( $ip, array $hits, array $decision ) { + $severity = Argus_WAF_Rules::highest_severity( $hits ); + $categories = array_unique( wp_list_pluck( $hits, 'category' ) ); + + $rule_ids = array_values( array_unique( wp_list_pluck( $hits, 'id' ) ) ); + $sources = array_unique( wp_list_pluck( $hits, 'source' ) ); + + Argus_Findings::record( + 'waf', + $severity, + array( + 'what_happened' => sprintf( 'A request from %s matched %d local WAF rule(s): %s', $ip, count( $rule_ids ), implode( ', ', $rule_ids ) ), + 'why_it_matters' => 'This request contained a pattern associated with ' . implode( ', ', $categories ) . ', a common technique used to compromise WordPress sites.', + 'what_argus_found' => sprintf( 'Matched in: %s. Action taken: %s (%s).', implode( ', ', $sources ), $decision['action'], $decision['observation_only'] ? 'observed only, MONITOR mode' : 'enforced' ), + 'when_it_happened' => current_time( 'mysql' ), + 'why_suspicious' => 'The matched pattern is not something a normal WordPress visitor, editor, or REST API client would ever legitimately send.', + 'what_could_be_affected' => 'If successful, this class of request could read or modify site data, execute code, or access files outside what the request should be able to reach.', + 'what_should_you_do' => $decision['observation_only'] + ? 'ARGUS is in MONITOR mode and did not block this request. Review recent WAF findings and switch to BLOCK mode once you are confident legitimate traffic is not being flagged.' + : 'No action needed -- ARGUS already blocked this request. If you believe this was a false positive, add an exception for this rule or IP in Settings.', + ), + array( 'ip' => $ip, 'hits' => $hits, 'decision' => $decision ) + ); + } +} diff --git a/mu-loader/argus-mu-core.php b/mu-loader/argus-mu-core.php new file mode 100644 index 0000000..f732dcf --- /dev/null +++ b/mu-loader/argus-mu-core.php @@ -0,0 +1,45 @@ + 300 ) { + update_option( $heartbeat_option, time(), false ); + } + + $plugin_dir = WP_PLUGIN_DIR . '/argus-wordpress-defence/includes/'; + + if ( ! file_exists( $plugin_dir . 'class-argus-db.php' ) ) { + + return; + } + + require_once $plugin_dir . 'class-argus-request-inputs.php'; + require_once $plugin_dir . 'class-argus-waf-rules.php'; + require_once $plugin_dir . 'class-argus-db.php'; + require_once $plugin_dir . 'class-argus-settings.php'; + require_once $plugin_dir . 'class-argus-events.php'; + require_once $plugin_dir . 'class-argus-findings.php'; + require_once $plugin_dir . 'class-argus-explain.php'; + require_once $plugin_dir . 'class-argus-ban-engine.php'; + require_once $plugin_dir . 'class-argus-challenge.php'; + require_once $plugin_dir . 'class-argus-policy-engine.php'; + require_once $plugin_dir . 'class-argus-waf.php'; + require_once $plugin_dir . 'class-argus-anis-client.php'; + + Argus_ANIS_Client::init(); + + $is_ajax_or_post_endpoint = wp_doing_ajax() || ( isset( $_SERVER['SCRIPT_NAME'] ) && false !== strpos( $_SERVER['SCRIPT_NAME'], 'admin-post.php' ) ); // phpcs:ignore + if ( ( is_admin() && ! $is_ajax_or_post_endpoint ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) { + return; + } + + Argus_WAF::inspect_and_enforce(); +} catch ( \Throwable $e ) { + + return; +} diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..9dccf9b --- /dev/null +++ b/readme.txt @@ -0,0 +1,60 @@ +=== ARGUS Defence === +Contributors: argus +Tags: security, firewall, malware, vulnerability, backup +Requires at least: 6.0 +Tested up to: 6.6 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Automatic, intelligent WordPress security. A local firewall, malware and file-integrity scanning, +vulnerability protection, and scheduled backups -- protecting your site with zero manual +configuration required. + +== Description == + +ARGUS Defence protects your WordPress website automatically: + +* **Firewall** -- blocks malicious requests (SQL injection, cross-site scripting, and more) before + they reach your site. +* **Scanner** -- regularly checks WordPress core, plugins, and themes for suspicious files and + integrity changes. +* **Vulnerability Protection** -- automatically checks your installed components against known + security issues. +* **Backups** -- scheduled recovery points for your website, stored securely. +* **Global Threat Intelligence** -- continuously updated, automatically connected, no setup + required. + +Everything works out of the box. There is nothing to configure to get protected. + +== Installation == + +1. Upload the plugin to your WordPress site (or install directly from the WordPress admin). +2. Activate the plugin. +3. That's it -- ARGUS Defence begins protecting your site automatically. + +== Frequently Asked Questions == + += Do I need to configure anything? = + +No. ARGUS Defence is designed to work automatically. Advanced settings are available for +administrators who want them, but nothing is required to get protected. + += Will this slow down my website? = + +No. ARGUS Defence's firewall and scanning run efficiently in the background, and the optional +page-cache feature can make your site faster, not slower. + += How do I update the plugin? = + +Download the latest release ZIP and repeat the installation steps above (WordPress will offer to +replace the existing version). Automatic in-dashboard updates depend on an update channel being +configured for your deployment; until then, updates are applied manually the same way the plugin +was installed. + +== Changelog == + += 1.0.0 = +* First production release: firewall, scanner, quarantine, vulnerability protection, backups, + cache & performance, and automatic global threat intelligence. diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..15a20df --- /dev/null +++ b/uninstall.php @@ -0,0 +1,77 @@ +isDir() ? rmdir( $item->getPathname() ) : unlink( $item->getPathname() ); + } + rmdir( $dir ); +} + +$argus_uploads = wp_get_upload_dir(); +$argus_data_dir = trailingslashit( $argus_uploads['basedir'] ) . 'argus-wpd-data'; +argus_wpd_rrmdir( $argus_data_dir ); + +delete_option( 'argus_wpd_settings' ); +delete_option( 'argus_wpd_activated_at' ); +delete_option( 'argus_wpd_mu_install_status' ); +delete_option( 'argus_wpd_geoip_cursor' ); +delete_option( 'argus_wpd_geoip_build_path' ); +delete_option( 'argus_wpd_geoip_last_refresh' ); +delete_option( 'argus_wpd_geoip_range_count' ); +delete_option( 'argus_wpd_geoip_last_error' ); +delete_option( 'argus_wpd_active_template' ); +delete_option( 'argus_wpd_integrity_watermark' ); +delete_option( 'argus_wpd_trusted_update_windows' ); +delete_option( 'argus_wpd_integrity_last_full_scan' ); +delete_option( 'argus_wpd_integrity_last_full_scan_files' ); +delete_option( 'argus_wpd_correlation_watermark' ); +delete_option( 'argus_wpd_malware_restored_trust' ); +delete_option( 'argus_wpd_mu_self_heal_last_logged' ); +delete_option( 'argus_wpd_cache_discovery_last_run' ); +delete_option( 'argus_wpd_cache_discovery_last_error' ); +delete_option( 'argus_wpd_cache_warmer_backoff' ); +delete_option( 'argus_wpd_geoip_corrupt_detected' ); +delete_option( 'argus_wpd_mu_core_heartbeat' ); +delete_option( 'argus_wpd_anis_status' ); +delete_option( 'argus_wpd_anis_tier' ); +delete_option( 'argus_wpd_anis_last_error' ); +delete_option( 'argus_wpd_anis_last_register' ); +delete_option( 'argus_wpd_anis_last_sync' ); +delete_option( 'argus_wpd_anis_sync_watermark' ); +delete_option( 'argus_wpd_anis_features' ); +delete_option( 'argus_wpd_anis_circuit' ); +delete_option( 'argus_wpd_anis_install_secret' ); +delete_option( 'argus_wpd_anis_retry_stage' ); +delete_option( 'argus_wpd_anis_next_retry_at' ); +delete_option( 'argus_wpd_anis_last_success' ); +delete_option( 'argus_wpd_vuln_last_success' ); +delete_option( 'argus_wpd_vuln_retry_stage' ); +delete_option( 'argus_wpd_vuln_next_retry_at' ); +delete_option( 'argus_wpd_backup_lockdown_status' ); +delete_option( 'argus_wpd_backup_last_success' ); +delete_option( 'argus_wpd_ajax_inventory' ); +delete_option( 'argus_wpd_rest_inventory' ); +delete_option( 'argus_wpd_malware_watermark' ); +delete_option( 'argus_wpd_uploads_lockdown_status' ); +delete_option( 'argus_wpd_quarantine_lockdown_status' ); +delete_option( 'argus_wpd_update_last_check' ); +delete_option( 'argus_wpd_update_manifest_etag' ); +delete_option( 'argus_wpd_update_pending_manifest' ); +delete_option( 'argus_wpd_update_last_success' ); +delete_option( 'argus_wpd_update_last_failure' );