commit 7b62556227e51995f999ecd40be495029b101e1b Author: Javier Casares Date: Tue Jun 2 13:41:43 2026 +0000 v5.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 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 GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. 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 +them 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 prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. 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. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey 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; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If 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 convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + 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. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +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. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + 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 +state 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 3 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 does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program 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, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU 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. But first, please read +. diff --git a/Screenshot_495.png b/Screenshot_495.png new file mode 100644 index 0000000..346683a Binary files /dev/null and b/Screenshot_495.png differ diff --git a/Screenshot_496.png b/Screenshot_496.png new file mode 100644 index 0000000..43add5a Binary files /dev/null and b/Screenshot_496.png differ diff --git a/assets/admin.css b/assets/admin.css new file mode 100644 index 0000000..4b3d601 --- /dev/null +++ b/assets/admin.css @@ -0,0 +1,1346 @@ +:root { + --red-dark: #b32d2e; + --red-mediun: #D54E21; + --red-light: #FAEDE8; +} +.blink { + animation: blinker 2s linear infinite; +} +@keyframes blinker { + 50% { + opacity: 0; + } +} + +/* Admin plugins table styles */ + +.plugins-php .vulnerability { + background-color: var(--red-mediun); + padding: 4px; +} +.plugins-php .vulnerability .alert { + color: white; +} +.plugins tr.wpvulnerability td, .plugins tr.wpvulnerability.active td { + background-color: var(--red-light); +} +.plugins tr.wpvulnerability:before { + background-color: var(--red-light); + content: ""; + display: table-cell; +} +.plugins tr.wpvulnerability.active::before { + border-left: 4px solid var( --red-mediun ); +} +.plugins tr.wpvulnerability p.text-red, .plugins tr.wpvulnerability.active p.text-red { + color: var(--red-mediun) +} + +/* Admin core table styles */ + +.update-core-php table.wpvulnerability td { + background-color: var(--red-light); +} +.update-core-php table.wpvulnerability tr:before { + background-color: var(--red-light); + content: ""; + display: table-cell; +} +.update-core-php table.wpvulnerability tr.active::before { + border-left: 4px solid var( --red-mediun ); +} +.update-core-php p.text-red { + color: var(--red-mediun) +} + +/* Configuration header */ + +.wpvulnerability-header { + background-color: #1d73be; + margin-left: -20px; + padding: 20px; + display: flex; + justify-content: space-between; + color: white; +} +.wpvulnerability-header .logo { + min-width: 20%; +} +.wpvulnerability-header h2 { + float: right; + color: white; + padding: 25px 0 0 0; + margin: 0; +} + +.wpvulnerability-settings { + margin-top: 20px; +} + +.wpvulnerability-tab-nav { + margin-bottom: 0; +} + +.wpvulnerability-tab-panel { + background: #fff; + border: 1px solid #c3c4c7; + border-top: none; + padding: 20px; +} + +.wpvulnerability-tab-nav .nav-tab:not(.nav-tab-active) { + border-bottom-color: #c3c4c7; +} + +.wpvulnerability-log-actions { + display: block; +} + +.wpvulnerability-log-actions form { + display: inline-block; + margin: 0 8px 8px 0; + vertical-align: bottom; +} + +/* Logs pagination */ +.wpvulnerability-settings .tablenav-pages .page-numbers { + display: inline-flex; + align-items: center; + margin: 0; + padding: 0; + list-style: none; +} + +.wpvulnerability-settings .tablenav-pages .page-numbers li { + margin: 0 0 0 4px; +} + +.wpvulnerability-settings .tablenav-pages .page-numbers li:first-child { + margin-left: 0; +} + +/* ================================================== + WPVulnerability Admin Panel Styles + Extracted from inline ' . "\n"; + $message .= '' . "\n"; + $message .= '' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= '
' . "\n"; + $message .= '
' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= '
' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= ' ' . "\n"; + $message .= '
' . "\n"; + $message .= ' WPVulnerability' . "\n"; + $message .= '

' . esc_html( $title ) . '

' . "\n"; + + // Add the site URL based on the multisite configuration. + if ( is_multisite() ) { + $message .= '

' . esc_html( network_site_url() ) . '

' . "\n"; + } else { + $message .= '

' . esc_html( site_url() ) . '

' . "\n"; + } + + $message .= '
' . "\n"; + $message .= $content; // Add the main content of the email. + $message .= '
' . "\n"; + $message .= ' ' . "\n"; + $message .= '
' . "\n"; + $message .= '
' . "\n"; + $message .= '
' . "\n"; + $message .= '' . "\n"; + $message .= ''; + + // Return the prepared HTML email message. + return $message; +} + +/** + * Send a vulnerability notification to Slack. + * + * @since 4.1.3 + * + * @param string $webhook_url Slack webhook URL. + * @param string $message Message body to deliver. + * + * @return bool True on success, false on failure. + */ +function wpvulnerability_send_slack_notification( $webhook_url, $message ) { + $webhook_url = wpvulnerability_validate_webhook_url( + $webhook_url, + array( + 'hooks.slack.com', + ) + ); + + if ( empty( $webhook_url ) || empty( $message ) ) { + return false; + } + + $encoded_slack = wp_json_encode( array( 'text' => $message ) ); + $args = array( + 'body' => false !== $encoded_slack ? $encoded_slack : '', + 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), + 'timeout' => 10, + 'data_format' => 'body', + ); + + $response = wp_remote_post( $webhook_url, $args ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $status_code = (int) wp_remote_retrieve_response_code( $response ); + + return $status_code >= 200 && $status_code < 300; +} + +/** + * Send a vulnerability notification to Microsoft Teams. + * + * @since 4.1.3 + * + * @param string $webhook_url Teams webhook URL. + * @param string $message Message body to deliver. + * + * @return bool True on success, false on failure. + */ +function wpvulnerability_send_teams_notification( $webhook_url, $message ) { + $webhook_url = wpvulnerability_validate_webhook_url( + $webhook_url, + array( + 'office.com', + 'office365.com', + 'api.hooks.microsoft.com', + ) + ); + + if ( empty( $webhook_url ) || empty( $message ) ) { + return false; + } + + $encoded_teams = wp_json_encode( array( 'text' => $message ) ); + $args = array( + 'body' => false !== $encoded_teams ? $encoded_teams : '', + 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), + 'timeout' => 10, + 'data_format' => 'body', + ); + + $response = wp_remote_post( $webhook_url, $args ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $status_code = (int) wp_remote_retrieve_response_code( $response ); + + return $status_code >= 200 && $status_code < 300; +} + +/** + * Send a vulnerability notification to Discord. + * + * @since 4.3.0 + * + * @param string $webhook_url Discord webhook URL. + * @param string $message Message body to deliver. + * + * @return bool True on success, false on failure. + */ +function wpvulnerability_send_discord_notification( $webhook_url, $message ) { + $webhook_url = wpvulnerability_validate_webhook_url( + $webhook_url, + array( + 'discord.com', + 'discordapp.com', + ) + ); + + if ( empty( $webhook_url ) || empty( $message ) ) { + return false; + } + + // Discord has a 2000 character limit per message. + if ( strlen( $message ) > 2000 ) { + $message = substr( $message, 0, 1997 ) . '...'; + } + + $encoded_discord = wp_json_encode( array( 'content' => $message ) ); + $args = array( + 'body' => false !== $encoded_discord ? $encoded_discord : '', + 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), + 'timeout' => 10, + 'data_format' => 'body', + ); + + $response = wp_remote_post( $webhook_url, $args ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $status_code = (int) wp_remote_retrieve_response_code( $response ); + + return $status_code >= 200 && $status_code < 300; +} + +/** + * Send a vulnerability notification to Telegram. + * + * @since 4.3.0 + * + * @param string $bot_token Telegram bot token. + * @param string $chat_id Telegram chat ID. + * @param string $message Message body to deliver. + * + * @return bool True on success, false on failure. + */ +function wpvulnerability_send_telegram_notification( $bot_token, $chat_id, $message ) { + // Sanitize inputs. + $bot_token = sanitize_text_field( trim( (string) $bot_token ) ); + $chat_id = sanitize_text_field( trim( (string) $chat_id ) ); + + if ( empty( $bot_token ) || empty( $chat_id ) || empty( $message ) ) { + return false; + } + + // Validate bot token format (should be like: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11). + if ( ! preg_match( '/^\d+:[A-Za-z0-9_-]+$/', $bot_token ) ) { + return false; + } + + // Telegram has a 4096 character limit per message. + if ( strlen( $message ) > 4096 ) { + $message = substr( $message, 0, 4093 ) . '...'; + } + + $api_url = 'https://api.telegram.org/bot' . $bot_token . '/sendMessage'; + + $encoded_telegram = wp_json_encode( + array( + 'chat_id' => $chat_id, + 'text' => $message, + ) + ); + $args = array( + 'body' => false !== $encoded_telegram ? $encoded_telegram : '', + 'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), + 'timeout' => 10, + 'data_format' => 'body', + ); + + $response = wp_remote_post( $api_url, $args ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $status_code = (int) wp_remote_retrieve_response_code( $response ); + + return $status_code >= 200 && $status_code < 300; +} + +/** + * Executes the vulnerability notification process for a WordPress site. + * + * This function checks for vulnerabilities in the WordPress core, plugins, themes, PHP environment, and web server components. + * It generates an HTML email report detailing any vulnerabilities found. If the function is called with + * the $forced parameter set to true, it will send an email even if no vulnerabilities are found, which is useful for testing purposes. + * + * @since 2.0.0 + * + * @param bool $forced Optional. If set to true, forces the sending of a notification email regardless of whether vulnerabilities are found. Default false. + * @return bool True when the email was successfully sent, false otherwise. + */ +function wpvulnerability_execute_notification( $forced = false ) { + $email_content = ''; + $wpvulnerability_settings = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' ); + + if ( ! is_array( $wpvulnerability_settings ) ) { + $wpvulnerability_settings = array(); + } + + $notify_settings = isset( $wpvulnerability_settings['notify'] ) ? $wpvulnerability_settings['notify'] : array(); + $wpvulnerability_settings['notify'] = wpvulnerability_normalize_notify_settings( $notify_settings ); + + $email_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['email'] ) && ! empty( $wpvulnerability_settings['emails'] ); + $slack_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['slack'] ) && ! empty( $wpvulnerability_settings['slack_webhook'] ); + $teams_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['teams'] ) && ! empty( $wpvulnerability_settings['teams_webhook'] ); + $discord_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['discord'] ) && ! empty( $wpvulnerability_settings['discord_webhook'] ); + $telegram_enabled = wpvulnerability_is_yes( $wpvulnerability_settings['notify']['telegram'] ) && ! empty( $wpvulnerability_settings['telegram_bot_token'] ) && ! empty( $wpvulnerability_settings['telegram_chat_id'] ); + + if ( ! $forced && ( empty( $wpvulnerability_settings['period'] ) || ( ! $email_enabled && ! $slack_enabled && ! $teams_enabled && ! $discord_enabled && ! $telegram_enabled ) ) ) { + return false; + } + + // Generate HTML for core, plugins, and themes vulnerabilities. + $html_core = wpvulnerability_analyze_filter( 'core' ) && wpvulnerability_get_component_count( 'core' ) ? wpvulnerability_html_core() : null; + + $html_plugins = wpvulnerability_analyze_filter( 'plugins' ) && wpvulnerability_get_component_count( 'plugins' ) ? wpvulnerability_html_plugins() : null; + + $html_themes = wpvulnerability_analyze_filter( 'themes' ) && wpvulnerability_get_component_count( 'themes' ) ? wpvulnerability_html_themes() : null; + + // Generate HTML for PHP, Apache, Nginx, MariaDB, MySQL... vulnerabilities. + $html_php = wpvulnerability_analyze_filter( 'php' ) && wpvulnerability_get_component_count( 'php' ) ? wpvulnerability_html_software( 'php' ) : null; + + $html_apache = wpvulnerability_analyze_filter( 'apache' ) && wpvulnerability_get_component_count( 'apache' ) ? wpvulnerability_html_software( 'apache' ) : null; + + $html_nginx = wpvulnerability_analyze_filter( 'nginx' ) && wpvulnerability_get_component_count( 'nginx' ) ? wpvulnerability_html_software( 'nginx' ) : null; + + $html_mariadb = wpvulnerability_analyze_filter( 'mariadb' ) && wpvulnerability_get_component_count( 'mariadb' ) ? wpvulnerability_html_software( 'mariadb' ) : null; + + $html_mysql = wpvulnerability_analyze_filter( 'mysql' ) && wpvulnerability_get_component_count( 'mysql' ) ? wpvulnerability_html_software( 'mysql' ) : null; + + $html_imagemagick = wpvulnerability_analyze_filter( 'imagemagick' ) && wpvulnerability_get_component_count( 'imagemagick' ) ? wpvulnerability_html_software( 'imagemagick' ) : null; + + $html_curl = wpvulnerability_analyze_filter( 'curl' ) && wpvulnerability_get_component_count( 'curl' ) ? wpvulnerability_html_software( 'curl' ) : null; + + $html_memcached = wpvulnerability_analyze_filter( 'memcached' ) && wpvulnerability_get_component_count( 'memcached' ) ? wpvulnerability_html_software( 'memcached' ) : null; + + $html_redis = wpvulnerability_analyze_filter( 'redis' ) && wpvulnerability_get_component_count( 'redis' ) ? wpvulnerability_html_software( 'redis' ) : null; + + $html_sqlite = wpvulnerability_analyze_filter( 'sqlite' ) && wpvulnerability_get_component_count( 'sqlite' ) ? wpvulnerability_html_software( 'sqlite' ) : null; + + $all_empty = ( empty( $html_core ) && empty( $html_plugins ) && empty( $html_themes ) && empty( $html_php ) && empty( $html_apache ) && empty( $html_nginx ) && empty( $html_mariadb ) && empty( $html_mysql ) && empty( $html_imagemagick ) && empty( $html_curl ) && empty( $html_memcached ) && empty( $html_redis ) && empty( $html_sqlite ) ); + + // If forced email sending is not enabled and no vulnerabilities were found, exit the function. + if ( ! $forced && $all_empty ) { + return false; + } elseif ( $forced && $all_empty ) { + $email_content .= '

' . esc_html__( 'No vulnerabilities found', 'wpvulnerability' ) . '

'; + $email_content .= '

' . esc_html__( 'This is likely a test. The site does not have vulnerabilities.', 'wpvulnerability' ) . '

'; + } + + // Append core vulnerabilities HTML to the email content. + if ( ! empty( $html_core ) ) { + $email_content .= '

' . esc_html__( 'Core vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_core; + } + + // Append plugins vulnerabilities HTML to the email content. + if ( ! empty( $html_plugins ) ) { + $email_content .= '

' . esc_html__( 'Plugins vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_plugins; + } + + // Append themes vulnerabilities HTML to the email content. + if ( ! empty( $html_themes ) ) { + $email_content .= '

' . esc_html__( 'Themes vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_themes; + } + + // Append PHP vulnerabilities HTML to the email content. + if ( ! empty( $html_php ) ) { + $email_content .= '

' . esc_html__( 'PHP vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_php; + } + + // Append Apache vulnerabilities HTML to the email content. + if ( ! empty( $html_apache ) ) { + $email_content .= '

' . esc_html__( 'Apache HTTPD vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_apache; + } + + // Append Nginx vulnerabilities HTML to the email content. + if ( ! empty( $html_nginx ) ) { + $email_content .= '

' . esc_html__( 'Nginx vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_nginx; + } + + // Append MariaDB vulnerabilities HTML to the email content. + if ( ! empty( $html_mariadb ) ) { + $email_content .= '

' . esc_html__( 'MariaDB vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_mariadb; + } + + // Append MySQL vulnerabilities HTML to the email content. + if ( ! empty( $html_mysql ) ) { + $email_content .= '

' . esc_html__( 'MySQL vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_mysql; + } + + // Append ImageMagick vulnerabilities HTML to the email content. + if ( ! empty( $html_imagemagick ) ) { + $email_content .= '

' . esc_html__( 'ImageMagick vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_imagemagick; + } + + // Append curl vulnerabilities HTML to the email content. + if ( ! empty( $html_curl ) ) { + $email_content .= '

' . esc_html__( 'curl vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_curl; + } + + // Append memcached vulnerabilities HTML to the email content. + if ( ! empty( $html_memcached ) ) { + $email_content .= '

' . esc_html__( 'memcached vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_memcached; + } + + // Append Redis vulnerabilities HTML to the email content. + if ( ! empty( $html_redis ) ) { + $email_content .= '

' . esc_html__( 'Redis vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_redis; + } + + // Append SQLite vulnerabilities HTML to the email content. + if ( ! empty( $html_sqlite ) ) { + $email_content .= '

' . esc_html__( 'SQLite vulnerabilities', 'wpvulnerability' ) . '

'; + $email_content .= $html_sqlite; + } + + // Get the site name. + $admin_site = is_multisite() ? get_site_option( 'site_name' ) : get_bloginfo( 'name' ); + + // Get the admin email. + $admin_email = is_multisite() ? get_site_option( 'admin_email' ) : get_bloginfo( 'admin_email' ); + $from_email = $admin_email; + + // Check if WPVULNERABILITY_MAIL is defined and valid, and use it if available. + if ( defined( 'WPVULNERABILITY_MAIL' ) ) { + $wpvulnerability_sender_email = sanitize_email( trim( (string) WPVULNERABILITY_MAIL ) ); + if ( is_email( $wpvulnerability_sender_email ) ) { + $from_email = $wpvulnerability_sender_email; + } + unset( $wpvulnerability_sender_email ); + } + + // Prepare email subject and content. + $email_subject = sprintf( + // translators: Site name. + __( 'Vulnerability found: %s', 'wpvulnerability' ), + ( is_scalar( $admin_site ) ? (string) $admin_site : '' ) + ); + + $email_prepared = wpvulnerability_email_prepare( esc_html__( 'Vulnerability found', 'wpvulnerability' ), $email_content ); + + // Prepare email headers. + $email_headers = array(); + $email_headers[] = 'From: WPVulnerability <' . ( is_scalar( $from_email ) ? (string) $from_email : '' ) . '>'; + $email_headers[] = 'Content-Type: text/html; charset=UTF-8'; + + if ( $forced && ( empty( $wpvulnerability_settings['emails'] ) ) ) { + // Determine the recipient email. + $wpvulnerability_settings['emails'] = array( $admin_email ); + } + + $wpmail = false; + + if ( $email_enabled ) { + $mail_to = is_array( $wpvulnerability_settings['emails'] ) ? array_map( + static function ( $e ) { + return is_scalar( $e ) ? (string) $e : ''; + }, + $wpvulnerability_settings['emails'] + ) : ( is_scalar( $wpvulnerability_settings['emails'] ) ? (string) $wpvulnerability_settings['emails'] : '' ); + $wpmail = wp_mail( $mail_to, $email_subject, $email_prepared, $email_headers ); + } + + $text_message_body = wpvulnerability_html_to_plain_text( $email_content ); + $text_message = trim( (string) ( $email_subject . "\n\n" . $text_message_body ) ); + + if ( $slack_enabled ) { + wpvulnerability_send_slack_notification( ( is_scalar( $wpvulnerability_settings['slack_webhook'] ) ? (string) $wpvulnerability_settings['slack_webhook'] : '' ), $text_message ); + } + + if ( $teams_enabled ) { + wpvulnerability_send_teams_notification( ( is_scalar( $wpvulnerability_settings['teams_webhook'] ) ? (string) $wpvulnerability_settings['teams_webhook'] : '' ), $text_message ); + } + + if ( $discord_enabled ) { + wpvulnerability_send_discord_notification( ( is_scalar( $wpvulnerability_settings['discord_webhook'] ) ? (string) $wpvulnerability_settings['discord_webhook'] : '' ), $text_message ); + } + + if ( $telegram_enabled ) { + wpvulnerability_send_telegram_notification( ( is_scalar( $wpvulnerability_settings['telegram_bot_token'] ) ? (string) $wpvulnerability_settings['telegram_bot_token'] : '' ), ( is_scalar( $wpvulnerability_settings['telegram_chat_id'] ) ? (string) $wpvulnerability_settings['telegram_chat_id'] : '' ), $text_message ); + } + + return $wpmail; +} + +// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM properties follow upstream naming. +/** + * Convert HTML notification content into a plain text representation. + * + * Ensures notifications sent to chat platforms retain meaningful structure by + * translating headings, paragraphs, lists, tables, and links into readable + * plain text. List indentation and table rows are preserved with appropriate + * line breaks so the resulting message can be consumed without HTML support. + * + * @since 4.1.4 + * + * @param string $html HTML fragment to convert. + * + * @return string Normalized plain text message. + */ +function wpvulnerability_html_to_plain_text( $html ) { + $html = (string) $html; + + if ( '' === trim( (string) $html ) ) { + return ''; + } + + if ( ! class_exists( 'DOMDocument', false ) ) { + return wp_strip_all_tags( $html ); + } + + $libxml_previous_state = libxml_use_internal_errors( true ); + $dom = new DOMDocument(); + $wrapped_html = '
' . $html . '
'; + $load_flags = 0; + + if ( defined( 'LIBXML_HTML_NOIMPLIED' ) ) { + $load_flags |= LIBXML_HTML_NOIMPLIED; + } + + if ( defined( 'LIBXML_HTML_NODEFDTD' ) ) { + $load_flags |= LIBXML_HTML_NODEFDTD; + } + + $dom->loadHTML( '' . $wrapped_html, $load_flags ); + libxml_clear_errors(); + libxml_use_internal_errors( $libxml_previous_state ); + + $list_stack = array(); + $output = ''; + + $doc_element = $dom->documentElement; + if ( null !== $doc_element ) { + foreach ( $doc_element->childNodes as $child_node ) { + $output .= wpvulnerability_dom_node_to_plain_text( $child_node, $list_stack ); + } + } + + $output = html_entity_decode( $output, ENT_QUOTES, 'UTF-8' ); + $output = preg_replace( '#/\\*.*?\\*/#s', '', $output ) ?? $output; + $output = preg_replace( '/[ \t]+\n/', "\n", $output ) ?? $output; + $output = preg_replace( "/\n{3,}/", "\n\n", $output ) ?? $output; + + return trim( (string) $output ); +} + +/** + * Recursively convert DOM nodes to plain text. + * + * @since 4.1.4 + * + * @param DOMNode $node Node being transformed. + * @param array> $list_stack Stack describing parent list context. + * + * @return string Plain text representation of the node. + */ +function wpvulnerability_dom_node_to_plain_text( DOMNode $node, array &$list_stack ): string { + if ( XML_TEXT_NODE === $node->nodeType ) { + $raw_value = $node->nodeValue ?? ''; + return preg_replace( '/\\s+/u', ' ', $raw_value ) ?? $raw_value; + } + + if ( XML_ELEMENT_NODE !== $node->nodeType ) { + return ''; + } + + $tag_name = strtolower( (string) $node->nodeName ); + + switch ( $tag_name ) { + case 'br': + return "\n"; + + case 'p': + case 'div': + case 'section': + case 'article': + case 'header': + case 'footer': + $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + + return '' === $content ? '' : $content . "\n\n"; + + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + + return '' === $content ? '' : $content . "\n\n"; + + case 'strong': + case 'em': + case 'span': + case 'code': + case 'b': + case 'i': + case 'u': + case 'small': + return wpvulnerability_dom_children_to_plain_text( $node, $list_stack ); + + case 'a': + $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + $href = ''; + + if ( null !== $node->attributes && null !== $node->attributes->getNamedItem( 'href' ) ) { + $href_attribute = $node->attributes->getNamedItem( 'href' ); + $href = trim( (string) $href_attribute->nodeValue ); + } + + if ( '' !== $href && '' !== $content && false === strpos( $content, $href ) ) { + return $content . ' (' . $href . ')'; + } + + return $content; + + case 'ul': + case 'ol': + $list_stack[] = array( + 'type' => $tag_name, + 'index' => 0, + ); + $content = wpvulnerability_dom_children_to_plain_text( $node, $list_stack ); + array_pop( $list_stack ); + + return $content . ( '' === $content ? '' : "\n" ); + + case 'li': + $depth = count( $list_stack ); + $indent = $depth > 0 ? str_repeat( ' ', $depth - 1 ) : ''; + $marker = '- '; + + if ( $depth > 0 ) { + $current_index = $depth - 1; + $list_stack[ $current_index ]['index'] = ( is_int( $list_stack[ $current_index ]['index'] ) ? $list_stack[ $current_index ]['index'] : 0 ) + 1; + + if ( isset( $list_stack[ $current_index ]['type'] ) && 'ol' === $list_stack[ $current_index ]['type'] ) { + $marker = $list_stack[ $current_index ]['index'] . '. '; + } + } + + $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + + if ( '' === $content ) { + return ''; + } + + $content = preg_replace( '/\n/', "\n" . $indent . ' ', $content ) ?? $content; + + return $indent . $marker . $content . "\n"; + + case 'table': + $rows = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + + return '' === $rows ? '' : $rows . "\n"; + + case 'thead': + case 'tbody': + case 'tfoot': + case 'tr': + return wpvulnerability_dom_children_to_plain_text( $node, $list_stack ); + + case 'th': + case 'td': + $content = trim( (string) wpvulnerability_dom_children_to_plain_text( $node, $list_stack ) ); + + return '' === $content ? '' : $content . ' | '; + + default: + return wpvulnerability_dom_children_to_plain_text( $node, $list_stack ); + } +} + +/** + * Generate plain text for the children of a DOM node. + * + * @since 4.1.4 + * + * @param DOMNode $node Parent DOM node. + * @param array> $list_stack Stack describing parent list context. + * + * @return string Concatenated plain text for child nodes. + */ +function wpvulnerability_dom_children_to_plain_text( DOMNode $node, array &$list_stack ): string { + $text = ''; + + foreach ( $node->childNodes as $child ) { + $child_text = wpvulnerability_dom_node_to_plain_text( $child, $list_stack ); + + if ( '' === $child_text ) { + continue; + } + + $text .= $child_text; + } + + if ( 'td' === strtolower( (string) $node->nodeName ) || 'th' === strtolower( (string) $node->nodeName ) ) { + $text = rtrim( (string) $text, ' |' ); + } + + if ( 'tr' === strtolower( (string) $node->nodeName ) ) { + $text = rtrim( (string) $text, ' |' ); + $text = '' === $text ? '' : $text . "\n"; + } + + return $text; +} +// phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase diff --git a/wpvulnerability-plugins.php b/wpvulnerability-plugins.php new file mode 100644 index 0000000..63639e5 --- /dev/null +++ b/wpvulnerability-plugins.php @@ -0,0 +1,791 @@ +> $plugins List of plugins returned by get_plugins(). + * @return string Hash representing the installed plugins and their versions. + */ +function wpvulnerability_plugins_generate_signature( $plugins ) { + $normalized = array(); + + foreach ( $plugins as $file_path => $plugin_data ) { + $plugin_file = sanitize_text_field( (string) $file_path ); + $version = ''; + + if ( isset( $plugin_data['Version'] ) ) { + $v_raw = $plugin_data['Version']; + $version = sanitize_text_field( is_scalar( $v_raw ) ? (string) $v_raw : '' ); + } + + $normalized[ $plugin_file ] = $version; + } + + ksort( $normalized ); + + $encoded = wp_json_encode( $normalized ); + return md5( false !== $encoded ? $encoded : '' ); +} + +/** + * Retrieve the signature of the currently installed plugins. + * + * @since 4.1.2 + * + * @return string Hash representing the installed plugins and their versions. + */ +function wpvulnerability_plugins_get_current_signature() { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return wpvulnerability_plugins_generate_signature( get_plugins() ); +} + +/** + * Adds a vulnerability notice under vulnerable plugins. + * + * This function retrieves the vulnerability data for the specified plugin from the WordPress options table + * and displays a detailed notice below the plugin's row on the plugins management page in the WordPress admin area. + * The notice includes information about the plugin's vulnerabilities, such as affected versions, severity, CVSS scores, + * and links to sources. + * + * The function is applicable both in single-site and multisite installations. In a multisite setup, the notice + * is displayed only in the network admin area or in the site admin area of individual sites. + * + * @since 2.0.0 + * + * @param string $plugin_file Main plugin folder/file name. + * @param array $plugin_data Plugin data array containing information about the plugin. + * @param string $plugin_status Plugin status (active, inactive, etc.). + * + * @return void + */ +function wpvulnerability_plugin_info_after( $plugin_file, $plugin_data, $plugin_status = '' ) { + + // Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. + $raw_plugins = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); + $plugin_vulnerabilities = json_decode( is_string( $raw_plugins ) ? $raw_plugins : '', true ); + if ( ! is_array( $plugin_vulnerabilities ) ) { + $plugin_vulnerabilities = array(); + } + + if ( ( is_multisite() && is_network_admin() ) || ! is_multisite() ) { + + // Determine whether the plugin is active and add an appropriate CSS class to the table row. + $tr_class = is_plugin_active( $plugin_file ) ? 'active' : ''; + + // Generate the vulnerability notice message with the plugin name. + $message = sprintf( + /* translators: 1: Plugin name */ + __( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ), + wp_kses( is_scalar( $plugin_data['Name'] ) ? (string) $plugin_data['Name'] : '', 'strip' ) + ); + + // Begin generating the table row HTML markup with appropriate CSS classes and the vulnerability notice message. + $information = ''; + $information .= ''; + $information .= '

' . esc_html( $message ) . ''; + $information .= '

'; + $information .= ''; + + // Loop through all vulnerabilities for the current plugin and add their details to the table row HTML markup. + $pf_entry = isset( $plugin_vulnerabilities[ $plugin_file ] ) && is_array( $plugin_vulnerabilities[ $plugin_file ] ) ? $plugin_vulnerabilities[ $plugin_file ] : array(); + $vulnerabilities = isset( $pf_entry['vulnerabilities'] ) && is_array( $pf_entry['vulnerabilities'] ) ? $pf_entry['vulnerabilities'] : array(); + + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; + } + + $vuln_versions_raw = $vulnerability['versions'] ?? ''; + $vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : ''; + $vuln_closed_raw = $vulnerability['closed'] ?? 0; + $vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0; + $vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0; + $vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0; + $vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array(); + $vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array(); + $vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array(); + $vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array(); + $vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array(); + $vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array(); + $vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array(); + $vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array(); + + $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); + $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; + $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; + $kev_date_raw = $vuln_ssvc['kev_date'] ?? null; + $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; + $epss_raw = $vuln_impact['epss'] ?? null; + $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; + $description = wpvulnerability_get_source_description( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. + $score_raw = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; + } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score_raw = $s; + $sev_raw = $v; + break; + } + } + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; + } + $cwe_name = $vulnerability_cwe['name'] ?? ''; + $cwe_desc = $vulnerability_cwe['description'] ?? ''; + $what[] = '
' . wp_kses( is_scalar( $cwe_name ) ? (string) $cwe_name : '', 'strip' ) . '
' . esc_html( is_scalar( $cwe_desc ) ? (string) $cwe_desc : '' ) . '
'; + } + + $version_display = wpvulnerability_clean_version_range( $vuln_versions ); + $source_pills = wpvulnerability_render_source_pills( $vuln_sources ); + $score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss ); + + $information .= ''; + // Version range column. + $information .= ''; + // Details column. + $information .= ''; + $information .= ''; + } + + $information .= '
'; + $information .= '' !== $version_display + ? '' . $version_display . '' + : '—'; + $information .= ''; + $show_active = $kev || 'active' === $exploitation; + $show_poc = 'poc' === $exploitation; + $show_auto = 'yes' === $automatable; + if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { + $information .= '
'; + if ( $show_active ) { + $information .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); + if ( $kev && null !== $kev_date ) { + $information .= ' · ' . esc_html( $kev_date ); + } + $information .= ''; + } + if ( $show_poc ) { + $information .= '⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . ''; + } + if ( $show_auto ) { + $information .= '⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $information .= $score_badge; + } + $information .= '
'; + } + if ( null !== $description ) { + $information .= '
' . esc_html( $description ) . '
'; + } + if ( $vuln_closed || $vuln_unfixed ) { + $information .= '
'; + if ( $vuln_closed ) { + $information .= '
' . esc_html__( 'This plugin is closed. Please replace it with another.', 'wpvulnerability' ) . '
'; + } + if ( $vuln_unfixed ) { + $information .= '
' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming plugin updates.', 'wpvulnerability' ) . '
'; + } + $information .= '
'; + } + if ( ! empty( $what ) ) { + $information .= '
'; + foreach ( $what as $w ) { + $information .= $w; + } + $information .= '
'; + } + if ( '' !== $source_pills ) { + $information .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $information .= $source_pills; + $information .= '
'; + } + $information .= '
'; + $information .= ''; + $information .= ''; + + echo $information; // phpcs:ignore + } +} + +/** + * Retrieves vulnerabilities for a given plugin and updates its data. + * + * @since 2.0.0 + * + * @param array $plugin_data The plugin data array. + * @param string $file_path The path to the plugin file. + * + * @return array The updated plugin data array. + */ +function wpvulnerability_get_fresh_plugin_vulnerabilities( $plugin_data, $file_path ) { + + $plugin_slug = null; + + // Extract the folder name from the file path. + $folder_name = explode( '/', $file_path ); + + // Use the first folder segment as the plugin slug. + $plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); + unset( $folder_name ); + + // If the plugin slug is empty, fall back to the TextDomain key. + if ( empty( $plugin_slug ) && isset( $plugin_data['TextDomain'] ) ) { + $td_raw = $plugin_data['TextDomain']; + $plugin_slug = wp_kses( is_scalar( $td_raw ) ? (string) $td_raw : '', 'strip' ); + } + + // Get the plugin version from the plugin data. + $plugin_version_raw = $plugin_data['Version'] ?? ''; + $plugin_version = wp_kses( is_scalar( $plugin_version_raw ) ? (string) $plugin_version_raw : '', 'strip' ); + + // Initialize vulnerability-related fields. + $plugin_data['vulnerabilities'] = null; + $plugin_data['vulnerable'] = 0; + + // Retrieve vulnerabilities for the plugin using its slug and version. + if ( ! empty( $plugin_slug ) ) { + + $plugin_api_response = wpvulnerability_get_plugin( $plugin_slug, $plugin_version, 0, 0 ); + + // If vulnerabilities are found, update the plugin data accordingly. + if ( ! empty( $plugin_api_response ) ) { + + $plugin_data['slug'] = $plugin_slug; + $plugin_data['vulnerabilities'] = $plugin_api_response; + $plugin_data['vulnerable'] = 1; + + } + } + + return $plugin_data; +} + +/** + * Retrieves updated data for a specified plugin, potentially including vulnerability information. + * + * @since 3.1.0 + * + * @param array $plugin_data The original plugin data array, expected to contain keys like 'TextDomain' and 'Version'. + * @param string $file_path The file path of the plugin, used to determine the plugin's slug if 'TextDomain' is not specified in `$plugin_data`. + * + * @return array|null Updated plugin data array with fresh information or null if the plugin slug cannot be determined or no updated information is available. + */ +function wpvulnerability_get_fresh_plugin_data( $plugin_data, $file_path ) { + + $plugin_slug = ''; + + // Extract the folder name from the file path. + $folder_name = explode( '/', $file_path ); + + // Use the first folder segment as the plugin slug. + $plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); + unset( $folder_name ); + + // If the plugin slug is still empty, use the TextDomain key from the plugin data if it exists. + if ( '' === $plugin_slug && isset( $plugin_data['TextDomain'] ) ) { + $td_raw2 = $plugin_data['TextDomain']; + $plugin_slug = wp_kses( is_scalar( $td_raw2 ) ? (string) $td_raw2 : '', 'strip' ); + } + + // Get the plugin version from the plugin data if it exists. + $plugin_version_raw2 = $plugin_data['Version'] ?? ''; + $plugin_version = wp_kses( is_scalar( $plugin_version_raw2 ) ? (string) $plugin_version_raw2 : '', 'strip' ); + + // Retrieve vulnerabilities for the plugin using its slug and version. + if ( ! empty( $plugin_slug ) ) { + + $plugin_api_response = wpvulnerability_get_plugin( $plugin_slug, $plugin_version, 1, 1 ); + + // If vulnerabilities are found, return the updated plugin data. + if ( ! empty( $plugin_api_response ) ) { + return $plugin_api_response; + } + } + + return null; // Return null if no valid data is found. +} + +/** + * Get Installed Plugins + * Retrieves the list of installed plugins, checks for vulnerabilities in each of them, caches the data, and sends an email notification if vulnerabilities are detected. + * + * @since 2.0.0 + * @since 4.1.2 Stores a signature of the installed plugins to detect inventory changes. + * + * @return string JSON-encoded array of plugin data with vulnerabilities and vulnerable status, or '[]' on encoding error. + */ +function wpvulnerability_plugin_get_installed() { + + $wpvulnerability_plugins_vulnerable = 0; + + // Ensure the get_plugins() function is available. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + // Retrieve the list of installed plugins. + $plugins = get_plugins(); + $signature = wpvulnerability_plugins_generate_signature( $plugins ); + + // Iterate through each plugin and check for vulnerabilities. + foreach ( $plugins as $file_path => $plugin_data ) { + + $plugins[ $file_path ] = wpvulnerability_get_fresh_plugin_vulnerabilities( $plugin_data, $file_path ); + + // Increment the vulnerable plugin counter if vulnerabilities are found. + $vuln_flag = $plugins[ $file_path ]['vulnerable'] ?? null; + if ( is_scalar( $vuln_flag ) && (int) $vuln_flag ) { + ++$wpvulnerability_plugins_vulnerable; + } + } + + // Update site options for multisite installations. + if ( is_multisite() ) { + update_site_option( 'wpvulnerability-plugins', wp_json_encode( $plugins ) ); + update_site_option( 'wpvulnerability-plugins-vulnerable', wp_json_encode( number_format( $wpvulnerability_plugins_vulnerable, 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ) ); + } else { + // Update options for single site installations. + update_option( 'wpvulnerability-plugins', wp_json_encode( $plugins ), false ); + update_option( 'wpvulnerability-plugins-vulnerable', wp_json_encode( number_format( $wpvulnerability_plugins_vulnerable, 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ), false ); + } + + // Return the JSON-encoded array of plugin data. + $encoded = wp_json_encode( $plugins ); + return false !== $encoded ? $encoded : '[]'; +} + +/** + * Retrieves cached data for installed plugins, optionally refreshing when forced. + * + * @since 3.1.0 + * @since 4.1.2 Refreshes automatically when the installed plugins signature changes. + * + * @param bool $clean Optional. Whether to force a refresh of the plugin data cache. Default false. + * + * @return string JSON-encoded array of plugin data, or '[]' on encoding error. + */ +function wpvulnerability_plugin_get_data( $clean = false ) { + if ( true === $clean ) { + // Ensure the get_plugins() function is available. + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + // Retrieve the list of installed plugins. + $plugins = get_plugins(); + $pluginsdata = array(); + $signature = wpvulnerability_plugins_generate_signature( $plugins ); + + // Iterate through each plugin and get fresh data. + foreach ( $plugins as $file_path => $plugin_data ) { + $pluginsdata[ $file_path ] = wpvulnerability_get_fresh_plugin_data( $plugin_data, $file_path ); + } + + // Update site options for multisite installations. + if ( is_multisite() ) { + update_site_option( 'wpvulnerability-plugins-data', wp_json_encode( $pluginsdata ) ); + update_site_option( 'wpvulnerability-plugins-cache-data', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ) ); + } else { + // Update options for single site installations. + update_option( 'wpvulnerability-plugins-data', wp_json_encode( $pluginsdata ), false ); + update_option( 'wpvulnerability-plugins-cache-data', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-plugins-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-plugins-signature', wp_json_encode( $signature ), false ); + } + + $encoded_data = wp_json_encode( $pluginsdata ); + return false !== $encoded_data ? $encoded_data : '[]'; + } + + $raw_pd = is_multisite() ? get_site_option( 'wpvulnerability-plugins-data', '' ) : get_option( 'wpvulnerability-plugins-data', '' ); + $plugin_data = json_decode( is_string( $raw_pd ) ? $raw_pd : '', true ); + + if ( ! is_array( $plugin_data ) ) { + $plugin_data = array(); + } + + $encoded = wp_json_encode( $plugin_data ); + return false !== $encoded ? $encoded : '[]'; +} + +/** + * Get cached plugin vulnerabilities without contacting the API. Data is refreshed by scheduled or manual updates. + * + * @since 2.0.0 + * @since 4.1.2 Refreshes when the installed plugins signature changes. + * + * @return array Array of installed plugins with their vulnerabilities. + */ +function wpvulnerability_plugin_get_vulnerabilities() { + + $raw_data = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); + $plugin_data = json_decode( is_string( $raw_data ) ? $raw_data : '', true ); + + return is_array( $plugin_data ) ? $plugin_data : array(); +} + +/** + * Update the installed plugins cache and remove any old cache data. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_plugin_get_vulnerabilities_clean() { + wpvulnerability_clear_cache( 'plugins' ); + wpvulnerability_plugin_get_installed(); + wpvulnerability_plugin_get_data( true ); +} + +/** + * Displays information in the 'Last Updated' column for each plugin in the plugins list table. + * + * This function is triggered for each row in the plugins list table when the 'Last Updated' column is rendered. + * It retrieves the last update date from stored plugin data, compares it against the current date to highlight + * plugins not updated in over a year or those marked as closed, and displays this information. + * + * @since 3.1.0 Introduced. + * + * @param string $column_name The name of the current column being rendered. + * @param string $plugin_file Path to the plugin file, relative to the plugins directory. + * @param array $plugin_data Array of plugin data, such as the plugin's name, version, and description. + * + * @return void Outputs the last updated information directly to the browser, including any warnings for plugins + * not updated in over a year or marked as closed. + */ +function wpvulnerability_plugin_show_lastupdated( $column_name, $plugin_file, $plugin_data ) { + + $now = time(); + $year = strtotime( '-1 year', $now ); + + if ( 'last_updated' === $column_name && $plugin_file ) { + + $plugin_slug = ''; + + // Extract the plugin slug from the file path. + $folder_name = explode( '/', $plugin_file ); + + // Use the first folder segment as the plugin slug. + $plugin_slug = wp_kses( trim( (string) $folder_name[0] ), 'strip' ); + unset( $folder_name ); + + // If the plugin slug is empty, extract it from the plugin data. + if ( '' === $plugin_slug && isset( $plugin_data['TextDomain'] ) ) { + $td_raw3 = $plugin_data['TextDomain']; + $plugin_slug = wp_kses( is_scalar( $td_raw3 ) ? (string) $td_raw3 : '', 'strip' ); + } + + if ( '' !== $plugin_slug ) { + + // Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. + $raw_plugins_data = is_multisite() ? get_site_option( 'wpvulnerability-plugins-data', '' ) : get_option( 'wpvulnerability-plugins-data', '' ); + $plugins_data = json_decode( is_string( $raw_plugins_data ) ? $raw_plugins_data : '', true ); + if ( ! is_array( $plugins_data ) ) { + $plugins_data = array(); + } + + // Get the plugin data from the stored data. + if ( isset( $plugins_data[ $plugin_file ] ) && is_array( $plugins_data[ $plugin_file ] ) ) { + $pd = $plugins_data[ $plugin_file ]; + + $pd_latest_raw = $pd['latest'] ?? 0; + $pd_latest = is_scalar( $pd_latest_raw ) ? intval( $pd_latest_raw ) : 0; + if ( $pd_latest > 0 ) { + + $timestamp = $pd_latest; + $df_raw = get_option( 'date_format', 'Y-m-d' ); + $date_format = is_scalar( $df_raw ) ? (string) $df_raw : 'Y-m-d'; + if ( function_exists( 'wp_date' ) ) { + $plugin_data_updated = (string) wp_date( $date_format, $timestamp ); + } else { + $plugin_data_updated = gmdate( $date_format, $timestamp ); + } + $plugin_data_ago = human_time_diff( $timestamp ); + + $warning_date = $pd_latest < $year; + $pd_closed_raw = $pd['closed'] ?? 0; + $warning_closed = isset( $pd['closed'] ) && ( is_scalar( $pd_closed_raw ) ? intval( $pd_closed_raw ) : 0 ); + + echo '

' . wp_kses( $plugin_data_updated, 'strip' ) . ' (' . wp_kses( (string) $plugin_data_ago, 'strip' ) . ')

'; + + if ( $warning_date ) { + echo '

⚠️ '; + esc_html_e( 'It hasn\'t been updated in over a year.', 'wpvulnerability' ); + echo '

'; + } + + if ( $warning_closed ) { + echo '

⚠️ '; + esc_html_e( 'It may no longer be available (closed?).', 'wpvulnerability' ); + echo '

'; + } + } else { + echo '

'; + } + } + } + } +} + +/** + * Adds a 'Last Updated' column to the plugins table list in the WordPress admin area. + * + * This function iterates over the existing columns in the plugins table and inserts a new column titled 'Last Updated' + * just before the 'auto-updates' column if it exists. If the 'auto-updates' column is not found, the 'Last Updated' + * column is appended at the end. The function is typically hooked to the 'manage_plugins_columns' filter in WordPress + * to modify the columns of the plugins table. + * + * @since 3.1.0 Introduced. + * + * @param array $columns An associative array of column names and titles for the plugins table. + * + * @return array An associative array containing the modified list of columns, including the new 'Last Updated' column. + */ +function wpvulnerability_plugin_add_lastupdated_column( $columns ) { + + $toadd = true; + $new_columns = array(); + + // Loop through each existing column and add it to the new columns array. + foreach ( $columns as $key => $title ) { + + // Add the existing column to the new columns array. + $new_columns[ $key ] = $title; + + // Insert your custom column before the 'auto-updates' column. + if ( 'description' === $key && $toadd ) { + $new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' ); + $toadd = false; + } + } + + // If 'auto-updates' column is not found, add 'last_updated' column at the end. + if ( $toadd ) { + $new_columns['last_updated'] = __( 'Last updated on', 'wpvulnerability' ); + } + + // Return the modified columns array. + return $new_columns; +} + +/** + * Admin Head + * Adds vulnerability information after the plugin row and notices on the plugin page based on the installed plugins cache. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_plugin_page() { + + // Check if the current page is the plugins page. + global $pagenow; + + if ( wpvulnerability_analyze_filter( 'plugins' ) && 'plugins.php' === $pagenow && wpvulnerability_capabilities() ) { + + // Get the vulnerabilities for the installed plugins. + $plugins = wpvulnerability_plugin_get_vulnerabilities(); + + // Loop through the plugins and add vulnerability information after the plugin row for vulnerable plugins. + foreach ( $plugins as $file_path => $plugin_data ) { + + if ( is_array( $plugin_data ) && isset( $plugin_data['vulnerable'] ) ) { + $vulnerable_raw = $plugin_data['vulnerable']; + if ( 1 === ( is_scalar( $vulnerable_raw ) ? intval( $vulnerable_raw ) : 0 ) ) { + add_action( 'after_plugin_row_' . $file_path, 'wpvulnerability_plugin_info_after', 10, 3 ); + } + } + } + + // Add 'Last Updated' column to the plugins table based on user capabilities. + if ( is_multisite() ) { + + add_filter( 'manage_plugins-network_columns', 'wpvulnerability_plugin_add_lastupdated_column' ); + + } else { + + add_filter( 'manage_plugins_columns', 'wpvulnerability_plugin_add_lastupdated_column' ); + + } + + add_action( 'manage_plugins_custom_column', 'wpvulnerability_plugin_show_lastupdated', 10, 3 ); + + } +} +// Add notices for vulnerable plugins on the plugin page. +add_action( 'admin_head', 'wpvulnerability_plugin_page' ); + +/** + * Filters the plugins list to show only vulnerable plugins when the "Vulnerable" tab is selected. + * + * This function hooks into the WordPress plugins listing to filter the displayed plugins based on their + * vulnerability status. When the "Vulnerable" tab is selected (identified by the `plugin_status=vulnerable` + * query parameter), it filters the plugins list to include only those plugins with known vulnerabilities. + * + * The function retrieves the vulnerabilities for all plugins from the WordPress options table and compares + * them against the active list of plugins. Plugins without vulnerabilities are removed from the list, leaving + * only those that are considered vulnerable. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_plugins_filter() { + if ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) { // phpcs:ignore + + // Verify nonce for CSRF protection. + $nonce_raw = isset( $_GET['wpv_nonce'] ) && is_string( $_GET['wpv_nonce'] ) ? $_GET['wpv_nonce'] : ''; // phpcs:ignore + $nonce = sanitize_text_field( wp_unslash( $nonce_raw ) ); + + if ( ! wp_verify_nonce( $nonce, 'wpvulnerability_filter_plugins' ) ) { + // If nonce verification fails, silently return without filtering. + // This provides graceful degradation - users simply see all plugins instead of an error. + return; + } + + global $wp_list_table; + + // Retrieve the vulnerabilities for all plugins from the options table and decode the JSON. + $raw_pv = is_multisite() ? get_site_option( 'wpvulnerability-plugins', '' ) : get_option( 'wpvulnerability-plugins', '' ); + $plugin_vulnerabilities = json_decode( is_string( $raw_pv ) ? $raw_pv : '', true ); + if ( ! is_array( $plugin_vulnerabilities ) ) { + $plugin_vulnerabilities = array(); + } + + foreach ( $wp_list_table->items as $plugin_file => $plugin_data ) { + $pf_data = isset( $plugin_vulnerabilities[ $plugin_file ] ) && is_array( $plugin_vulnerabilities[ $plugin_file ] ) ? $plugin_vulnerabilities[ $plugin_file ] : array(); + $pf_vulns = isset( $pf_data['vulnerabilities'] ) && is_array( $pf_data['vulnerabilities'] ) ? $pf_data['vulnerabilities'] : array(); + if ( empty( $pf_vulns ) ) { + unset( $wp_list_table->items[ $plugin_file ] ); + } + } + } +} +add_action( 'pre_current_active_plugins', 'wpvulnerability_plugins_filter' ); + +/** + * Adds a "Vulnerable" tab to the WordPress plugins page that displays the count of vulnerable plugins. + * + * This function checks the cache for the number of vulnerable plugins and adds a new tab to the plugins + * management page in the WordPress admin area. The tab displays the count of vulnerable plugins and highlights it + * if it is currently active. + * + * @since 3.3.5 + * + * @param array $views An array of existing plugin views (tabs) in the WordPress admin plugins page. + * + * @return array The modified array of views including the "Vulnerable" tab. + */ +function wpvulnerability_plugins_view( $views ) { + + if ( ! wpvulnerability_analyze_filter( 'plugins' ) ) { + return $views; + } + + // Retrieve the number of plugins vulnerabilities from cache. + $raw_count = is_multisite() + ? get_site_option( 'wpvulnerability-plugins-vulnerable', '0' ) + : get_option( 'wpvulnerability-plugins-vulnerable', '0' ); + + $decoded_count = json_decode( is_string( $raw_count ) ? $raw_count : '0', true ); + $wpvulnerability_plugins_total = is_scalar( $decoded_count ) ? intval( $decoded_count ) : 0; + + $current_class = ( isset( $_GET['plugin_status'] ) && 'vulnerable' === $_GET['plugin_status'] ) ? ' class="current"' : ''; // phpcs:ignore + + $url = is_multisite() + ? network_admin_url( 'plugins.php?plugin_status=vulnerable' ) + : admin_url( 'plugins.php?plugin_status=vulnerable' ); + + // Add nonce for CSRF protection. + $url = esc_url( wp_nonce_url( $url, 'wpvulnerability_filter_plugins', 'wpv_nonce' ) ); + + $views['vulnerable'] = sprintf( + '%s', + $url, + $current_class, + // translators: the number of vulnerabilities. + sprintf( __( 'Vulnerabilities (%d)', 'wpvulnerability' ), $wpvulnerability_plugins_total ) + ); + + return $views; +} + +/** + * Adds a custom filter to the plugins page in the WordPress admin to display a tab for vulnerable plugins. + * + * This function hooks into the 'views_plugins' filter to add a custom tab or view for displaying vulnerable plugins + * on the plugins management page in the WordPress admin area. The tab is added in both single-site and multisite + * installations, but in a multisite setup, it is only added to the network admin area. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_plugins_add_tab() { + + if ( is_multisite() ) { + if ( is_network_admin() ) { + add_filter( 'views_plugins-network', 'wpvulnerability_plugins_view' ); + } + } else { + add_filter( 'views_plugins', 'wpvulnerability_plugins_view' ); + } +} +add_action( 'admin_head', 'wpvulnerability_plugins_add_tab' ); diff --git a/wpvulnerability-process.php b/wpvulnerability-process.php new file mode 100644 index 0000000..103d702 --- /dev/null +++ b/wpvulnerability-process.php @@ -0,0 +1,822 @@ + $sources Array of source objects from the vulnerability API. + * @return string HTML div.wpvuln-source-pills, or empty string if no sources. + */ +function wpvulnerability_render_source_pills( $sources ) { + if ( empty( $sources ) ) { + return ''; + } + $pills = array(); + foreach ( $sources as $src ) { + if ( ! is_array( $src ) ) { + continue; + } + $link = is_scalar( $src['link'] ?? '' ) ? (string) ( $src['link'] ?? '' ) : ''; + + // Derive label and CSS slug from the URL hostname; fall back to name/id. + $label = ''; + $slug_input = ''; + if ( '' !== $link ) { + $parsed = wp_parse_url( $link ); + $host = ( is_array( $parsed ) && isset( $parsed['host'] ) ) ? (string) $parsed['host'] : ''; + if ( 0 === strpos( $host, 'www.' ) ) { + $host = substr( $host, 4 ); + } + if ( '' !== $host ) { + $label = $host; + $slug_input = $host; + } + } + if ( '' === $label ) { + $name = is_scalar( $src['name'] ?? '' ) ? (string) ( $src['name'] ?? '' ) : ''; + if ( '' === $name ) { + $name = is_scalar( $src['id'] ?? '' ) ? (string) ( $src['id'] ?? '' ) : ''; + } + $label = $name; + $slug_input = $name; + } + if ( '' === $label ) { + continue; + } + + $slug = wpvulnerability_source_css_slug( $slug_input ); + $cls = esc_attr( 'wpvuln-source-pill wpvuln-source-' . $slug ); + $inner = esc_html( $label ); + if ( '' !== $link ) { + $pills[] = '' . $inner . ''; + } else { + $pills[] = '' . $inner . ''; + } + } + if ( empty( $pills ) ) { + return ''; + } + return '
' . implode( '', $pills ) . '
'; +} + +/** + * Build a colour-coded CVSS score + severity badge, optionally followed by an EPSS badge. + * + * @since 5.0.0 + * + * @param string|null $score Formatted CVSS score (e.g. "7.5") or null. + * @param string|null $sev_raw Raw severity string (single-char or full word) or null. + * @param float|null $epss EPSS exploitation probability 0–1, or null if not available. + * @return string HTML span(s) for score and/or EPSS, or empty string if no data. + */ +function wpvulnerability_render_score_badge( $score, $sev_raw, $epss = null ) { + if ( is_null( $score ) && ( is_null( $sev_raw ) || '' === $sev_raw ) && is_null( $epss ) ) { + return ''; + } + $sev_lower = is_string( $sev_raw ) ? strtolower( trim( $sev_raw ) ) : ''; + $css_map = array( + 'c' => 'critical', + 'critical' => 'critical', + 'h' => 'high', + 'high' => 'high', + 'm' => 'medium', + 'medium' => 'medium', + 'l' => 'low', + 'low' => 'low', + 'n' => 'none', + 'none' => 'none', + ); + $css_key = isset( $css_map[ $sev_lower ] ) ? $css_map[ $sev_lower ] : 'none'; + $sev_label = ( is_string( $sev_raw ) && '' !== $sev_raw ) ? wpvulnerability_severity( $sev_raw ) : null; + $parts = array(); + if ( ! is_null( $score ) ) { + $parts[] = esc_html( $score ); + } + if ( ! is_null( $sev_label ) ) { + $parts[] = esc_html( $sev_label ); + } + $badge = ''; + if ( ! empty( $parts ) ) { + $badge = '' . implode( ' · ', $parts ) . ''; + } + if ( null !== $epss ) { + $badge .= 'EPSS ' . esc_html( number_format( $epss * 100, 1 ) ) . '%'; + } + return $badge; +} + +/** + * Extract the best available description from a vulnerability's source array. + * + * Iterates source objects and returns the first non-empty description string, + * stripping any leading language tag (e.g. "[en-US] ") added by the CVE API. + * + * @since 5.0.0 + * + * @param array $sources Source objects from the vulnerability API. + * @return string|null First non-empty description found, or null if none. + */ +function wpvulnerability_get_source_description( $sources ) { + if ( empty( $sources ) ) { + return null; + } + foreach ( $sources as $src ) { + if ( ! is_array( $src ) ) { + continue; + } + $raw = isset( $src['description'] ) && is_string( $src['description'] ) ? trim( $src['description'] ) : ''; + if ( '' === $raw ) { + continue; + } + // Strip leading language tag like "[en-US] " or "[ja] ". + if ( '[' === $raw[0] ) { + $close = strpos( $raw, '] ' ); + if ( false !== $close ) { + $raw = substr( $raw, $close + 2 ); + } + } + if ( '' !== $raw ) { + return $raw; + } + } + return null; +} + +/** + * Clean a raw API version range string for display. + * + * Examples: + * "* - < 1.0.0" → "< 1.0.0" + * "- - < 1.0.0" → "< 1.0.0" + * "- < 1.0.0" → "< 1.0.0" (no space-dash-space separator) + * "1.0.0 - < 2.0" → "≥ 1.0.0 – < 2.0" + * + * @since 5.0.0 + * + * @param string $versions Raw versions string from the API. + * @return string Cleaned version range for display (HTML-safe). + */ +function wpvulnerability_clean_version_range( $versions ) { + $v = trim( (string) $versions ); + if ( '' === $v ) { + return ''; + } + // Handle "- < 1.0" / "* < 1.0": leading wildcard without a space-dash-space separator. + if ( 0 === strpos( $v, '- ' ) || 0 === strpos( $v, '* ' ) ) { + return esc_html( ltrim( substr( $v, 2 ) ) ); + } + $parts = explode( ' - ', $v, 2 ); + if ( 2 === count( $parts ) ) { + $from = trim( $parts[0] ); + $to = trim( $parts[1] ); + if ( '' === $from || '*' === $from || '-' === $from ) { + return esc_html( $to ); + } + return '≥ ' . esc_html( $from ) . ' – ' . esc_html( $to ); + } + return esc_html( $v ); +} + +/** + * Return an img tag for a component-type icon. + * + * @since 5.0.0 + * + * @param string $type Component type: plugin, theme, core, php, apache, nginx, mariadb, mysql, imagemagick, curl, memcached, redis, sqlite. + * @return string HTML img tag with class wpvuln-component-icon, or empty string. + */ +function wpvulnerability_component_icon_html( $type ) { + $icon_map = array( + 'plugin' => 'icon-plugin.svg', + 'theme' => 'icon-theme.svg', + 'core' => 'icon-wordpress.svg', + 'php' => 'icon-php.svg', + 'apache' => 'icon-apache.svg', + 'nginx' => 'icon-nginx.svg', + 'mariadb' => 'icon-mariadb.svg', + 'mysql' => 'icon-mysql.svg', + 'imagemagick' => 'icon-imagemagick.svg', + 'curl' => 'icon-curl.svg', + 'memcached' => 'icon-memcached.svg', + 'redis' => 'icon-redis.svg', + 'sqlite' => 'icon-sqlite.svg', + ); + if ( ! isset( $icon_map[ $type ] ) ) { + return ''; + } + return ''; +} + +/** + * Convert vulnerabilities into pretty HTML + * + * @since 2.0.0 + * + * @param string $type Type: core, plugin, theme, php, apache, nginx, mariadb, mysql, imagemagick, curl. + * @param array $vulnerabilities Vulnerability data. + * + * @return string The HTML representation of vulnerabilities. + */ +function wpvulnerability_html( $type, $vulnerabilities ) { + $html = ''; + + if ( in_array( $type, array( 'plugin', 'theme' ), true ) ) { + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; } + $vuln_impact_raw = $vulnerability['impact'] ?? null; + $vuln_impact = is_array( $vuln_impact_raw ) ? $vuln_impact_raw : array(); + $vuln_cvss_raw = $vuln_impact['cvss'] ?? null; + $vuln_cvss = is_array( $vuln_cvss_raw ) ? $vuln_cvss_raw : array(); + $vuln_cvss2_raw = $vuln_impact['cvss2'] ?? null; + $vuln_cvss2 = is_array( $vuln_cvss2_raw ) ? $vuln_cvss2_raw : array(); + $vuln_cvss3_raw = $vuln_impact['cvss3'] ?? null; + $vuln_cvss3 = is_array( $vuln_cvss3_raw ) ? $vuln_cvss3_raw : array(); + $vuln_cvss4_raw = $vuln_impact['cvss4'] ?? null; + $vuln_cvss4 = is_array( $vuln_cvss4_raw ) ? $vuln_cvss4_raw : array(); + $vuln_ssvc_raw = $vuln_impact['ssvc'] ?? null; + $vuln_ssvc = is_array( $vuln_ssvc_raw ) ? $vuln_ssvc_raw : array(); + $vuln_cwe_raw = $vuln_impact['cwe'] ?? null; + $vuln_cwe = is_array( $vuln_cwe_raw ) ? $vuln_cwe_raw : array(); + $vuln_src_raw = $vulnerability['source'] ?? null; + $vuln_sources = is_array( $vuln_src_raw ) ? $vuln_src_raw : array(); + + $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); + $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; + $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; + $kev_date_raw = $vuln_ssvc['kev_date'] ?? null; + $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; + $epss_raw = $vuln_impact['epss'] ?? null; + $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; + $description = wpvulnerability_get_source_description( $vuln_sources ); + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; } + $cwe_name = is_scalar( $vulnerability_cwe['name'] ?? '' ) ? (string) ( $vulnerability_cwe['name'] ?? '' ) : ''; + $cwe_desc = is_scalar( $vulnerability_cwe['description'] ?? '' ) ? (string) ( $vulnerability_cwe['description'] ?? '' ) : ''; + $what[] = '
' . wp_kses( $cwe_name, 'strip' ) . '
' . esc_html( $cwe_desc ) . '
'; + } + + $source = wpvulnerability_render_source_pills( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. + $score = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score = $s; + $sev_raw = $v; + break; + } + } + + $vuln_name = is_scalar( $vulnerability['name'] ?? '' ) ? (string) ( $vulnerability['name'] ?? '' ) : ''; + $html .= '

' . wp_kses( $vuln_name, 'strip' ) . '

'; + $vuln_closed = is_numeric( $vulnerability['closed'] ?? 0 ) ? (int) ( $vulnerability['closed'] ?? 0 ) : 0; + $vuln_unfixed = is_numeric( $vulnerability['unfixed'] ?? 0 ) ? (int) ( $vulnerability['unfixed'] ?? 0 ) : 0; + $score_badge = wpvulnerability_render_score_badge( $score, $sev_raw, $epss ); + $show_active = $kev || 'active' === $exploitation; + $show_poc = 'poc' === $exploitation; + $show_auto = 'yes' === $automatable; + if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { + $html .= '
'; + if ( $show_active ) { + $html .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); + if ( $kev && null !== $kev_date ) { + $html .= ' · ' . esc_html( $kev_date ); + } + $html .= ''; + } + if ( $show_poc ) { + $html .= '⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . ''; + } + if ( $show_auto ) { + $html .= '⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $html .= $score_badge; + } + $html .= '
'; + } + if ( null !== $description ) { + $html .= '
' . esc_html( $description ) . '
'; + } + if ( $vuln_closed || $vuln_unfixed ) { + $html .= '
'; + if ( $vuln_closed ) { + $html .= '
' . esc_html__( 'This plugin is closed. Please replace it with another.', 'wpvulnerability' ) . '
'; + } + if ( $vuln_unfixed ) { + $html .= '
' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming plugin updates.', 'wpvulnerability' ) . '
'; + } + $html .= '
'; + } + + if ( count( $what ) ) { + $html .= '
' . implode( '', $what ) . '
'; + } + + if ( '' !== $source ) { + $html .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $html .= $source; + $html .= '
'; + } + } + } elseif ( 'core' === $type ) { + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; } + $vuln_impact_raw = $vulnerability['impact'] ?? null; + $vuln_impact = is_array( $vuln_impact_raw ) ? $vuln_impact_raw : array(); + $vuln_cvss_raw = $vuln_impact['cvss'] ?? null; + $vuln_cvss = is_array( $vuln_cvss_raw ) ? $vuln_cvss_raw : array(); + $vuln_cvss2_raw = $vuln_impact['cvss2'] ?? null; + $vuln_cvss2 = is_array( $vuln_cvss2_raw ) ? $vuln_cvss2_raw : array(); + $vuln_cvss3_raw = $vuln_impact['cvss3'] ?? null; + $vuln_cvss3 = is_array( $vuln_cvss3_raw ) ? $vuln_cvss3_raw : array(); + $vuln_cvss4_raw = $vuln_impact['cvss4'] ?? null; + $vuln_cvss4 = is_array( $vuln_cvss4_raw ) ? $vuln_cvss4_raw : array(); + $vuln_ssvc_raw = $vuln_impact['ssvc'] ?? null; + $vuln_ssvc = is_array( $vuln_ssvc_raw ) ? $vuln_ssvc_raw : array(); + $vuln_cwe_raw = $vuln_impact['cwe'] ?? null; + $vuln_cwe = is_array( $vuln_cwe_raw ) ? $vuln_cwe_raw : array(); + $vuln_src_raw = $vulnerability['source'] ?? null; + $vuln_sources = is_array( $vuln_src_raw ) ? $vuln_src_raw : array(); + + $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); + $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; + $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; + $kev_date_raw = $vuln_ssvc['kev_date'] ?? null; + $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; + $epss_raw = $vuln_impact['epss'] ?? null; + $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; + $description = wpvulnerability_get_source_description( $vuln_sources ); + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; } + $cwe_name = is_scalar( $vulnerability_cwe['name'] ?? '' ) ? (string) ( $vulnerability_cwe['name'] ?? '' ) : ''; + $cwe_desc = is_scalar( $vulnerability_cwe['description'] ?? '' ) ? (string) ( $vulnerability_cwe['description'] ?? '' ) : ''; + $what[] = '
' . wp_kses( $cwe_name, 'strip' ) . '
' . esc_html( $cwe_desc ) . '
'; + } + + $source = wpvulnerability_render_source_pills( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. + $score = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score = $s; + $sev_raw = $v; + break; + } + } + + $vuln_name = is_scalar( $vulnerability['name'] ?? '' ) ? (string) ( $vulnerability['name'] ?? '' ) : ''; + $html .= '

' . wpvulnerability_component_icon_html( 'core' ) . ' WordPress ' . wp_kses( $vuln_name, 'strip' ) . '

'; + $score_badge = wpvulnerability_render_score_badge( $score, $sev_raw, $epss ); + $show_active = $kev || 'active' === $exploitation; + $show_poc = 'poc' === $exploitation; + $show_auto = 'yes' === $automatable; + if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { + $html .= '
'; + if ( $show_active ) { + $html .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); + if ( $kev && null !== $kev_date ) { + $html .= ' · ' . esc_html( $kev_date ); + } + $html .= ''; + } + if ( $show_poc ) { + $html .= '⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . ''; + } + if ( $show_auto ) { + $html .= '⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $html .= $score_badge; + } + $html .= '
'; + } + if ( null !== $description ) { + $html .= '
' . esc_html( $description ) . '
'; + } + + if ( count( $what ) ) { + $html .= '
' . implode( '', $what ) . '
'; + } + + if ( '' !== $source ) { + $html .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $html .= $source; + $html .= '
'; + } + } + } elseif ( in_array( $type, array( 'php', 'apache', 'nginx', 'mariadb', 'mysql', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ), true ) ) { + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; } + $vuln_impact_raw = $vulnerability['impact'] ?? null; + $vuln_impact = is_array( $vuln_impact_raw ) ? $vuln_impact_raw : array(); + $vuln_cvss2_raw = $vuln_impact['cvss2'] ?? null; + $vuln_cvss2 = is_array( $vuln_cvss2_raw ) ? $vuln_cvss2_raw : array(); + $vuln_cvss3_raw = $vuln_impact['cvss3'] ?? null; + $vuln_cvss3 = is_array( $vuln_cvss3_raw ) ? $vuln_cvss3_raw : array(); + $vuln_cvss4_raw = $vuln_impact['cvss4'] ?? null; + $vuln_cvss4 = is_array( $vuln_cvss4_raw ) ? $vuln_cvss4_raw : array(); + $vuln_cwe_raw = $vuln_impact['cwe'] ?? null; + $vuln_cwe = is_array( $vuln_cwe_raw ) ? $vuln_cwe_raw : array(); + $vuln_src_raw = $vulnerability['source'] ?? null; + $vuln_sources = is_array( $vuln_src_raw ) ? $vuln_src_raw : array(); + + // For software endpoints, kev is at impact.kev (not inside ssvc). + $kev = ( isset( $vuln_impact['kev'] ) && true === $vuln_impact['kev'] ); + $description = wpvulnerability_get_source_description( $vuln_sources ); + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; } + $cwe_name = is_scalar( $vulnerability_cwe['name'] ?? '' ) ? (string) ( $vulnerability_cwe['name'] ?? '' ) : ''; + $cwe_desc = is_scalar( $vulnerability_cwe['description'] ?? '' ) ? (string) ( $vulnerability_cwe['description'] ?? '' ) : ''; + $what[] = '
' . wp_kses( $cwe_name, 'strip' ) . '
' . esc_html( $cwe_desc ) . '
'; + } + + $source = wpvulnerability_render_source_pills( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2. + $score = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2 ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score = $s; + $sev_raw = $v; + break; + } + } + + $vuln_name = is_scalar( $vulnerability['name'] ?? '' ) ? (string) ( $vulnerability['name'] ?? '' ) : ''; + $html .= '

' . wp_kses( $vuln_name, 'strip' ) . '

'; + $score_badge = wpvulnerability_render_score_badge( $score, $sev_raw ); + if ( $kev || '' !== $score_badge ) { + $html .= '
'; + if ( $kev ) { + $html .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $html .= $score_badge; + } + $html .= '
'; + } + if ( null !== $description ) { + $html .= '
' . esc_html( $description ) . '
'; + } + if ( count( $what ) ) { + $html .= '
' . implode( '', $what ) . '
'; + } + + if ( '' !== $source ) { + $html .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $html .= $source; + $html .= '
'; + } + } + } + + return $html; +} + +/** + * Convert vulnerabilities into HTML format. + * + * @since 3.5.0 + * + * @param string $type Type of software (php, apache, nginx, mariadb, mysql, imagemagick, curl). + * @return string|false The HTML output if vulnerabilities were found, false otherwise. + */ +function wpvulnerability_html_software( $type ) { + $html = ''; + $found = false; + $software_name = null; + + // Map software types to their names. + $software_names = array( + 'php' => 'PHP', + 'apache' => 'Apache HTTP', + 'nginx' => 'Nginx', + 'mariadb' => 'MariaDB', + 'mysql' => 'MySQL', + 'imagemagick' => 'ImageMagick', + 'curl' => 'curl', + 'memcached' => 'memcached', + 'redis' => 'redis', + 'sqlite' => 'sqlite', + ); + + // Check if the type is valid and get the software name. + if ( isset( $software_names[ $type ] ) ) { + $software_name = $software_names[ $type ]; + } else { + return false; // Invalid type. + } + + $version = wpvulnerability_sanitize_and_validate_version( wpvulnerability_get_software_version( $type ) ); + $software_data = wpvulnerability_software_get_vulnerabilities( $type ); + $vulnerabilities = array(); + + if ( is_array( $software_data ) && isset( $software_data['vulnerabilities'] ) && is_array( $software_data['vulnerabilities'] ) ) { + $vulnerabilities = $software_data['vulnerabilities']; + } + + // Check if vulnerabilities were found. + if ( 0 < count( $vulnerabilities ) ) { + $found = true; + + // translators: %s: software name. + $html .= '

' . wpvulnerability_component_icon_html( $type ) . sprintf( esc_html__( '%s running', 'wpvulnerability' ), esc_html( $software_name ) ) . ': ' . wp_kses( (string) $version, 'strip' ) . '

'; + + // Show lifecycle status if available. + $lifecycle = isset( $software_data['lifecycle'] ) && is_array( $software_data['lifecycle'] ) ? $software_data['lifecycle'] : array(); + $lc_status = is_scalar( $lifecycle['status'] ?? '' ) ? (string) ( $lifecycle['status'] ?? '' ) : ''; + $lc_date_end = is_scalar( $lifecycle['date_end'] ?? '' ) ? (string) ( $lifecycle['date_end'] ?? '' ) : ''; + + if ( 'e' === $lc_status || 's' === $lc_status ) { + $html .= '
'; + if ( 'e' === $lc_status ) { + $html .= '● ' . esc_html__( 'End of Life', 'wpvulnerability' ) . ''; + if ( '' !== $lc_date_end ) { + $html .= ' — ' . esc_html__( 'End of life:', 'wpvulnerability' ) . ' ' . esc_html( $lc_date_end ) . ''; + } + } else { + $html .= '● ' . esc_html__( 'Supported', 'wpvulnerability' ) . ''; + if ( '' !== $lc_date_end ) { + $html .= ' — ' . esc_html__( 'End of life:', 'wpvulnerability' ) . ' ' . esc_html( $lc_date_end ); + } + } + $html .= '
'; + } + + $html .= wpvulnerability_html( $type, $vulnerabilities ); + } + + return $found ? $html : false; +} + +/** + * Convert plugin vulnerabilities into HTML format. + * + * @since 2.0.0 + * + * @return string|false The HTML output if plugin vulnerabilities were found, false otherwise. + */ +function wpvulnerability_html_plugins() { + $html = ''; + $found = false; + + $plugins = wpvulnerability_plugin_get_vulnerabilities(); + + foreach ( $plugins as $file_path => $plugin_data ) { + if ( ! is_array( $plugin_data ) ) { + continue; } + // Check if the plugin is marked as vulnerable. + if ( isset( $plugin_data['vulnerable'] ) && 1 === $plugin_data['vulnerable'] ) { + $found = true; + + // Generate HTML markup for the plugin vulnerability. + $plugin_name = is_scalar( $plugin_data['Name'] ?? '' ) ? (string) ( $plugin_data['Name'] ?? '' ) : ''; + $html .= '

' . wpvulnerability_component_icon_html( 'plugin' ) . esc_html__( 'Plugin', 'wpvulnerability' ) . ': ' . wp_kses( $plugin_name, 'strip' ) . '

'; + $plugin_vulns = isset( $plugin_data['vulnerabilities'] ) && is_array( $plugin_data['vulnerabilities'] ) ? $plugin_data['vulnerabilities'] : array(); + $html .= wpvulnerability_html( 'plugin', $plugin_vulns ); + } + } + + // Return the HTML if vulnerabilities were found. + return $found ? $html : false; +} + +/** + * Convert plugin vulnerabilities into list format. + * + * @since 2.2.0 + * + * @return string|false The HTML output if plugin vulnerabilities were found, false otherwise. + */ +function wpvulnerability_list_plugins() { + $html = '
    '; + $found = false; + + // Get vulnerabilities data for plugins. + $plugins = wpvulnerability_plugin_get_vulnerabilities(); + + // Iterate through each plugin's data. + foreach ( $plugins as $file_path => $plugin_data ) { + if ( ! is_array( $plugin_data ) ) { + continue; } + // Check if the plugin is marked as vulnerable. + if ( isset( $plugin_data['vulnerable'] ) && 1 === $plugin_data['vulnerable'] ) { + $found = true; + + // Generate HTML markup for the plugin vulnerability. + $plugin_name = is_scalar( $plugin_data['Name'] ?? '' ) ? (string) ( $plugin_data['Name'] ?? '' ) : ''; + $html .= '
  • ' . wp_kses( $plugin_name, 'strip' ) . '
  • '; + } + } + + $html .= '
'; + + // Return the HTML if vulnerabilities were found. + return $found ? $html : false; +} + +/** + * Convert theme vulnerabilities into HTML format. + * + * @since 2.0.0 + * + * @return string|false The HTML output if theme vulnerabilities were found, false otherwise. + */ +function wpvulnerability_html_themes() { + $html = ''; + $found = false; + + // Get vulnerabilities data for themes. + $themes = wpvulnerability_theme_get_vulnerabilities(); + + // Iterate through each theme's data. + foreach ( $themes as $theme_data ) { + if ( ! is_array( $theme_data ) ) { + continue; } + $td_wpv = isset( $theme_data['wpvulnerability'] ) && is_array( $theme_data['wpvulnerability'] ) ? $theme_data['wpvulnerability'] : array(); + // Check if the theme is marked as vulnerable. + if ( isset( $td_wpv['vulnerable'] ) && 1 === ( is_numeric( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) { + $found = true; + + // Generate HTML markup for the theme vulnerability. + $theme_name = is_scalar( $td_wpv['name'] ?? '' ) ? (string) ( $td_wpv['name'] ?? '' ) : ''; + $html .= '

' . wpvulnerability_component_icon_html( 'theme' ) . esc_html__( 'Theme', 'wpvulnerability' ) . ': ' . wp_kses( $theme_name, 'strip' ) . '

'; + $vuln_list = isset( $td_wpv['vulnerabilities'] ) && is_array( $td_wpv['vulnerabilities'] ) ? $td_wpv['vulnerabilities'] : array(); + $html .= wpvulnerability_html( 'theme', $vuln_list ); + } + } + + // Return the HTML if vulnerabilities were found. + return $found ? $html : false; +} + +/** + * Convert theme vulnerabilities into list format. + * + * @since 2.2.0 + * + * @return string|false The HTML output if theme vulnerabilities were found, false otherwise. + */ +function wpvulnerability_list_themes() { + $html = '
    '; + $found = false; + + // Get vulnerabilities data for themes. + $themes = wpvulnerability_theme_get_vulnerabilities(); + + // Iterate through each theme's data. + foreach ( $themes as $theme_data ) { + if ( ! is_array( $theme_data ) ) { + continue; } + $td_wpv = isset( $theme_data['wpvulnerability'] ) && is_array( $theme_data['wpvulnerability'] ) ? $theme_data['wpvulnerability'] : array(); + // Check if the theme is marked as vulnerable. + if ( isset( $td_wpv['vulnerable'] ) && 1 === ( is_numeric( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) { + $found = true; + + // Generate HTML markup for the theme vulnerability. + $theme_name = is_scalar( $td_wpv['name'] ?? '' ) ? (string) ( $td_wpv['name'] ?? '' ) : ''; + $html .= '
  • ' . wp_kses( $theme_name, 'strip' ) . '
  • '; + } + } + + $html .= '
'; + + // Return the HTML if vulnerabilities were found. + return $found ? $html : false; +} + +/** + * Returns an EOL badge HTML span for a software component. + * + * Reads the cached lifecycle data for the given software type and returns + * a styled badge when the component has reached end-of-life status. + * + * @since 5.0.0 + * + * @param string $type The software type (e.g., 'php', 'apache', 'mariadb'). + * + * @return string HTML badge string, or empty string if not EOL or no data. + */ +function wpvulnerability_eol_badge_html( $type ) { + $sw_data = wpvulnerability_software_get_vulnerabilities( $type ); + $lc = isset( $sw_data['lifecycle'] ) && is_array( $sw_data['lifecycle'] ) ? $sw_data['lifecycle'] : array(); + $status = is_scalar( $lc['status'] ?? '' ) ? (string) ( $lc['status'] ?? '' ) : ''; + $date_end = is_scalar( $lc['date_end'] ?? '' ) ? (string) ( $lc['date_end'] ?? '' ) : ''; + + if ( 'e' !== $status ) { + return ''; + } + + if ( '' !== $date_end ) { + /* translators: %s: end-of-life date */ + $title = sprintf( __( 'End of life: %s', 'wpvulnerability' ), $date_end ); + } else { + $title = __( 'End of Life', 'wpvulnerability' ); + } + + return '' . esc_html__( 'EOL', 'wpvulnerability' ) . ''; +} + +/** + * Convert core vulnerabilities into HTML format. + * + * @since 2.0.0 + * + * @return string|false The HTML output if core vulnerabilities were found, false otherwise. + */ +function wpvulnerability_html_core() { + $html = ''; + $found = false; + + // Get vulnerabilities data for WordPress core. + $core = wpvulnerability_core_get_vulnerabilities(); + + // Check if there are any vulnerabilities. + if ( count( $core ) ) { + $found = true; + + // Generate HTML markup for the core vulnerabilities. + $html .= wpvulnerability_html( 'core', $core ); + } + + // Return the HTML if vulnerabilities were found. + return $found ? $html : false; +} diff --git a/wpvulnerability-run.php b/wpvulnerability-run.php new file mode 100644 index 0000000..d0a061d --- /dev/null +++ b/wpvulnerability-run.php @@ -0,0 +1,859 @@ + $links The links that appear in the plugin row. + * + * @return array The modified array of links. + */ +function wpvulnerability_add_settings_link( $links ) { + // Check if the user has the required capabilities to view the settings link. + if ( wpvulnerability_capabilities() ) { + // Determine the correct settings link based on the environment. + if ( is_multisite() && is_network_admin() ) { + // Network admin settings link for multisite. + $links[] = '' . __( 'Network Settings', 'wpvulnerability' ) . ''; + } elseif ( ! is_multisite() && is_admin() ) { + // Standard settings link for single site. + $links[] = '' . __( 'Settings', 'wpvulnerability' ) . ''; + } + } + return $links; +} + +// Hook the function to the appropriate filters. +if ( is_multisite() ) { + add_filter( 'network_admin_plugin_action_links_' . WPVULNERABILITY_PLUGIN_BASE, 'wpvulnerability_add_settings_link' ); +} else { + add_filter( 'plugin_action_links_' . WPVULNERABILITY_PLUGIN_BASE, 'wpvulnerability_add_settings_link' ); +} + +/** + * Updates the plugin's vulnerability data. + * + * This function updates the vulnerability data for WordPress core, plugins, themes, PHP, Apache, nginx, MariaDB, and MySQL. + * It ensures that the required functions are available by including the necessary files. + * After updating the vulnerabilities, it flushes the WordPress cache. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_update_database_data() { + + // Ensure necessary files are included for core, plugins, and themes. + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-core.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-plugins.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-themes.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-software.php'; + + wpvulnerability_delete_transients(); + + // Update core, plugins, and themes vulnerabilities. + wpvulnerability_core_get_vulnerabilities_clean(); + wpvulnerability_plugin_get_vulnerabilities_clean(); + wpvulnerability_theme_get_vulnerabilities_clean(); + + // Array of software types to update. + $software_types = array( 'php', 'apache', 'nginx', 'mariadb', 'mysql', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ); + + // Update vulnerabilities for each software type. + foreach ( $software_types as $software ) { + wpvulnerability_get_vulnerabilities_clean( $software ); + } + + wpvulnerability_statistics_get(); + + // Clean the WordPress cache. + wp_cache_flush(); +} + +/** + * Updates the plugin's vulnerability data if the cache has expired. + * + * This function checks if the cached vulnerability data for various components (core, plugins, themes, PHP, Apache, nginx, MariaDB, MySQL) has expired and updates it accordingly. + * It ensures that the required functions are available by including the necessary files. + * The function handles both multisite and single site installations. + * + * @since 3.0.0 + * + * @return void + */ +function wpvulnerability_expired_database_data() { + + // Ensure necessary files are included for core, plugins, and themes. + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-core.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-plugins.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-themes.php'; + require_once WPVULNERABILITY_PLUGIN_PATH . '/wpvulnerability-software.php'; + + // Current time for cache expiration comparison. + $cache_time = time(); + + // Check and update core, plugins, and themes vulnerabilities if cache has expired. + $components = array( + 'core' => 'wpvulnerability-core-cache', + 'plugin' => 'wpvulnerability-plugins-cache', + 'theme' => 'wpvulnerability-themes-cache', + ); + + foreach ( $components as $component => $cache_key ) { + $cache_value = is_multisite() ? get_site_option( $cache_key ) : get_option( $cache_key ); + + if ( json_decode( is_string( $cache_value ) ? $cache_value : '' ) < $cache_time ) { + call_user_func( "wpvulnerability_{$component}_get_vulnerabilities_clean" ); + } + } + + // Array of software types to update. + $software_types = array( 'php', 'apache', 'nginx', 'mariadb', 'mysql', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ); + + // Ensure necessary files are included and update vulnerabilities for each software type. + foreach ( $software_types as $software ) { + if ( is_multisite() ) { + $site_opt = get_site_option( 'wpvulnerability-' . $software . '-cache' ); + if ( json_decode( is_string( $site_opt ) ? $site_opt : '' ) < $cache_time ) { + wpvulnerability_get_vulnerabilities_clean( $software ); + } + } else { + $opt = get_option( 'wpvulnerability-' . $software . '-cache' ); + if ( json_decode( is_string( $opt ) ? $opt : '' ) < $cache_time ) { + wpvulnerability_get_vulnerabilities_clean( $software ); + } + } + } + + $statistics_cache_raw = is_multisite() ? get_site_option( 'wpvulnerability-statistics-cache' ) : get_option( 'wpvulnerability-statistics-cache' ); + + if ( is_numeric( $statistics_cache_raw ) ) { + $statistics_cache = (int) $statistics_cache_raw; + } elseif ( is_string( $statistics_cache_raw ) ) { + $decoded_cache = json_decode( $statistics_cache_raw ); + $statistics_cache = is_numeric( $decoded_cache ) ? (int) $decoded_cache : 0; + } else { + $statistics_cache = 0; + } + + if ( $statistics_cache < $cache_time ) { + wpvulnerability_statistics_get(); + } + + unset( $cache_time, $statistics_cache, $statistics_cache_raw ); +} + +/** + * Initializes persistent plugin data, ensures required options exist, and tracks + * initialization metadata so upgrades provision new defaults when needed. + * + * @since 4.1.2 + * + * @param bool $is_real_activation Optional. Whether the routine runs during the activation hook. Default false. + * + * @return void + */ +function wpvulnerability_initialize_plugin_data( $is_real_activation = false ) { + $is_multisite = is_multisite(); + $config_key = $is_multisite ? 'get_site_option' : 'get_option'; + $add_option = $is_multisite ? 'add_site_option' : 'add_option'; + $update_option = $is_multisite ? 'update_site_option' : 'update_option'; + + $initialized_raw = $config_key( 'wpvulnerability_initialized' ); + $initialized = array( + 'timestamp' => 0, + 'version' => '', + ); + + if ( is_array( $initialized_raw ) ) { + if ( isset( $initialized_raw['timestamp'] ) && is_numeric( $initialized_raw['timestamp'] ) ) { + $initialized['timestamp'] = (int) $initialized_raw['timestamp']; + } + + if ( isset( $initialized_raw['version'] ) && is_string( $initialized_raw['version'] ) ) { + $initialized['version'] = $initialized_raw['version']; + } + } elseif ( is_numeric( $initialized_raw ) ) { + $initialized['timestamp'] = (int) $initialized_raw; + } + + $needs_upgrade = version_compare( (string) $initialized['version'], WPVULNERABILITY_PLUGIN_VERSION, '<' ); + + if ( ! $is_real_activation && ! empty( $initialized_raw ) && ! $needs_upgrade ) { + return; + } + + if ( $is_real_activation ) { + wpvulnerability_delete_transients(); + } + + // Add wpvulnerability-config option if it does not exist. + if ( ! $config_key( 'wpvulnerability-config' ) ) { + $default_config = array( + 'emails' => get_bloginfo( 'admin_email' ), + 'period' => 'weekly', + 'day' => 'monday', + 'hour' => 0, + 'minute' => 0, + 'cache' => 12, + 'log_retention' => 0, + 'delete_on_uninstall' => 0, + 'notify' => array( + 'email' => 'y', + 'slack' => 'n', + 'teams' => 'n', + ), + 'slack_webhook' => '', + 'teams_webhook' => '', + ); + $add_option( 'wpvulnerability-config', $default_config ); + } + + // Add other options if they do not exist. + $options = array( + 'wpvulnerability-plugins' => '', + 'wpvulnerability-plugins-cache' => 0, + 'wpvulnerability-plugins-vulnerable' => 0, + 'wpvulnerability-plugins-data' => '', + 'wpvulnerability-plugins-data-cache' => 0, + 'wpvulnerability-themes' => '', + 'wpvulnerability-themes-cache' => 0, + 'wpvulnerability-themes-vulnerable' => 0, + 'wpvulnerability-core' => '', + 'wpvulnerability-core-cache' => 0, + 'wpvulnerability-core-vulnerable' => 0, + 'wpvulnerability-php' => '', + 'wpvulnerability-php-cache' => 0, + 'wpvulnerability-php-vulnerable' => 0, + 'wpvulnerability-apache' => '', + 'wpvulnerability-apache-cache' => 0, + 'wpvulnerability-apache-vulnerable' => 0, + 'wpvulnerability-nginx' => '', + 'wpvulnerability-nginx-cache' => 0, + 'wpvulnerability-nginx-vulnerable' => 0, + 'wpvulnerability-mariadb' => '', + 'wpvulnerability-mariadb-cache' => 0, + 'wpvulnerability-mariadb-vulnerable' => 0, + 'wpvulnerability-mysql' => '', + 'wpvulnerability-mysql-cache' => 0, + 'wpvulnerability-mysql-vulnerable' => 0, + 'wpvulnerability-imagemagick' => '', + 'wpvulnerability-imagemagick-cache' => 0, + 'wpvulnerability-imagemagick-vulnerable' => 0, + 'wpvulnerability-curl' => '', + 'wpvulnerability-curl-cache' => 0, + 'wpvulnerability-curl-vulnerable' => 0, + 'wpvulnerability-memcached' => '', + 'wpvulnerability-memcached-cache' => 0, + 'wpvulnerability-memcached-vulnerable' => 0, + 'wpvulnerability-redis' => '', + 'wpvulnerability-redis-cache' => 0, + 'wpvulnerability-redis-vulnerable' => 0, + 'wpvulnerability-sqlite' => '', + 'wpvulnerability-sqlite-cache' => 0, + 'wpvulnerability-sqlite-vulnerable' => 0, + 'wpvulnerability-statistics' => '', + 'wpvulnerability-statistics-cache' => 0, + 'wpvulnerability_initialized' => 0, + ); + + // Large data blobs should not autoload on every WP request (single-site only; + // add_site_option does not support autoload control). + $no_autoload_keys = array( + 'wpvulnerability-plugins', + 'wpvulnerability-plugins-data', + 'wpvulnerability-themes', + 'wpvulnerability-core', + 'wpvulnerability-php', + 'wpvulnerability-apache', + 'wpvulnerability-nginx', + 'wpvulnerability-mariadb', + 'wpvulnerability-mysql', + 'wpvulnerability-imagemagick', + 'wpvulnerability-curl', + 'wpvulnerability-memcached', + 'wpvulnerability-redis', + 'wpvulnerability-sqlite', + 'wpvulnerability-statistics', + ); + + foreach ( $options as $key => $value ) { + if ( ! $config_key( $key ) ) { + if ( ! $is_multisite && in_array( $key, $no_autoload_keys, true ) ) { + add_option( $key, $value, '', false ); + } else { + $add_option( $key, $value ); + } + } + } + + // Add wpvulnerability-analyze option if it does not exist. + if ( ! $config_key( 'wpvulnerability-analyze' ) ) { + $default_analyze = array( + 'core' => 0, + 'plugins' => 0, + 'themes' => 0, + 'php' => 0, + 'apache' => 0, + 'nginx' => 0, + 'mariadb' => 0, + 'mysql' => 0, + 'imagemagick' => 0, + 'curl' => 0, + 'memcached' => 0, + 'redis' => 0, + 'sqlite' => 0, + ); + + foreach ( array_keys( $default_analyze ) as $component ) { + $constant = 'WPVULNERABILITY_HIDE_' . strtoupper( (string) $component ); + if ( defined( $constant ) && constant( $constant ) ) { + $default_analyze[ $component ] = 1; + } + } + $current_option_raw = $config_key( 'wpvulnerability-analyze' ); + $current_option = is_array( $current_option_raw ) ? $current_option_raw : false; + + if ( false === $current_option ) { + $add_option( 'wpvulnerability-analyze', $default_analyze ); + } else { + $updated_option = array_merge( $default_analyze, $current_option ); + $update_option( 'wpvulnerability-analyze', $updated_option ); + } + } + + $update_option( + 'wpvulnerability_initialized', + array( + 'timestamp' => (int) time(), + 'version' => WPVULNERABILITY_PLUGIN_VERSION, + ) + ); +} + +/** + * Callback function for when the plugin is activated. + * Adds plugin data options if they are not already created. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_activation() { + wpvulnerability_initialize_plugin_data( true ); +} + +/** + * Callback function to run when the plugin is deactivated. + * Deletes options and removes scheduled wp-cron jobs. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_deactivation() { + $options = array( + 'wpvulnerability_settings', + 'wpvulnerability-data', + 'wpvulnerability-analyze', + 'wpvulnerability-themes', + 'wpvulnerability-themes-cache', + 'wpvulnerability-themes-vulnerable', + 'wpvulnerability-plugins', + 'wpvulnerability-plugins-cache', + 'wpvulnerability-plugins-vulnerable', + 'wpvulnerability-core', + 'wpvulnerability-core-cache', + 'wpvulnerability-core-vulnerable', + 'wpvulnerability-php', + 'wpvulnerability-php-cache', + 'wpvulnerability-php-vulnerable', + 'wpvulnerability-apache', + 'wpvulnerability-apache-cache', + 'wpvulnerability-apache-vulnerable', + 'wpvulnerability-nginx', + 'wpvulnerability-nginx-cache', + 'wpvulnerability-nginx-vulnerable', + 'wpvulnerability-mariadb', + 'wpvulnerability-mariadb-cache', + 'wpvulnerability-mariadb-vulnerable', + 'wpvulnerability-mysql', + 'wpvulnerability-mysql-cache', + 'wpvulnerability-mysql-vulnerable', + 'wpvulnerability-imagemagick', + 'wpvulnerability-imagemagick-cache', + 'wpvulnerability-imagemagick-vulnerable', + 'wpvulnerability-curl', + 'wpvulnerability-curl-cache', + 'wpvulnerability-curl-vulnerable', + 'wpvulnerability-memcached', + 'wpvulnerability-memcached-cache', + 'wpvulnerability-memcached-vulnerable', + 'wpvulnerability-redis', + 'wpvulnerability-redis-cache', + 'wpvulnerability-redis-vulnerable', + 'wpvulnerability-sqlite', + 'wpvulnerability-sqlite-cache', + 'wpvulnerability-sqlite-vulnerable', + 'wpvulnerability-statistics', + 'wpvulnerability-statistics-cache', + 'wpvulnerability_initialized', + ); + + // Delete options based on the installation type. + $delete_option_func = is_multisite() ? 'delete_site_option' : 'delete_option'; + foreach ( $options as $option ) { + $delete_option_func( $option ); + } + + wpvulnerability_delete_transients(); + + // Unschedule and remove scheduled wp-cron jobs. + $cron_jobs = array( + 'wpvulnerability_notification', + 'wpvulnerability_update_database', + 'wpvulnerability_pull_db_data_event', + 'wpvulnerability_cleanup_logs', + ); + foreach ( $cron_jobs as $job ) { + $next_ts = wp_next_scheduled( $job ); + if ( false !== $next_ts ) { + wp_unschedule_event( $next_ts, $job ); + } + wp_clear_scheduled_hook( $job ); + } +} + +/** + * Deletes all transients that start with 'wpvulnerability_'. + * + * @since 3.5.0 + * + * @return void + */ +function wpvulnerability_delete_transients() { + global $wpdb; + + // Determine if the site is multisite. + $is_multisite = is_multisite(); + + // Define the prefix according to whether it is multisite or not. + $transient_prefix = $is_multisite ? '_site_transient_wpvulnerability_' : '_transient_wpvulnerability_'; + + // Prepare the LIKE pattern securely. + $like_pattern = $wpdb->esc_like( $transient_prefix ) . '%'; + + // Try to get transients from cache first. + $cache_key = 'wpvulnerability_transients_list'; + $transients = wp_cache_get( $cache_key ); + + if ( false === $transients ) { + // If cache is empty, query the database for matching transients. + if ( $is_multisite ) { + $transients = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->prepare( + "SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s AND site_id = %d", + $like_pattern, + get_current_network_id() + ) + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching + } else { + $transients = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", + $like_pattern + ) + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching + } + + // Store the result in cache for future use. + wp_cache_set( $cache_key, $transients, '', HOUR_IN_SECONDS ); + } + + // If no transients are found, exit early. + if ( empty( $transients ) ) { + return; + } + + foreach ( $transients as $transient ) { + if ( $is_multisite ) { + // For multisite, delete using delete_site_transient. + $transient_name = str_replace( '_site_transient_', '', $transient ); + delete_site_transient( $transient_name ); + } else { + // For single sites, delete using delete_transient. + $transient_name = str_replace( '_transient_', '', $transient ); + delete_transient( $transient_name ); + } + } + + // Optionally clear the cache after deletion. + wp_cache_delete( $cache_key ); +} + +/** + * Reset the plugin data to defaults and repopulate from the API. + * + * This routine mimics a full uninstall by deleting options, logs, and cron jobs, + * then provisions default settings, reschedules events, and reloads data. + * + * @since 4.3.0 + * + * @return void + */ +function wpvulnerability_reset_plugin_data() { + wpvulnerability_uninstall(); + + wpvulnerability_initialize_plugin_data( true ); + + if ( function_exists( 'wpvulnerability_schedule_core_events' ) ) { + wpvulnerability_schedule_core_events(); + } + + $config_raw = is_multisite() ? get_site_option( 'wpvulnerability-config', array() ) : get_option( 'wpvulnerability-config', array() ); + $config = is_array( $config_raw ) ? $config_raw : array(); + + if ( function_exists( 'wpvulnerability_schedule_notification_event' ) ) { + wpvulnerability_schedule_notification_event( $config ); + } + + wpvulnerability_update_database_data(); + wp_cache_flush(); +} + +/** + * Callback function to run when the plugin is uninstalled. + * Deletes options and removes scheduled wp-cron jobs. + * + * @since 3.0.0 + * + * @return void + */ +function wpvulnerability_uninstall() { + // Delete deprecated options. + delete_option( 'wpvulnerability_settings' ); + delete_option( 'wpvulnerability-data' ); + if ( function_exists( 'delete_site_option' ) ) { + delete_site_option( 'wpvulnerability_settings' ); + delete_site_option( 'wpvulnerability-data' ); + } + + // Options to delete for both single site and multisite. + $options = array( + 'wpvulnerability-themes', + 'wpvulnerability-themes-cache', + 'wpvulnerability-themes-vulnerable', + 'wpvulnerability-themes-signature', + 'wpvulnerability-plugins', + 'wpvulnerability-plugins-cache', + 'wpvulnerability-plugins-vulnerable', + 'wpvulnerability-plugins-signature', + 'wpvulnerability-plugins-data', + 'wpvulnerability-plugins-cache-data', + 'wpvulnerability-plugins-data-cache', + 'wpvulnerability-core', + 'wpvulnerability-core-cache', + 'wpvulnerability-core-vulnerable', + 'wpvulnerability-core-version', + 'wpvulnerability-php', + 'wpvulnerability-php-cache', + 'wpvulnerability-php-vulnerable', + 'wpvulnerability-apache', + 'wpvulnerability-apache-cache', + 'wpvulnerability-apache-vulnerable', + 'wpvulnerability-nginx', + 'wpvulnerability-nginx-cache', + 'wpvulnerability-nginx-vulnerable', + 'wpvulnerability-mariadb', + 'wpvulnerability-mariadb-cache', + 'wpvulnerability-mariadb-vulnerable', + 'wpvulnerability-mysql', + 'wpvulnerability-mysql-cache', + 'wpvulnerability-mysql-vulnerable', + 'wpvulnerability-imagemagick', + 'wpvulnerability-imagemagick-cache', + 'wpvulnerability-imagemagick-vulnerable', + 'wpvulnerability-curl', + 'wpvulnerability-curl-cache', + 'wpvulnerability-curl-vulnerable', + 'wpvulnerability-memcached', + 'wpvulnerability-memcached-cache', + 'wpvulnerability-memcached-vulnerable', + 'wpvulnerability-redis', + 'wpvulnerability-redis-cache', + 'wpvulnerability-redis-vulnerable', + 'wpvulnerability-sqlite', + 'wpvulnerability-sqlite-cache', + 'wpvulnerability-sqlite-vulnerable', + 'wpvulnerability-statistics', + 'wpvulnerability-statistics-cache', + 'wpvulnerability_initialized', + 'wpvulnerability-analyze', + ); + + foreach ( $options as $option ) { + delete_option( $option ); + if ( function_exists( 'delete_site_option' ) ) { + delete_site_option( $option ); + } + } + + // Delete all stored log entries in batches to avoid timeouts. + $log_query_args = array( + 'post_type' => 'wpvulnerability_log', + 'fields' => 'ids', + 'post_status' => 'any', + 'posts_per_page' => 100, + 'orderby' => 'ID', + 'order' => 'ASC', + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'suppress_filters' => false, + ); + + while ( true ) { + $log_ids = get_posts( $log_query_args ); + + if ( empty( $log_ids ) ) { + break; + } + + foreach ( $log_ids as $log_id ) { + wp_delete_post( (int) $log_id, true ); + } + } + + // Delete config data. + delete_option( 'wpvulnerability-config' ); + if ( function_exists( 'delete_site_option' ) ) { + delete_site_option( 'wpvulnerability-config' ); + } + + wpvulnerability_delete_transients(); + + // Unschedule and remove scheduled wp-cron jobs. + $cron_jobs = array( + 'wpvulnerability_notification', + 'wpvulnerability_update_database', + 'wpvulnerability_pull_db_data_event', + 'wpvulnerability_cleanup_logs', + ); + foreach ( $cron_jobs as $job ) { + $next_ts = wp_next_scheduled( $job ); + if ( false !== $next_ts ) { + wp_unschedule_event( $next_ts, $job ); + } + wp_clear_scheduled_hook( $job ); + } +} + +/** + * Filters and returns the WPVulnerability analysis setting for a given type. + * + * This function retrieves the WPVulnerability analysis settings, either from + * the single site or the multisite network, depending on the WordPress setup. + * It returns false if the specified type ('core', 'plugins', 'themes', + * 'php', 'apache', 'nginx', 'mariadb', 'mysql') is set. If the type is not set or is invalid, it returns true. + * + * @since 3.3.0 + * + * @param string $type The type of analysis setting to retrieve ('core', 'plugins', 'themes', 'php', 'apache', 'nginx', 'mariadb', 'mysql'). + * + * @return bool False if the specified type is set, true if not set or invalid. + */ +function wpvulnerability_analyze_filter( $type ) { + // Retrieve the analysis settings based on the WordPress setup. + $raw_analyze = is_multisite() ? get_site_option( 'wpvulnerability-analyze', array() ) : get_option( 'wpvulnerability-analyze', array() ); + $wpvulnerability_analyze = is_array( $raw_analyze ) ? $raw_analyze : array(); + + // Define the valid types for analysis. + $valid_types = array( 'core', 'plugins', 'themes', 'php', 'apache', 'nginx', 'mariadb', 'mysql', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ); + + if ( in_array( $type, $valid_types, true ) ) { + $constant = 'WPVULNERABILITY_HIDE_' . strtoupper( (string) $type ); + if ( defined( $constant ) && constant( $constant ) ) { + return false; + } + + $type_val = $wpvulnerability_analyze[ $type ] ?? 0; + return ! ( is_numeric( $type_val ) && (int) $type_val ); + } + + return true; // Return true for invalid types. +} + +/** + * Clean the cache after an update. + * + * This function is triggered after a plugin or theme update to clean the cache + * and refresh the vulnerability data. + * + * @since 2.0.0 + * + * @return void + */ +add_action( 'upgrader_process_complete', 'wpvulnerability_update_database_data', 10, 0 ); + +/** + * Adds a notification count to the Plugins menu item in the WordPress admin if there are vulnerable plugins. + * + * This function retrieves the number of vulnerable plugins from the cache, either from a single site + * or a multisite setup, and displays the count in the WordPress admin menu next to the Plugins menu item. + * The count is shown with a gold background (#FFD700) and black text. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_counter_plugins() { + + if ( ! wpvulnerability_analyze_filter( 'plugins' ) ) { + return; // Skip if plugin analysis is disabled. + } + + // Retrieve the number of vulnerable plugins from cache. + $wpvulnerability_plugins_count = is_multisite() && is_network_admin() + ? get_site_option( 'wpvulnerability-plugins-vulnerable' ) + : get_option( 'wpvulnerability-plugins-vulnerable' ); + + // Decode the count from JSON, default to 0 if not set. + $wpvulnerability_plugins_decoded = is_string( $wpvulnerability_plugins_count ) ? json_decode( $wpvulnerability_plugins_count ) : 0; + $wpvulnerability_plugins_total = is_numeric( $wpvulnerability_plugins_decoded ) ? (int) $wpvulnerability_plugins_decoded : 0; + + if ( $wpvulnerability_plugins_total > 0 ) { + global $menu; + foreach ( $menu as $key => $value ) { + if ( 'plugins.php' === $menu[ $key ][2] ) { + $menu[ $key ][0] .= ' ' . esc_html( (string) $wpvulnerability_plugins_total ) . ''; // phpcs:ignore + break; + } + } + } +} + +// Hook into the appropriate admin menu action based on the site type. +if ( is_multisite() && is_network_admin() ) { + add_action( 'network_admin_menu', 'wpvulnerability_counter_plugins' ); +} elseif ( ! is_multisite() ) { + add_action( 'admin_menu', 'wpvulnerability_counter_plugins' ); +} + +/** + * Adds a notification count to the Themes menu item in the WordPress admin if there are vulnerable themes. + * + * This function retrieves the number of vulnerable themes from the cache, either from a single site + * or a multisite setup, and displays the count in the WordPress admin menu next to the Themes menu item. + * The count is displayed with a gold background (#FFD700) and black text. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_counter_themes() { + + if ( ! wpvulnerability_analyze_filter( 'themes' ) ) { + return; // Skip if theme analysis is disabled. + } + + // Retrieve the number of theme vulnerabilities from cache. + $wpvulnerability_themes_count = ( is_multisite() && is_network_admin() ) + ? get_site_option( 'wpvulnerability-themes-vulnerable' ) + : get_option( 'wpvulnerability-themes-vulnerable' ); + + // Decode the count from JSON, default to 0 if not set. + $wpvulnerability_themes_decoded = is_string( $wpvulnerability_themes_count ) ? json_decode( $wpvulnerability_themes_count ) : 0; + $wpvulnerability_themes_total = is_numeric( $wpvulnerability_themes_decoded ) ? (int) $wpvulnerability_themes_decoded : 0; + + if ( $wpvulnerability_themes_total > 0 ) { + + // Check if we are in a multisite setup or not. + if ( ! is_multisite() ) { + global $submenu; + + // Check if the submenu for themes exists. + if ( isset( $submenu['themes.php'] ) ) { + foreach ( $submenu['themes.php'] as $key => $value ) { + if ( 'themes.php' === $submenu['themes.php'][ $key ][2] ) { + $submenu['themes.php'][ $key ][0] .= ' ' . esc_html( (string) $wpvulnerability_themes_total ) . ''; // phpcs:ignore + break; + } + } + } + } elseif ( is_network_admin() ) { + global $menu; + + foreach ( $menu as $key => $value ) { + if ( 'themes.php' === $menu[ $key ][2] ) { + $menu[ $key ][0] .= ' ' . esc_html( (string) $wpvulnerability_themes_total ) . ''; // phpcs:ignore + break; + } + } + } + } +} + +// Hook into the appropriate admin menu action based on the site type. +if ( is_multisite() && is_network_admin() ) { + add_action( 'network_admin_menu', 'wpvulnerability_counter_themes' ); +} elseif ( ! is_multisite() ) { + add_action( 'admin_menu', 'wpvulnerability_counter_themes' ); +} + +/** + * Adds a notification count to the Updates submenu item under Dashboard in the WordPress admin if there are core updates. + * + * This function checks for core updates and then displays the count in the WordPress admin submenu + * next to the Updates menu item with a gold background (#FFD700) and black text. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_counter_core() { + + if ( ! wpvulnerability_analyze_filter( 'core' ) ) { + return; // Skip if core analysis is disabled. + } + + // Retrieve the number of core vulnerabilities from cache. + $wpvulnerability_core_count = is_multisite() && is_network_admin() + ? get_site_option( 'wpvulnerability-core-vulnerable' ) + : get_option( 'wpvulnerability-core-vulnerable' ); + + // Decode the count from JSON, default to 0 if not set. + $wpvulnerability_core_decoded = is_string( $wpvulnerability_core_count ) ? json_decode( $wpvulnerability_core_count ) : 0; + $wpvulnerability_core_total = is_numeric( $wpvulnerability_core_decoded ) ? (int) $wpvulnerability_core_decoded : 0; + + if ( $wpvulnerability_core_total > 0 ) { + global $submenu; + if ( isset( $submenu['index.php'] ) ) { + foreach ( $submenu['index.php'] as $key => $value ) { + if ( 'update-core.php' === $submenu['index.php'][ $key ][2] ) { + $submenu['index.php'][ $key ][0] .= ' ' . esc_html( (string) $wpvulnerability_core_total ) . ''; // phpcs:ignore + break; + } + } + } + } +} + +// Hook into the appropriate admin menu action based on the site type. +if ( is_multisite() && is_network_admin() ) { + add_action( 'network_admin_menu', 'wpvulnerability_counter_core' ); +} elseif ( ! is_multisite() ) { + add_action( 'admin_menu', 'wpvulnerability_counter_core' ); +} diff --git a/wpvulnerability-schedule.php b/wpvulnerability-schedule.php new file mode 100644 index 0000000..5916b2d --- /dev/null +++ b/wpvulnerability-schedule.php @@ -0,0 +1,544 @@ +> $schedules Existing schedules. + * + * @return array> Modified schedules. + */ +function wpvulnerability_add_every_six_hours( $schedules ) { + $label = did_action( 'init' ) ? __( 'Every 6 hours', 'wpvulnerability' ) : 'Every 6 hours'; + + $schedules['wpvulnerability_six_hours'] = array( + 'interval' => 6 * HOUR_IN_SECONDS, + 'display' => $label, + ); + + return $schedules; +} + +// Remove legacy scheduled events on subsites in multisite installs. +if ( is_multisite() && ! is_main_site() ) { + wpvulnerability_clear_plugin_cron_hooks(); +} + +/** + * Schedule Automatic Vulnerability Database Update. + * If the 'wpvulnerability_update_database' event is not already scheduled, schedule it to run twice daily. + * + * @since 2.0.0 + * + * @return void + */ +wpvulnerability_schedule_core_events(); + +// Hook the event to the function that updates the database. +add_action( 'wpvulnerability_update_database', 'wpvulnerability_update_database_data' ); + +/** + * Calculate the next notification timestamp based on plugin settings. + * + * @since 4.1.1 + * + * @param array $config Plugin configuration. + * @return int Timestamp for next notification. + */ +function wpvulnerability_get_next_notification_timestamp( $config ) { + $hour_raw = $config['hour'] ?? 0; + $hour = is_numeric( $hour_raw ) ? max( 0, min( 23, (int) $hour_raw ) ) : 0; + $min_raw = $config['minute'] ?? 0; + $minute = is_numeric( $min_raw ) ? max( 0, min( 59, (int) $min_raw ) ) : 0; + + $timezone = wp_timezone(); + + $current_time = new DateTime( 'now', $timezone ); + $scheduled_time = new DateTime( 'now', $timezone ); + $scheduled_time->setTime( $hour, $minute, 0 ); + + if ( isset( $config['period'] ) && 'weekly' === $config['period'] ) { + $day_raw = $config['day'] ?? 'monday'; + $day = is_scalar( $day_raw ) ? strtolower( (string) $day_raw ) : 'monday'; + $weekdays = array( 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday' ); + $day_index = array_search( $day, $weekdays, true ); + if ( false === $day_index ) { + $day_index = 1; // Monday. + } + while ( (int) $scheduled_time->format( 'w' ) !== $day_index || $scheduled_time->getTimestamp() <= $current_time->getTimestamp() ) { + $scheduled_time->modify( '+1 day' ); + } + } elseif ( $scheduled_time->getTimestamp() <= $current_time->getTimestamp() ) { + $scheduled_time->modify( '+1 day' ); + } + + return (int) $scheduled_time->getTimestamp(); +} + +/** + * Schedule vulnerability notifications. + * + * When $force is false, the function first checks the current cron schedule + * for the notification hook and returns early if it already matches the + * desired period from $config. This avoids rewriting the wp_options 'cron' + * row on every page load when the schedule is already correct. + * + * Callers that change notification settings (period, hour, minute, day) must + * keep $force as true so the event is rescheduled with the new timestamp. + * + * @since 4.1.1 + * + * @param array $config Plugin configuration. + * @param bool $force Whether to reschedule unconditionally. Defaults to true. + * + * @return void + */ +function wpvulnerability_schedule_notification_event( $config, $force = true ) { + if ( ! $force ) { + $desired = ''; + if ( isset( $config['period'] ) + && in_array( $config['period'], array( 'daily', 'weekly' ), true ) + && ( ! is_multisite() || is_main_site() ) ) { + $desired = (string) $config['period']; // Narrowed to 'daily'|'weekly' by in_array check above. + } + + $current = wp_get_schedule( 'wpvulnerability_notification' ); + if ( false === $current ) { + $current = ''; + } + + if ( $current === $desired ) { + return; + } + } + + wp_clear_scheduled_hook( 'wpvulnerability_notification' ); + if ( ! isset( $config['period'] ) || 'never' === $config['period'] ) { + return; + } + + if ( ! is_multisite() || is_main_site() ) { + $period_val = $config['period']; + $period_str = is_scalar( $period_val ) ? (string) $period_val : ''; + $timestamp = wpvulnerability_get_next_notification_timestamp( $config ); + wp_schedule_event( $timestamp, $period_str, 'wpvulnerability_notification' ); + } +} + +$wpvulnerability_s_raw = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' ); +$wpvulnerability_s = is_array( $wpvulnerability_s_raw ) ? $wpvulnerability_s_raw : array(); +wpvulnerability_schedule_notification_event( $wpvulnerability_s, false ); +add_action( 'wpvulnerability_notification', 'wpvulnerability_execute_notification' ); // @phpstan-ignore return.void (function returns bool but action callbacks are not required to be void) +unset( $wpvulnerability_s ); + +/** + * Returns the WPVulnerability cron hooks. + * + * @since 4.3.0 + * + * @return array List of cron hooks belonging to the plugin. + */ +function wpvulnerability_get_plugin_cron_hooks() { + return array( + 'wpvulnerability_update_database', + 'wpvulnerability_cleanup_logs', + 'wpvulnerability_notification', + ); +} + +/** + * Determines the schedule slug for database updates based on cache hours. + * + * @since 4.3.0 + * + * @return string Schedule identifier. + */ +function wpvulnerability_get_update_schedule_slug() { + $cache_hours = wpvulnerability_cache_hours(); + $mapping = array( + 1 => 'hourly', + 6 => 'wpvulnerability_six_hours', + 12 => 'twicedaily', + 24 => 'daily', + ); + + $schedule = isset( $mapping[ $cache_hours ] ) ? $mapping[ $cache_hours ] : 'twicedaily'; + + $schedules = function_exists( 'wp_get_schedules' ) ? wp_get_schedules() : array(); + + if ( ! isset( $schedules[ $schedule ] ) ) { + return 'twicedaily'; + } + + return $schedule; +} + +/** + * Retrieves the main site ID with backwards compatibility. + * + * @since 4.3.0 + * + * @return int Main site ID. + */ +function wpvulnerability_get_main_site_id() { + if ( function_exists( 'get_main_site_id' ) ) { + return (int) get_main_site_id(); + } + + $current_site = get_current_site(); + if ( isset( $current_site->blog_id ) ) { + return (int) $current_site->blog_id; + } + + return (int) get_current_blog_id(); +} + +/** + * Clears all WPVulnerability cron hooks for the current site. + * + * @since 4.3.0 + * + * @return void + */ +function wpvulnerability_clear_plugin_cron_hooks() { + $hooks = wpvulnerability_get_plugin_cron_hooks(); + + foreach ( $hooks as $hook ) { + wp_clear_scheduled_hook( $hook ); + } +} + +/** + * Schedules core WPVulnerability cron events for the current site. + * + * @since 4.3.0 + * + * @return void + */ +function wpvulnerability_schedule_core_events() { + if ( is_multisite() && ! is_main_site() ) { + return; + } + + $update_schedule = wpvulnerability_get_update_schedule_slug(); + + $current_schedule = wp_get_schedule( 'wpvulnerability_update_database' ); + if ( $current_schedule !== $update_schedule ) { + wp_clear_scheduled_hook( 'wpvulnerability_update_database' ); + wp_schedule_event( time(), $update_schedule, 'wpvulnerability_update_database' ); + } + + if ( ! wp_next_scheduled( 'wpvulnerability_cleanup_logs' ) ) { + wp_schedule_event( time(), 'daily', 'wpvulnerability_cleanup_logs' ); + } +} + +/** + * Returns the notification schedule string based on settings. + * + * @since 4.3.0 + * + * @param array $config Plugin configuration. + * + * @return string Notification schedule name or empty string when disabled. + */ +function wpvulnerability_get_notification_schedule_from_config( $config ) { + if ( ! isset( $config['period'] ) ) { + return ''; + } + + $period_raw = $config['period']; + $period = is_scalar( $period_raw ) ? strtolower( trim( (string) $period_raw ) ) : ''; + + if ( ! in_array( $period, array( 'daily', 'weekly' ), true ) ) { + return ''; + } + + return $period; +} + +/** + * Builds the list of expected cron events for the current site. + * + * @since 4.3.0 + * + * @param array|mixed $config Plugin configuration. + * @param bool $is_main_site Whether the current site is the main site on a multisite network. + * + * @return array> Expected cron events. + */ +function wpvulnerability_get_expected_cron_events( $config, $is_main_site ) { + if ( ! is_array( $config ) ) { + $config = is_multisite() ? get_site_option( 'wpvulnerability-config' ) : get_option( 'wpvulnerability-config' ); + if ( ! is_array( $config ) ) { + $config = array(); + } + } + + $expect_main_site = ( ! is_multisite() || $is_main_site ); + $update_schedule = wpvulnerability_get_update_schedule_slug(); + $expected_events = array( + array( + 'hook' => 'wpvulnerability_update_database', + 'schedule' => $update_schedule, + 'should_exist' => $expect_main_site, + 'label' => __( 'Database updates', 'wpvulnerability' ), + ), + array( + 'hook' => 'wpvulnerability_cleanup_logs', + 'schedule' => 'daily', + 'should_exist' => $expect_main_site, + 'label' => __( 'Log cleanup', 'wpvulnerability' ), + ), + ); + + $notification_schedule = ''; + + if ( $expect_main_site ) { + $notification_schedule = wpvulnerability_get_notification_schedule_from_config( $config ); + } + + $expected_events[] = array( + 'hook' => 'wpvulnerability_notification', + 'schedule' => $notification_schedule, + 'should_exist' => ( '' !== $notification_schedule ), + 'label' => __( 'Notifications', 'wpvulnerability' ), + ); + + return $expected_events; +} + +/** + * Collects scheduled WPVulnerability cron entries for the current site. + * + * Uses only public WordPress Cron API functions (wp_next_scheduled and + * wp_get_schedule) to avoid relying on the private _get_cron_array() function. + * As a result, only hooks registered by wpvulnerability_get_plugin_cron_hooks() + * are inspected; duplicate-instance detection is not performed. + * + * @since 4.3.0 + * @since 5.0.0 Replaced _get_cron_array() with wp_next_scheduled()/wp_get_schedule(). + * + * @return array> Scheduled cron entries. + */ +function wpvulnerability_get_cron_snapshot() { + $hooks = wpvulnerability_get_plugin_cron_hooks(); + $events = array(); + + foreach ( $hooks as $hook ) { + $timestamp = wp_next_scheduled( $hook ); + if ( false === $timestamp ) { + continue; + } + $schedule = wp_get_schedule( $hook ); + $events[] = array( + 'hook' => $hook, + 'timestamp' => (int) $timestamp, + 'schedule' => is_string( $schedule ) ? sanitize_key( $schedule ) : '', + ); + } + + return $events; +} + +/** + * Builds a status report comparing expected and actual cron events. + * + * @since 4.3.0 + * + * @param array $config Plugin configuration. + * @param bool $is_main_site Whether the current site is the main site on a multisite network. + * + * @return array>> Report including expected rows and unexpected hooks. + */ +function wpvulnerability_get_cron_status( $config, $is_main_site ) { + $expected = wpvulnerability_get_expected_cron_events( $config, $is_main_site ); + $snapshot = wpvulnerability_get_cron_snapshot(); + $expected_rows = array(); + $extra_events = array(); + + foreach ( $expected as $item ) { + $hook_raw = $item['hook'] ?? ''; + $hook = is_scalar( $hook_raw ) ? (string) $hook_raw : ''; + $expected_rows[ $hook ] = $item; + $expected_rows[ $hook ]['schedules_found'] = array(); + $expected_rows[ $hook ]['next_run'] = null; + $expected_rows[ $hook ]['count'] = 0; + $expected_rows[ $hook ]['messages'] = array(); + } + + foreach ( $snapshot as $event ) { + $hook_raw = $event['hook'] ?? ''; + $hook = is_scalar( $hook_raw ) ? (string) $hook_raw : ''; + $ts_raw = $event['timestamp'] ?? 0; + $timestamp = is_numeric( $ts_raw ) ? (int) $ts_raw : 0; + $sched_raw = $event['schedule'] ?? ''; + $schedule = is_scalar( $sched_raw ) ? (string) $sched_raw : ''; + + if ( isset( $expected_rows[ $hook ] ) ) { + ++$expected_rows[ $hook ]['count']; + if ( null === $expected_rows[ $hook ]['next_run'] || $timestamp < $expected_rows[ $hook ]['next_run'] ) { + $expected_rows[ $hook ]['next_run'] = $timestamp; + } + if ( '' !== $schedule ) { + $expected_rows[ $hook ]['schedules_found'][ $schedule ] = true; + } + continue; + } + + if ( 0 === strpos( $hook, 'wpvulnerability_' ) ) { + if ( ! isset( $extra_events[ $hook ] ) ) { + $extra_events[ $hook ] = array( + 'hook' => $hook, + 'count' => 0, + 'next_run' => null, + 'schedules' => array(), + ); + } + + ++$extra_events[ $hook ]['count']; + + if ( null === $extra_events[ $hook ]['next_run'] || $timestamp < $extra_events[ $hook ]['next_run'] ) { + $extra_events[ $hook ]['next_run'] = $timestamp; + } + + if ( '' !== $schedule ) { + $extra_events[ $hook ]['schedules'][ $schedule ] = true; + } + } + } + + foreach ( $expected_rows as $hook => $row ) { + $sched_raw = $row['schedule'] ?? ''; + $expected_schedule = is_scalar( $sched_raw ) ? (string) $sched_raw : ''; + $should_exist = isset( $row['should_exist'] ) ? (bool) $row['should_exist'] : false; + $found_schedules = array_keys( $row['schedules_found'] ); + + if ( ! $should_exist ) { + if ( $row['count'] > 0 ) { + if ( 'wpvulnerability_notification' === $hook ) { + $row['status'] = 'needs_attention'; + $row['messages'][] = __( 'Notifications are scheduled, but settings currently disable them. Please review the notifications tab.', 'wpvulnerability' ); + } else { + $row['status'] = 'unexpected'; + $row['messages'][] = __( 'This event should not be scheduled for this site.', 'wpvulnerability' ); + } + } else { + $row['status'] = 'not_expected'; + $row['messages'][] = __( 'Not expected for this site.', 'wpvulnerability' ); + } + } elseif ( 0 === $row['count'] ) { + $row['status'] = 'missing'; + $row['messages'][] = __( 'No instances found.', 'wpvulnerability' ); + } else { + $mismatched_schedule = ( '' !== $expected_schedule && ! in_array( $expected_schedule, $found_schedules, true ) ); + $duplicate_events = ( $row['count'] > 1 ); + + if ( $mismatched_schedule ) { + $row['messages'][] = __( 'Scheduled with an unexpected interval.', 'wpvulnerability' ); + } + + if ( $duplicate_events ) { + $row['messages'][] = __( 'Multiple instances detected.', 'wpvulnerability' ); + } + + if ( empty( $row['messages'] ) ) { + $row['status'] = 'ok'; + $row['messages'][] = __( 'Scheduled as expected.', 'wpvulnerability' ); + } else { + $row['status'] = 'needs_attention'; + } + } + + $row['schedules_found'] = $found_schedules; + $expected_rows[ $hook ] = $row; + } + + foreach ( $extra_events as $hook => $row ) { + $extra_events[ $hook ]['schedules'] = array_keys( $row['schedules'] ); + } + + return array( + 'expected' => array_values( $expected_rows ), + 'unexpected' => array_values( $extra_events ), + ); +} + +/** + * Repairs WPVulnerability cron events for the current site. + * + * @since 4.3.0 + * + * @param array $config Plugin configuration. + * + * @return void + */ +function wpvulnerability_repair_cron_events( $config ) { + wpvulnerability_clear_plugin_cron_hooks(); + wpvulnerability_schedule_core_events(); + wpvulnerability_schedule_notification_event( $config ); +} + +/** + * Repairs WPVulnerability cron events across all sites in a network. + * + * @since 4.3.0 + * + * @param array $config Plugin configuration. + * + * @return void + */ +function wpvulnerability_repair_network_cron_events( $config ) { + if ( ! is_multisite() ) { + return; + } + + $sites = get_sites( + array( + 'fields' => 'ids', + ) + ); + + if ( empty( $sites ) ) { + return; + } + + $main_site_id = wpvulnerability_get_main_site_id(); + $current_blog_id = get_current_blog_id(); + $sanitized_config = $config; + + foreach ( $sites as $site_id ) { + switch_to_blog( (int) $site_id ); + + wpvulnerability_clear_plugin_cron_hooks(); + + if ( (int) $site_id === (int) $main_site_id ) { + wpvulnerability_schedule_core_events(); + wpvulnerability_schedule_notification_event( $sanitized_config ); + } + + restore_current_blog(); + } + + if ( get_current_blog_id() !== $current_blog_id ) { + switch_to_blog( $current_blog_id ); + } +} diff --git a/wpvulnerability-sitehealth.php b/wpvulnerability-sitehealth.php new file mode 100644 index 0000000..295b121 --- /dev/null +++ b/wpvulnerability-sitehealth.php @@ -0,0 +1,491 @@ + __( 'There aren\'t plugins vulnerabilities', 'wpvulnerability' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'wpvulnerability' ), + 'color' => 'green', + ), + 'description' => sprintf( + '

%s

', + __( 'Shows possible vulnerabilities that exist in installed plugins.', 'wpvulnerability' ) + ), + 'actions' => '', + 'test' => 'wpvulnerability_plugins', + ); + + // Check if any plugin vulnerabilities were found. + $wpvulnerability_test_plugins_counter = wpvulnerability_get_component_count( 'plugins' ); + + if ( $wpvulnerability_test_plugins_counter ) { + $result['status'] = 'critical'; + $result['label'] = sprintf( + // translators: Number of plugins vulnerabilities. + _n( 'There is %d plugin with vulnerabilities', 'There are %d plugins with vulnerabilities', $wpvulnerability_test_plugins_counter, 'wpvulnerability' ), + $wpvulnerability_test_plugins_counter + ); + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '

%1$s

%2$s', + __( 'We\'ve detected potential vulnerabilities in installed plugins. Please check them and keep them updated.', 'wpvulnerability' ), + wpvulnerability_html_plugins() + ); + + // Add action links to update plugins. + $result['actions'] .= sprintf( + '

%s

', + esc_url( is_multisite() ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ) ), + __( 'Update plugins', 'wpvulnerability' ) + ); + } + + return $result; +} + +/** + * Tests for vulnerabilities in installed themes. + * + * @since 2.0.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_themes() { + // Define the initial test result values. + $result = array( + 'label' => __( 'There aren\'t themes vulnerabilities', 'wpvulnerability' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'wpvulnerability' ), + 'color' => 'green', + ), + 'description' => sprintf( + '

%s

', + __( 'Shows possible vulnerabilities that exist in installed themes.', 'wpvulnerability' ) + ), + 'actions' => '', + 'test' => 'wpvulnerability_themes', + ); + + // Check if any theme vulnerabilities were found. + $wpvulnerability_test_themes_counter = wpvulnerability_get_component_count( 'themes' ); + + if ( $wpvulnerability_test_themes_counter ) { + $result['status'] = 'critical'; + $result['label'] = sprintf( + // translators: Number of themes vulnerabilities. + _n( 'There is %d theme with vulnerabilities', 'There are %d themes with vulnerabilities', $wpvulnerability_test_themes_counter, 'wpvulnerability' ), + $wpvulnerability_test_themes_counter + ); + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '

%1$s

%2$s', + __( 'We\'ve detected potential vulnerabilities in installed themes. Please check them and keep them updated.', 'wpvulnerability' ), + wpvulnerability_html_themes() + ); + + // Add action links to update themes. + $result['actions'] .= sprintf( + '

%s

', + esc_url( is_multisite() ? network_admin_url( 'themes.php' ) : admin_url( 'themes.php' ) ), + __( 'Update themes', 'wpvulnerability' ) + ); + } + + return $result; +} + +/** + * Tests for vulnerabilities in core. + * + * @since 2.0.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_core() { + // Define the initial test result values. + $result = array( + 'label' => __( 'There aren\'t WordPress vulnerabilities', 'wpvulnerability' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'wpvulnerability' ), + 'color' => 'green', + ), + 'description' => sprintf( + '

%s

', + __( 'Shows possible vulnerabilities existing in the WordPress core.', 'wpvulnerability' ) + ), + 'actions' => '', + 'test' => 'wpvulnerability_core', + ); + + // Check if any core vulnerabilities were found. + $wpvulnerability_test_core_counter = wpvulnerability_get_component_count( 'core' ); + + if ( $wpvulnerability_test_core_counter ) { + $result['status'] = 'critical'; + $result['label'] = sprintf( + // translators: Number of core vulnerabilities. + _n( 'There is %d core vulnerability', 'There are %d core vulnerabilities', $wpvulnerability_test_core_counter, 'wpvulnerability' ), + $wpvulnerability_test_core_counter + ); + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '

%1$s

%2$s', + __( 'We\'ve detected potential vulnerabilities in this WordPress installation. Please check them and keep your installation updated.', 'wpvulnerability' ), + wpvulnerability_html_core() + ); + + // Add action links to update WordPress. + $result['actions'] .= sprintf( + '

%s

', + esc_url( is_multisite() ? network_admin_url( 'update-core.php' ) : admin_url( 'update-core.php' ) ), + __( 'Update WordPress', 'wpvulnerability' ) + ); + } + + return $result; +} + +/** + * Tests for vulnerabilities in a specified software component. + * + * @since 3.5.0 + * + * @param string $software The type of software to test (php, apache, nginx, mariadb, mysql). + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_software( $software ) { + $software_list = array( + 'php' => __( 'PHP', 'wpvulnerability' ), + 'apache' => __( 'Apache HTTPD', 'wpvulnerability' ), + 'nginx' => __( 'Nginx', 'wpvulnerability' ), + 'mariadb' => __( 'MariaDB', 'wpvulnerability' ), + 'mysql' => __( 'MySQL', 'wpvulnerability' ), + 'imagemagick' => __( 'ImageMagick', 'wpvulnerability' ), + 'curl' => __( 'curl', 'wpvulnerability' ), + ); + + if ( ! array_key_exists( $software, $software_list ) ) { + return array( + 'label' => __( 'Invalid software type', 'wpvulnerability' ), + 'status' => 'error', + 'badge' => array( + 'label' => __( 'Error', 'wpvulnerability' ), + 'color' => 'red', + ), + 'description' => sprintf( + '

%s

', + __( 'The specified software type is not valid.', 'wpvulnerability' ) + ), + 'actions' => '', + 'test' => 'wpvulnerability_' . $software, + ); + } + + // Define the initial test result values. + $result = array( + // translators: name of the software. + 'label' => sprintf( __( 'There aren\'t %s vulnerabilities', 'wpvulnerability' ), $software_list[ $software ] ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Security', 'wpvulnerability' ), + 'color' => 'green', + ), + 'description' => sprintf( + '

%s

', + // translators: software with vulnerabilities. + sprintf( __( 'Shows possible vulnerabilities existing in %s.', 'wpvulnerability' ), $software_list[ $software ] ) + ), + 'actions' => '', + 'test' => 'wpvulnerability_' . $software, + ); + + // Check if any vulnerabilities were found. + $vulnerability_counter = wpvulnerability_get_component_count( $software ); + + if ( $vulnerability_counter ) { + $result['status'] = 'critical'; + $result['label'] = sprintf( + // translators: Software and number of vulnerabilities. + _n( 'There is %1$d %2$s vulnerability', 'There are %1$d %2$s vulnerabilities', $vulnerability_counter, 'wpvulnerability' ), + $vulnerability_counter, + $software_list[ $software ] + ); + $result['badge']['color'] = 'red'; + $result['description'] = sprintf( + '

%1$s

%2$s', + // translators: software with vulnerabilities. + sprintf( __( 'We\'ve detected potential vulnerabilities in %s. Please check them and keep your installation updated.', 'wpvulnerability' ), $software_list[ $software ] ), + wpvulnerability_html_software( $software ) + ); + + // Add specific action links if necessary. + if ( 'php' === $software && function_exists( 'wp_get_update_php_url' ) && class_exists( 'WP_Site_Health' ) ) { + $result['actions'] .= sprintf( + '

%s

', + esc_url( wp_get_update_php_url() ), + __( 'How to update PHP', 'wpvulnerability' ) + ); + } + } + + return $result; +} + +/** + * Tests for vulnerabilities in MySQL. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_mysql() { + return wpvulnerability_test_software( 'mysql' ); +} + +/** + * Tests for vulnerabilities in MariaDB. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_mariadb() { + return wpvulnerability_test_software( 'mariadb' ); +} + +/** + * Tests for vulnerabilities in Apache. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_apache() { + return wpvulnerability_test_software( 'apache' ); +} + +/** + * Tests for vulnerabilities in Nginx. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_nginx() { + return wpvulnerability_test_software( 'nginx' ); +} + +/** + * Tests for vulnerabilities in PHP. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_php() { + return wpvulnerability_test_software( 'php' ); +} + +/** + * Tests for vulnerabilities in ImageMagick. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_imagemagick() { + return wpvulnerability_test_software( 'imagemagick' ); +} + +/** + * Tests for vulnerabilities in curl. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_curl() { + return wpvulnerability_test_software( 'curl' ); +} + +/** + * Tests for vulnerabilities in memcached. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_memcached() { + return wpvulnerability_test_software( 'memcached' ); +} + +/** + * Tests for vulnerabilities in Redis. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_redis() { + return wpvulnerability_test_software( 'redis' ); +} + +/** + * Tests for vulnerabilities in SQLite. + * + * @since 3.5.0 + * + * @return array{label: string, status: string, badge: array{label: string, color: string}, description: string, actions: string, test: string} Array with the results of the vulnerability test. + */ +function wpvulnerability_test_sqlite() { + return wpvulnerability_test_software( 'sqlite' ); +} + +/** + * Adds vulnerability tests to the Health Check & Troubleshooting page. + * + * This function registers various vulnerability tests for different components of the site, such as + * WordPress core, themes, plugins, PHP, Apache, Nginx, MariaDB, and MySQL, to the Site Health status page. + * + * @since 2.0.0 + * + * @param array $tests Array of current site status tests. + * + * @return array The updated array of site status tests. + */ +function wpvulnerability_tests( $tests ) { + + // Ensure the 'direct' key is a typed array before assigning test entries. + if ( ! isset( $tests['direct'] ) || ! is_array( $tests['direct'] ) ) { + $tests['direct'] = array(); + } + + if ( wpvulnerability_analyze_filter( 'core' ) ) { + // Add test for Core WordPress vulnerabilities. + $tests['direct']['wpvulnerability_core'] = array( + 'label' => __( 'WPVulnerability Core', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_core', + ); + } + + if ( wpvulnerability_analyze_filter( 'themes' ) ) { + // Add test for Theme vulnerabilities. + $tests['direct']['wpvulnerability_themes'] = array( + 'label' => __( 'WPVulnerability Themes', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_themes', + ); + } + + if ( wpvulnerability_analyze_filter( 'plugins' ) ) { + // Add test for Plugin vulnerabilities. + $tests['direct']['wpvulnerability_plugins'] = array( + 'label' => __( 'WPVulnerability Plugins', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_plugins', + ); + } + + if ( wpvulnerability_analyze_filter( 'php' ) ) { + // Add test for PHP vulnerabilities. + $tests['direct']['wpvulnerability_php'] = array( + 'label' => __( 'WPVulnerability PHP', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_php', + ); + } + + if ( wpvulnerability_analyze_filter( 'apache' ) ) { + // Add test for Apache vulnerabilities. + $tests['direct']['wpvulnerability_apache'] = array( + 'label' => __( 'WPVulnerability Apache HTTPD', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_apache', + ); + } + + if ( wpvulnerability_analyze_filter( 'nginx' ) ) { + // Add test for Nginx vulnerabilities. + $tests['direct']['wpvulnerability_nginx'] = array( + 'label' => __( 'WPVulnerability Nginx', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_nginx', + ); + } + + if ( wpvulnerability_analyze_filter( 'mariadb' ) ) { + // Add test for MariaDB vulnerabilities. + $tests['direct']['wpvulnerability_mariadb'] = array( + 'label' => __( 'WPVulnerability MariaDB', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_mariadb', + ); + } + + if ( wpvulnerability_analyze_filter( 'mysql' ) ) { + // Add test for MySQL vulnerabilities. + $tests['direct']['wpvulnerability_mysql'] = array( + 'label' => __( 'WPVulnerability MySQL', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_mysql', + ); + } + + if ( wpvulnerability_analyze_filter( 'imagemagick' ) ) { + // Add test for ImageMagick vulnerabilities. + $tests['direct']['wpvulnerability_imagemagick'] = array( + 'label' => __( 'WPVulnerability ImageMagick', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_imagemagick', + ); + } + + if ( wpvulnerability_analyze_filter( 'curl' ) ) { + // Add test for curl vulnerabilities. + $tests['direct']['wpvulnerability_curl'] = array( + 'label' => __( 'WPVulnerability curl', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_curl', + ); + } + + if ( wpvulnerability_analyze_filter( 'memcached' ) ) { + // Add test for memcached vulnerabilities. + $tests['direct']['wpvulnerability_memcached'] = array( + 'label' => __( 'WPVulnerability memcached', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_memcached', + ); + } + + if ( wpvulnerability_analyze_filter( 'redis' ) ) { + // Add test for Redis vulnerabilities. + $tests['direct']['wpvulnerability_redis'] = array( + 'label' => __( 'WPVulnerability redis', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_redis', + ); + } + + if ( wpvulnerability_analyze_filter( 'sqlite' ) ) { + // Add test for SQLite vulnerabilities. + $tests['direct']['wpvulnerability_sqlite'] = array( + 'label' => __( 'WPVulnerability sqlite', 'wpvulnerability' ), + 'test' => 'wpvulnerability_test_sqlite', + ); + } + + return $tests; +} + +// Adds the vulnerability tests to the site status tests. +add_filter( 'site_status_tests', 'wpvulnerability_tests' ); diff --git a/wpvulnerability-software.php b/wpvulnerability-software.php new file mode 100644 index 0000000..269ac55 --- /dev/null +++ b/wpvulnerability-software.php @@ -0,0 +1,269 @@ + The updated data array containing vulnerability information. + */ +function wpvulnerability_get_fresh_vulnerabilities( $software ) { + + $version = null; + $data = array( + 'vulnerabilities' => null, + 'vulnerable' => 0, + 'lifecycle' => array(), + ); + + switch ( $software ) { + case 'php': + case 'apache': + case 'nginx': + case 'mysql': + case 'mariadb': + case 'imagemagick': + case 'curl': + case 'memcached': + case 'redis': + case 'sqlite': + $version = wpvulnerability_get_software_version( $software ); + break; + + default: + return $data; + } + + if ( $version ) { + $transient_key = 'wpvulnerability_' . $software; + + // Delete the transient so the next call fetches fresh data and repopulates it. + if ( is_multisite() ) { + delete_site_transient( $transient_key ); + } else { + delete_transient( $transient_key ); + } + + switch ( $software ) { + case 'php': + case 'apache': + case 'nginx': + case 'mysql': + case 'mariadb': + case 'imagemagick': + case 'curl': + case 'memcached': + case 'redis': + case 'sqlite': + // cache=1: transient was just cleared, so a fresh API call is made and result cached. + $api_response = wpvulnerability_get_vulnerabilities( $software, $version, 1 ); + break; + } + + if ( ! empty( $api_response ) ) { + $data['vulnerabilities'] = $api_response; + $data['vulnerable'] = 1; + } + + // Read back the transient to extract lifecycle fields from the full API response. + $raw_body = is_multisite() ? get_site_transient( $transient_key ) : get_transient( $transient_key ); + $raw_response = json_decode( is_string( $raw_body ) ? $raw_body : '', true ); + if ( is_array( $raw_response ) && isset( $raw_response['data'] ) && is_array( $raw_response['data'] ) ) { + $resp_data = $raw_response['data']; + $data['lifecycle'] = array( + 'name' => is_scalar( $resp_data['name'] ?? '' ) ? (string) ( $resp_data['name'] ?? '' ) : '', + 'status' => is_scalar( $resp_data['status'] ?? '' ) ? (string) ( $resp_data['status'] ?? '' ) : '', + 'date_start' => is_scalar( $resp_data['date_start'] ?? '' ) ? (string) ( $resp_data['date_start'] ?? '' ) : '', + 'date_end' => is_scalar( $resp_data['date_end'] ?? '' ) ? (string) ( $resp_data['date_end'] ?? '' ) : '', + ); + } + } + + return $data; +} + + +/** + * Get Installed Software + * + * Retrieves the list of installed software versions, checks for vulnerabilities, + * caches the data, and sends an email notification if vulnerabilities are detected. + * + * @since 3.5.0 + * + * @param string $software The software name (e.g., 'php', 'apache'). + * + * @return string JSON-encoded array of software data with vulnerabilities and vulnerable status. + */ +function wpvulnerability_get_installed( $software ) { + + $wpvulnerability_software_vulnerable = 0; + + // Retrieve fresh vulnerabilities for the installed software version. + $data = wpvulnerability_get_fresh_vulnerabilities( $software ); + + // Check if the software version is vulnerable and count the vulnerabilities. + if ( isset( $data['vulnerable'] ) && is_numeric( $data['vulnerable'] ) && (int) $data['vulnerable'] ) { + $vulns = isset( $data['vulnerabilities'] ) && is_array( $data['vulnerabilities'] ) ? $data['vulnerabilities'] : array(); + $wpvulnerability_software_vulnerable = count( $vulns ); + } + + // Cache the vulnerability data and the timestamp for cache expiration. + if ( is_multisite() ) { + update_site_option( 'wpvulnerability-' . $software, wp_json_encode( $data ) ); + update_site_option( 'wpvulnerability-' . $software . '-vulnerable', wp_json_encode( number_format( $wpvulnerability_software_vulnerable, 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-' . $software . '-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); + } else { + update_option( 'wpvulnerability-' . $software, wp_json_encode( $data ), false ); + update_option( 'wpvulnerability-' . $software . '-vulnerable', wp_json_encode( number_format( $wpvulnerability_software_vulnerable, 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-' . $software . '-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); + } + + // Return the JSON-encoded array of software data. + $encoded = wp_json_encode( $data ); + return false !== $encoded ? $encoded : ''; +} + +/** + * Get cached software vulnerabilities without triggering remote calls. + * + * @since 3.5.0 + * + * @param string $software The software name (e.g., 'php', 'apache'). + * + * @return array|null Array of software data with vulnerabilities, or null if software is invalid. + */ +function wpvulnerability_software_get_vulnerabilities( $software ) { + + $valid_software = array( 'php', 'apache', 'mariadb', 'mysql', 'nginx', 'imagemagick', 'curl', 'memcached', 'redis', 'sqlite' ); + + // Use strict comparison for in_array. + if ( in_array( $software, $valid_software, true ) ) { + if ( is_multisite() ) { + $raw = get_site_option( 'wpvulnerability-' . $software ); + $data = json_decode( is_string( $raw ) ? $raw : '', true ); + } else { + $raw = get_option( 'wpvulnerability-' . $software ); + $data = json_decode( is_string( $raw ) ? $raw : '', true ); + } + + return is_array( $data ) ? $data : array(); + } else { + return null; + } +} + +/** + * Update the software cache and remove any old cache data. + * + * @since 3.0.0 + * + * @param string $software The software name (e.g., 'php', 'apache'). + * + * @return void + */ +function wpvulnerability_get_vulnerabilities_clean( $software ) { + + // Update the installed software cache. + wpvulnerability_get_installed( $software ); +} diff --git a/wpvulnerability-themes.php b/wpvulnerability-themes.php new file mode 100644 index 0000000..4e443be --- /dev/null +++ b/wpvulnerability-themes.php @@ -0,0 +1,727 @@ +> $themes List of themes returned by wp_get_themes(). + * @return string Hash representing the installed themes and their versions. + */ +function wpvulnerability_themes_generate_signature( $themes ) { + $normalized = array(); + + foreach ( $themes as $slug => $theme_data ) { + $theme_slug = sanitize_text_field( (string) $slug ); + $version = ''; + + if ( $theme_data instanceof WP_Theme ) { + $version = sanitize_text_field( (string) $theme_data->get( 'Version' ) ); + } elseif ( isset( $theme_data['Version'] ) ) { + $version = sanitize_text_field( is_scalar( $theme_data['Version'] ) ? (string) $theme_data['Version'] : '' ); + } + + $normalized[ $theme_slug ] = $version; + } + + ksort( $normalized ); + + $encoded = wp_json_encode( $normalized ); + return md5( false !== $encoded ? $encoded : '' ); +} + +/** + * Retrieve the signature of the currently installed themes. + * + * @since 4.1.2 + * + * @return string Hash representing the installed themes and their versions. + */ +function wpvulnerability_themes_get_current_signature() { + return wpvulnerability_themes_generate_signature( wp_get_themes() ); +} + +/** + * Adds a vulnerability notice under vulnerable themes. + * + * @since 2.0.0 + * + * @param string $theme_file Main themes folder/file name. + * @param WP_Theme $theme_data Theme data object. + * + * @return void + */ +function wpvulnerability_theme_info_after( $theme_file, $theme_data ) { + + // Retrieve the vulnerabilities for all themes from the options table and decode the JSON. + $raw_themes = is_multisite() ? get_site_option( 'wpvulnerability-themes', '' ) : get_option( 'wpvulnerability-themes', '' ); + $theme_vulnerabilities = json_decode( is_string( $raw_themes ) ? $raw_themes : '', true ); + if ( ! is_array( $theme_vulnerabilities ) ) { + $theme_vulnerabilities = array(); + } + + // Determine whether the theme is active and add an appropriate CSS class to the table row. + $current_theme = wp_get_theme(); + $tr_class = ''; + if ( $theme_file === $current_theme->get_stylesheet() ) { + $tr_class .= 'active'; + } + + // Generate the vulnerability notice message with the theme name. + $message = sprintf( + /* translators: 1: theme name */ + __( '%1$s has a known vulnerability that may be affecting your installed version.', 'wpvulnerability' ), + wp_kses( (string) $theme_data->get( 'Name' ), 'strip' ) + ); + + // Begin generating the table row HTML markup with appropriate CSS classes and the vulnerability notice message. + $information = ''; + $information .= ''; + $information .= '

' . esc_html( $message ) . '

'; + $information .= ''; + + // Loop through all vulnerabilities for the current theme and add their details to the table row HTML markup. + $tf_entry = isset( $theme_vulnerabilities[ $theme_file ] ) && is_array( $theme_vulnerabilities[ $theme_file ] ) ? $theme_vulnerabilities[ $theme_file ] : array(); + $tf_wpv = isset( $tf_entry['wpvulnerability'] ) && is_array( $tf_entry['wpvulnerability'] ) ? $tf_entry['wpvulnerability'] : array(); + $vulnerabilities = isset( $tf_wpv['vulnerabilities'] ) && is_array( $tf_wpv['vulnerabilities'] ) ? $tf_wpv['vulnerabilities'] : array(); + + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; + } + + $vuln_versions_raw = $vulnerability['versions'] ?? ''; + $vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : ''; + $vuln_closed_raw = $vulnerability['closed'] ?? 0; + $vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0; + $vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0; + $vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0; + $vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array(); + $vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array(); + $vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array(); + $vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array(); + $vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array(); + $vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array(); + $vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array(); + $vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array(); + + $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); + $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; + $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; + $kev_date_raw = $vuln_ssvc['kev_date'] ?? null; + $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; + $epss_raw = $vuln_impact['epss'] ?? null; + $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; + $description = wpvulnerability_get_source_description( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. + $score_raw = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; + } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score_raw = $s; + $sev_raw = $v; + break; + } + } + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; + } + $cwe_name = $vulnerability_cwe['name'] ?? ''; + $cwe_desc = $vulnerability_cwe['description'] ?? ''; + $what[] = '
' . wp_kses( is_scalar( $cwe_name ) ? (string) $cwe_name : '', 'strip' ) . '
' . esc_html( is_scalar( $cwe_desc ) ? (string) $cwe_desc : '' ) . '
'; + } + + $version_display = wpvulnerability_clean_version_range( $vuln_versions ); + $source_pills = wpvulnerability_render_source_pills( $vuln_sources ); + $score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss ); + + $information .= ''; + // Version range column. + $information .= ''; + // Details column. + $information .= ''; + $information .= ''; + } + + $information .= '
'; + $information .= '' !== $version_display + ? '' . $version_display . '' + : '—'; + $information .= ''; + $show_active = $kev || 'active' === $exploitation; + $show_poc = 'poc' === $exploitation; + $show_auto = 'yes' === $automatable; + if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { + $information .= '
'; + if ( $show_active ) { + $information .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); + if ( $kev && null !== $kev_date ) { + $information .= ' · ' . esc_html( $kev_date ); + } + $information .= ''; + } + if ( $show_poc ) { + $information .= '⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . ''; + } + if ( $show_auto ) { + $information .= '⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $information .= $score_badge; + } + $information .= '
'; + } + if ( null !== $description ) { + $information .= '
' . esc_html( $description ) . '
'; + } + if ( $vuln_closed || $vuln_unfixed ) { + $information .= '
'; + if ( $vuln_closed ) { + $information .= '
' . esc_html__( 'This theme is closed. Please replace it with another.', 'wpvulnerability' ) . '
'; + } + if ( $vuln_unfixed ) { + $information .= '
' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming theme updates.', 'wpvulnerability' ) . '
'; + } + $information .= '
'; + } + if ( ! empty( $what ) ) { + $information .= '
'; + foreach ( $what as $w ) { + $information .= $w; + } + $information .= '
'; + } + if ( '' !== $source_pills ) { + $information .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $information .= $source_pills; + $information .= '
'; + } + $information .= '
'; + $information .= ''; + $information .= ''; + + echo $information; // phpcs:ignore +} + +/** + * Retrieves vulnerabilities for a given theme and updates its data. + * + * @since 2.0.0 + * + * @param array $theme_data The theme data array (must contain a 'data' key with a WP_Theme object). + * @param string $theme_slug The slug to the theme. + * + * @return array The updated theme data array. + */ +function wpvulnerability_get_fresh_theme_vulnerabilities( $theme_data, $theme_slug ) { + + // Get the theme version and slug from the theme data. + $theme_obj = isset( $theme_data['data'] ) && ( $theme_data['data'] instanceof WP_Theme ) ? $theme_data['data'] : null; + $theme_version = null !== $theme_obj ? wp_kses( (string) $theme_obj->get( 'Version' ), 'strip' ) : ''; + + $theme_data_v = array(); + $theme_data_v['slug'] = $theme_slug; + $theme_data_v['name'] = null !== $theme_obj ? (string) $theme_obj->get( 'Name' ) : ''; + + // Initialize vulnerability related fields. + $theme_data_v['vulnerabilities'] = null; + $theme_data_v['vulnerable'] = 0; + + // Retrieve vulnerabilities for the theme using its slug and version. + if ( ! empty( $theme_slug ) ) { + + $theme_api_response = wpvulnerability_get_theme( $theme_slug, $theme_version, 0 ); + + // If vulnerabilities are found, update the theme data accordingly. + if ( ! empty( $theme_api_response ) ) { + + $theme_data_v['vulnerabilities'] = $theme_api_response; + $theme_data_v['vulnerable'] = 1; + + } + } + + return $theme_data_v; +} + +/** + * Get Installed Themes + * Retrieves the list of installed themes, checks for vulnerabilities in each of them, caches the data, and sends an email notification if vulnerabilities are detected. + * + * @since 2.0.0 + * @since 4.1.2 Stores a signature of the installed themes to detect inventory changes. + * + * @return string JSON-encoded array of theme data with vulnerabilities and vulnerable status, or '[]' on encoding error. + */ +function wpvulnerability_theme_get_installed() { + + $wpvulnerability_themes_vulnerable = 0; + $themes_v = array(); + $themes = wp_get_themes(); + $signature = wpvulnerability_themes_generate_signature( $themes ); + + foreach ( $themes as $slug => $theme_data ) { + + // Store the theme data. + $themes_v[ $slug ]['data'] = $theme_data; + + // Get fresh vulnerabilities for the theme. + $themes_v[ $slug ]['wpvulnerability'] = wpvulnerability_get_fresh_theme_vulnerabilities( $themes_v[ $slug ], $slug ); + + // If the theme is vulnerable, increment the vulnerable themes counter. + if ( isset( $themes_v[ $slug ]['wpvulnerability']['vulnerable'] ) && 1 === ( is_scalar( $themes_v[ $slug ]['wpvulnerability']['vulnerable'] ) ? (int) $themes_v[ $slug ]['wpvulnerability']['vulnerable'] : 0 ) ) { + ++$wpvulnerability_themes_vulnerable; + } + } + + // Update options for multisite installations. + if ( is_multisite() ) { + update_site_option( 'wpvulnerability-themes', wp_json_encode( $themes_v ) ); + update_site_option( 'wpvulnerability-themes-vulnerable', wp_json_encode( number_format( $wpvulnerability_themes_vulnerable, 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-themes-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ) ); + update_site_option( 'wpvulnerability-themes-signature', wp_json_encode( $signature ) ); + } else { + update_option( 'wpvulnerability-themes', wp_json_encode( $themes_v ), false ); + update_option( 'wpvulnerability-themes-vulnerable', wp_json_encode( number_format( $wpvulnerability_themes_vulnerable, 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-themes-cache', wp_json_encode( number_format( time() + ( 3600 * wpvulnerability_cache_hours() ), 0, '.', '' ) ), false ); + update_option( 'wpvulnerability-themes-signature', wp_json_encode( $signature ), false ); + } + + $encoded = wp_json_encode( $themes_v ); + return false !== $encoded ? $encoded : '[]'; +} + +/** + * Get cached themes vulnerabilities without contacting the API. Data updates via scheduled or manual refreshes. + * + * @since 2.0.0 + * @since 4.1.2 Refreshes when the installed themes signature changes. + * + * @return array Array of installed themes with their vulnerabilities. + */ +function wpvulnerability_theme_get_vulnerabilities() { + + $raw = is_multisite() ? get_site_option( 'wpvulnerability-themes', '' ) : get_option( 'wpvulnerability-themes', '' ); + $theme_data = json_decode( is_string( $raw ) ? $raw : '', true ); + + return is_array( $theme_data ) ? $theme_data : array(); +} + +/** + * Update the installed themes cache and remove any old cache data. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_theme_get_vulnerabilities_clean() { + wpvulnerability_clear_cache( 'themes' ); + wpvulnerability_theme_get_installed(); +} + +/** + * Admin Head + * Adds vulnerability information after the theme row and notices on the theme page based on the installed theme cache. + * + * @since 2.0.0 + * + * @return void + */ +function wpvulnerability_theme_page() { + + // Check if the current page is the themes page. + global $pagenow; + if ( wpvulnerability_analyze_filter( 'themes' ) && 'themes.php' === $pagenow && wpvulnerability_capabilities() ) { + + // Get the vulnerabilities for the installed themes. + $themes = wpvulnerability_theme_get_vulnerabilities(); + + // Loop through the themes and add vulnerability information after the theme row for vulnerable themes. + foreach ( $themes as $theme_file => $theme_data ) { + if ( ! is_array( $theme_data ) ) { + continue; + } + $td_wpv = isset( $theme_data['wpvulnerability'] ) && is_array( $theme_data['wpvulnerability'] ) ? $theme_data['wpvulnerability'] : array(); + if ( isset( $td_wpv['vulnerable'] ) && 1 === ( is_scalar( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) { + add_action( 'after_theme_row_' . esc_attr( $theme_file ), 'wpvulnerability_theme_info_after', 10, 2 ); + } + } + } +} +// Add notices for vulnerable themes on the theme page. +add_action( 'admin_head', 'wpvulnerability_theme_page' ); + +/** + * Build the vulnerability HTML block for the single-site theme details modal. + * + * Generates a standalone table (no wrapping ) with one row per vulnerability, + * using the same column layout and badges as the theme-row renderer. + * + * @since 5.0.0 + * + * @param array $vulnerabilities Array of vulnerability objects from the cache. + * @return string HTML string ready to be injected into the modal, or empty string if none. + */ +function wpvulnerability_theme_modal_html( $vulnerabilities ) { + if ( empty( $vulnerabilities ) ) { + return ''; + } + + $icon = ''; + $html = '

' . $icon . ' ' . esc_html__( 'This theme has known vulnerabilities that may be affecting your installed version.', 'wpvulnerability' ) . '

'; + $html .= ''; + + foreach ( $vulnerabilities as $vulnerability ) { + if ( ! is_array( $vulnerability ) ) { + continue; + } + + $vuln_versions_raw = $vulnerability['versions'] ?? ''; + $vuln_versions = is_scalar( $vuln_versions_raw ) ? (string) $vuln_versions_raw : ''; + $vuln_closed_raw = $vulnerability['closed'] ?? 0; + $vuln_closed = is_scalar( $vuln_closed_raw ) ? intval( $vuln_closed_raw ) : 0; + $vuln_unfixed_raw = $vulnerability['unfixed'] ?? 0; + $vuln_unfixed = is_scalar( $vuln_unfixed_raw ) ? intval( $vuln_unfixed_raw ) : 0; + $vuln_impact = isset( $vulnerability['impact'] ) && is_array( $vulnerability['impact'] ) ? $vulnerability['impact'] : array(); + $vuln_cvss = isset( $vuln_impact['cvss'] ) && is_array( $vuln_impact['cvss'] ) ? $vuln_impact['cvss'] : array(); + $vuln_cvss2 = isset( $vuln_impact['cvss2'] ) && is_array( $vuln_impact['cvss2'] ) ? $vuln_impact['cvss2'] : array(); + $vuln_cvss3 = isset( $vuln_impact['cvss3'] ) && is_array( $vuln_impact['cvss3'] ) ? $vuln_impact['cvss3'] : array(); + $vuln_cvss4 = isset( $vuln_impact['cvss4'] ) && is_array( $vuln_impact['cvss4'] ) ? $vuln_impact['cvss4'] : array(); + $vuln_ssvc = isset( $vuln_impact['ssvc'] ) && is_array( $vuln_impact['ssvc'] ) ? $vuln_impact['ssvc'] : array(); + $vuln_cwe = isset( $vuln_impact['cwe'] ) && is_array( $vuln_impact['cwe'] ) ? $vuln_impact['cwe'] : array(); + $vuln_sources = isset( $vulnerability['source'] ) && is_array( $vulnerability['source'] ) ? $vulnerability['source'] : array(); + + $kev = ( isset( $vuln_ssvc['kev'] ) && true === $vuln_ssvc['kev'] ); + $exploitation = isset( $vuln_ssvc['exploitation'] ) && is_string( $vuln_ssvc['exploitation'] ) ? $vuln_ssvc['exploitation'] : ''; + $automatable = isset( $vuln_ssvc['automatable'] ) && is_string( $vuln_ssvc['automatable'] ) ? $vuln_ssvc['automatable'] : ''; + $kev_date_raw = $vuln_ssvc['kev_date'] ?? null; + $kev_date = is_string( $kev_date_raw ) && '' !== $kev_date_raw ? $kev_date_raw : null; + $epss_raw = $vuln_impact['epss'] ?? null; + $epss = is_numeric( $epss_raw ) ? (float) $epss_raw : null; + $description = wpvulnerability_get_source_description( $vuln_sources ); + + // Best available CVSS score and severity: cvss4 > cvss3 > cvss2 > legacy cvss. + $score_raw = null; + $sev_raw = null; + foreach ( array( $vuln_cvss4, $vuln_cvss3, $vuln_cvss2, $vuln_cvss ) as $cvss_c ) { + if ( empty( $cvss_c ) ) { + continue; + } + $s_raw = $cvss_c['score'] ?? null; + $v_raw = $cvss_c['severity'] ?? null; + $s = is_numeric( $s_raw ) ? number_format( (float) $s_raw, 1, '.', '' ) : null; + $v = is_string( $v_raw ) && '' !== $v_raw ? $v_raw : null; + if ( null !== $s || null !== $v ) { + $score_raw = $s; + $sev_raw = $v; + break; + } + } + + $what = array(); + foreach ( $vuln_cwe as $vulnerability_cwe ) { + if ( ! is_array( $vulnerability_cwe ) ) { + continue; + } + $cwe_name = $vulnerability_cwe['name'] ?? ''; + $cwe_desc = $vulnerability_cwe['description'] ?? ''; + $what[] = '
' . wp_kses( is_scalar( $cwe_name ) ? (string) $cwe_name : '', 'strip' ) . '
' . esc_html( is_scalar( $cwe_desc ) ? (string) $cwe_desc : '' ) . '
'; + } + + $version_display = wpvulnerability_clean_version_range( $vuln_versions ); + $source_pills = wpvulnerability_render_source_pills( $vuln_sources ); + $score_badge = wpvulnerability_render_score_badge( $score_raw, $sev_raw, $epss ); + $show_active = $kev || 'active' === $exploitation; + $show_poc = 'poc' === $exploitation; + $show_auto = 'yes' === $automatable; + + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + + $html .= '
'; + $html .= '' !== $version_display + ? '' . $version_display . '' + : '—'; + $html .= ''; + + if ( $show_active || $show_poc || $show_auto || '' !== $score_badge ) { + $html .= '
'; + if ( $show_active ) { + $html .= '⚠ ' . esc_html__( 'Actively exploited', 'wpvulnerability' ); + if ( $kev && null !== $kev_date ) { + $html .= ' · ' . esc_html( $kev_date ); + } + $html .= ''; + } + if ( $show_poc ) { + $html .= '⚡ ' . esc_html__( 'Public exploit', 'wpvulnerability' ) . ''; + } + if ( $show_auto ) { + $html .= '⚙ ' . esc_html__( 'Automatable', 'wpvulnerability' ) . ''; + } + if ( '' !== $score_badge ) { + $html .= $score_badge; + } + $html .= '
'; + } + if ( null !== $description ) { + $html .= '
' . esc_html( $description ) . '
'; + } + if ( $vuln_closed || $vuln_unfixed ) { + $html .= '
'; + if ( $vuln_closed ) { + $html .= '
' . esc_html__( 'This theme is closed. Please replace it with another.', 'wpvulnerability' ) . '
'; + } + if ( $vuln_unfixed ) { + $html .= '
' . esc_html__( 'This vulnerability appears to be unpatched. Stay tuned for upcoming theme updates.', 'wpvulnerability' ) . '
'; + } + $html .= '
'; + } + if ( ! empty( $what ) ) { + $html .= '
' . implode( '', $what ) . '
'; + } + if ( '' !== $source_pills ) { + $html .= '
' . esc_html__( 'References:', 'wpvulnerability' ) . ''; + $html .= $source_pills; + $html .= '
'; + } + + $html .= '
'; + return $html; +} + +/** + * Inject pre-rendered vulnerability HTML into the theme data passed to the JS modal. + * + * Hooks into wp_prepare_themes_for_js to add a wpvulnerability_html key for each + * vulnerable theme. The JS template patch in wpvulnerability_theme_modal_template_patch() + * then surfaces this data inside the modal. + * + * @since 5.0.0 + * + * @param array $prepared_themes Themes data prepared for JS. + * @return array Modified themes data. + */ +function wpvulnerability_filter_prepare_themes_for_js( $prepared_themes ) { + if ( ! wpvulnerability_analyze_filter( 'themes' ) || ! wpvulnerability_capabilities() ) { + return $prepared_themes; + } + + $theme_vulns = wpvulnerability_theme_get_vulnerabilities(); + + foreach ( $prepared_themes as $slug => $theme ) { + if ( ! isset( $theme_vulns[ $slug ] ) || ! is_array( $theme_vulns[ $slug ] ) ) { + continue; + } + $entry = $theme_vulns[ $slug ]; + $td_wpv = isset( $entry['wpvulnerability'] ) && is_array( $entry['wpvulnerability'] ) ? $entry['wpvulnerability'] : array(); + if ( ! isset( $td_wpv['vulnerable'] ) || 1 !== ( is_scalar( $td_wpv['vulnerable'] ) ? (int) $td_wpv['vulnerable'] : 0 ) ) { + continue; + } + $vulnerabilities = isset( $td_wpv['vulnerabilities'] ) && is_array( $td_wpv['vulnerabilities'] ) ? $td_wpv['vulnerabilities'] : array(); + if ( empty( $vulnerabilities ) ) { + continue; + } + if ( is_array( $prepared_themes[ $slug ] ) ) { + $prepared_themes[ $slug ]['wpvulnerability_html'] = wpvulnerability_theme_modal_html( $vulnerabilities ); + } + } + + return $prepared_themes; +} +add_filter( 'wp_prepare_themes_for_js', 'wpvulnerability_filter_prepare_themes_for_js' ); + +/** + * Patch the #tmpl-theme-single Underscore template to display vulnerability data. + * + * Injects a conditional block after the tags section so that when a theme's JS data + * contains wpvulnerability_html, that HTML is rendered inside the details modal. + * Only runs on the single-site themes.php page. + * + * @since 5.0.0 + * + * @return void + */ +function wpvulnerability_theme_modal_template_patch() { + global $pagenow; + if ( 'themes.php' !== $pagenow || is_multisite() || ! wpvulnerability_capabilities() || ! wpvulnerability_analyze_filter( 'themes' ) ) { + return; + } + ?> + + items as $theme_file => $theme_data ) { + $tf_entry = isset( $theme_vulnerabilities[ $theme_file ] ) && is_array( $theme_vulnerabilities[ $theme_file ] ) ? $theme_vulnerabilities[ $theme_file ] : array(); + $tf_wpv = isset( $tf_entry['wpvulnerability'] ) && is_array( $tf_entry['wpvulnerability'] ) ? $tf_entry['wpvulnerability'] : array(); + if ( empty( $tf_wpv['vulnerable'] ) || 0 === ( is_scalar( $tf_wpv['vulnerable'] ) ? (int) $tf_wpv['vulnerable'] : 0 ) ) { + unset( $wp_list_table->items[ $theme_file ] ); + } + } + } +} + +/** + * Initializes the vulnerability filtering for the themes list in the network admin area of a multisite installation. + * + * This function checks if the current environment is a multisite network and whether the user is in the network + * admin area. If both conditions are met, it hooks into the 'admin_head-themes.php' action to apply a filter that + * shows only vulnerable themes in the themes list. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_themes_filter_init() { + if ( is_multisite() && is_network_admin() ) { + add_action( 'admin_head-themes.php', 'wpvulnerability_themes_filter' ); + } +} +add_action( 'network_admin_menu', 'wpvulnerability_themes_filter_init' ); + +/** + * Adds a "Vulnerable" tab to the WordPress themes page that displays the count of vulnerable themes. + * + * This function checks the cache for the number of vulnerable themes and adds a new tab to the themes + * management page in the WordPress admin area. The tab displays the count of vulnerable themes and highlights it + * if it is currently active. The tab is added only in the network admin area of a multisite installation. + * + * @since 3.3.5 + * + * @param array $views An array of existing theme views (tabs) in the WordPress admin themes page. + * + * @return array The modified array of views including the "Vulnerable" tab. + */ +function wpvulnerability_themes_view( $views ) { + if ( ! wpvulnerability_analyze_filter( 'themes' ) ) { + return $views; + } + + $raw_count = is_multisite() ? get_site_option( 'wpvulnerability-themes-vulnerable', '0' ) : '0'; + $wpvulnerability_themes_total = ( is_scalar( json_decode( is_string( $raw_count ) ? $raw_count : '0', true ) ) ? (int) json_decode( is_string( $raw_count ) ? $raw_count : '0', true ) : 0 ); + + if ( is_multisite() && is_network_admin() ) { + $url = network_admin_url( 'themes.php?theme_status=vulnerable' ); + + // Add nonce for CSRF protection. + $url = esc_url( wp_nonce_url( $url, 'wpvulnerability_filter_themes', 'wpv_nonce' ) ); + + $views['vulnerable'] = sprintf( + '%s', + $url, + ( isset( $_GET['theme_status'] ) && 'vulnerable' === $_GET['theme_status'] ? ' class="current"' : '' ), // phpcs:ignore + // translators: the number of vulnerabilities. + sprintf( __( 'Vulnerabilities (%d)', 'wpvulnerability' ), $wpvulnerability_themes_total ) + ); + } + + return $views; +} + +/** + * Adds a custom filter to the themes page in the WordPress admin to display a tab for vulnerable themes. + * + * This function hooks into the 'views_themes-network' filter to add a custom tab or view for displaying vulnerable themes + * on the themes management page in the WordPress network admin area. The tab is added only in a multisite setup + * and specifically in the network admin context. + * + * @since 3.3.5 + * + * @return void + */ +function wpvulnerability_themes_add_tab() { + if ( is_multisite() && is_network_admin() ) { + add_filter( 'views_themes-network', 'wpvulnerability_themes_view' ); + } +} +add_action( 'admin_head', 'wpvulnerability_themes_add_tab' ); diff --git a/wpvulnerability.php b/wpvulnerability.php new file mode 100644 index 0000000..fcf2492 --- /dev/null +++ b/wpvulnerability.php @@ -0,0 +1,138 @@ +