├── COPYING ├── Changelog.md ├── GenericSecurityCheckPlugin.php ├── MediaWikiSecurityCheckPlugin.php ├── README.md ├── internal ├── make_phar.php └── make_phar.sh ├── scripts ├── base-config.php ├── generic-config.php └── seccheck └── src ├── CausedByLines.php ├── FunctionCausedByLines.php ├── FunctionTaintedness.php ├── LinksSet.php ├── MWPreVisitor.php ├── MWVisitor.php ├── MediaWikiHooksHelper.php ├── MethodLinks.php ├── ParamLinksOffsets.php ├── PreTaintednessVisitor.php ├── PreservedTaintedness.php ├── ReturnObjectsCollectVisitor.php ├── SecurityCheckPlugin.php ├── SingleMethodLinks.php ├── Taintedness.php ├── TaintednessAccessorsTrait.php ├── TaintednessAssignVisitor.php ├── TaintednessBackpropVisitor.php ├── TaintednessBaseVisitor.php ├── TaintednessLoopVisitor.php ├── TaintednessVisitor.php ├── TaintednessWithError.php └── VarLinksSet.php /COPYING: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # MediaWiki Security Check Plugin changelog 2 | 3 | ## v6.1.0 4 | ### New features 5 | * Improved accuracy of error reporting ("caused-by lines") by tracking array shapes in more places. 6 | * Added support for analyzing `call_user_func` and `call_user_func_array`. 7 | * Improved readability of error reporting by reordering the lines to follow parameters from call to sink (i.e. opposite to what they were before). 8 | 9 | ### Bug fixes 10 | * Fixed a host of bugs affecting backpropagation of taintedness, resulting in anything between false positives, false negatives, inaccurate error reporting, and OOM. 11 | * Made error reporting more accurate for arguments passed by reference. 12 | 13 | ### Internal changes 14 | * Bumped phan/phan to 5.4.5 15 | 16 | ## v6.0.0 17 | ### Breaking changes 18 | * (MW) Most of the taintedness values hardcoded in MediaWikiSecurityCheckPlugin::getCustomFuncTaints() have been removed, and annotations have been 19 | added to the relevant methods in MediaWiki itself. Therefore, this version of phan-taint-check-plugin is only compatible with MediaWiki 1.41+. 20 | 21 | ### New features 22 | * getCustomFuncTaints implementations can now return FunctionTaintedness objects directly, in addition to arrays. 23 | * Array keys (and shapes in general) are now tracked more granularly when backpropagating the effects of a function call. 24 | * (MW) Analyze the `$rows` argument to `Database::insert()` more accurately, and apply similar rules to `InsertQueryBuilder::row()` and `::rows()`. 25 | * Improved shape inference for several built-in array functions. 26 | * (MW) Treat the `help` key in HTMLForm descriptors as an HTML sink. 27 | * (MW) Add compatibility for new Parser FQSEN `\MediaWiki\Parser\Parser`. The non-namespaced version is also still supported. 28 | 29 | ### Bug fixes 30 | * Fixed a bug where `*-taint` annotations in an interface method were only inherited by the method implementation in children classes. 31 | 32 | ### Internal changes 33 | * Bumped phan/phan to 5.4.3 34 | 35 | ## v5.0.0 36 | ### Breaking changes 37 | * The raw_param taint flag was removed; error reporting is now sufficiently good that this is no longer needed, and can be treated as normal exec. 38 | * The taint type "misc" was removed. Use the appropriate category instead. This type was originally used for "rce" and "path", so the appropriate replacement could be one of those. 39 | * The `SecurityCheckMulti` issue type was removed. Now, the plugin emits one issue per taint type. 40 | * Dropped support for PHP 7.2 and 7.3. 41 | 42 | ### New features 43 | * Added support for the effects of `unset( $var['k'] )` on the shape of `$var`. 44 | * The plugin now infers the effect of some array_* functions on the resulting taintedness more accurately. 45 | 46 | ### Changed 47 | * The `SecurityCheck-RCE` and `SecurityCheck-PathTraversal` issue types now have critical severity. 48 | 49 | ### Internal changes 50 | * Bumped phan/phan to 5.4.2 51 | 52 | ## v4.0.0 53 | ### Breaking changes 54 | * Global variables and property no longer have EXEC flags if they're later output. Previously, it was supposed to report assigning a tainted value to an object 55 | that is later output, but didn't work due to a bug. The relevant logic was complicating maintenance. Running phan with `--analyze-twice` will catch this kind of issues. 56 | * The plugin no longer reanalyzes classes when the taintedness of a property is changed. Running phan with `--analyze-twice` will catch this kind of issues. 57 | 58 | ### New features 59 | * Added a new issue type, `SecurityCheckInvalidAnnotation`, emitted for `-taint` annotations that cannot be parsed, use unknown or forbidden values (e.g. EXEC bits in `return-taint`), document non-existing parameters, or have redundant/missing `...`. 60 | 61 | ### Bug fixes 62 | * Caused-by lines are now much more accurate for code involving function calls. 63 | 64 | ### Internal changes 65 | * Bumped phan/phan to 5.4.1 66 | 67 | ## v3.3.2 68 | ### Bug fixes 69 | * Improved caused-by lines for return statements consisting of a function-like call and for inherited methods. 70 | ### Internal changes 71 | * Bumped phan/phan to 5.2.0 72 | 73 | ## v3.3.1 74 | ### Internal changes 75 | * Bumped phan/phan to 5.1.0 76 | 77 | ## v3.3.0 78 | ### Breaking changes 79 | * Removed support for standalone install on MediaWiki repos. Generic standalone is still supported, but the script is now called `seccheck`, not `seccheck-generic`. 80 | * `raw_param` is now a modifier for EXEC taintedness, so it must be specified together with EXEC bits, not normal bits. 81 | * The plugin now limits reanalysis of classes to 1 per class when the taintedness of a property is changed. This might hide some issues, but is much faster. Running phan with `--analyze-twice` will help; this might become officially suggested in the future. 82 | 83 | ### New features 84 | * Added support for the following PHP 7.4 and PHP 8 features: arrow functions, `match`, named arguments, nullsafe method calls and property access, typed properties, constructor property promotion 85 | * Infer array shape mutations for several array-related builtin functions 86 | * Improved taint data for $_FILES 87 | * The plugin now properly infers when a parameter is passed through by a function (even partially or conditionally), and can determine the resulting taintedness of a function call much more accurately 88 | * Improved caused-by lines for setters and some functions that pass their parameters through 89 | * It is now possible to put comments after `@param-taint` and `@return-taint` annotations 90 | * Added taintedness data for PDO functions 91 | * Added partial support for backpropagating NUMKEY taint, in the very few cases where false positives are highly unlikely (this will be improved) 92 | * (MW) Improved hook registration, being now able to infer the callback in more cases 93 | * (MW) Added partial support for HookHandlers in extension.json 94 | * Improved handling of pass-by-reference parameters when the parameter is essentially left unchanged 95 | * The plugin can now track array shapes when backpropagating EXEC taintedness. This brings increased accuracy when analyzing method calls. 96 | * The following hashing functions were annotated as removing taintedness from their arguments: `md5`, `sha1` and `crc32` 97 | 98 | ### Bug fixes 99 | * Improved merging caused-by lines to avoid duplicates 100 | * Avoid tracking dependencies of functions with hardcoded taintedness, so to keep caused-by lines shorter and more relevant 101 | * Fixed a bug that caused EXEC taints to be backpropagated to local variables, thus creating weird-looking issues 102 | * Fixed some edge cases which would make phan issues disappear with taint-check enabled 103 | 104 | ### Internal changes 105 | * Bumped phan/phan to 4.0.4 106 | * The plugin now caches taintedness data inside AST nodes. This requires additional memory (300 MB for MW core), but reduces the runtime (30 seconds for MW core) 107 | 108 | ## v3.2.1 109 | ### Bug fixes 110 | * Fixed a crash observed when using the polyfill parser 111 | * Fixed two crashes introduced with the 3.2.0 release 112 | 113 | ### Internal changes 114 | * Bumped phan/phan to 3.2.6 115 | 116 | ## v3.2.0 117 | ### New features 118 | * Variadic parameters are now properly handled 119 | * Array keys are now tracked separately from values 120 | * (MW) Properly track taintedness of array keys in some HTMLForm specifiers 121 | * Created new taint types and issues for RCE and path traversal: `SecurityCheck-RCE` and `SecurityCheck-PathTraversal` 122 | * Added detection for ReDoS vulnerabilities. New issue: `SecurityCheck-ReDoS` 123 | * The plugin can now properly analyze assignments with an array at the LHS 124 | * Array shapes are now tracked more precisely when a key that cannot be determined statically is found 125 | 126 | ## v3.1.1 127 | ### Internal changes 128 | * Allow installing the plugin in PHP 8. Analyzing code with new PHP 8 features is not supported yet (T269263) 129 | 130 | ## v3.1.0 131 | ### New features 132 | * Increased the length limit for caused-by lines. The new limit is at 12 entries, rather than fixed at 255 characters (it was roughly doubled) 133 | * Caused-by lines are now stored together with a taintedness value, which allows filtering taintedness depending on the sink type 134 | * Handle a few more edge cases in foreach loops. Notably, class properties used as key or value are now 135 | properly analyzed, and caused-by lines now include sources of taintedness outside the loop. 136 | * The plugin now filters taintedness based on the (real) type of variables using `if` conditions, 137 | parameters and return type declarations. 138 | * Binops are now properly analysed, removing taintedness if the operation is safe. 139 | * Caused-by lines for function calls now include a code snippet with the argument, together with its ordinal. 140 | * Added an annotation to print the taintedness of a variable (use it with `'@phan-debug-var-taintedness $varname'`) 141 | * Added taint data for a bunch of built-in functions 142 | 143 | ### Bug fixes 144 | * (MW) Fixed a crash observed when using `$this` as hook handler 145 | * Fixed an edge case that made the plugin crash when attempting to use an undeclared variable as a 146 | callable 147 | * Fixed a bug that caused the same issue to be reported on multiple lines, hence creating redundant 148 | warnings, making it difficult to suppress them all. 149 | * (MW) Avoid crash when Hooks::run has no arguments array 150 | * Fixed an edge case where literal integers/strings weren't recognized as integers/strings; this brings improved tracking of SQL_NUMKEY. 151 | * (MW) Fixed incorrect taint data for Sanitizer::removeHTMLtags (T268353) 152 | * Slightly improved performance for recursive methods (analysis is not attempted, rather than letting it reach the recursion limit of 5) 153 | 154 | ### Internal changes 155 | * Taintedness is now stored in a value object, rather than a plain integer. 156 | * Function taintedness is now stored in a value object, rather than an array of integers. 157 | * Issue descriptions now use phan templates, which notably adds support for selective colorizing. 158 | * (MW) The plugin no longer forces types for MW globals in non-standalone mode. This is now done by mediawiki-phan-config. 159 | * Plugin classes were moved to the `SecurityCheckPlugin` namespace. 160 | * Bumped phan/phan to 3.2.4 161 | 162 | ## v3.0.4 163 | ### New features 164 | * Added explicit taint info for `LinkRenderer::makeBrokenLink` 165 | * Added explicit taint info for `shell_exec` and friends 166 | * The plugin is now able to properly analyze conditionals, and merge the possible taints of each branch 167 | * The plugin can now analyze pass by reference variables better 168 | * Added support for analyzing each array element on its own 169 | 170 | ### Bug fixes 171 | * Fixed several plugin crashes observed when analyzing weird syntax 172 | * Fixed a crash observed with non-literal keys in getQueryInfo methods (T268055) 173 | 174 | ### Internal changes 175 | * Bumped phan/phan to 3.0.3 176 | * The plugin is now using PluginV3 177 | * Objects returned by methods are now tracked in-place, and `GetReturnObjVisitor` was deleted 178 | 179 | ## v3.0.3 180 | * Remove reference to `AST_LIST` (Daimona Eaytoy) 181 | * Avoid shelling out to run tests (Daimona Eaytoy) 182 | * Move hooks-related methods to a new class (Daimona Eaytoy) 183 | * composer: Add Daimona as an author (James D. Forrester) 184 | * Split a long method (Daimona Eaytoy) 185 | * Expand docs for "manual" mode (Daimona Eaytoy) 186 | * Assert that the config options we need are enabled (Daimona Eaytoy) 187 | * Avoid conflating `stdClass` instances (Daimona Eaytoy) 188 | * Cleanup: Various improvements suggested by PHPStorm (Daimona Eaytoy) 189 | * Cleanup: Add return type hints where applicable (Daimona Eaytoy) 190 | * Exclude invalid PHP files from analysis (Daimona Eaytoy) 191 | 192 | ## v3.0.2 193 | * Fix `PhanTypeComparisonFromArray` edge cases (Daimona Eaytoy) 194 | * Don't check type validity in `nodeIs(String|Int)` (Daimona Eaytoy) 195 | * Optim: Don't reanalyze functions if we already have data (Daimona Eaytoy) 196 | * Fix edge cases with `getOriginalScope` (Daimona Eaytoy) 197 | * Make `handleMethodCall` always require a `FunctionInterface` and a function FQSEN (Daimona Eaytoy) 198 | * Fix bad interaction with phan, part 3 (Daimona Eaytoy) 199 | * Cleanup: Remove unnecessary `try/catch` constructs (Daimona Eaytoy) 200 | * Cleanup: Add method to extract data from exceptions (Daimona Eaytoy) 201 | * Cleanup: Change `taintToIssueAndSeverity` to use a switch (Daimona Eaytoy) 202 | * Fix another edge case interaction with phan (Daimona Eaytoy) 203 | * Fix edge case with prop access confusing other parts of phan (Daimona Eaytoy) 204 | 205 | ## v3.0.1 206 | * build: Upgrade minus-x from 0.3.2 to 1.1.0 (James D. Forrester) 207 | * Upgrade phan to latest version (Daimona Eaytoy) 208 | * Properly handle `list()` assignments (Daimona Eaytoy) 209 | * Upgrade phan to 2.4.0 (Daimona Eaytoy) 210 | 211 | ## v3.0.0 212 | * Fix phan crash when analyzing MediaWiki core (Daimona Eaytoy) 213 | * Add `RAW_PARAM` taint type (Daimona Eaytoy) 214 | * build: Upgrade mediawiki-codesniffer from v29.0.0 to v30.0.0 (James D. Forrester) 215 | * Remove outdated config settings (Daimona Eaytoy) 216 | * Add `UnusedSuppressionPlugin` limited to our warnings (Daimona Eaytoy) 217 | * Actually handle binary addition (Daimona Eaytoy) 218 | * Update PHPUnit to 8.5 (Umherirrender) 219 | * build: Upgrade mediawiki-codesniffer to v29.0.0 (James D. Forrester) 220 | * build: Updating composer dependencies (Umherirrender) 221 | * Upgrade phan to 2.2.13 (Daimona Eaytoy) 222 | * Remove hack for OOUI constructors (Daimona Eaytoy) 223 | * Upgrade to phan 2.2.5 (Daimona Eaytoy) 224 | * Further improvements for same var reassignments (Daimona Eaytoy) 225 | * Better handling of reassignments of the same var (Daimona Eaytoy) 226 | * Don't fail hard when core methods cannot be found (Daimona Eaytoy) 227 | * Shrink config files even more (Daimona Eaytoy) 228 | * Remove explicit dependency on `ext-ast` (Daimona Eaytoy) 229 | * Cleanup parent var linking code (Daimona Eaytoy) 230 | * Remove awful hack for var context (Daimona Eaytoy) 231 | * Upgrade to PHPUnit 8.4 (Daimona Eaytoy) 232 | * build: Upgrade MW phpcs to 28.0.0 (Daimona Eaytoy) 233 | * Replace `EXEC_TAINT` with `ALL_EXEC_TAINT` where latter was meant (Brian Wolff) 234 | * Upgrade phan to 2.0.0, ast to 1.0.1 and require PHP72+ (Daimona Eaytoy) 235 | 236 | ## v2.1.0 237 | * Improve caused-by lines (Daimona Eaytoy) 238 | * Add debug for reaching max analysis depth (Daimona Eaytoy) 239 | * Add some unhandled node kinds (Daimona Eaytoy) 240 | * Visit `AST_EMPTY` (Daimona Eaytoy) 241 | * Further improvements (Daimona Eaytoy) 242 | * Handle closure vars (Daimona Eaytoy) 243 | * Handle closures (Daimona Eaytoy) 244 | * Make CI run phpunit tests (Daimona Eaytoy) 245 | * Make CI run phpunit tests (Daimona Eaytoy) 246 | 247 | ## v2.0.2 248 | * Fix a crash with the literal '`class`' (Daimona Eaytoy) 249 | * Handle pre/post-increment/decrement operators (Daimona Eaytoy) 250 | * Various code quality improvements (Daimona Eaytoy) 251 | * Restore `TypedElementInterface` typehints (Daimona Eaytoy) 252 | * Fix some FIXMEs (Daimona Eaytoy) 253 | * Fix some issues with CI (Daimona Eaytoy) 254 | * Add missing slashes to `MW_INSTALL_PATH` (Daimona Eaytoy) 255 | * Re-fix failing test (Daimona Eaytoy) 256 | * Fix a failing test (Daimona Eaytoy) 257 | 258 | ## v2.0.1 259 | * Fix some issues with CI (Daimona Eaytoy) 260 | * Add missing slashes to `MW_INSTALL_PATH` (Daimona Eaytoy) 261 | * Re-fix failing test (Daimona Eaytoy) 262 | * Fix a failing test (Daimona Eaytoy) 263 | * Remove a duplicated method (Daimona Eaytoy) 264 | * When suppressing a warning, also suppress side effects (Brian Wolff) 265 | * Mark `IDatabase::buildLike` as something that escapes SQL (Brian Wolff) 266 | * Special handling for `Linker::makeExternalLink` (Brian Wolff) 267 | * When in MW mode, consider XSS in the maintenance directory to be false positives (Brian Wolff) 268 | * Prevent an `EXEC` variable from tainting itself (Brian Wolff) 269 | 270 | ## v2.0.0 271 | * Remove wrong `EXEC` bits from MW functions (Daimona Eaytoy) 272 | * Take into account implicit BranchScopes (Daimona Eaytoy) 273 | * Update readme (Daimona Eaytoy) 274 | * Add a file with base config (Daimona Eaytoy) 275 | * Temporarily lower ast requirement (Daimona Eaytoy) 276 | * Hotfix for OOUI exclusion (Daimona Eaytoy) 277 | * Handle nested calls (Daimona Eaytoy) 278 | * Set taintedness to `NO_TAINT` for `class-string` and `callable-string` (Daimona Eaytoy) 279 | * Update integration tests (Daimona Eaytoy) 280 | * Fix global variable handling (Daimona Eaytoy) 281 | * Add checks for `ClosureType` (Daimona Eaytoy) 282 | * Transfer the taintedness from objects to props (Daimona Eaytoy) 283 | * Prevent class props from sending taintedness too far (Daimona Eaytoy) 284 | * Restore code bit for linking var to parentvar (Daimona Eaytoy) 285 | * Make `nodeIsString` work again (Daimona Eaytoy) 286 | * Unbreak `passByReference` parameters handling (Daimona Eaytoy) 287 | * Hack: exclude OOUI constructors from DoubleEscape reporting (Daimona Eaytoy) 288 | * Unbreak handling of `$argc` and `$argv` (Daimona Eaytoy) 289 | * Unbreak docblock parsing (Daimona Eaytoy) 290 | * Fix phan issues (Daimona Eaytoy) 291 | * Upgrade phan to 1.3.2 and php-ast to 1.0.1 (Daimona Eaytoy) 292 | * When suppressing a warning, also suppress side effects (Brian Wolff) 293 | * Mark `IDatabase::buildLike` as something that escapes SQL (Brian Wolff) 294 | * Special handling for `Linker::makeExternalLink` (Brian Wolff) 295 | * When in MW mode, consider XSS in the maintenance directory to be false positives (Brian Wolff) 296 | * Prevent an `EXEC` variable from tainting itself (Brian Wolff) 297 | * Upgrade phan to 1.2.6 (Daimona Eaytoy) 298 | * Minor fixes (Daimona Eaytoy) 299 | * Upgrade phan to 1.0.0 (Daimona Eaytoy) 300 | * Upgrade to PluginV2 (Daimona Eaytoy) 301 | * Turn `TaintednessBaseVisitor` into a trait (Daimona Eaytoy) 302 | * Change inheritance for MW analyzer (Daimona Eaytoy) 303 | * Upgrade phan to 0.9.6 (Daimona Eaytoy) 304 | * Upgrade phan to 0.8.13 (Daimona Eaytoy) 305 | * Move regression test to PHPUnit (Daimona Eaytoy) 306 | * Upgrade phan to 0.8.6 (Daimona Eaytoy) 307 | * Minor fixes (Daimona Eaytoy) 308 | * Remove phpcs bootstrap. (Brian Wolff) 309 | * build: Updating mediawiki/mediawiki-codesniffer to 24.0.0 (libraryupgrader) 310 | * build: Updating mediawiki/mediawiki-codesniffer to 23.0.0 (libraryupgrader) 311 | * Add another test case related to batch insert. (Brian Wolff) 312 | 313 | ## v1.5.1 314 | * Fix fatal when using global keyword with indirect variable (Brian Wolff) 315 | * Clarify `SECURITY_CHECK_EXT_PATH` documentation (Kunal Mehta) 316 | 317 | ## v1.5.0 318 | * Avoid false positive related to `getQueryInfo()` methods. (Brian Wolff) 319 | * Include syntax errors in the output of plugin. (Brian Wolff) 320 | * Fix a fatal during a misdetected `HTMLForm` specifier with empty class (Brian Wolff) 321 | * Fix IN list case for db conds when doing `$conds['field'][] = $tainted` (Brian Wolff) 322 | * Fix some confusion over which group of taints to mask out in various places (Brian Wolff) 323 | * Treat `htmlform type=info`'s 'rawrow' option like 'raw' (Brian Wolff) 324 | * Disable `htmlform` detection inside `AuthenticationRequest` (Brian Wolff) 325 | * Better handling of `HTMLForm $options` (Brian Wolff) 326 | * Support custom checking for `IDatabase::makeList` (Brian Wolff) 327 | * Update README expand limitation section (Brian Wolff) 328 | * Link to docker image instructions in README.md (Brian Wolff) 329 | * Make parser hooks work properly even without type hints (Brian Wolff) 330 | * build: Updating mediawiki/mediawiki-codesniffer to 22.0.0 (libraryupgrader) 331 | * Fix bug in how taint propagation works (Brian Wolff) 332 | 333 | ## v1.4.0 334 | * Make `seccheck-mwext` and `seccheck-fast-mwext` work with skins (Brian Wolff) 335 | * Make `onlysafefor_html` not mark things as `exec_escaped`. (Brian Wolff) 336 | * Mark `base64_encode` as escaping taint. (Brian Wolff) 337 | * Fix error in argument handling in test script (Brian Wolff) 338 | * Add an indirect test case to taghook test (Brian Wolff) 339 | * Move builtin taints for `Parser` & `ParserOutput` into inline annotations (Brian Wolff) 340 | * Prevent `NO_OVERRIDE` flag from being propagated during assignment (Brian Wolff) 341 | * Add support for reading skin.json in addition to extension.json (Brian Wolff) 342 | 343 | ## v1.3.1 344 | * Ignore tests/ in mwext-fast (Kunal Mehta) 345 | * Fix markdown syntax in README (Umherirrender) 346 | 347 | ## v1.3.0 348 | * Refactor docblock taint annotation to support docblocks on interfaces (Brian Wolff) 349 | * Improve tracking of outputting class members (Brian Wolff) 350 | * Standardize casing in error as "Calling method..." (method is lowercase) (Kunal Mehta) 351 | * Fix bug when argument both normal taint and execute taint (Brian Wolff) 352 | * build: Updating mediawiki/mediawiki-codesniffer to 21.0.0 (libraryupgrader) 353 | * Fix bug where pass by ref causing func to be treated as unknown (Brian Wolff) 354 | * rm the hardcoded OOUI taints. They were wrong. (Brian Wolff) 355 | * Add code to force type for MW globals (Brian Wolff) 356 | * build: Updating mediawiki/mediawiki-codesniffer to 20.0.0 (libraryupgrader) 357 | 358 | ## v1.2.0 359 | * Add support for docblock taint annotations (Brian Wolff) 360 | * Fix phan tests (Brian Wolff) 361 | * build: Updating mediawiki/mediawiki-codesniffer to 18.0.0 (libraryupgrader) 362 | * build: Updating mediawiki/mediawiki-codesniffer to 17.0.0 (libraryupgrader) 363 | * build: Updating jakub-onderka/php-parallel-lint to 1.0.0 (libraryupgrader) 364 | * Add support for checking `HTMLForm` specifiers (Brian Wolff) 365 | * Use SPDX 3.0 license identifier (Umherirrender) 366 | * build: Updating mediawiki/mediawiki-codesniffer to 16.0.1 (libraryupgrader) 367 | * build: Adding MinusX (Umherirrender) 368 | * Don't mark `\Xml::encodeJsVar` and `encodeJsCall` as double escaping (Brian Wolff) 369 | * Fix missing initial `\` in class name list (Brian Wolff) 370 | * build: Updating mediawiki/mediawiki-codesniffer to 16.0.0 (libraryupgrader) 371 | * Add support for looking at `__toString()` when object in string context (Brian Wolff) 372 | * Improve some of the double escaping checks. (Brian Wolff) 373 | * Depend upon phan/phan instead of deprecated etsy/phan (Kunal Mehta) 374 | * build: Updating mediawiki/mediawiki-codesniffer to 15.0.0 (Kunal Mehta) 375 | * Add `Hooks::runWithoutAbort` support (Phantom42) 376 | * Add double escaping detection (Albert221) 377 | * Appearently this doesn't work with php-ast 0.1.5 (Brian Wolff) 378 | 379 | ## v1.1.0 380 | * Html escaping functions shouldn't clear non-html taint (Brian Wolff) 381 | * Finish rename to mediawiki/phan-taint-check-plugin (Brian Wolff) 382 | * Add .gitattributes file (Brian Wolff) 383 | * Fix some typos (Kunal Mehta) 384 | * Fix indentation in .phpcs.xml (Kunal Mehta) 385 | * Replace `SecurityCheckPlugin::` with `self::` where possible (Brian Wolff) 386 | * Add a test script for people whose php bin is not 7 (Brian Wolff) 387 | * Disable progress bar in composer test, as ugly on jenkins (Brian Wolff) 388 | * Rename plugin to mediawiki/phan-taint-check-plugin (Brian Wolff) 389 | * Add a note about how it can't validate certain types of SQL (Brian Wolff) 390 | * Version should be php 7.0 (7.1 not supported due to dependency) (Brian Wolff) 391 | * Rename to "mediawiki/phan-security-plugin" (Kunal Mehta) 392 | * Fix test that didn't pass lint (Brian Wolff) 393 | * Follow-up on Ie9106c80 (MarcoAurelio) 394 | * build: update composer.json (MarcoAurelio) 395 | * Add .gitreview (MarcoAurelio) 396 | * Add GPL license headers (Brian Wolff) 397 | * Make README prettier (Bryan Davis) 398 | 399 | ## v1.0.0 400 | * Update composer.json (Brian Wolff) 401 | * Support installing via composer. (Brian Wolff) 402 | * Update README (Brian Wolff) 403 | * Move plugin entry points to root directory (Brian Wolff) 404 | * Fix some false positives discovered while testing with MW (Brian Wolff) 405 | * Fix various false positives found when testing with MW (Brian Wolff) 406 | * Add a test for `list()` support (Brian Wolff) 407 | * Add test for array addition with `SQL_NUMKEY` (Brian Wolff) 408 | * Minor fixes discovered during testing (Brian Wolff) 409 | * Ensure that errors related to function are per param (Brian Wolff) 410 | * Minor fixes to the eval case (Brian Wolff) 411 | * Some debugging fixes (Brian Wolff) 412 | * Support checking `getQueryInfo()` return; Process `$options` & `$join_conds` (Brian Wolff) 413 | * Fix handling of `IN(...)` lists in db `select` wrapper (Brian Wolff) 414 | * Add support for `IDatabase::select` style arguments (Brian Wolff) 415 | * Fix bug where non-local variables are treated like local (Brian Wolff) 416 | * Add `ARRAY_OK` flag for functions that are safe with arrays (Brian Wolff) 417 | * Make unit tests for extension.json always work (Brian Wolff) 418 | * Make error messages from hooks be in extension instead of core (Brian Wolff) 419 | * Avoid duplication in output (Brian Wolff) 420 | * Fix some minor issues (Brian Wolff) 421 | * Handle dispatching of hooks on `Hooks::run()` (Brian Wolff) 422 | * Support loading hook information from extension.json (Brian Wolff) 423 | * Make more clear error messages, distinguishing different issue types (Brian Wolff) 424 | * Support recognizing `$wgHooks/$_GLOBALS['wgHooks']` (Brian Wolff) 425 | * Keep track of hook registrations (Brian Wolff) 426 | * Add support for parser tag hooks (Brian Wolff) 427 | * Support `ParserFunctions`, and start of work for hooks in general (Brian Wolff) 428 | * Add taint for db related function. Fix handling of subclasses (Brian Wolff) 429 | * Mention phan version requirements (Brian Wolff) 430 | * Fix remaining tests (mostly phpcs) (Brian Wolff) 431 | * Fix various tests (Brian Wolff) 432 | * Add composer and phpcs. (Brian Wolff) 433 | * Use the normal GPL v2 (Kunal Mehta) 434 | * Do not ouput very noisy debug by default (Brian Wolff) 435 | * Initial commit. (Brian Wolff) 436 | -------------------------------------------------------------------------------- /GenericSecurityCheckPlugin.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * This program is free software; you can redistribute it and/or modify 17 | * it under the terms of the GNU General Public License as published by 18 | * the Free Software Foundation; either version 2 of the License, or 19 | * (at your option) any later version. 20 | * 21 | * This program is distributed in the hope that it will be useful, 22 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | * GNU General Public License for more details. 25 | * 26 | * You should have received a copy of the GNU General Public License along 27 | * with this program; if not, write to the Free Software Foundation, Inc., 28 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 29 | */ 30 | 31 | use SecurityCheckPlugin\PreTaintednessVisitor; 32 | use SecurityCheckPlugin\SecurityCheckPlugin; 33 | use SecurityCheckPlugin\TaintednessVisitor; 34 | 35 | class GenericSecurityCheckPlugin extends SecurityCheckPlugin { 36 | /** 37 | * @inheritDoc 38 | */ 39 | public static function getPostAnalyzeNodeVisitorClassName(): string { 40 | return TaintednessVisitor::class; 41 | } 42 | 43 | /** 44 | * @inheritDoc 45 | */ 46 | public static function getPreAnalyzeNodeVisitorClassName(): string { 47 | return PreTaintednessVisitor::class; 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | protected function getCustomFuncTaints(): array { 54 | return []; 55 | } 56 | } 57 | 58 | return new GenericSecurityCheckPlugin; 59 | -------------------------------------------------------------------------------- /MediaWikiSecurityCheckPlugin.php: -------------------------------------------------------------------------------- 1 | 8 | * 9 | * This program is free software; you can redistribute it and/or modify 10 | * it under the terms of the GNU General Public License as published by 11 | * the Free Software Foundation; either version 2 of the License, or 12 | * (at your option) any later version. 13 | * 14 | * This program is distributed in the hope that it will be useful, 15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | * GNU General Public License for more details. 18 | * 19 | * You should have received a copy of the GNU General Public License along 20 | * with this program; if not, write to the Free Software Foundation, Inc., 21 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 22 | * 23 | */ 24 | 25 | use ast\Node; 26 | use Phan\AST\UnionTypeVisitor; 27 | use Phan\CodeBase; 28 | use Phan\Exception\CodeBaseException; 29 | use Phan\Language\Context; 30 | use Phan\Language\Element\FunctionInterface; 31 | use Phan\Language\Element\Method; 32 | use Phan\Language\FQSEN\FullyQualifiedClassName; 33 | use Phan\Language\Type\GenericArrayType; 34 | use SecurityCheckPlugin\CausedByLines; 35 | use SecurityCheckPlugin\FunctionTaintedness; 36 | use SecurityCheckPlugin\MWPreVisitor; 37 | use SecurityCheckPlugin\MWVisitor; 38 | use SecurityCheckPlugin\SecurityCheckPlugin; 39 | use SecurityCheckPlugin\Taintedness; 40 | use SecurityCheckPlugin\TaintednessVisitor; 41 | 42 | class MediaWikiSecurityCheckPlugin extends SecurityCheckPlugin { 43 | /** 44 | * @inheritDoc 45 | */ 46 | public static function getPostAnalyzeNodeVisitorClassName(): string { 47 | return MWVisitor::class; 48 | } 49 | 50 | /** 51 | * @inheritDoc 52 | */ 53 | public static function getPreAnalyzeNodeVisitorClassName(): string { 54 | return MWPreVisitor::class; 55 | } 56 | 57 | /** 58 | * @inheritDoc 59 | */ 60 | protected function getCustomFuncTaints(): array { 61 | $shellCommandOutput = [ 62 | // This is a bit unclear. Most of the time 63 | // you should probably be escaping the results 64 | // of a shell command, but not all the time. 65 | 'overall' => self::YES_TAINT 66 | ]; 67 | 68 | $sqlExecTaint = new Taintedness( self::SQL_EXEC_TAINT ); 69 | $insertTaint = FunctionTaintedness::emptySingleton(); 70 | // table name 71 | $insertTaint = $insertTaint->withParamSinkTaint( 0, $sqlExecTaint, self::NO_OVERRIDE ); 72 | // Insert values. The keys names are unsafe. The argument can be either a single row or an array of rows. 73 | // Note, here we are assuming the single row case. The multiple rows case is handled in modifyParamSinkTaint. 74 | $sqlExecKeysTaint = Taintedness::newFromShape( [], null, self::SQL_EXEC_TAINT ); 75 | $insertTaint = $insertTaint->withParamSinkTaint( 1, $sqlExecKeysTaint, self::NO_OVERRIDE ); 76 | // method name 77 | $insertTaint = $insertTaint->withParamSinkTaint( 2, $sqlExecTaint, self::NO_OVERRIDE ); 78 | // options. They are not escaped 79 | $insertTaint = $insertTaint->withParamSinkTaint( 3, $sqlExecTaint, self::NO_OVERRIDE ); 80 | 81 | $insertQBRowTaint = FunctionTaintedness::emptySingleton(); 82 | $insertQBRowTaint = $insertQBRowTaint->withParamSinkTaint( 0, clone $sqlExecKeysTaint, self::NO_OVERRIDE ); 83 | 84 | $insertQBRowsTaint = FunctionTaintedness::emptySingleton(); 85 | $multiRowsTaint = Taintedness::newFromShape( [], clone $sqlExecKeysTaint ); 86 | $insertQBRowsTaint = $insertQBRowsTaint->withParamSinkTaint( 0, $multiRowsTaint, self::NO_OVERRIDE ); 87 | 88 | return [ 89 | // Note, at the moment, this checks where the function 90 | // is implemented, so you can't use IDatabase. 91 | '\Wikimedia\Rdbms\IDatabase::insert' => $insertTaint, 92 | '\Wikimedia\Rdbms\IMaintainableDatabase::insert' => $insertTaint, 93 | '\Wikimedia\Rdbms\Database::insert' => $insertTaint, 94 | '\Wikimedia\Rdbms\DBConnRef::insert' => $insertTaint, 95 | '\Wikimedia\Rdbms\InsertQueryBuilder::row' => $insertQBRowTaint, 96 | '\Wikimedia\Rdbms\InsertQueryBuilder::rows' => $insertQBRowsTaint, 97 | // FIXME Doesn't handle array args right. 98 | '\wfShellExec' => [ 99 | self::SHELL_EXEC_TAINT | self::ARRAY_OK, 100 | 'overall' => self::YES_TAINT 101 | ], 102 | '\wfShellExecWithStderr' => [ 103 | self::SHELL_EXEC_TAINT | self::ARRAY_OK, 104 | 'overall' => self::YES_TAINT 105 | ], 106 | '\wfEscapeShellArg' => [ 107 | ( self::YES_TAINT & ~self::SHELL_TAINT ) | self::VARIADIC_PARAM, 108 | 'overall' => self::NO_TAINT 109 | ], 110 | '\MediaWiki\Shell\Shell::escape' => [ 111 | ( self::YES_TAINT & ~self::SHELL_TAINT ) | self::VARIADIC_PARAM, 112 | 'overall' => self::NO_TAINT 113 | ], 114 | '\MediaWiki\Shell\Command::unsafeParams' => [ 115 | self::SHELL_EXEC_TAINT | self::VARIADIC_PARAM, 116 | 'overall' => self::NO_TAINT 117 | ], 118 | '\MediaWiki\Shell\Result::getStdout' => $shellCommandOutput, 119 | '\MediaWiki\Shell\Result::getStderr' => $shellCommandOutput, 120 | // Methods from wikimedia/Shellbox 121 | '\Shellbox\Shellbox::escape' => [ 122 | ( self::YES_TAINT & ~self::SHELL_TAINT ) | self::VARIADIC_PARAM, 123 | 'overall' => self::NO_TAINT 124 | ], 125 | '\Shellbox\Command\Command::unsafeParams' => [ 126 | self::SHELL_EXEC_TAINT | self::VARIADIC_PARAM, 127 | 'overall' => self::NO_TAINT 128 | ], 129 | '\Shellbox\Command\UnboxedResult::getStdout' => $shellCommandOutput, 130 | '\Shellbox\Command\UnboxedResult::getStderr' => $shellCommandOutput, 131 | // The value of a status object can be pretty much anything, with any degree of taintedness 132 | // and escaping. Since it's a widely used class, it will accumulate a lot of links and taintedness 133 | // offset, resulting in huge objects (the short string representation of those Taintedness objects 134 | // can reach lengths in the order of tens of millions). 135 | // Since the plugin cannot keep track the taintedness of a property per-instance (as it assumes that 136 | // every property will be used with the same escaping level), we just annotate the methods as safe. 137 | '\StatusValue::newGood' => [ 138 | self::NO_TAINT, 139 | 'overall' => self::NO_TAINT 140 | ], 141 | '\Status::newGood' => [ 142 | self::NO_TAINT, 143 | 'overall' => self::NO_TAINT 144 | ], 145 | '\StatusValue::getValue' => [ 146 | 'overall' => self::NO_TAINT 147 | ], 148 | '\Status::getValue' => [ 149 | 'overall' => self::NO_TAINT 150 | ], 151 | '\StatusValue::setResult' => [ 152 | self::NO_TAINT, 153 | self::NO_TAINT, 154 | 'overall' => self::NO_TAINT 155 | ], 156 | '\Status::setResult' => [ 157 | self::NO_TAINT, 158 | self::NO_TAINT, 159 | 'overall' => self::NO_TAINT 160 | ], 161 | ]; 162 | } 163 | 164 | /** 165 | * Mark XSS's that happen in a Maintenance subclass as false a positive 166 | * 167 | * @inheritDoc 168 | */ 169 | public function isFalsePositive( 170 | int $combinedTaint, 171 | string &$msg, 172 | Context $context, 173 | CodeBase $code_base 174 | ): bool { 175 | if ( $combinedTaint === self::HTML_TAINT ) { 176 | $path = str_replace( '\\', '/', $context->getFile() ); 177 | if ( 178 | strpos( $path, 'maintenance/' ) === 0 || 179 | strpos( $path, '/maintenance/' ) !== false 180 | ) { 181 | // For classes not using Maintenance subclasses 182 | $msg .= ' [Likely false positive because in maintenance subdirectory, thus probably CLI]'; 183 | return true; 184 | } 185 | if ( !$context->isInClassScope() ) { 186 | return false; 187 | } 188 | $maintFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( 189 | '\\Maintenance' 190 | ); 191 | if ( !$code_base->hasClassWithFQSEN( $maintFQSEN ) ) { 192 | return false; 193 | } 194 | $classFQSEN = $context->getClassFQSEN(); 195 | $isMaint = TaintednessVisitor::isSubclassOf( $classFQSEN, $maintFQSEN, $code_base ); 196 | if ( $isMaint ) { 197 | $msg .= ' [Likely false positive because in a subclass of Maintenance, thus probably CLI]'; 198 | return true; 199 | } 200 | } 201 | return false; 202 | } 203 | 204 | /** 205 | * Special-case the $rows argument to Database::insert (T290563) 206 | * @inheritDoc 207 | * @suppress PhanUnusedPublicMethodParameter 208 | */ 209 | public function modifyParamSinkTaint( 210 | Taintedness $paramSinkTaint, 211 | Taintedness $curArgTaintedness, 212 | Node $argument, 213 | int $argIndex, 214 | FunctionInterface $func, 215 | FunctionTaintedness $funcTaint, 216 | CausedByLines $paramSinkError, 217 | Context $context, 218 | CodeBase $code_base 219 | ): array { 220 | if ( !$func instanceof Method || $argIndex !== 1 || $func->getName() !== 'insert' ) { 221 | return [ $paramSinkTaint, $paramSinkError ]; 222 | } 223 | 224 | $classFQSEN = $func->getClassFQSEN(); 225 | if ( $classFQSEN->__toString() !== '\\Wikimedia\\Rdbms\\Database' ) { 226 | $idbFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( '\\Wikimedia\\Rdbms\\IDatabase' ); 227 | $isDBSubclass = $classFQSEN->asType()->asExpandedTypes( $code_base )->hasType( $idbFQSEN->asType() ); 228 | if ( !$isDBSubclass ) { 229 | return [ $paramSinkTaint, $paramSinkError ]; 230 | } 231 | } 232 | 233 | $argType = UnionTypeVisitor::unionTypeFromNode( $code_base, $context, $argument ); 234 | $keyType = GenericArrayType::keyUnionTypeFromTypeSetStrict( $argType->getTypeSet() ); 235 | if ( $keyType !== GenericArrayType::KEY_INT ) { 236 | // Note, it might still be an array of rows, but it's too hard for us to tell. 237 | return [ $paramSinkTaint, $paramSinkError ]; 238 | } 239 | 240 | // Definitely a list of rows, so remove taintedness from the outer array keys, and instead add it to the 241 | // keys of inner arrays. 242 | $sqlExecKeysTaint = Taintedness::newFromShape( [], null, self::SQL_EXEC_TAINT ); 243 | $adjustedTaint = Taintedness::newFromShape( [], $sqlExecKeysTaint ); 244 | 245 | $curErrorLines = $paramSinkError->toLinesArray(); 246 | assert( count( $curErrorLines ) === 1 && str_starts_with( $curErrorLines[0], 'Builtin' ) ); 247 | $adjustedError = CausedByLines::emptySingleton() 248 | ->withAddedLines( $curErrorLines, $adjustedTaint->asExecToYesTaint() ); 249 | 250 | return [ $adjustedTaint, $adjustedError ]; 251 | } 252 | 253 | /** 254 | * Disable double escape checking for messages with polymorphic methods 255 | * 256 | * A common cause of false positives for double escaping is that some 257 | * methods take a string|Message, and this confuses the tool given 258 | * the __toString() behaviour of Message. So disable double escape 259 | * checking for that. 260 | * 261 | * This is quite hacky. Ideally the tool would treat methods taking 262 | * multiple types as separate for each type, and also be able to 263 | * reason out simple conditions of the form if ( $arg instanceof Message ). 264 | * However that's much more complicated due to dependence on phan. 265 | * 266 | * @inheritDoc 267 | * @suppress PhanUnusedPublicMethodParameter 268 | */ 269 | public function modifyArgTaint( 270 | Taintedness $curArgTaintedness, 271 | Node $argument, 272 | int $argIndex, 273 | FunctionInterface $func, 274 | FunctionTaintedness $funcTaint, 275 | Context $context, 276 | CodeBase $code_base 277 | ): Taintedness { 278 | if ( $curArgTaintedness->has( self::ESCAPED_TAINT ) ) { 279 | $argumentIsMaybeAMsg = false; 280 | /** @var \Phan\Language\Element\Clazz[] $classes */ 281 | $classes = UnionTypeVisitor::unionTypeFromNode( $code_base, $context, $argument ) 282 | ->asClassList( $code_base, $context ); 283 | try { 284 | foreach ( $classes as $cl ) { 285 | $classFQSEN = $cl->getFQSEN()->__toString(); 286 | // TODO: drop first check when the `\Message` alias is dropped from MW core. 287 | if ( $classFQSEN === '\Message' || $classFQSEN === '\MediaWiki\Message\Message' ) { 288 | $argumentIsMaybeAMsg = true; 289 | break; 290 | } 291 | } 292 | } catch ( CodeBaseException $_ ) { 293 | // A class that doesn't exist, don't crash. 294 | return $curArgTaintedness; 295 | } 296 | 297 | $param = $func->getParameterForCaller( $argIndex ); 298 | if ( !$argumentIsMaybeAMsg || !$param || !$param->getUnionType()->hasStringType() ) { 299 | return $curArgTaintedness; 300 | } 301 | /** @var \Phan\Language\Element\Clazz[] $classesParam */ 302 | $classesParam = $param->getUnionType()->asClassList( $code_base, $context ); 303 | try { 304 | foreach ( $classesParam as $cl ) { 305 | $classFQSEN = $cl->getFQSEN()->__toString(); 306 | // TODO: drop first check when the `\Message` alias is dropped from MW core. 307 | if ( $classFQSEN === '\Message' || $classFQSEN === '\MediaWiki\Message\Message' ) { 308 | // So we are here. Input is a Message, and func expects either a Message or string 309 | // (or something else). So disable double escape check. 310 | return $curArgTaintedness->without( self::ESCAPED_TAINT ); 311 | } 312 | } 313 | } catch ( CodeBaseException $_ ) { 314 | // A class that doesn't exist, don't crash. 315 | return $curArgTaintedness; 316 | } 317 | } 318 | return $curArgTaintedness; 319 | } 320 | } 321 | 322 | return new MediaWikiSecurityCheckPlugin; 323 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Phan Security Check Plugin 2 | =============================== 3 | 4 | This is a plugin to [Phan] to try and detect security issues 5 | (such as [XSS]). It keeps track of any time a user can modify 6 | a variable, and checks to see that such variables are 7 | escaped before being output as html or used as an sql query, etc. 8 | 9 | It supports generic PHP projects, and it also has a dedicated mode 10 | for MediaWiki code (analyzes hooks, HTMLForms and Database methods). 11 | 12 | A [web demo] is available. 13 | 14 | Usage 15 | ----- 16 | 17 | ### Install 18 | 19 | $ composer require --dev mediawiki/phan-taint-check-plugin 20 | 21 | ### Usage 22 | The plugin can be used in both "manual" and "standalone" mode. The former is the best 23 | choice if your project is already running phan, and almost no configuration is needed. 24 | The latter should only be used if you don't want to add phan to your project, and is not 25 | supported for MediaWiki-related code. For more information about Wikimedia's use of this 26 | plugin see https://www.mediawiki.org/wiki/Phan-taint-check-plugin. 27 | 28 | #### Manual 29 | You simply have to add taint-check to the `plugins` section of your phan config. Assuming 30 | that taint-check is in the standard vendor location, e.g. 31 | ` $seccheckPath = 'vendor/mediawiki/phan-taint-check-plugin/';`, the file to include is 32 | `"$seccheckPath/GenericSecurityCheckPlugin.php"` for a generic project, and 33 | `"$seccheckPath/MediaWikiSecurityCheckPlugin.php"` for a MediaWiki project. 34 | 35 | Also, make sure that quick mode is disabled, or the plugin won't work: 36 | ```php 37 | 'quick_mode' => false 38 | ``` 39 | 40 | You should also add `SecurityCheck-LikelyFalsePositive` and 41 | `SecurityCheck-PHPSerializeInjection` to `suppress_issue_types` (the latter 42 | has a high rate of false positives). 43 | 44 | Then run phan as you normally would: 45 | 46 | $ vendor/bin/phan -d . --long-progress-bar 47 | 48 | Running phan with `--analyze-twice` will catch additional security issues that 49 | might go unnoticed in the normal analysis phase. A known limitation of this is that 50 | the same issue might be reported more than once with different caused-by lines. 51 | 52 | #### Standalone 53 | You can run taint-check via: 54 | 55 | $ ./vendor/bin/seccheck 56 | 57 | You might want to add a composer script alias for that: 58 | 59 | ```json 60 | "scripts": { 61 | "seccheck": "seccheck" 62 | } 63 | ``` 64 | 65 | Note that false positives are disabled by default. 66 | 67 | 68 | Plugin output 69 | ------------- 70 | 71 | The plugin will output various issue types depending on what it 72 | detects. The issue types it outputs are: 73 | 74 | * `SecurityCheck-XSS` 75 | * `SecurityCheck-SQLInjection` 76 | * `SecurityCheck-ShellInjection` 77 | * `SecurityCheck-PHPSerializeInjection` - For when someone does `unserialize( $_GET['d'] );` 78 | This issue type seems to have a high false positive rate currently. 79 | * `SecurityCheck-CUSTOM1` - To allow people to have custom taint types 80 | * `SecurityCheck-CUSTOM2` - ditto 81 | * `SecurityCheck-DoubleEscaped` - Detecting that HTML is being double escaped 82 | * `SecurityCheck-RCE` - Remote code execution, e.g. `eval( $_GET['foo'] )` 83 | * `SecurityCheck-PathTraversal` - Path traversal, e.g. `require $_GET['foo']` 84 | * `SecurityCheck-ReDoS` - Regular expression denial of service (ReDoS), e.g. `preg_match( $_GET['foo'], 'foo')` 85 | * `SecurityCheck-LikelyFalsePositive` - A potential issue, but probably not. 86 | Mostly happens when the plugin gets confused. 87 | 88 | The severity field is usually marked as `Issue::SEVERITY_NORMAL (5)`. False 89 | positives get `Issue::SEVERITY_LOW (0)`. Issues that may result in server 90 | compromise (as opposed to just end user compromise) such as shell or sql 91 | injection are marked as `Issue::SEVERITY_CRITICAL (10)`. 92 | SerializationInjection would normally be "critical" but its currently denoted 93 | as a severity of NORMAL because the check seems to have a high false positive 94 | rate at the moment. 95 | 96 | You can use the `-y` command line option of Phan to filter by severity. 97 | 98 | How to avoid false positives 99 | ---------------------------- 100 | 101 | If you need to suppress a false positive, you can put `@suppress NAME-OF-WARNING` 102 | in the docblock for a function/method. Alternatively, you can use other types of 103 | suppression, like `@phan-suppress-next-line`. See phan's readme for a complete 104 | list. 105 | The `@param-taint` and `@return-taint` (see "Customizing" section) are also very useful 106 | with dealing with false positives. 107 | 108 | Note that the plugin will report possible XSS vulnerabilities in CLI context. To avoid them, 109 | you can suppress `SecurityCheck-XSS` file-wide with `@phan-file-suppress` in CLI scripts, or 110 | for the whole application (using the `suppress_issue_types` config option) if the application only 111 | consists of CLI scripts. Alternatively, if all outputting happens from an internal function, you 112 | can use `@param-taint` as follows: 113 | ```php 114 | /** 115 | * @param-taint $stuffToPrint none 116 | */ 117 | public function printMyStuff( string $stuffToPrint ) { 118 | echo $stuffToPrint; 119 | } 120 | ``` 121 | 122 | When debugging security issues, you can use: 123 | ``` 124 | '@phan-debug-var-taintedness $varname'; 125 | ``` 126 | this will emit a `SecurityCheckDebugTaintedness` issue containing the taintedness of `$varname` 127 | at the line where the annotation is found. Note that you have to insert the annotation in a string 128 | literal; comments will not work. See also phan's `@phan-debug-var` annotation. 129 | 130 | Notable limitations 131 | ------------------- 132 | ### General limitations 133 | 134 | * When an issue is output, the plugin tries to include details about what line 135 | originally caused the issue. Usually it works, but sometimes it gives 136 | misleading/wrong information 137 | * The plugin won't recognize things that do custom escaping. If you have 138 | custom escaping methods, you must add annotations to its docblock so 139 | that the plugin can recognize it. See the Customizing section. 140 | * Phan does not currently have an API for accessing subclasses for a given class. 141 | Therefore the SecurityCheckPlugin cannot accommodate certain data flows for 142 | subclasses that should obviously be considered tainted. The workaround for this 143 | is to mark any relevant subclass functions as `@return-taint html`. 144 | 145 | ### MediaWiki specific limitations 146 | * With pass by reference parameters to MediaWiki hooks, 147 | sometimes the line number is the hook call in MediaWiki core, instead of 148 | the hook subscriber in the extension that caused the issue. 149 | * The plugin can only validate the fifth (`$options`) and sixth (`$join_cond`) 150 | of MediaWiki's `IDatabase::select()` if its provided directly as an array 151 | literal, or directly returned as an array literal from a `getQueryInfo()` 152 | method. 153 | 154 | Customizing 155 | ----------- 156 | The plugin supports being customized, by subclassing the [SecurityCheckPlugin] 157 | class. For a complex example of doing so, see [MediaWikiSecurityCheckPlugin]. 158 | 159 | Sometimes you have methods in your codebase that alter the taint of a 160 | variable. For example, a custom html escaping function should clear the 161 | html taint bit. Similarly, sometimes phan-taint-check can get confused and 162 | you want to override the taint calculated for a specific function. 163 | 164 | You can do this by adding a taint directive in a docblock comment. For example: 165 | 166 | ```php 167 | /** 168 | * My function description 169 | * 170 | * @param string $html the text to be escaped 171 | * @param-taint $html escapes_html 172 | */ 173 | function escapeHtml( $html ) { 174 | } 175 | ``` 176 | 177 | Methods also inherit these directives from abstract definitions in ancestor interfaces, but not from concrete implementations in ancestor classes. 178 | 179 | Taint directives are prefixed with either `@param-taint $parametername` or `@return-taint`. If there are multiple directives they can be separated by a comma. `@param-taint` is used for either marking how taint is transmitted from the parameter to the methods return value, or when used with `exec_` directives, to mark places where parameters are outputted/executed. `@return-taint` is used to adjust the return value's taint regardless of the input parameters. 180 | 181 | The type of directives include: 182 | * `exec_$TYPE` - If a parameter is marked as `exec_$TYPE` then feeding that parameter a value with `$TYPE` taint will result in a warning triggered. Typically you would use this when a function that outputs or executes its parameter 183 | * `escapes_$TYPE` - Used for parameters where the function escapes and then returns the parameter. So `escapes_sql` would clear the sql taint bit, but leave other taint bits alone. 184 | * `onlysafefor_$TYPE` - For use in `@return-taint`, marks the return type as safe for a specific `$TYPE` but unsafe for the other types. 185 | * `$TYPE` - if just the type is specified in a parameter, it is bitwised AND with the input variable's taint. Normally you wouldn't want to do this, but can be useful when `$TYPE` is `none` to specify that the parameter is not used to generate the return value. In an `@return` this could be used to enumerate which taint flags the return value has, which is usually only useful when specified as `tainted` to say it has all flags. 186 | * `array_ok` - special purpose flag to say ignore tainted arguments if they are in an array. 187 | * `allow_override` - Special purpose flag to specify that that taint annotation should be overridden by phan-taint-check if it can detect a specific taint. 188 | 189 | The value for `$TYPE` can be one of `htmlnoent`, `html`, `sql`, `shell`, `serialize`, `custom1`, `custom2`, `code`, `path`, `regex`, `sql_numkey`, `escaped`, `none`, `tainted`. Most of these are taint categories, except: 190 | * `htmlnoent` - like `html` but disable double escaping detection that gets used with `html`. When `escapes_html` is specified, escaped automatically gets added to `@return`, and `exec_escaped` is added to `@param`. Similarly `onlysafefor_html` is equivalent to `onlysafefor_htmlnoent,escaped`. 191 | * `none` - Means no taint 192 | * `tainted` - Means all taint categories except special categories (equivalent to `SecurityCheckPlugin::YES_TAINT`) 193 | * `escaped` - Is used to mean the value is already escaped (To track double escaping) 194 | * `sql_numkey` - Is fairly special purpose for MediaWiki. It ignores taint in arrays if they are for associative keys. 195 | 196 | The default value for `@param-taint` is `tainted` if it's a string (or other dangerous type), and `none` if it's something like an integer. The default value for `@return-taint` is `allow_override` (Which is equivalent to `none` unless something better can be autodetected). 197 | 198 | Instead of annotating methods in your codebase, you can also customize 199 | phan-taint-check to have builtin knowledge of method taints. In addition 200 | you can extend the plugin to have fairly arbitrary behaviour. 201 | 202 | To do this, you override the `getCustomFuncTaints()` method. This method 203 | returns an associative array of fully qualified method names to an array 204 | describing how the taint of the return value of the function in terms of its 205 | arguments. The numeric keys correspond to the number of an argument, and an 206 | 'overall' key adds taint that is not present in any of the arguments. 207 | Basically for each argument, the plugin takes the taint of the argument, 208 | bitwise AND's it to its entry in the array, and then bitwise OR's the overall 209 | key. If any of the keys in the array have an EXEC flags, then an issue is 210 | immediately raised if the corresponding taint is fed the function (For 211 | example, an output function). The EXEC flags don't work in the 'overall' key. 212 | 213 | For example, [htmlspecialchars] which removes html taint, escapes its argument and returns the 214 | escaped value would look like: 215 | 216 | ```php 217 | 'htmlspecialchars' => [ 218 | ( self::YES_TAINT & ~self::HTML_TAINT ) | self::ESCAPED_EXEC_TAINT, 219 | 'overall' => self::ESCAPED, 220 | ]; 221 | ``` 222 | 223 | Environment variables 224 | --------------------- 225 | 226 | The following environment variables affect the plugin. Normally you would not 227 | have to adjust these. 228 | 229 | * `SECURITY_CHECK_EXT_PATH` - Path to directory containing 230 | `extension.json`/`skin.json` when in MediaWiki mode. 231 | If not set assumes the project root directory. 232 | * `SECCHECK_DEBUG` - File to output extra debug information (If running from 233 | `shell`, `/dev/stderr` is convenient) 234 | 235 | License 236 | ------- 237 | 238 | [GNU General Public License, version 2 or later] 239 | 240 | [web demo]: https://doc.wikimedia.org/mediawiki-tools-phan-SecurityCheckPlugin/master/demos/ 241 | [Phan]: https://github.com/phan/phan 242 | [XSS]: https://en.wikipedia.org/wiki/Cross-site_scripting 243 | [SecurityCheckPlugin]: src/SecurityCheckPlugin.php 244 | [MediaWikiSecurityCheckPlugin]: MediaWikiSecurityCheckPlugin.php 245 | [htmlspecialchars]: https://secure.php.net/htmlspecialchars 246 | [GNU General Public License, version 2 or later]: COPYING 247 | -------------------------------------------------------------------------------- /internal/make_phar.php: -------------------------------------------------------------------------------- 1 | append( 19 | new RecursiveIteratorIterator( 20 | new RecursiveDirectoryIterator( 21 | $subdir, 22 | RecursiveDirectoryIterator::FOLLOW_SYMLINKS 23 | ) 24 | ) 25 | ); 26 | } 27 | 28 | // Include all files with suffix .php, excluding those found in the tests folder. 29 | $iterator = new CallbackFilterIterator( 30 | $iterators, 31 | static function ( SplFileInfo $file_info ): bool { 32 | if ( $file_info->getExtension() !== 'php' ) { 33 | return false; 34 | } 35 | if ( preg_match( 36 | '@^vendor/symfony/(console|debug)/Tests/@i', 37 | str_replace( '\\', '/', $file_info->getPathname() ) 38 | ) ) { 39 | return false; 40 | } 41 | return true; 42 | } 43 | ); 44 | 45 | $phar->buildFromIterator( $iterator, $dir ); 46 | foreach ( glob( '*SecurityCheckPlugin.php' ) as $plugin ) { 47 | $phar->addFile( $plugin ); 48 | } 49 | 50 | foreach ( $phar as $file ) { 51 | // @phan-suppress-next-line PhanPluginUnknownObjectMethodCall TODO fix https://github.com/phan/phan/issues/3723 52 | echo $file->getFileName() . "\n"; 53 | } 54 | 55 | // We don't want to use https://secure.php.net/manual/en/phar.interceptfilefuncs.php , which Phar does by default. 56 | // That causes annoying bugs. 57 | // Also, phan.phar is has no use cases to use as a web server, so don't include that, either. 58 | // See https://github.com/composer/xdebug-handler/issues/46 and 59 | // https://secure.php.net/manual/en/phar.createdefaultstub.php 60 | $stub = <<<'EOT' 61 | #!/usr/bin/env php 62 | setStub( $stub ); 68 | 69 | echo "Created phar in build/phan.phar\n"; 70 | -------------------------------------------------------------------------------- /internal/make_phar.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Taken from phan's internal/make_phar. See history on github for attribution. 4 | # File distributed under the MIT license 5 | 6 | set -xeu 7 | 8 | if [[ ! -d "src" ]]; then 9 | echo "Run this script from the root" 10 | exit 1 11 | fi 12 | 13 | composer install --classmap-authoritative --prefer-dist --no-dev 14 | rm -rf build 15 | mkdir build 16 | php -d phar.readonly=0 internal/make_phar.php 17 | chmod a+x build/taint-check.phar 18 | php build/taint-check.phar --version 19 | -------------------------------------------------------------------------------- /scripts/base-config.php: -------------------------------------------------------------------------------- 1 | false, 14 | 15 | 'analyzed_file_extensions' => [ 16 | 'php', 17 | 'inc' 18 | ], 19 | 20 | /** 21 | * A set of fully qualified class-names for which 22 | * a call to parent::__construct() is required 23 | */ 24 | 'parent_constructor_required' => [ 25 | ], 26 | 27 | 'quick_mode' => false, 28 | 29 | 'analyze_signature_compatibility' => false, 30 | 31 | /** 32 | * Do not emit false positives 33 | */ 34 | "minimum_severity" => 1, 35 | 'allow_missing_properties' => false, 36 | 'null_casts_as_any_type' => true, 37 | 'scalar_implicit_cast' => true, 38 | 'ignore_undeclared_variables_in_global_scope' => true, 39 | 'dead_code_detection' => false, 40 | 'dead_code_detection_prefer_false_negative' => true, 41 | 'read_type_annotations' => true, 42 | 'disable_suppression' => false, 43 | 'dump_ast' => false, 44 | 'dump_signatures_file' => null, 45 | // Include a progress bar in the output 46 | 'progress_bar' => true, 47 | 48 | /** 49 | * The number of processes to fork off during the analysis 50 | * phase. 51 | */ 52 | 'processes' => 1, 53 | 54 | /** We selectively enable the checks we want, rather than disabling the ones we don't want */ 55 | 'suppress_issue_types' => [], 56 | 57 | /** 58 | * If empty, no filter against issues types will be applied. 59 | * If this allowed list is non-empty, only issues within the list 60 | * will be emitted by Phan. 61 | */ 62 | 'whitelist_issue_types' => [ 63 | 'SecurityCheck-XSS', 64 | 'SecurityCheck-SQLInjection', 65 | 'SecurityCheck-ShellInjection', 66 | 'SecurityCheck-DoubleEscaped', 67 | 'SecurityCheck-CUSTOM1', 68 | 'SecurityCheck-CUSTOM2', 69 | 'SecurityCheck-RCE', 70 | 'SecurityCheck-PathTraversal', 71 | 'SecurityCheck-ReDoS', 72 | // Rely on severity setting to prevent false positive. 73 | 'SecurityCheck-LikelyFalsePositive', 74 | 'PhanSyntaxError', 75 | 'SecurityCheckDebugTaintedness', 76 | 'SecurityCheckInvalidAnnotation', 77 | ], 78 | 79 | /** 80 | * Override to hardcode existence and types of (non-builtin) globals in the global scope. 81 | * Class names must be prefixed with '\\'. 82 | * (E.g. ['_FOO' => '\\FooClass', 'page' => '\\PageClass', 'userId' => 'int']) 83 | */ 84 | 'globals_type_map' => [ 85 | // 'IP' => 'string', 86 | ], 87 | 88 | // Emit issue messages with markdown formatting 89 | 'markdown_issue_messages' => false, 90 | 91 | /** 92 | * Enable or disable support for generic templated 93 | * class types. 94 | */ 95 | 'generic_types_enabled' => true, 96 | 97 | 'plugins' => [ 98 | 'UnusedSuppressionPlugin' 99 | ], 100 | 101 | 'plugin_config' => [ 102 | // Only report unused suppressions for security issues 103 | 'unused_suppression_whitelisted_only' => true 104 | ], 105 | ]; 106 | -------------------------------------------------------------------------------- /scripts/generic-config.php: -------------------------------------------------------------------------------- 1 | [], 13 | 'directory_list' => [ 14 | '.' 15 | ], 16 | 17 | /** 18 | * A file list that defines files that will be excluded 19 | * from parsing and analysis and will not be read at all. 20 | * 21 | * This is useful for excluding hopelessly unanalyzable 22 | * files that can't be removed for whatever reason. 23 | */ 24 | 'exclude_file_list' => [], 25 | 26 | /** 27 | * A list of directories holding code that we want 28 | * to parse, but not analyze. Also works for individual 29 | * files. 30 | */ 31 | "exclude_analysis_directory_list" => [ 32 | 'vendor' 33 | ], 34 | ]; 35 | 36 | $cfg = $thisCfg + $baseCfg; 37 | $cfg['plugins'][] = __DIR__ . '/../GenericSecurityCheckPlugin.php'; 38 | 39 | return $cfg; 40 | -------------------------------------------------------------------------------- /scripts/seccheck: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # Run the security check test on the extension 4 | # in the current directory. 5 | # 6 | # This script is meant to be run via composer 7 | # 8 | # Assumes that either MediaWiki is installed in ../../ 9 | # or the user has set MW_INSTALL_PATH environment variable. 10 | 11 | php vendor/phan/phan/phan \ 12 | -d . \ 13 | -k vendor/mediawiki/phan-taint-check-plugin/scripts/generic-config.php \ 14 | --output "php://stdout" "$@" 15 | 16 | exit $? 17 | -------------------------------------------------------------------------------- /src/CausedByLines.php: -------------------------------------------------------------------------------- 1 | > 19 | * @phan-var list 20 | */ 21 | private $lines = []; 22 | 23 | public static function emptySingleton(): self { 24 | static $singleton; 25 | if ( !$singleton ) { 26 | $singleton = new self(); 27 | } 28 | return $singleton; 29 | } 30 | 31 | /** 32 | * Adds the given lines to this object. For assignment statements, use {@see self::withAddedAssignmentLines} 33 | * @param string[] $lines 34 | * @param Taintedness $taintedness 35 | * @param MethodLinks|null $links 36 | * @return self 37 | */ 38 | public function withAddedLines( array $lines, Taintedness $taintedness, ?MethodLinks $links = null ): self { 39 | if ( $links && $links->isEmpty() ) { 40 | $links = null; 41 | } 42 | if ( !$links && $taintedness->isSafe() ) { 43 | return $this; 44 | } 45 | 46 | $ret = new self(); 47 | 48 | if ( !$this->lines ) { 49 | foreach ( $lines as $line ) { 50 | $ret->lines[] = [ $taintedness, $line, $links ]; 51 | } 52 | return $ret; 53 | } 54 | 55 | foreach ( $this->lines as $line ) { 56 | $ret->lines[] = [ $line[0], $line[1], $line[2] ]; 57 | } 58 | 59 | foreach ( $lines as $line ) { 60 | if ( count( $ret->lines ) >= self::LINES_HARD_LIMIT ) { 61 | break; 62 | } 63 | $idx = array_search( $line, array_column( $ret->lines, 1 ), true ); 64 | if ( $idx !== false ) { 65 | $ret->lines[ $idx ][0] = $ret->lines[ $idx ][0]->asMergedWith( $taintedness ); 66 | if ( $links && !$ret->lines[$idx][2] ) { 67 | $ret->lines[$idx][2] = $links; 68 | } elseif ( $links && $links !== $ret->lines[$idx][2] ) { 69 | $ret->lines[$idx][2] = $ret->lines[$idx][2]->asMergedWith( $links ); 70 | } 71 | } else { 72 | $ret->lines[] = [ $taintedness, $line, $links ]; 73 | } 74 | } 75 | 76 | return $ret; 77 | } 78 | 79 | /** 80 | * Merge caused-by lines from the RHS, and add the given additional lines to this object, as part of an assignment 81 | * statement. 82 | * 83 | * @param CausedByLines $rightLines For the RHS expression 84 | * @param string[] $lines 85 | * @param Taintedness $taintedness 86 | * @param MethodLinks|null $links 87 | * @return self 88 | */ 89 | public function asMergedForAssignment( 90 | self $rightLines, 91 | array $lines, 92 | Taintedness $taintedness, 93 | ?MethodLinks $links = null 94 | ): self { 95 | if ( $links && $links->isEmpty() ) { 96 | $links = null; 97 | } 98 | if ( !$links && $taintedness->isSafe() ) { 99 | return $this->asMergedWith( $rightLines ); 100 | } 101 | 102 | if ( !$rightLines->lines ) { 103 | return $this->withAddedLines( $lines, $taintedness, $links ); 104 | } 105 | 106 | $ret = $this->withAddedLines( $lines, $taintedness ) 107 | ->asMergedWith( $rightLines ); 108 | 109 | if ( $links ) { 110 | $ret = clone $ret; 111 | foreach ( $lines as $line ) { 112 | if ( count( $ret->lines ) >= self::LINES_HARD_LIMIT ) { 113 | break; 114 | } 115 | 116 | $remainingLinks = $links; 117 | foreach ( $ret->lines as [ $_, $lineLine, $lineLinks ] ) { 118 | if ( $lineLine === $line ) { 119 | $remainingLinks = $lineLinks ? $remainingLinks->withoutShape( $lineLinks ) : $remainingLinks; 120 | } 121 | } 122 | if ( !$remainingLinks->isEmpty() ) { 123 | $ret->lines[] = [ Taintedness::safeSingleton(), $line, $remainingLinks ]; 124 | } 125 | } 126 | } 127 | 128 | return $ret; 129 | } 130 | 131 | /** 132 | * If this object represents the caused-by lines for a given function parameter, apply the effect of a method call 133 | * where the argument for that parameter has the specified taintedness and links. 134 | */ 135 | public function asPreservedForParameter( 136 | Taintedness $argTaint, 137 | MethodLinks $argLinks, 138 | FunctionInterface $func, 139 | int $param 140 | ): self { 141 | if ( !$this->lines ) { 142 | return $this; 143 | } 144 | $ret = new self; 145 | $argHasLinks = !$argLinks->isEmpty(); 146 | foreach ( $this->lines as [ $eTaint, $eLine, $eLinks ] ) { 147 | if ( $eLinks ) { 148 | $preservedTaint = $eLinks->asPreservedTaintednessForFuncParam( $func, $param ) 149 | ->asTaintednessForArgument( $argTaint ); 150 | $newTaint = $eTaint->asMergedWith( $preservedTaint ); 151 | if ( $argHasLinks || !$newTaint->isSafe() ) { 152 | $ret->lines[] = [ $newTaint, $eLine, $argLinks ]; 153 | } 154 | } else { 155 | $ret->lines[] = [ $eTaint, $eLine, $argLinks ]; 156 | } 157 | } 158 | return $ret; 159 | } 160 | 161 | /** 162 | * If this object represents the caused-by lines for a given function argument, apply the effect of a method call 163 | * that preserves the given taintedness. 164 | */ 165 | public function asPreservedForArgument( 166 | PreservedTaintedness $preservedTaint 167 | ): self { 168 | if ( !$this->lines ) { 169 | return $this; 170 | } 171 | $ret = new self; 172 | foreach ( $this->lines as [ $eTaint, $eLine, $_ ] ) { 173 | $newTaint = $preservedTaint->asTaintednessForArgument( $eTaint ); 174 | // TODO: Pass appropriate links through, see I1bd8ae302e91a2b6b951953bc321ea6ae89d5955 175 | $newLinks = null; 176 | if ( !$newTaint->isSafe() ) { 177 | $ret->lines[] = [ $newTaint, $eLine, $newLinks ]; 178 | } 179 | } 180 | return $ret; 181 | } 182 | 183 | /** 184 | * @param Taintedness $taintedness 185 | * @return self 186 | * @todo Migrate callers to asPreservedForArgument and drop this. 187 | */ 188 | public function asIntersectedWithTaintedness( Taintedness $taintedness ): self { 189 | if ( !$this->lines ) { 190 | return $this; 191 | } 192 | $ret = new self; 193 | $curTaint = $taintedness->get(); 194 | foreach ( $this->lines as [ $eTaint, $eLine, $links ] ) { 195 | $newTaint = $curTaint !== SecurityCheckPlugin::NO_TAINT 196 | ? $eTaint->withOnly( $curTaint ) 197 | : Taintedness::safeSingleton(); 198 | $ret->lines[] = [ $newTaint, $eLine, $links ]; 199 | } 200 | return $ret; 201 | } 202 | 203 | /** 204 | * @param FunctionInterface $func 205 | * @param int $param 206 | * @return self 207 | */ 208 | public function asFilteredForFuncAndParam( FunctionInterface $func, int $param ): self { 209 | if ( !$this->lines ) { 210 | return $this; 211 | } 212 | $ret = new self; 213 | $safeTaint = Taintedness::safeSingleton(); 214 | foreach ( $this->lines as [ $_, $lineLine, $lineLinks ] ) { 215 | if ( $lineLinks && $lineLinks->hasDataForFuncAndParam( $func, $param ) ) { 216 | $ret->lines[] = [ $safeTaint, $lineLine, $lineLinks ]; 217 | } 218 | } 219 | return $ret; 220 | } 221 | 222 | /** 223 | * @return self 224 | */ 225 | public function getLinesForGenericReturn(): self { 226 | if ( !$this->lines ) { 227 | return $this; 228 | } 229 | $ret = new self; 230 | foreach ( $this->lines as [ $lineTaint, $lineLine, $_ ] ) { 231 | if ( !$lineTaint->isSafe() ) { 232 | // For generic lines, links don't matter 233 | $ret->lines[] = [ $lineTaint, $lineLine, null ]; 234 | } 235 | } 236 | return $ret; 237 | } 238 | 239 | /** 240 | * For every line in this object, check if the line has links for $func, and if so, add preserved taintedness from 241 | * $taintedness to the line. 242 | * 243 | * @param Taintedness $taintedness 244 | * @param FunctionInterface $func 245 | * @param int $i Parameter index 246 | * @param bool $isSink True when backpropagating method links for a sink (and $taintedness is the taintedness of the 247 | * sink); false when backpropagating variable links (and $taintedness is the new taintedness of the variable). 248 | * @return self 249 | */ 250 | public function withTaintAddedToMethodArgLinks( 251 | Taintedness $taintedness, 252 | FunctionInterface $func, 253 | int $i, 254 | bool $isSink 255 | ): self { 256 | if ( !$this->lines ) { 257 | return $this; 258 | } 259 | $ret = new self; 260 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 261 | if ( $lineLinks && $lineLinks->hasDataForFuncAndParam( $func, $i ) ) { 262 | $preservedTaint = $lineLinks->asPreservedTaintednessForFuncParam( $func, $i ); 263 | $newTaint = $isSink 264 | ? $preservedTaint->asTaintednessForBackpropError( $taintedness ) 265 | : $preservedTaint->asTaintednessForVarBackpropError( $taintedness ); 266 | $ret->lines[] = [ $lineTaint->asMergedWith( $newTaint ), $lineLine, $lineLinks ]; 267 | } else { 268 | $ret->lines[] = [ $lineTaint, $lineLine, $lineLinks ]; 269 | } 270 | } 271 | return $ret; 272 | } 273 | 274 | public function forSinkBackprop( MethodLinks $links, FunctionInterface $func, int $param ): self { 275 | if ( !$this->lines ) { 276 | return $this; 277 | } 278 | $ret = new self; 279 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 280 | $newTaint = $lineTaint->asYesToExecTaint()->appliedToLinksForBackprop( $links, $func, $param ); 281 | if ( !$newTaint->isSafe() ) { 282 | $ret->lines[] = [ $newTaint->asExecToYesTaint(), $lineLine, $lineLinks ]; 283 | } 284 | } 285 | return $ret; 286 | } 287 | 288 | /** 289 | * Returns a copy of $this with all taintedness and links moved at the given offset. 290 | * @param Node|mixed $offset 291 | * @return self 292 | */ 293 | public function asAllMaybeMovedAtOffset( $offset ): self { 294 | if ( !$this->lines ) { 295 | return $this; 296 | } 297 | $ret = new self; 298 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 299 | $ret->lines[] = [ 300 | $lineTaint->asMaybeMovedAtOffset( $offset ), 301 | $lineLine, 302 | $lineLinks ? $lineLinks->asMaybeMovedAtOffset( $offset ) : null 303 | ]; 304 | } 305 | return $ret; 306 | } 307 | 308 | /** 309 | * Returns a copy of $this with all taintedness and links moved inside keys. 310 | * @return self 311 | */ 312 | public function asAllMovedToKeys(): self { 313 | if ( !$this->lines ) { 314 | return $this; 315 | } 316 | $ret = new self; 317 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 318 | $ret->lines[] = [ 319 | $lineTaint->asMovedToKeys(), 320 | $lineLine, 321 | $lineLinks ? $lineLinks->asMovedToKeys() : null 322 | ]; 323 | } 324 | return $ret; 325 | } 326 | 327 | /** 328 | * @param Node|mixed $dim 329 | * @param bool $pushOffsetsInLinks 330 | * @return self 331 | */ 332 | public function getForDim( $dim, bool $pushOffsetsInLinks = true ): self { 333 | if ( !$this->lines ) { 334 | return $this; 335 | } 336 | $ret = new self; 337 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 338 | $newTaint = $lineTaint->getTaintednessForOffsetOrWhole( $dim ); 339 | $newLinks = $lineLinks ? $lineLinks->getForDim( $dim, $pushOffsetsInLinks ) : null; 340 | if ( $newLinks && $newLinks->isEmpty() ) { 341 | $newLinks = null; 342 | } 343 | if ( $newLinks || !$newTaint->isSafe() ) { 344 | $ret->lines[] = [ 345 | $newTaint, 346 | $lineLine, 347 | $newLinks 348 | ]; 349 | } 350 | } 351 | return $ret; 352 | } 353 | 354 | public function asAllCollapsed(): self { 355 | if ( !$this->lines ) { 356 | return $this; 357 | } 358 | $ret = new self; 359 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 360 | $ret->lines[] = [ 361 | $lineTaint->asCollapsed(), 362 | $lineLine, 363 | $lineLinks ? $lineLinks->asCollapsed() : null 364 | ]; 365 | } 366 | return $ret; 367 | } 368 | 369 | public function asAllValueFirstLevel(): self { 370 | if ( !$this->lines ) { 371 | return $this; 372 | } 373 | $ret = new self; 374 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 375 | $ret->lines[] = [ 376 | $lineTaint->asValueFirstLevel(), 377 | $lineLine, 378 | $lineLinks ? $lineLinks->asValueFirstLevel() : null 379 | ]; 380 | } 381 | return $ret; 382 | } 383 | 384 | public function asAllKeyForForeach(): self { 385 | if ( !$this->lines ) { 386 | return $this; 387 | } 388 | $ret = new self; 389 | foreach ( $this->lines as [ $lineTaint, $lineLine, $lineLinks ] ) { 390 | $newTaint = $lineTaint->asKeyForForeach(); 391 | $newLinks = $lineLinks ? $lineLinks->asKeyForForeach() : null; 392 | if ( $newLinks && $newLinks->isEmpty() ) { 393 | $newLinks = null; 394 | } 395 | if ( $newLinks || !$newTaint->isSafe() ) { 396 | $ret->lines[] = [ $newTaint, $lineLine, $newLinks ]; 397 | } 398 | } 399 | return $ret; 400 | } 401 | 402 | public function withOnlyLinks(): self { 403 | if ( !$this->lines ) { 404 | return $this; 405 | } 406 | $ret = new self; 407 | $safeTaint = Taintedness::safeSingleton(); 408 | foreach ( $this->lines as [ $_, $lineLine, $lineLinks ] ) { 409 | if ( $lineLinks && !$lineLinks->isEmpty() ) { 410 | $ret->lines[] = [ $safeTaint, $lineLine, $lineLinks ]; 411 | } 412 | } 413 | return $ret; 414 | } 415 | 416 | /** 417 | * @note this isn't a merge operation like array_merge. What this method does is: 418 | * 1 - if $other is a subset of $this, leave $this as-is; 419 | * 2 - update taintedness values in $this if the *lines* (not taint values) in $other 420 | * are a subset of the lines in $this; 421 | * 3 - if an upper set of $this *lines* is also a lower set of $other *lines*, remove that upper 422 | * set from $this and merge the rest with $other; 423 | * 4 - array_merge otherwise; 424 | * 425 | * Step 2 is very important, because otherwise, caused-by lines can grow exponentially if 426 | * even a single taintedness value in $this changes. 427 | * 428 | * @param self $other 429 | * @param int $dimDepth Only used for assignments; depth of the array index access on the LHS. 430 | * @return self 431 | */ 432 | public function asMergedWith( self $other, int $dimDepth = 0 ): self { 433 | $emptySingleton = self::emptySingleton(); 434 | if ( $this === $emptySingleton ) { 435 | return $other; 436 | } 437 | if ( $other === $emptySingleton ) { 438 | return $this; 439 | } 440 | 441 | $ret = clone $this; 442 | 443 | if ( !$ret->lines ) { 444 | $ret->lines = $other->lines; 445 | return $ret; 446 | } 447 | if ( !$other->lines || self::getArraySubsetIdx( $ret->lines, $other->lines ) !== false ) { 448 | return $ret; 449 | } 450 | 451 | $baseLines = array_column( $ret->lines, 1 ); 452 | $newLines = array_column( $other->lines, 1 ); 453 | $subsIdx = self::getArraySubsetIdx( $baseLines, $newLines ); 454 | 455 | if ( $subsIdx === false ) { 456 | // Try reversing the order to see if we get a better merge. 457 | // TODO This whole thing is horrible. We need a better way to merge caused-by lines programmatically 458 | $reverseSubsetIdx = self::getArraySubsetIdx( $newLines, $baseLines ); 459 | if ( $reverseSubsetIdx !== false ) { 460 | [ $ret, $other ] = [ clone $other, $ret ]; 461 | [ $baseLines, $newLines ] = [ $newLines, $baseLines ]; 462 | $subsIdx = $reverseSubsetIdx; 463 | } 464 | } 465 | 466 | if ( $subsIdx !== false ) { 467 | foreach ( $other->lines as $i => $otherLine ) { 468 | /** @var Taintedness $curTaint */ 469 | $curTaint = $ret->lines[ $i + $subsIdx ][0]; 470 | $ret->lines[ $i + $subsIdx ][0] = $dimDepth 471 | ? $curTaint->asMergedForAssignment( $otherLine[0], $dimDepth ) 472 | : $curTaint->asMergedWith( $otherLine[0] ); 473 | /** @var MethodLinks $curLinks */ 474 | $curLinks = $ret->lines[ $i + $subsIdx ][2]; 475 | $otherLinks = $otherLine[2]; 476 | if ( $otherLinks && !$curLinks ) { 477 | $ret->lines[$i + $subsIdx][2] = $otherLinks; 478 | } elseif ( $otherLinks && $otherLinks !== $curLinks ) { 479 | $ret->lines[$i + $subsIdx][2] = $dimDepth 480 | ? $curLinks->asMergedForAssignment( $otherLinks, $dimDepth ) 481 | : $curLinks->asMergedWith( $otherLinks ); 482 | } 483 | } 484 | return $ret; 485 | } 486 | 487 | $resultingLines = null; 488 | $baseLen = count( $ret->lines ); 489 | $newLen = count( $other->lines ); 490 | // NOTE: array_shift is O(n), and O(n^2) over all iterations, because it reindexes the whole array. 491 | // So reverse the arrays, that is O(n) twice, and use array_pop which is O(1) (O(n) for all iterations) 492 | $remaining = array_reverse( $baseLines ); 493 | $newRev = array_reverse( $newLines ); 494 | // Assuming the lines as posets with the "natural" order used by PHP (that is, not the keys): 495 | // since we're working with reversed arrays, remaining lines should be an upper set of the reversed 496 | // new lines; which is to say, a lower set of the non-reversed new lines. 497 | $expectedIndex = $newLen - $baseLen; 498 | do { 499 | if ( $expectedIndex >= 0 && self::getArraySubsetIdx( $newRev, $remaining ) === $expectedIndex ) { 500 | $startIdx = $baseLen - $newLen + $expectedIndex; 501 | for ( $j = $startIdx; $j < $baseLen; $j++ ) { 502 | /** @var Taintedness $curTaint */ 503 | $curTaint = $ret->lines[$j][0]; 504 | $otherTaint = $other->lines[$j - $startIdx][0]; 505 | $ret->lines[$j][0] = $dimDepth 506 | ? $curTaint->asMergedForAssignment( $otherTaint, $dimDepth ) 507 | : $curTaint->asMergedWith( $otherTaint ); 508 | $secondLinks = $other->lines[$j - $startIdx][2]; 509 | /** @var MethodLinks $curLinks */ 510 | $curLinks = $ret->lines[$j][2]; 511 | if ( $secondLinks && !$curLinks ) { 512 | $ret->lines[$j][2] = $secondLinks; 513 | } elseif ( $secondLinks && $secondLinks !== $curLinks ) { 514 | $ret->lines[$j][2] = $dimDepth 515 | ? $curLinks->asMergedForAssignment( $secondLinks, $dimDepth ) 516 | : $curLinks->asMergedWith( $secondLinks ); 517 | } 518 | } 519 | $resultingLines = array_merge( $ret->lines, array_slice( $other->lines, $newLen - $expectedIndex ) ); 520 | break; 521 | } 522 | array_pop( $remaining ); 523 | $expectedIndex++; 524 | } while ( $remaining ); 525 | $resultingLines ??= array_merge( $ret->lines, $other->lines ); 526 | 527 | $ret->lines = array_slice( $resultingLines, 0, self::LINES_HARD_LIMIT ); 528 | 529 | return $ret; 530 | } 531 | 532 | /** 533 | * Check whether $needle is subset of $haystack, regardless of the keys, and returns 534 | * the starting index of the subset in the $haystack array. If the subset occurs multiple 535 | * times, this will just find the first one. 536 | * 537 | * @param array[] $haystack 538 | * @phan-param list $haystack 539 | * @param array[] $needle 540 | * @phan-param list $needle 541 | * @return false|int False if not a subset, the starting index if it is. 542 | * @note Use strict comparisons with the return value! 543 | */ 544 | private static function getArraySubsetIdx( array $haystack, array $needle ) { 545 | if ( !$needle || !$haystack ) { 546 | // For our needs, the empty array is not a subset of anything 547 | return false; 548 | } 549 | 550 | $needleLength = count( $needle ); 551 | $haystackLength = count( $haystack ); 552 | if ( $haystackLength < $needleLength ) { 553 | return false; 554 | } 555 | $curIdx = 0; 556 | foreach ( $haystack as $i => $el ) { 557 | if ( $el === $needle[ $curIdx ] ) { 558 | $curIdx++; 559 | } else { 560 | $curIdx = 0; 561 | } 562 | if ( $curIdx === $needleLength ) { 563 | return $i - ( $needleLength - 1 ); 564 | } 565 | } 566 | return false; 567 | } 568 | 569 | /** 570 | * Return a truncated, stringified representation of these lines to be used when reporting issues. 571 | * 572 | * @todo Perhaps this should include the first and last X lines, not the first 2X. However, 573 | * doing so would make phan emit a new issue for the same line whenever new caused-by 574 | * lines are added to the array. 575 | * 576 | * @param Taintedness $sinkTaint Must have EXEC flags only. 577 | * @param Taintedness $exprTaint Must have normal flags only. 578 | * @param bool $isSinkError Whether this object refers to a sink (and not the expr) 579 | * @return string 580 | */ 581 | public function toStringForIssue( Taintedness $sinkTaint, Taintedness $exprTaint, bool $isSinkError ): string { 582 | $filteredLines = $this->getRelevantLinesForTaintedness( $sinkTaint, $exprTaint, $isSinkError ); 583 | if ( !$filteredLines ) { 584 | return ''; 585 | } 586 | 587 | if ( count( $filteredLines ) <= self::MAX_LINES_PER_ISSUE ) { 588 | $linesPart = implode( '; ', $filteredLines ); 589 | } else { 590 | $linesPart = implode( '; ', array_slice( $filteredLines, 0, self::MAX_LINES_PER_ISSUE ) ) . '; ...'; 591 | } 592 | return ' (Caused by: ' . $linesPart . ')'; 593 | } 594 | 595 | /** 596 | * @param Taintedness $sinkTaint With EXEC flags only. 597 | * @param Taintedness $exprTaint With normal flags only. 598 | * @param bool $isSinkError 599 | * @return string[] 600 | */ 601 | private function getRelevantLinesForTaintedness( 602 | Taintedness $sinkTaint, 603 | Taintedness $exprTaint, 604 | bool $isSinkError 605 | ): array { 606 | $ret = []; 607 | foreach ( $this->lines as [ $lineTaint, $lineText ] ) { 608 | $intersection = $isSinkError 609 | ? Taintedness::intersectForSink( $lineTaint->asYesToExecTaint(), $exprTaint ) 610 | : Taintedness::intersectForSink( $sinkTaint, $lineTaint ); 611 | if ( !$intersection->isSafe() ) { 612 | $ret[] = $lineText; 613 | } 614 | } 615 | return $ret; 616 | } 617 | 618 | public function isEmpty(): bool { 619 | return $this->lines === []; 620 | } 621 | 622 | /** 623 | * @return string[] 624 | * @suppress PhanUnreferencedPublicMethod 625 | */ 626 | public function toLinesArray(): array { 627 | return array_column( $this->lines, 1 ); 628 | } 629 | 630 | /** 631 | * @suppress PhanUnreferencedPublicMethod 632 | * @codeCoverageIgnore 633 | */ 634 | public function toDebugString(): string { 635 | if ( $this === self::emptySingleton() ) { 636 | return '(empty)'; 637 | } 638 | $r = []; 639 | foreach ( $this->lines as [ $t, $line, $links ] ) { 640 | $r[] = "\t[\n\t\tT: " . $t->toShortString() . "\n\t\tL: " . $line . "\n\t\tLinks: " . 641 | ( $links ? $links->toString( "\t\t" ) : 'none' ) . "\n\t]"; 642 | } 643 | return "[\n" . implode( ",\n", $r ) . "\n]"; 644 | } 645 | } 646 | -------------------------------------------------------------------------------- /src/FunctionCausedByLines.php: -------------------------------------------------------------------------------- 1 | genericLines = CausedByLines::emptySingleton(); 30 | } 31 | 32 | public static function emptySingleton(): self { 33 | static $singleton; 34 | if ( !$singleton ) { 35 | $singleton = new self(); 36 | } 37 | return $singleton; 38 | } 39 | 40 | /** 41 | * @return CausedByLines 42 | * @suppress PhanUnreferencedPublicMethod 43 | */ 44 | public function getGenericLines(): CausedByLines { 45 | return $this->genericLines; 46 | } 47 | 48 | /** 49 | * @param string[] $lines 50 | * @param Taintedness $taint 51 | * @param ?MethodLinks $links 52 | * @return self 53 | */ 54 | public function withAddedGenericLines( array $lines, Taintedness $taint, ?MethodLinks $links = null ): self { 55 | $ret = clone $this; 56 | $ret->genericLines = $this->genericLines->withAddedLines( $lines, $taint, $links ); 57 | return $ret; 58 | } 59 | 60 | public function withGenericLines( CausedByLines $lines ): self { 61 | $ret = clone $this; 62 | $ret->genericLines = $lines; 63 | return $ret; 64 | } 65 | 66 | /** 67 | * @param int $param 68 | * @param string[] $lines 69 | * @param Taintedness $taint 70 | * @return self 71 | */ 72 | public function withAddedParamSinkLines( int $param, array $lines, Taintedness $taint ): self { 73 | assert( $param !== $this->variadicParamIndex ); 74 | $ret = clone $this; 75 | if ( !isset( $ret->paramSinkLines[$param] ) ) { 76 | $ret->paramSinkLines[$param] = CausedByLines::emptySingleton(); 77 | } 78 | $ret->paramSinkLines[$param] = $ret->paramSinkLines[$param]->withAddedLines( $lines, $taint ); 79 | return $ret; 80 | } 81 | 82 | /** 83 | * @param int $param 84 | * @param string[] $lines 85 | * @param Taintedness $taint 86 | * @param ?MethodLinks $links 87 | * @return self 88 | */ 89 | public function withAddedParamPreservedLines( 90 | int $param, 91 | array $lines, 92 | Taintedness $taint, 93 | ?MethodLinks $links = null 94 | ): self { 95 | assert( $param !== $this->variadicParamIndex ); 96 | $ret = clone $this; 97 | if ( !isset( $ret->paramPreservedLines[$param] ) ) { 98 | $ret->paramPreservedLines[$param] = CausedByLines::emptySingleton(); 99 | } 100 | $ret->paramPreservedLines[$param] = $ret->paramPreservedLines[$param] 101 | ->withAddedLines( $lines, $taint, $links ); 102 | return $ret; 103 | } 104 | 105 | public function withParamSinkLines( int $param, CausedByLines $lines ): self { 106 | $ret = clone $this; 107 | $ret->paramSinkLines[$param] = $lines; 108 | return $ret; 109 | } 110 | 111 | public function withParamPreservedLines( int $param, CausedByLines $lines ): self { 112 | $ret = clone $this; 113 | $ret->paramPreservedLines[$param] = $lines; 114 | return $ret; 115 | } 116 | 117 | public function withVariadicParamSinkLines( int $param, CausedByLines $lines ): self { 118 | $ret = clone $this; 119 | $ret->variadicParamIndex = $param; 120 | $ret->variadicParamSinkLines = $lines; 121 | return $ret; 122 | } 123 | 124 | public function withVariadicParamPreservedLines( int $param, CausedByLines $lines ): self { 125 | $ret = clone $this; 126 | $ret->variadicParamIndex = $param; 127 | $ret->variadicParamPreservedLines = $lines; 128 | return $ret; 129 | } 130 | 131 | /** 132 | * @param int $param 133 | * @param string[] $lines 134 | * @param Taintedness $taint 135 | * @return self 136 | */ 137 | public function withAddedVariadicParamSinkLines( 138 | int $param, 139 | array $lines, 140 | Taintedness $taint 141 | ): self { 142 | assert( !isset( $this->paramSinkLines[$param] ) && !isset( $this->paramPreservedLines[$param] ) ); 143 | $ret = clone $this; 144 | $ret->variadicParamIndex = $param; 145 | if ( !$ret->variadicParamSinkLines ) { 146 | $ret->variadicParamSinkLines = CausedByLines::emptySingleton(); 147 | } 148 | $ret->variadicParamSinkLines = $ret->variadicParamSinkLines->withAddedLines( $lines, $taint ); 149 | return $ret; 150 | } 151 | 152 | /** 153 | * @param int $param 154 | * @param string[] $lines 155 | * @param Taintedness $taint 156 | * @param ?MethodLinks $links 157 | * @return self 158 | */ 159 | public function withAddedVariadicParamPreservedLines( 160 | int $param, 161 | array $lines, 162 | Taintedness $taint, 163 | ?MethodLinks $links = null 164 | ): self { 165 | assert( !isset( $this->paramSinkLines[$param] ) && !isset( $this->paramPreservedLines[$param] ) ); 166 | $ret = clone $this; 167 | $ret->variadicParamIndex = $param; 168 | if ( !$ret->variadicParamPreservedLines ) { 169 | $ret->variadicParamPreservedLines = CausedByLines::emptySingleton(); 170 | } 171 | $ret->variadicParamPreservedLines = $ret->variadicParamPreservedLines 172 | ->withAddedLines( $lines, $taint, $links ); 173 | return $ret; 174 | } 175 | 176 | /** 177 | * @param int $param 178 | * @return CausedByLines 179 | */ 180 | public function getParamSinkLines( int $param ): CausedByLines { 181 | if ( isset( $this->paramSinkLines[$param] ) ) { 182 | return $this->paramSinkLines[$param]; 183 | } 184 | if ( 185 | $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex && 186 | $this->variadicParamSinkLines 187 | ) { 188 | return $this->variadicParamSinkLines; 189 | } 190 | return CausedByLines::emptySingleton(); 191 | } 192 | 193 | /** 194 | * @param int $param 195 | * @return CausedByLines 196 | */ 197 | public function getParamPreservedLines( int $param ): CausedByLines { 198 | if ( isset( $this->paramPreservedLines[$param] ) ) { 199 | return $this->paramPreservedLines[$param]; 200 | } 201 | if ( 202 | $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex && 203 | $this->variadicParamPreservedLines 204 | ) { 205 | return $this->variadicParamPreservedLines; 206 | } 207 | return CausedByLines::emptySingleton(); 208 | } 209 | 210 | /** 211 | * @param FunctionCausedByLines $other 212 | * @param FunctionTaintedness $funcTaint To check NO_OVERRIDE 213 | * @return self 214 | */ 215 | public function asMergedWith( self $other, FunctionTaintedness $funcTaint ): self { 216 | $ret = clone $this; 217 | $canOverrideOverall = $funcTaint->canOverrideOverall(); 218 | if ( $canOverrideOverall ) { 219 | $ret->genericLines = $ret->genericLines->asMergedWith( $other->genericLines ); 220 | } 221 | 222 | foreach ( $other->paramSinkLines as $param => $lines ) { 223 | if ( $funcTaint->canOverrideNonVariadicParam( $param ) ) { 224 | if ( isset( $ret->paramSinkLines[$param] ) ) { 225 | $ret->paramSinkLines[$param] = $ret->paramSinkLines[$param]->asMergedWith( $lines ); 226 | } else { 227 | $ret->paramSinkLines[$param] = $lines; 228 | } 229 | } 230 | } 231 | if ( $canOverrideOverall ) { 232 | foreach ( $other->paramPreservedLines as $param => $lines ) { 233 | if ( $funcTaint->canOverrideNonVariadicParam( $param ) ) { 234 | if ( isset( $ret->paramPreservedLines[$param] ) ) { 235 | $ret->paramPreservedLines[$param] = $ret->paramPreservedLines[$param]->asMergedWith( $lines ); 236 | } else { 237 | $ret->paramPreservedLines[$param] = $lines; 238 | } 239 | } 240 | } 241 | } 242 | $variadicIndex = $other->variadicParamIndex; 243 | if ( $variadicIndex !== null && $funcTaint->canOverrideVariadicParam() ) { 244 | $ret->variadicParamIndex = $variadicIndex; 245 | $sinkVariadic = $other->variadicParamSinkLines; 246 | if ( $sinkVariadic ) { 247 | if ( $ret->variadicParamSinkLines ) { 248 | $ret->variadicParamSinkLines = $ret->variadicParamSinkLines->asMergedWith( $sinkVariadic ); 249 | } else { 250 | $ret->variadicParamSinkLines = $sinkVariadic; 251 | } 252 | } 253 | if ( $canOverrideOverall ) { 254 | $preserveVariadic = $other->variadicParamPreservedLines; 255 | if ( $preserveVariadic ) { 256 | if ( $ret->variadicParamPreservedLines ) { 257 | $ret->variadicParamPreservedLines = $this->variadicParamPreservedLines 258 | ->asMergedWith( $preserveVariadic ); 259 | } else { 260 | $ret->variadicParamPreservedLines = $preserveVariadic; 261 | } 262 | } 263 | } 264 | } 265 | return $ret; 266 | } 267 | 268 | /** 269 | * @codeCoverageIgnore 270 | */ 271 | public function toString(): string { 272 | $str = "{\nGeneric: " . $this->genericLines->toDebugString() . ",\n"; 273 | foreach ( $this->paramSinkLines as $par => $lines ) { 274 | $str .= "$par (sink): " . $lines->toDebugString() . ",\n"; 275 | } 276 | foreach ( $this->paramPreservedLines as $par => $lines ) { 277 | $str .= "$par (preserved): " . $lines->toDebugString() . ",\n"; 278 | } 279 | if ( $this->variadicParamSinkLines ) { 280 | $str .= "...{$this->variadicParamIndex} (sink): " . $this->variadicParamSinkLines->toDebugString() . "\n"; 281 | } 282 | if ( $this->variadicParamPreservedLines ) { 283 | $str .= "...{$this->variadicParamIndex} (preserved): " . 284 | $this->variadicParamPreservedLines->toDebugString() . "\n"; 285 | } 286 | return "$str}"; 287 | } 288 | 289 | /** 290 | * @codeCoverageIgnore 291 | */ 292 | public function __toString(): string { 293 | return $this->toString(); 294 | } 295 | } 296 | -------------------------------------------------------------------------------- /src/FunctionTaintedness.php: -------------------------------------------------------------------------------- 1 | overall = $overall; 44 | $this->overallFlags = $overallFlags; 45 | } 46 | 47 | public static function emptySingleton(): self { 48 | static $singleton; 49 | if ( !$singleton ) { 50 | $singleton = new self( Taintedness::safeSingleton() ); 51 | } 52 | return $singleton; 53 | } 54 | 55 | public function withOverall( Taintedness $val, int $flags = 0 ): self { 56 | $ret = clone $this; 57 | $ret->overall = $val; 58 | $ret->overallFlags |= $flags; 59 | return $ret; 60 | } 61 | 62 | /** 63 | * Get the overall taint (NOT a clone) 64 | * 65 | * @return Taintedness 66 | */ 67 | public function getOverall(): Taintedness { 68 | return $this->overall; 69 | } 70 | 71 | /** 72 | * @return bool 73 | */ 74 | public function canOverrideOverall(): bool { 75 | return ( $this->overallFlags & SecurityCheckPlugin::NO_OVERRIDE ) === 0; 76 | } 77 | 78 | /** 79 | * Set the sink taint for a given param 80 | * 81 | * @param int $param 82 | * @param Taintedness $taint 83 | * @param int $flags 84 | * @return self 85 | */ 86 | public function withParamSinkTaint( int $param, Taintedness $taint, int $flags = 0 ): self { 87 | $ret = clone $this; 88 | assert( $param !== $ret->variadicParamIndex ); 89 | $ret->paramSinkTaints[$param] = $taint; 90 | $ret->paramFlags[$param] = ( $ret->paramFlags[$param] ?? 0 ) | $flags; 91 | return $ret; 92 | } 93 | 94 | /** 95 | * Set the preserved taint for a given param 96 | * 97 | * @param int $param 98 | * @param PreservedTaintedness $taint 99 | * @param int $flags 100 | * @return self 101 | */ 102 | public function withParamPreservedTaint( int $param, PreservedTaintedness $taint, int $flags = 0 ): self { 103 | $ret = clone $this; 104 | assert( $param !== $ret->variadicParamIndex ); 105 | $ret->paramPreserveTaints[$param] = $taint; 106 | $ret->paramFlags[$param] = ( $ret->paramFlags[$param] ?? 0 ) | $flags; 107 | return $ret; 108 | } 109 | 110 | public function withVariadicParamSinkTaint( int $index, Taintedness $taint, int $flags = 0 ): self { 111 | $ret = clone $this; 112 | assert( !isset( $ret->paramPreserveTaints[$index] ) && !isset( $ret->paramSinkTaints[$index] ) ); 113 | $ret->variadicParamIndex = $index; 114 | $ret->variadicParamSinkTaint = $taint; 115 | $ret->variadicParamFlags |= $flags; 116 | return $ret; 117 | } 118 | 119 | public function withVariadicParamPreservedTaint( int $index, PreservedTaintedness $taint, int $flags = 0 ): self { 120 | $ret = clone $this; 121 | assert( !isset( $ret->paramPreserveTaints[$index] ) && !isset( $ret->paramSinkTaints[$index] ) ); 122 | $ret->variadicParamIndex = $index; 123 | $ret->variadicParamPreserveTaint = $taint; 124 | $ret->variadicParamFlags |= $flags; 125 | return $ret; 126 | } 127 | 128 | /** 129 | * Get the sink taintedness of the given param (NOT a clone), and NO_TAINT if not set. 130 | * 131 | * @param int $param 132 | * @return Taintedness 133 | */ 134 | public function getParamSinkTaint( int $param ): Taintedness { 135 | if ( isset( $this->paramSinkTaints[$param] ) ) { 136 | return $this->paramSinkTaints[$param]; 137 | } 138 | if ( 139 | $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex && 140 | $this->variadicParamSinkTaint 141 | ) { 142 | return $this->variadicParamSinkTaint; 143 | } 144 | return Taintedness::safeSingleton(); 145 | } 146 | 147 | /** 148 | * Get the preserved taintedness of the given param (NOT a clone), and NO_TAINT if not set. 149 | * 150 | * @param int $param 151 | * @return PreservedTaintedness 152 | */ 153 | public function getParamPreservedTaint( int $param ): PreservedTaintedness { 154 | if ( isset( $this->paramPreserveTaints[$param] ) ) { 155 | return $this->paramPreserveTaints[$param]; 156 | } 157 | if ( 158 | $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex && 159 | $this->variadicParamPreserveTaint 160 | ) { 161 | return $this->variadicParamPreserveTaint; 162 | } 163 | return PreservedTaintedness::emptySingleton(); 164 | } 165 | 166 | /** 167 | * @param int $param 168 | * @return int 169 | */ 170 | public function getParamFlags( int $param ): int { 171 | if ( isset( $this->paramFlags[$param] ) ) { 172 | return $this->paramFlags[$param]; 173 | } 174 | if ( $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex ) { 175 | return $this->variadicParamFlags; 176 | } 177 | return 0; 178 | } 179 | 180 | /** 181 | * @param int $param 182 | * @return bool 183 | */ 184 | public function canOverrideNonVariadicParam( int $param ): bool { 185 | return ( ( $this->paramFlags[$param] ?? 0 ) & SecurityCheckPlugin::NO_OVERRIDE ) === 0; 186 | } 187 | 188 | /** 189 | * @return Taintedness|null 190 | */ 191 | public function getVariadicParamSinkTaint(): ?Taintedness { 192 | return $this->variadicParamSinkTaint; 193 | } 194 | 195 | /** 196 | * @return PreservedTaintedness|null 197 | * @suppress PhanUnreferencedPublicMethod 198 | */ 199 | public function getVariadicParamPreservedTaint(): ?PreservedTaintedness { 200 | return $this->variadicParamPreserveTaint; 201 | } 202 | 203 | /** 204 | * @return int|null 205 | */ 206 | public function getVariadicParamIndex(): ?int { 207 | return $this->variadicParamIndex; 208 | } 209 | 210 | /** 211 | * @return bool 212 | */ 213 | public function canOverrideVariadicParam(): bool { 214 | return ( $this->variadicParamFlags & SecurityCheckPlugin::NO_OVERRIDE ) === 0; 215 | } 216 | 217 | /** 218 | * Get the *keys* of the params for which we have sink data, excluding variadic parameters 219 | * 220 | * @return int[] 221 | */ 222 | public function getSinkParamKeysNoVariadic(): array { 223 | return array_keys( $this->paramSinkTaints ); 224 | } 225 | 226 | /** 227 | * Get the *keys* of the params for which we have preserve data, excluding variadic parameters 228 | * 229 | * @return int[] 230 | */ 231 | public function getPreserveParamKeysNoVariadic(): array { 232 | return array_keys( $this->paramPreserveTaints ); 233 | } 234 | 235 | /** 236 | * Check whether we have preserve taint data for the given param 237 | * 238 | * @param int $param 239 | * @return bool 240 | */ 241 | public function hasParamPreserve( int $param ): bool { 242 | if ( isset( $this->paramPreserveTaints[$param] ) ) { 243 | return true; 244 | } 245 | if ( $this->variadicParamIndex !== null && $param >= $this->variadicParamIndex ) { 246 | return (bool)$this->variadicParamPreserveTaint; 247 | } 248 | return false; 249 | } 250 | 251 | /** 252 | * Merge this object with another. This respects NO_OVERRIDE, since it doesn't touch any element 253 | * where it's set. If the overall taint has UNKNOWN, it's cleared if we're setting it now. 254 | * 255 | * @param self $other 256 | * @return self 257 | */ 258 | public function asMergedWith( self $other ): self { 259 | $ret = clone $this; 260 | 261 | foreach ( $other->paramSinkTaints as $index => $baseT ) { 262 | if ( ( ( $ret->paramFlags[$index] ?? 0 ) & SecurityCheckPlugin::NO_OVERRIDE ) === 0 ) { 263 | if ( isset( $ret->paramSinkTaints[$index] ) ) { 264 | $ret->paramSinkTaints[$index] = $ret->paramSinkTaints[$index]->asMergedWith( $baseT ); 265 | } else { 266 | $ret->paramSinkTaints[$index] = $baseT; 267 | } 268 | $ret->paramFlags[$index] = ( $ret->paramFlags[$index] ?? 0 ) | ( $other->paramFlags[$index] ?? 0 ); 269 | } 270 | } 271 | foreach ( $other->paramPreserveTaints as $index => $baseT ) { 272 | if ( ( ( $ret->paramFlags[$index] ?? 0 ) & SecurityCheckPlugin::NO_OVERRIDE ) === 0 ) { 273 | if ( isset( $ret->paramPreserveTaints[$index] ) ) { 274 | $ret->paramPreserveTaints[$index] = $ret->paramPreserveTaints[$index]->asMergedWith( $baseT ); 275 | } else { 276 | $ret->paramPreserveTaints[$index] = $baseT; 277 | } 278 | $ret->paramFlags[$index] = ( $ret->paramFlags[$index] ?? 0 ) | ( $other->paramFlags[$index] ?? 0 ); 279 | } 280 | } 281 | 282 | if ( ( $ret->variadicParamFlags & SecurityCheckPlugin::NO_OVERRIDE ) === 0 ) { 283 | $variadicIndex = $other->variadicParamIndex; 284 | if ( $variadicIndex !== null ) { 285 | $ret->variadicParamIndex = $variadicIndex; 286 | $sinkVariadic = $other->variadicParamSinkTaint; 287 | if ( $sinkVariadic ) { 288 | if ( $ret->variadicParamSinkTaint ) { 289 | $ret->variadicParamSinkTaint = $ret->variadicParamSinkTaint->asMergedWith( $sinkVariadic ); 290 | } else { 291 | $ret->variadicParamSinkTaint = $sinkVariadic; 292 | } 293 | } 294 | $presVariadic = $other->variadicParamPreserveTaint; 295 | if ( $presVariadic ) { 296 | if ( $ret->variadicParamPreserveTaint ) { 297 | $ret->variadicParamPreserveTaint = $ret->variadicParamPreserveTaint 298 | ->asMergedWith( $presVariadic ); 299 | } else { 300 | $ret->variadicParamPreserveTaint = $presVariadic; 301 | } 302 | } 303 | $ret->variadicParamFlags |= $other->variadicParamFlags; 304 | } 305 | } 306 | 307 | if ( ( $ret->overallFlags & SecurityCheckPlugin::NO_OVERRIDE ) === 0 ) { 308 | // Remove UNKNOWN, which could be added e.g. when building func taint from the return type. 309 | $ret->overall = $ret->overall->without( SecurityCheckPlugin::UNKNOWN_TAINT ) 310 | ->asMergedWith( $other->overall ); 311 | $ret->overallFlags |= $other->overallFlags; 312 | } 313 | 314 | return $ret; 315 | } 316 | 317 | public function withoutPreserved(): self { 318 | $ret = clone $this; 319 | $ret->paramPreserveTaints = []; 320 | $ret->variadicParamPreserveTaint = null; 321 | return $ret; 322 | } 323 | 324 | public function asOnlyPreserved(): self { 325 | $ret = new self( Taintedness::safeSingleton() ); 326 | $ret->paramPreserveTaints = $this->paramPreserveTaints; 327 | $ret->variadicParamPreserveTaint = $this->variadicParamPreserveTaint; 328 | return $ret; 329 | } 330 | 331 | /** 332 | * @codeCoverageIgnore 333 | */ 334 | public function toString(): string { 335 | $str = "[\n\toverall: " . $this->overall->toShortString() . 336 | self::flagsToString( $this->overallFlags ) . ",\n"; 337 | $parKeys = array_unique( array_merge( 338 | array_keys( $this->paramSinkTaints ), 339 | array_keys( $this->paramPreserveTaints ) 340 | ) ); 341 | foreach ( $parKeys as $par ) { 342 | $str .= "\t$par: {"; 343 | if ( isset( $this->paramSinkTaints[$par] ) ) { 344 | $str .= "Sink: " . $this->paramSinkTaints[$par]->toShortString() . ', '; 345 | } 346 | if ( isset( $this->paramPreserveTaints[$par] ) ) { 347 | $str .= "Preserve: " . $this->paramPreserveTaints[$par]->toShortString(); 348 | } 349 | $str .= '} ' . self::flagsToString( $this->paramFlags[$par] ?? 0 ) . ",\n"; 350 | } 351 | if ( $this->variadicParamIndex !== null ) { 352 | $str .= "\t...{$this->variadicParamIndex}: {"; 353 | if ( $this->variadicParamSinkTaint ) { 354 | $str .= "Sink: " . $this->variadicParamSinkTaint->toShortString() . ', '; 355 | } 356 | if ( $this->variadicParamPreserveTaint ) { 357 | $str .= "Preserve: " . $this->variadicParamPreserveTaint->toShortString(); 358 | } 359 | $str .= '} ' . self::flagsToString( $this->variadicParamFlags ) . "\n"; 360 | } 361 | return "$str]"; 362 | } 363 | 364 | /** 365 | * @codeCoverageIgnore 366 | */ 367 | private static function flagsToString( int $flags ): string { 368 | $bits = []; 369 | if ( $flags & SecurityCheckPlugin::NO_OVERRIDE ) { 370 | $bits[] = 'no override'; 371 | } 372 | if ( $flags & SecurityCheckPlugin::ARRAY_OK ) { 373 | $bits[] = 'array ok'; 374 | } 375 | return $bits ? ' (' . implode( ', ', $bits ) . ')' : ''; 376 | } 377 | 378 | /** 379 | * @codeCoverageIgnore 380 | */ 381 | public function __toString(): string { 382 | return $this->toString(); 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/LinksSet.php: -------------------------------------------------------------------------------- 1 | contains( $method ) ) { 25 | $this[$method] = $this[$method]->asMergedWith( $other[$method] ); 26 | } else { 27 | $this->attach( $method, $other[$method] ); 28 | } 29 | } 30 | } 31 | 32 | /** 33 | * @param LinksSet $other 34 | * @return self 35 | */ 36 | public function asMergedWith( self $other ): self { 37 | $ret = clone $this; 38 | $ret->mergeWith( $other ); 39 | return $ret; 40 | } 41 | 42 | public function withoutShape( self $other ): self { 43 | $ret = clone $this; 44 | foreach ( $other as $func ) { 45 | if ( $ret->contains( $func ) ) { 46 | $newFuncData = $ret[$func]->withoutShape( $other[$func] ); 47 | if ( $newFuncData->getParams() ) { 48 | $ret[$func] = $newFuncData; 49 | } else { 50 | unset( $ret[$func] ); 51 | } 52 | } 53 | } 54 | return $ret; 55 | } 56 | 57 | /** 58 | * @return self 59 | */ 60 | public function asAllMovedToKeys(): self { 61 | $ret = new self; 62 | foreach ( $this as $func ) { 63 | $ret[$func] = $this[$func]->asAllParamsMovedToKeys(); 64 | } 65 | return $ret; 66 | } 67 | 68 | /** 69 | * @codeCoverageIgnore 70 | */ 71 | public function __toString(): string { 72 | $children = []; 73 | foreach ( $this as $func ) { 74 | $children[] = $func->getFQSEN()->__toString() . ': ' . $this[$func]->__toString(); 75 | } 76 | return '{ ' . implode( ',', $children ) . ' }'; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/MWPreVisitor.php: -------------------------------------------------------------------------------- 1 | 12 | * 13 | * This program is free software; you can redistribute it and/or modify 14 | * it under the terms of the GNU General Public License as published by 15 | * the Free Software Foundation; either version 2 of the License, or 16 | * (at your option) any later version. 17 | * 18 | * This program is distributed in the hope that it will be useful, 19 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | * GNU General Public License for more details. 22 | * 23 | * You should have received a copy of the GNU General Public License along 24 | * with this program; if not, write to the Free Software Foundation, Inc., 25 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 26 | */ 27 | class MWPreVisitor extends PreTaintednessVisitor { 28 | /** 29 | * Set taint for certain hook types. 30 | * 31 | * Also handles FuncDecl 32 | * @param Node $node 33 | */ 34 | public function visitMethod( Node $node ): void { 35 | parent::visitMethod( $node ); 36 | 37 | $fqsen = $this->context->getFunctionLikeFQSEN(); 38 | $hookType = MediaWikiHooksHelper::getInstance()->isSpecialHookSubscriber( $fqsen ); 39 | if ( !$hookType ) { 40 | return; 41 | } 42 | $params = $node->children['params']->children; 43 | 44 | switch ( $hookType ) { 45 | case '!ParserFunctionHook': 46 | $this->setFuncHookParamTaint( $params ); 47 | break; 48 | case '!ParserHook': 49 | $this->setTagHookParamTaint( $params ); 50 | break; 51 | } 52 | } 53 | 54 | /** 55 | * Set taint for a tag hook. 56 | * 57 | * The parameters are: 58 | * string contents (Tainted from wikitext) 59 | * array attribs (Tainted from wikitext) 60 | * Parser object 61 | * PPFrame object 62 | * 63 | * @param array $params formal parameters of tag hook 64 | * @phan-param array $params 65 | */ 66 | private function setTagHookParamTaint( array $params ): void { 67 | // Only care about first 2 parameters. 68 | $scope = $this->context->getScope(); 69 | for ( $i = 0; $i < 2 && $i < count( $params ); $i++ ) { 70 | $param = $params[$i]; 71 | if ( !$scope->hasVariableWithName( $param->children['name'] ) ) { 72 | // @codeCoverageIgnoreStart 73 | $this->debug( __METHOD__, "Missing variable for param \$" . $param->children['name'] ); 74 | continue; 75 | // @codeCoverageIgnoreEnd 76 | } 77 | $varObj = $scope->getVariableByName( $param->children['name'] ); 78 | $argTaint = Taintedness::newTainted(); 79 | self::setTaintednessRaw( $varObj, $argTaint ); 80 | $this->addTaintError( $varObj, $argTaint, null, 'tainted argument to tag hook' ); 81 | // $this->debug( __METHOD__, "In $method setting param $varObj as tainted" ); 82 | } 83 | // If there are no type hints, phan won't know that the parser 84 | // is a parser as the hook isn't triggered from a real func call. 85 | $hooksHelper = MediaWikiHooksHelper::getInstance(); 86 | $paramTypes = [ 87 | 2 => $hooksHelper->getMwParserClassFQSEN( $this->code_base )->__toString(), 88 | 3 => $hooksHelper->getPPFrameClassFQSEN( $this->code_base )->__toString(), 89 | ]; 90 | foreach ( $paramTypes as $i => $type ) { 91 | if ( isset( $params[$i] ) ) { 92 | $param = $params[$i]; 93 | if ( !$scope->hasVariableWithName( $param->children['name'] ) ) { 94 | // @codeCoverageIgnoreStart 95 | $this->debug( __METHOD__, "Missing variable for param \$" . $param->children['name'] ); 96 | // @codeCoverageIgnoreEnd 97 | } else { 98 | $varObj = $scope->getVariableByName( $param->children['name'] ); 99 | $varObj->setUnionType( 100 | UnionType::fromFullyQualifiedPHPDocString( $type ) 101 | ); 102 | } 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * Set the appropriate taint for a parser function hook 109 | * 110 | * Basically all but the first arg comes from wikitext 111 | * and is tainted. 112 | * 113 | * @todo This is handling SFH_OBJECT type func hooks incorrectly. 114 | * @param Node[] $params Children of the AST_PARAM_LIST 115 | */ 116 | private function setFuncHookParamTaint( array $params ): void { 117 | // First make sure the first arg is set to be a Parser 118 | $scope = $this->context->getScope(); 119 | if ( isset( $params[0] ) ) { 120 | $param = $params[0]; 121 | if ( !$scope->hasVariableWithName( $param->children['name'] ) ) { 122 | // @codeCoverageIgnoreStart 123 | $this->debug( __METHOD__, "Missing variable for param \$" . $param->children['name'] ); 124 | // @codeCoverageIgnoreEnd 125 | } else { 126 | $varObj = $scope->getVariableByName( $param->children['name'] ); 127 | $varObj->setUnionType( 128 | MediaWikiHooksHelper::getInstance()->getMwParserClassFQSEN( $this->code_base )->asPHPDocUnionType() 129 | ); 130 | } 131 | } 132 | 133 | foreach ( $params as $i => $param ) { 134 | if ( $i === 0 ) { 135 | continue; 136 | } 137 | if ( !$scope->hasVariableWithName( $param->children['name'] ) ) { 138 | // @codeCoverageIgnoreStart 139 | $this->debug( __METHOD__, "Missing variable for param \$" . $param->children['name'] ); 140 | continue; 141 | // @codeCoverageIgnoreEnd 142 | } 143 | $varObj = $scope->getVariableByName( $param->children['name'] ); 144 | $argTaint = Taintedness::newTainted(); 145 | self::setTaintednessRaw( $varObj, $argTaint ); 146 | $this->addTaintError( $varObj, $argTaint, null, 'tainted argument to parser hook' ); 147 | } 148 | } 149 | 150 | /** 151 | * @param Node $node 152 | */ 153 | public function visitAssign( Node $node ): void { 154 | parent::visitAssign( $node ); 155 | 156 | $lhs = $node->children['var']; 157 | if ( $lhs instanceof Node && $lhs->kind === \ast\AST_ARRAY ) { 158 | // Don't try interpreting the node as an HTMLForm specifier later on, both for performance, and because 159 | // resolving values might cause phan to emit issues (see test undeclaredvar3) 160 | // @phan-suppress-next-line PhanUndeclaredProperty 161 | $lhs->skipHTMLFormAnalysis = true; 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/MediaWikiHooksHelper.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | private $hookSubscribers = []; 39 | 40 | private ?FullyQualifiedClassName $parserFQSEN = null; 41 | private ?FullyQualifiedClassName $ppFrameFQSEN = null; 42 | 43 | /** @var self|null */ 44 | private static $instance; 45 | 46 | /** 47 | * @return self 48 | */ 49 | public static function getInstance(): self { 50 | if ( !self::$instance ) { 51 | self::$instance = new self; 52 | } 53 | return self::$instance; 54 | } 55 | 56 | /** 57 | * Clear the extension.json cache, for testing purpose 58 | * 59 | * @suppress PhanUnreferencedPublicMethod 60 | */ 61 | public function clearCache(): void { 62 | $this->extensionJsonLoaded = false; 63 | } 64 | 65 | /** 66 | * Add a hook implementation to our list. 67 | * 68 | * This also handles parser hooks which aren't normal hooks. 69 | * Non-normal hooks start their name with a "!" 70 | * 71 | * @param string $hookName Name of hook 72 | * @param FullyQualifiedFunctionLikeName $fqsen The implementing method 73 | * @return bool true if already registered, false otherwise 74 | */ 75 | public function registerHook( string $hookName, FullyQualifiedFunctionLikeName $fqsen ): bool { 76 | if ( !isset( $this->hookSubscribers[$hookName] ) ) { 77 | $this->hookSubscribers[$hookName] = []; 78 | } 79 | if ( in_array( $fqsen, $this->hookSubscribers[$hookName], true ) ) { 80 | return true; 81 | } 82 | $this->hookSubscribers[$hookName][] = $fqsen; 83 | return false; 84 | } 85 | 86 | /** 87 | * Register hooks from extension.json/skin.json 88 | * 89 | * Assumes extension.json/skin.json is in project root directory 90 | * unless SECURITY_CHECK_EXT_PATH is set 91 | */ 92 | protected function loadExtensionJson(): void { 93 | if ( $this->extensionJsonLoaded ) { 94 | return; 95 | } 96 | foreach ( [ 'extension.json', 'skin.json' ] as $filename ) { 97 | $envPath = getenv( 'SECURITY_CHECK_EXT_PATH' ); 98 | if ( $envPath ) { 99 | $jsonPath = $envPath . '/' . $filename; 100 | } else { 101 | $jsonPath = Config::projectPath( $filename ); 102 | } 103 | if ( file_exists( $jsonPath ) ) { 104 | $this->readJsonFile( $jsonPath ); 105 | } 106 | } 107 | $this->extensionJsonLoaded = true; 108 | } 109 | 110 | /** 111 | * @param string $jsonPath 112 | */ 113 | private function readJsonFile( string $jsonPath ): void { 114 | $json = json_decode( file_get_contents( $jsonPath ), true ); 115 | if ( !is_array( $json ) || !isset( $json['Hooks'] ) || !is_array( $json['Hooks'] ) ) { 116 | return; 117 | } 118 | $namedHandlers = []; 119 | foreach ( $json['HookHandlers'] ?? [] as $name => $handler ) { 120 | // TODO: This key is not unique if more than one extension is being analyzed. Is that wanted, though? 121 | $namedHandlers[$name] = $handler; 122 | } 123 | 124 | foreach ( $json['Hooks'] as $hookName => $cbList ) { 125 | if ( isset( $cbList["handler"] ) ) { 126 | $cbList = $cbList["handler"]; 127 | } 128 | if ( is_string( $cbList ) ) { 129 | $cbList = [ $cbList ]; 130 | } 131 | 132 | foreach ( $cbList as $cb ) { 133 | if ( isset( $namedHandlers[$cb] ) ) { 134 | // TODO ObjectFactory not fully handled here. Would deserve some code in a general-purpose 135 | // MediaWiki plugin, see T275742. 136 | if ( isset( $namedHandlers[$cb]['class'] ) ) { 137 | // Like core's HookContainer::run 138 | $normalizedHookName = ucfirst( strtr( $hookName, ':-', '__' ) ); 139 | $callbackString = $namedHandlers[$cb]['class'] . "::on$normalizedHookName"; 140 | } elseif ( isset( $namedHandlers[$cb]['factory'] ) ) { 141 | // TODO: We'd need a CodeBase to retrieve the factory method and check its return value 142 | continue; 143 | } else { 144 | // @phan-suppress-previous-line PhanPluginDuplicateIfStatements 145 | continue; 146 | } 147 | $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $callbackString ); 148 | } elseif ( strpos( $cb, '::' ) === false ) { 149 | $callback = FullyQualifiedFunctionName::fromFullyQualifiedString( $cb ); 150 | } else { 151 | $callback = FullyQualifiedMethodName::fromFullyQualifiedString( $cb ); 152 | } 153 | $this->registerHook( $hookName, $callback ); 154 | } 155 | } 156 | } 157 | 158 | /** 159 | * Get a list of subscribers for hook 160 | * 161 | * @param string $hookName Hook in question. Hooks starting with ! are special. 162 | * @return FullyQualifiedFunctionLikeName[] 163 | */ 164 | public function getHookSubscribers( string $hookName ): array { 165 | $this->loadExtensionJson(); 166 | return $this->hookSubscribers[$hookName] ?? []; 167 | } 168 | 169 | /** 170 | * Is a particular function implementing a special hook. 171 | * 172 | * @note This assumes that any given func will only implement 173 | * one hook 174 | * @param FullyQualifiedFunctionLikeName $fqsen The function to check 175 | * @return string|null The hook it is implementing or null if no hook 176 | */ 177 | public function isSpecialHookSubscriber( FullyQualifiedFunctionLikeName $fqsen ): ?string { 178 | $this->loadExtensionJson(); 179 | $specialHooks = [ 180 | '!ParserFunctionHook', 181 | '!ParserHook' 182 | ]; 183 | 184 | // @todo This is probably not the most efficient thing. 185 | foreach ( $specialHooks as $hook ) { 186 | if ( !isset( $this->hookSubscribers[$hook] ) ) { 187 | continue; 188 | } 189 | if ( in_array( $fqsen, $this->hookSubscribers[$hook], true ) ) { 190 | return $hook; 191 | } 192 | } 193 | return null; 194 | } 195 | 196 | public function getMwParserClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName { 197 | if ( !$this->parserFQSEN ) { 198 | $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( 199 | '\\MediaWiki\\Parser\\Parser' 200 | ); 201 | if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) { 202 | $this->parserFQSEN = $namespacedFQSEN; 203 | } else { 204 | $this->parserFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( 205 | '\\Parser' 206 | ); 207 | } 208 | } 209 | return $this->parserFQSEN; 210 | } 211 | 212 | public function getPPFrameClassFQSEN( CodeBase $codeBase ): FullyQualifiedClassName { 213 | if ( !$this->ppFrameFQSEN ) { 214 | $namespacedFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( 215 | '\\MediaWiki\\Parser\\PPFrame' 216 | ); 217 | if ( $codeBase->hasClassWithFQSEN( $namespacedFQSEN ) ) { 218 | $this->ppFrameFQSEN = $namespacedFQSEN; 219 | } else { 220 | $this->ppFrameFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( 221 | '\\PPFrame' 222 | ); 223 | } 224 | } 225 | return $this->ppFrameFQSEN; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/MethodLinks.php: -------------------------------------------------------------------------------- 1 | links = $links ?? new LinksSet(); 30 | } 31 | 32 | /** 33 | * @return self 34 | */ 35 | public static function emptySingleton(): self { 36 | static $singleton; 37 | if ( !$singleton ) { 38 | $singleton = new self( new LinksSet ); 39 | } 40 | return $singleton; 41 | } 42 | 43 | /** 44 | * @param self[] $dimLinks 45 | * @param self|null $unknownDimLinks Pass null for performance 46 | * @param LinksSet|null $keysLinks 47 | * @return self 48 | */ 49 | public static function newFromShape( 50 | array $dimLinks, 51 | ?self $unknownDimLinks = null, 52 | ?LinksSet $keysLinks = null 53 | ): self { 54 | // Don't add empty link sets, for performance 55 | if ( $keysLinks && !count( $keysLinks ) ) { 56 | $keysLinks = null; 57 | } 58 | if ( !$dimLinks && !$unknownDimLinks && !$keysLinks ) { 59 | return self::emptySingleton(); 60 | } 61 | 62 | $ret = new self(); 63 | foreach ( $dimLinks as $key => $value ) { 64 | assert( $value instanceof self ); 65 | $ret->dimLinks[$key] = $value; 66 | } 67 | $ret->unknownDimLinks = $unknownDimLinks; 68 | $ret->keysLinks = $keysLinks; 69 | return $ret; 70 | } 71 | 72 | /** 73 | * @note This returns a clone 74 | * @param mixed $dim 75 | * @param bool $pushOffsets 76 | * @return self 77 | */ 78 | public function getForDim( $dim, bool $pushOffsets = true ): self { 79 | if ( $this === self::emptySingleton() ) { 80 | return $this; 81 | } 82 | if ( !is_scalar( $dim ) ) { 83 | $ret = ( new self( $this->links ) ); 84 | if ( $pushOffsets ) { 85 | $ret = $ret->withAddedOffset( $dim ); 86 | } 87 | if ( $this->unknownDimLinks ) { 88 | $ret = $ret->asMergedWith( $this->unknownDimLinks ); 89 | } 90 | foreach ( $this->dimLinks as $links ) { 91 | $ret = $ret->asMergedWith( $links ); 92 | } 93 | return $ret; 94 | } 95 | if ( isset( $this->dimLinks[$dim] ) ) { 96 | $ret = ( new self( $this->links ) ); 97 | if ( $pushOffsets ) { 98 | $ret = $ret->withAddedOffset( $dim ); 99 | } 100 | if ( $this->unknownDimLinks ) { 101 | $offsetLinks = $this->dimLinks[$dim]->asMergedWith( $this->unknownDimLinks ); 102 | } else { 103 | $offsetLinks = $this->dimLinks[$dim]; 104 | } 105 | return $ret->asMergedWith( $offsetLinks ); 106 | } 107 | if ( $this->unknownDimLinks ) { 108 | $ret = clone $this->unknownDimLinks; 109 | $ret->links = $ret->links->asMergedWith( $this->links ); 110 | } else { 111 | $ret = new self( $this->links ); 112 | } 113 | 114 | return $pushOffsets ? $ret->withAddedOffset( $dim ) : $ret; 115 | } 116 | 117 | /** 118 | * @return self 119 | */ 120 | public function asValueFirstLevel(): self { 121 | if ( $this === self::emptySingleton() ) { 122 | return $this; 123 | } 124 | $ret = ( new self( $this->links ) )->withAddedOffset( null ); 125 | if ( $this->unknownDimLinks ) { 126 | $ret = $ret->asMergedWith( $this->unknownDimLinks ); 127 | } 128 | foreach ( $this->dimLinks as $links ) { 129 | $ret = $ret->asMergedWith( $links ); 130 | } 131 | return $ret; 132 | } 133 | 134 | /** 135 | * @return self 136 | */ 137 | public function asKeyForForeach(): self { 138 | $emptySingleton = self::emptySingleton(); 139 | if ( $this === $emptySingleton ) { 140 | return $this; 141 | } 142 | 143 | $hasBaseLinks = count( $this->links ) !== 0; 144 | $hasKeyLinks = $this->keysLinks && count( $this->keysLinks ) !== 0; 145 | 146 | if ( $hasBaseLinks ) { 147 | $newLinks = $this->links->asAllMovedToKeys(); 148 | if ( $hasKeyLinks ) { 149 | $newLinks = $newLinks->asMergedWith( $this->keysLinks ); 150 | } 151 | } elseif ( $hasKeyLinks ) { 152 | $newLinks = $this->keysLinks; 153 | } else { 154 | return $emptySingleton; 155 | } 156 | 157 | return new self( $newLinks ); 158 | } 159 | 160 | /** 161 | * @param mixed $dim 162 | * @param MethodLinks $links 163 | * @return self 164 | */ 165 | public function withLinksAtDim( $dim, self $links ): self { 166 | $ret = clone $this; 167 | if ( is_scalar( $dim ) ) { 168 | $ret->dimLinks[$dim] = $links; 169 | } elseif ( $ret->unknownDimLinks ) { 170 | $ret->unknownDimLinks = $ret->unknownDimLinks->asMergedWith( $links ); 171 | } else { 172 | $ret->unknownDimLinks = $links; 173 | } 174 | return $ret; 175 | } 176 | 177 | public function withKeysLinks( LinksSet $links ): self { 178 | if ( !count( $links ) ) { 179 | return $this; 180 | } 181 | $ret = clone $this; 182 | if ( !$ret->keysLinks ) { 183 | $ret->keysLinks = $links; 184 | } else { 185 | $ret->keysLinks = $ret->keysLinks->asMergedWith( $links ); 186 | } 187 | return $ret; 188 | } 189 | 190 | /** 191 | * @return self 192 | */ 193 | public function asCollapsed(): self { 194 | if ( $this === self::emptySingleton() ) { 195 | return $this; 196 | } 197 | $ret = new self( $this->links ); 198 | foreach ( $this->dimLinks as $links ) { 199 | $ret = $ret->asMergedWith( $links->asCollapsed() ); 200 | } 201 | if ( $this->unknownDimLinks ) { 202 | $ret = $ret->asMergedWith( $this->unknownDimLinks->asCollapsed() ); 203 | } 204 | return $ret; 205 | } 206 | 207 | /** 208 | * Merge this object with $other, recursively, creating a copy. 209 | * 210 | * @param self $other 211 | * @return self 212 | */ 213 | public function asMergedWith( self $other ): self { 214 | $emptySingleton = self::emptySingleton(); 215 | if ( $other === $emptySingleton ) { 216 | return $this; 217 | } 218 | if ( $this === $emptySingleton ) { 219 | return $other; 220 | } 221 | $ret = clone $this; 222 | 223 | $ret->links = $ret->links->asMergedWith( $other->links ); 224 | foreach ( $other->dimLinks as $key => $links ) { 225 | if ( isset( $ret->dimLinks[$key] ) ) { 226 | $ret->dimLinks[$key] = $ret->dimLinks[$key]->asMergedWith( $links ); 227 | } else { 228 | $ret->dimLinks[$key] = $links; 229 | } 230 | } 231 | if ( $other->unknownDimLinks && !$ret->unknownDimLinks ) { 232 | $ret->unknownDimLinks = $other->unknownDimLinks; 233 | } elseif ( $other->unknownDimLinks ) { 234 | $ret->unknownDimLinks = $ret->unknownDimLinks->asMergedWith( $other->unknownDimLinks ); 235 | } 236 | if ( $other->keysLinks && !$ret->keysLinks ) { 237 | $ret->keysLinks = $other->keysLinks; 238 | } elseif ( $other->keysLinks ) { 239 | $ret->keysLinks = $ret->keysLinks->asMergedWith( $other->keysLinks ); 240 | } 241 | 242 | return $ret; 243 | } 244 | 245 | public function withoutShape( self $other ): self { 246 | $ret = clone $this; 247 | 248 | $ret->links = $ret->links->withoutShape( $other->links ); 249 | foreach ( $other->dimLinks as $key => $val ) { 250 | if ( isset( $ret->dimLinks[$key] ) ) { 251 | $ret->dimLinks[$key] = $ret->dimLinks[$key]->withoutShape( $val ); 252 | } 253 | } 254 | if ( $ret->unknownDimLinks && $other->unknownDimLinks ) { 255 | $ret->unknownDimLinks = $ret->unknownDimLinks->withoutShape( $other->unknownDimLinks ); 256 | } 257 | if ( $ret->keysLinks && $other->keysLinks ) { 258 | $ret->keysLinks = $ret->keysLinks->withoutShape( $other->keysLinks ); 259 | } 260 | return $ret; 261 | } 262 | 263 | /** 264 | * @param Node|mixed $offset 265 | * @return self 266 | */ 267 | public function withAddedOffset( $offset ): self { 268 | $ret = clone $this; 269 | $ret->links = clone $ret->links; 270 | foreach ( $ret->links as $func ) { 271 | $ret->links[$func] = $ret->links[$func]->withOffsetPushedToAll( $offset ); 272 | } 273 | return $ret; 274 | } 275 | 276 | /** 277 | * Create a new object with $this at the given $offset (if scalar) or as unknown object. 278 | * 279 | * @param Node|string|int|bool|float|null $offset 280 | * @param LinksSet|null $keyLinks 281 | * @return self Always a copy 282 | */ 283 | public function asMaybeMovedAtOffset( $offset, ?LinksSet $keyLinks = null ): self { 284 | $ret = new self; 285 | if ( $offset instanceof Node || $offset === null ) { 286 | $ret->unknownDimLinks = $this; 287 | } else { 288 | $ret->dimLinks[$offset] = $this; 289 | } 290 | $ret->keysLinks = $keyLinks; 291 | return $ret; 292 | } 293 | 294 | public function asMovedToKeys(): self { 295 | $ret = new self; 296 | $ret->keysLinks = $this->getLinksCollapsing(); 297 | return $ret; 298 | } 299 | 300 | /** 301 | * @param self $other 302 | * @param int $depth 303 | * @return self 304 | */ 305 | public function asMergedForAssignment( self $other, int $depth ): self { 306 | if ( $depth === 0 ) { 307 | return $other; 308 | } 309 | $ret = clone $this; 310 | $ret->links = $ret->links->asMergedWith( $other->links ); 311 | if ( !$ret->keysLinks ) { 312 | $ret->keysLinks = $other->keysLinks; 313 | } elseif ( $other->keysLinks ) { 314 | $ret->keysLinks = $ret->keysLinks->asMergedWith( $other->keysLinks ); 315 | } 316 | if ( !$ret->unknownDimLinks ) { 317 | $ret->unknownDimLinks = $other->unknownDimLinks; 318 | } elseif ( $other->unknownDimLinks ) { 319 | $ret->unknownDimLinks = $ret->unknownDimLinks->asMergedWith( $other->unknownDimLinks ); 320 | } 321 | foreach ( $other->dimLinks as $k => $v ) { 322 | $ret->dimLinks[$k] = isset( $ret->dimLinks[$k] ) 323 | ? $ret->dimLinks[$k]->asMergedForAssignment( $v, $depth - 1 ) 324 | : $v; 325 | } 326 | $ret->normalize(); 327 | return $ret; 328 | } 329 | 330 | /** 331 | * Remove offset links which are already present in the "main" links. This is done for performance 332 | * (see test backpropoffsets-blowup). 333 | * 334 | * @todo Improve (e.g. recurse) 335 | * @todo Might happen sometime earlier 336 | */ 337 | private function normalize(): void { 338 | if ( !count( $this->links ) ) { 339 | return; 340 | } 341 | foreach ( $this->dimLinks as $k => $links ) { 342 | $alreadyCloned = false; 343 | foreach ( $links->links as $func ) { 344 | if ( $this->links->contains( $func ) ) { 345 | $dimParams = array_keys( $links->links[$func]->getParams() ); 346 | $thisParams = array_keys( $this->links[$func]->getParams() ); 347 | $keepParams = array_diff( $dimParams, $thisParams ); 348 | if ( !$alreadyCloned ) { 349 | $this->dimLinks[$k] = clone $links; 350 | $this->dimLinks[$k]->links = clone $links->links; 351 | $alreadyCloned = true; 352 | } 353 | if ( !$keepParams ) { 354 | unset( $this->dimLinks[$k]->links[$func] ); 355 | } else { 356 | $this->dimLinks[$k]->links[$func] = $this->dimLinks[$k]->links[$func] 357 | ->withOnlyParams( $keepParams ); 358 | } 359 | } 360 | } 361 | if ( $this->dimLinks[$k]->isEmpty() ) { 362 | unset( $this->dimLinks[$k] ); 363 | } 364 | } 365 | if ( $this->unknownDimLinks ) { 366 | $alreadyCloned = false; 367 | foreach ( $this->unknownDimLinks->links as $func ) { 368 | if ( $this->links->contains( $func ) ) { 369 | $dimParams = array_keys( $this->unknownDimLinks->links[$func]->getParams() ); 370 | $thisParams = array_keys( $this->links[$func]->getParams() ); 371 | $keepParams = array_diff( $dimParams, $thisParams ); 372 | if ( !$alreadyCloned ) { 373 | $this->unknownDimLinks = clone $this->unknownDimLinks; 374 | $this->unknownDimLinks->links = clone $this->unknownDimLinks->links; 375 | $alreadyCloned = true; 376 | } 377 | if ( !$keepParams ) { 378 | unset( $this->unknownDimLinks->links[$func] ); 379 | } else { 380 | $this->unknownDimLinks->links[$func] = $this->unknownDimLinks->links[$func] 381 | ->withOnlyParams( $keepParams ); 382 | } 383 | } 384 | } 385 | if ( $this->unknownDimLinks->isEmpty() ) { 386 | $this->unknownDimLinks = null; 387 | } 388 | } 389 | } 390 | 391 | /** 392 | * Returns all the links stored in this object as a single LinkSet object, destroying the shape. This should only 393 | * be used when the shape is not relevant. 394 | * 395 | * @return LinksSet 396 | */ 397 | public function getLinksCollapsing(): LinksSet { 398 | $ret = clone $this->links; 399 | foreach ( $this->dimLinks as $link ) { 400 | $ret->mergeWith( $link->getLinksCollapsing() ); 401 | } 402 | if ( $this->unknownDimLinks ) { 403 | $ret->mergeWith( $this->unknownDimLinks->getLinksCollapsing() ); 404 | } 405 | if ( $this->keysLinks ) { 406 | $ret->mergeWith( $this->keysLinks ); 407 | } 408 | return $ret; 409 | } 410 | 411 | /** 412 | * @return array[] 413 | * @phan-return array 414 | */ 415 | public function getMethodAndParamTuples(): array { 416 | $ret = []; 417 | foreach ( $this->links as $func ) { 418 | $info = $this->links[$func]; 419 | foreach ( $info->getParams() as $i => $_ ) { 420 | $ret[] = [ $func, $i ]; 421 | } 422 | } 423 | foreach ( $this->dimLinks as $link ) { 424 | $ret = array_merge( $ret, $link->getMethodAndParamTuples() ); 425 | } 426 | if ( $this->unknownDimLinks ) { 427 | $ret = array_merge( $ret, $this->unknownDimLinks->getMethodAndParamTuples() ); 428 | } 429 | foreach ( $this->keysLinks ?? [] as $func ) { 430 | $info = $this->keysLinks[$func]; 431 | foreach ( $info->getParams() as $i => $_ ) { 432 | $ret[] = [ $func, $i ]; 433 | } 434 | } 435 | return array_unique( $ret, SORT_REGULAR ); 436 | } 437 | 438 | /** 439 | * @return bool 440 | */ 441 | public function isEmpty(): bool { 442 | if ( count( $this->links ) ) { 443 | return false; 444 | } 445 | foreach ( $this->dimLinks as $links ) { 446 | if ( !$links->isEmpty() ) { 447 | return false; 448 | } 449 | } 450 | if ( $this->unknownDimLinks && !$this->unknownDimLinks->isEmpty() ) { 451 | return false; 452 | } 453 | if ( $this->keysLinks && count( $this->keysLinks ) ) { 454 | return false; 455 | } 456 | return true; 457 | } 458 | 459 | /** 460 | * @param FunctionInterface $func 461 | * @param int $i 462 | * @return bool 463 | */ 464 | public function hasDataForFuncAndParam( FunctionInterface $func, int $i ): bool { 465 | if ( $this->links->contains( $func ) && $this->links[$func]->hasParam( $i ) ) { 466 | return true; 467 | } 468 | foreach ( $this->dimLinks as $dimLinks ) { 469 | if ( $dimLinks->hasDataForFuncAndParam( $func, $i ) ) { 470 | return true; 471 | } 472 | } 473 | if ( $this->unknownDimLinks && $this->unknownDimLinks->hasDataForFuncAndParam( $func, $i ) ) { 474 | return true; 475 | } 476 | if ( $this->keysLinks && $this->keysLinks->contains( $func ) && $this->keysLinks[$func]->hasParam( $i ) ) { 477 | return true; 478 | } 479 | return false; 480 | } 481 | 482 | public function withFuncAndParam( 483 | FunctionInterface $func, 484 | int $i, 485 | bool $isVariadic, 486 | int $initialFlags = SecurityCheckPlugin::ALL_TAINT 487 | ): self { 488 | $ret = clone $this; 489 | 490 | if ( $isVariadic ) { 491 | $baseUnkLinks = $ret->unknownDimLinks ?? self::emptySingleton(); 492 | $ret->unknownDimLinks = $baseUnkLinks->withFuncAndParam( $func, $i, false, $initialFlags ); 493 | return $ret; 494 | } 495 | 496 | $ret->links = clone $ret->links; 497 | if ( $ret->links->contains( $func ) ) { 498 | $ret->links[$func] = $ret->links[$func]->withParam( $i, $initialFlags ); 499 | } else { 500 | $ret->links[$func] = SingleMethodLinks::instanceWithParam( $i, $initialFlags ); 501 | } 502 | return $ret; 503 | } 504 | 505 | /** 506 | * @param FunctionInterface $func 507 | * @param int $param 508 | * @return PreservedTaintedness 509 | */ 510 | public function asPreservedTaintednessForFuncParam( FunctionInterface $func, int $param ): PreservedTaintedness { 511 | $ret = null; 512 | if ( $this->links->contains( $func ) ) { 513 | $ownInfo = $this->links[$func]; 514 | if ( $ownInfo->hasParam( $param ) ) { 515 | $ret = new PreservedTaintedness( $ownInfo->getParamOffsets( $param ) ); 516 | } 517 | } 518 | if ( !$ret ) { 519 | $ret = PreservedTaintedness::emptySingleton(); 520 | } 521 | foreach ( $this->dimLinks as $dim => $dimLinks ) { 522 | $ret = $ret->withOffsetTaintedness( $dim, $dimLinks->asPreservedTaintednessForFuncParam( $func, $param ) ); 523 | } 524 | if ( $this->unknownDimLinks ) { 525 | $ret = $ret->withOffsetTaintedness( 526 | null, 527 | $this->unknownDimLinks->asPreservedTaintednessForFuncParam( $func, $param ) 528 | ); 529 | } 530 | if ( $this->keysLinks && $this->keysLinks->contains( $func ) ) { 531 | $keyInfo = $this->keysLinks[$func]; 532 | if ( $keyInfo->hasParam( $param ) ) { 533 | $ret = $ret->withKeysOffsets( $keyInfo->getParamOffsets( $param ) ); 534 | } 535 | } 536 | return $ret; 537 | } 538 | 539 | /** 540 | * If $taintFlags are the taintedness flags of a sink, and $this are the links passed to that sink, return a 541 | * Taintedness object representing the backpropagated exec taintedness to be added to the given function parameter. 542 | */ 543 | public function asTaintednessForBackprop( int $taintFlags, FunctionInterface $func, int $param ): Taintedness { 544 | $ret = Taintedness::safeSingleton(); 545 | if ( !$taintFlags ) { 546 | return $ret; 547 | } 548 | $allLinks = $this->getLinksCollapsing(); 549 | if ( $allLinks->contains( $func ) ) { 550 | $paramInfo = $allLinks[$func]; 551 | if ( $paramInfo->hasParam( $param ) ) { 552 | $paramOffsets = $paramInfo->getParamOffsets( $param ); 553 | $taintAsYes = new Taintedness( Taintedness::flagsAsExecToYesTaint( $taintFlags ) ); 554 | $ret = $paramOffsets->appliedToTaintednessForBackprop( $taintAsYes )->asYesToExecTaint(); 555 | } 556 | } 557 | 558 | return $ret; 559 | } 560 | 561 | /** 562 | * @param FunctionInterface $func 563 | * @param int $param 564 | * @return self 565 | */ 566 | public function asFilteredForFuncAndParam( FunctionInterface $func, int $param ): self { 567 | if ( $this === self::emptySingleton() ) { 568 | return $this; 569 | } 570 | $retLinks = new LinksSet(); 571 | if ( $this->links->contains( $func ) ) { 572 | $retLinks->attach( $func, $this->links[$func] ); 573 | } 574 | $ret = new self( $retLinks ); 575 | 576 | $dimLinksShape = []; 577 | foreach ( $this->dimLinks as $dim => $dimLinks ) { 578 | $dimLinksShape[$dim] = $dimLinks->asFilteredForFuncAndParam( $func, $param ); 579 | } 580 | $unknownDimLinks = null; 581 | if ( $this->unknownDimLinks ) { 582 | $unknownDimLinks = $this->unknownDimLinks->asFilteredForFuncAndParam( $func, $param ); 583 | } 584 | $keysLinks = new LinksSet(); 585 | if ( $this->keysLinks && $this->keysLinks->contains( $func ) ) { 586 | $keysLinks->attach( $func, $this->keysLinks[$func] ); 587 | } 588 | 589 | return $ret->asMergedWith( self::newFromShape( $dimLinksShape, $unknownDimLinks, $keysLinks ) ); 590 | } 591 | 592 | /** 593 | * @codeCoverageIgnore 594 | */ 595 | public function toString( string $indent = '' ): string { 596 | if ( $this === self::emptySingleton() ) { 597 | return '(empty)'; 598 | } 599 | $elementsIndent = $indent . "\t"; 600 | $ret = "{\n$elementsIndent" . 'OWN: ' . $this->links->__toString() . ','; 601 | if ( $this->keysLinks ) { 602 | $ret .= "\n{$elementsIndent}KEYS: " . $this->keysLinks->__toString() . ','; 603 | } 604 | if ( $this->dimLinks || $this->unknownDimLinks ) { 605 | $ret .= "\n{$elementsIndent}CHILDREN: {"; 606 | $childrenIndent = $elementsIndent . "\t"; 607 | foreach ( $this->dimLinks as $key => $links ) { 608 | $ret .= "\n$childrenIndent$key: " . $links->toString( $childrenIndent ) . ','; 609 | } 610 | if ( $this->unknownDimLinks ) { 611 | $ret .= "\n$childrenIndent(UNKNOWN): " . $this->unknownDimLinks->toString( $childrenIndent ); 612 | } 613 | $ret .= "\n$elementsIndent}"; 614 | } 615 | return $ret . "\n$indent}"; 616 | } 617 | 618 | /** 619 | * @codeCoverageIgnore 620 | */ 621 | public function __toString(): string { 622 | return $this->toString(); 623 | } 624 | } 625 | -------------------------------------------------------------------------------- /src/ParamLinksOffsets.php: -------------------------------------------------------------------------------- 1 | ownFlags = $flags; 28 | } 29 | 30 | public static function getInstance( int $flags ): self { 31 | static $singletons = []; 32 | if ( !isset( $singletons[$flags] ) ) { 33 | $singletons[$flags] = new self( $flags ); 34 | } 35 | return $singletons[$flags]; 36 | } 37 | 38 | public function asMergedWith( self $other ): self { 39 | if ( $this === $other ) { 40 | return $this; 41 | } 42 | 43 | $ret = clone $this; 44 | 45 | $ret->ownFlags |= $other->ownFlags; 46 | if ( $other->unknown && !$ret->unknown ) { 47 | $ret->unknown = $other->unknown; 48 | } elseif ( $other->unknown ) { 49 | $ret->unknown = $ret->unknown->asMergedWith( $other->unknown ); 50 | } 51 | foreach ( $other->dims as $key => $val ) { 52 | if ( !isset( $ret->dims[$key] ) ) { 53 | $ret->dims[$key] = $val; 54 | } else { 55 | $ret->dims[$key] = $ret->dims[$key]->asMergedWith( $val ); 56 | } 57 | } 58 | $ret->keysFlags |= $other->keysFlags; 59 | 60 | return $ret; 61 | } 62 | 63 | public function withoutShape( self $other ): self { 64 | $ret = clone $this; 65 | 66 | $ret->ownFlags &= ~$other->ownFlags; 67 | foreach ( $other->dims as $key => $val ) { 68 | if ( isset( $ret->dims[$key] ) ) { 69 | $ret->dims[$key] = $ret->dims[$key]->withoutShape( $val ); 70 | } 71 | } 72 | if ( $other->unknown && $ret->unknown ) { 73 | $ret->unknown = $ret->unknown->withoutShape( $other->unknown ); 74 | } 75 | $ret->keysFlags &= ~$other->keysFlags; 76 | return $ret; 77 | } 78 | 79 | /** 80 | * Pushes $offsets to all leaves. 81 | * @param Node|string|int|null $offset 82 | */ 83 | public function withOffsetPushed( $offset ): self { 84 | $ret = clone $this; 85 | 86 | foreach ( $ret->dims as $key => $val ) { 87 | $ret->dims[$key] = $val->withOffsetPushed( $offset ); 88 | } 89 | if ( $ret->unknown ) { 90 | $ret->unknown = $ret->unknown->withOffsetPushed( $offset ); 91 | } 92 | 93 | if ( $ret->ownFlags === SecurityCheckPlugin::NO_TAINT ) { 94 | return $ret; 95 | } 96 | 97 | $ownFlags = $ret->ownFlags; 98 | $ret->ownFlags = SecurityCheckPlugin::NO_TAINT; 99 | if ( is_scalar( $offset ) && !isset( $ret->dims[$offset] ) ) { 100 | $ret->dims[$offset] = self::getInstance( $ownFlags ); 101 | } elseif ( !is_scalar( $offset ) && !$ret->unknown ) { 102 | $ret->unknown = self::getInstance( $ownFlags ); 103 | } 104 | 105 | return $ret; 106 | } 107 | 108 | /** 109 | * @return self 110 | */ 111 | public function asMovedToKeys(): self { 112 | $ret = new self( SecurityCheckPlugin::NO_TAINT ); 113 | 114 | foreach ( $this->dims as $k => $val ) { 115 | $ret->dims[$k] = $val->asMovedToKeys(); 116 | } 117 | if ( $this->unknown ) { 118 | $ret->unknown = $this->unknown->asMovedToKeys(); 119 | } 120 | 121 | $ret->keysFlags = $this->ownFlags; 122 | 123 | return $ret; 124 | } 125 | 126 | /** 127 | * @param Taintedness $taintedness 128 | * @return Taintedness 129 | */ 130 | public function appliedToTaintedness( Taintedness $taintedness ): Taintedness { 131 | if ( $this->ownFlags ) { 132 | $ret = $taintedness->withOnly( $this->ownFlags ); 133 | } else { 134 | $ret = Taintedness::safeSingleton(); 135 | } 136 | foreach ( $this->dims as $k => $val ) { 137 | $ret = $ret->asMergedWith( 138 | $val->appliedToTaintedness( $taintedness->getTaintednessForOffsetOrWhole( $k ) ) 139 | ); 140 | } 141 | if ( $this->unknown ) { 142 | $ret = $ret->asMergedWith( 143 | $this->unknown->appliedToTaintedness( $taintedness->getTaintednessForOffsetOrWhole( null ) ) 144 | ); 145 | } 146 | $ret = $ret->with( $taintedness->asKeyForForeach()->withOnly( $this->keysFlags )->get() ); 147 | return $ret; 148 | } 149 | 150 | public function appliedToTaintednessForBackprop( Taintedness $taintedness ): Taintedness { 151 | if ( $this->ownFlags ) { 152 | $ret = $taintedness->withOnly( $this->ownFlags ); 153 | } else { 154 | $ret = Taintedness::safeSingleton(); 155 | } 156 | 157 | $dimTaint = []; 158 | foreach ( $this->dims as $k => $val ) { 159 | $dimTaint[$k] = $val->appliedToTaintednessForBackprop( $taintedness->getTaintednessForOffsetOrWhole( $k ) ); 160 | } 161 | $unknownDimsTaint = null; 162 | if ( $this->unknown ) { 163 | $unknownDimsTaint = $this->unknown->appliedToTaintednessForBackprop( 164 | $taintedness->getTaintednessForOffsetOrWhole( null ) 165 | ); 166 | } 167 | $keysTaint = $taintedness->asKeyForForeach()->withOnly( $this->keysFlags )->get(); 168 | 169 | return $ret->asMergedWith( Taintedness::newFromShape( $dimTaint, $unknownDimsTaint, $keysTaint ) ); 170 | } 171 | 172 | public function isEmpty(): bool { 173 | if ( $this->ownFlags || $this->keysFlags ) { 174 | return false; 175 | } 176 | foreach ( $this->dims as $val ) { 177 | if ( !$val->isEmpty() ) { 178 | return false; 179 | } 180 | } 181 | 182 | if ( $this->unknown && !$this->unknown->isEmpty() ) { 183 | return false; 184 | } 185 | 186 | return true; 187 | } 188 | 189 | /** 190 | * @codeCoverageIgnore 191 | */ 192 | public function __toString(): string { 193 | $ret = '<(own): ' . SecurityCheckPlugin::taintToString( $this->ownFlags ); 194 | 195 | if ( $this->keysFlags ) { 196 | $ret .= ', keys: ' . SecurityCheckPlugin::taintToString( $this->keysFlags ); 197 | } 198 | 199 | if ( $this->dims || $this->unknown ) { 200 | $ret .= ', dims: ['; 201 | $dimBits = []; 202 | foreach ( $this->dims as $k => $val ) { 203 | $dimBits[] = "$k => " . $val->__toString(); 204 | } 205 | if ( $this->unknown ) { 206 | $dimBits[] = '(unknown): ' . $this->unknown->__toString(); 207 | } 208 | $ret .= implode( ', ', $dimBits ) . ']'; 209 | } 210 | return $ret . '>'; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/PreTaintednessVisitor.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * This program is free software; you can redistribute it and/or modify 19 | * it under the terms of the GNU General Public License as published by 20 | * the Free Software Foundation; either version 2 of the License, or 21 | * (at your option) any later version. 22 | * 23 | * This program is distributed in the hope that it will be useful, 24 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 25 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 26 | * GNU General Public License for more details. 27 | * 28 | * You should have received a copy of the GNU General Public License along 29 | * with this program; if not, write to the Free Software Foundation, Inc., 30 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 31 | */ 32 | class PreTaintednessVisitor extends PluginAwarePreAnalysisVisitor { 33 | use TaintednessBaseVisitor; 34 | 35 | /** 36 | * @see visitMethod 37 | * @param Node $node 38 | */ 39 | public function visitFuncDecl( Node $node ): void { 40 | $this->visitMethod( $node ); 41 | } 42 | 43 | /** 44 | * @see visitMethod 45 | * @param Node $node 46 | */ 47 | public function visitClosure( Node $node ): void { 48 | $this->visitMethod( $node ); 49 | } 50 | 51 | /** 52 | * @param Node $node 53 | */ 54 | public function visitArrowFunc( Node $node ): void { 55 | $this->visitMethod( $node ); 56 | } 57 | 58 | /** 59 | * Set the taintedness of parameters to method/function. 60 | * 61 | * Parameters that are ints (etc) are clearly safe so 62 | * this marks them as such. For other parameters, it 63 | * creates a map between the function object and the 64 | * parameter object so if anyone later calls the method 65 | * with a dangerous argument we can determine if we need 66 | * to output a warning. 67 | * 68 | * Also handles FuncDecl and Closure 69 | * @param Node $node 70 | */ 71 | public function visitMethod( Node $node ): void { 72 | // var_dump( __METHOD__ ); Debug::printNode( $node ); 73 | $method = $this->context->getFunctionLikeInScope( $this->code_base ); 74 | // Initialize retObjs to avoid recursing on methods that don't return anything. 75 | self::initRetObjs( $method ); 76 | $promotedProps = []; 77 | if ( $node->kind === \ast\AST_METHOD && $node->children['name'] === '__construct' ) { 78 | foreach ( $method->getParameterList() as $i => $param ) { 79 | if ( $param->getFlags() & Parameter::PARAM_MODIFIER_FLAGS ) { 80 | $promotedProps[$i] = $this->getPropInCurrentScopeByName( $param->getName() ); 81 | } 82 | } 83 | } 84 | 85 | $params = $node->children['params']->children; 86 | foreach ( $params as $i => $param ) { 87 | $paramName = $param->children['name']; 88 | $scope = $this->context->getScope(); 89 | if ( !$scope->hasVariableWithName( $paramName ) ) { 90 | // @codeCoverageIgnoreStart 91 | $this->debug( __METHOD__, "Missing variable for param \$" . $paramName ); 92 | continue; 93 | // @codeCoverageIgnoreEnd 94 | } 95 | $varObj = $scope->getVariableByName( $paramName ); 96 | 97 | $paramTypeTaint = $this->getTaintByType( $varObj->getUnionType() ); 98 | // Initially, the variable starts off with no taint. 99 | $startTaint = Taintedness::safeSingleton(); 100 | // No point in adding a caused-by line here. 101 | self::setTaintednessRaw( $varObj, $startTaint ); 102 | 103 | if ( !$paramTypeTaint->isSafe() ) { 104 | // If the param is not an integer or something, link it to the func 105 | $this->linkParamAndFunc( $varObj, $method, $i ); 106 | } 107 | if ( isset( $promotedProps[$i] ) ) { 108 | $this->ensureTaintednessIsSet( $promotedProps[$i] ); 109 | $paramLinks = self::getMethodLinks( $varObj ); 110 | if ( $paramLinks ) { 111 | $this->mergeTaintDependencies( $promotedProps[$i], $paramLinks, false ); 112 | } 113 | $this->addTaintError( $promotedProps[$i], $startTaint, $paramLinks ); 114 | } 115 | } 116 | 117 | if ( !self::getFuncTaint( $method ) ) { 118 | $this->getSetKnownTaintOfFunctionWithoutAnalysis( $method ); 119 | } 120 | } 121 | 122 | /** 123 | * Determine whether this operation is safe, based on the operand types. This needs to be done 124 | * in preorder because phan infers types from operators, e.g. from `$a += $b` phan will infer 125 | * that they're both numbers. We need to use the types of the operands *before* inferring 126 | * types from the operator. 127 | * 128 | * @param Node $node 129 | */ 130 | public function visitAssignOp( Node $node ): void { 131 | $lhs = $node->children['var']; 132 | $rhs = $node->children['expr']; 133 | // @phan-suppress-next-line PhanUndeclaredProperty 134 | $node->assignTaintMask = $this->getBinOpTaintMask( $node, $lhs, $rhs ); 135 | } 136 | 137 | /** 138 | * When a class property is declared 139 | * @param Node $node 140 | */ 141 | public function visitPropElem( Node $node ): void { 142 | $prop = $this->getPropInCurrentScopeByName( $node->children['name'] ); 143 | $this->ensureTaintednessIsSet( $prop ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/PreservedTaintedness.php: -------------------------------------------------------------------------------- 1 | ownOffsets = $offsets; 30 | } 31 | 32 | /** 33 | * @return self 34 | */ 35 | public static function emptySingleton(): self { 36 | static $singleton; 37 | if ( !$singleton ) { 38 | $singleton = new self( ParamLinksOffsets::getInstance( SecurityCheckPlugin::NO_TAINT ) ); 39 | } 40 | return $singleton; 41 | } 42 | 43 | /** 44 | * Set the taintedness for $offset to $value, in place 45 | * 46 | * @param Node|mixed $offset Node or a scalar value, already resolved 47 | * @param self $value 48 | * @return self 49 | */ 50 | public function withOffsetTaintedness( $offset, self $value ): self { 51 | $ret = clone $this; 52 | if ( is_scalar( $offset ) ) { 53 | $ret->dimTaint[$offset] = $value; 54 | } else { 55 | $ret->unknownDimsTaint = $ret->unknownDimsTaint 56 | ? $ret->unknownDimsTaint->asMergedWith( $value ) 57 | : $value; 58 | } 59 | return $ret; 60 | } 61 | 62 | public function withKeysOffsets( ParamLinksOffsets $offsets ): self { 63 | $ret = clone $this; 64 | $ret->keysOffsets = $offsets; 65 | return $ret; 66 | } 67 | 68 | /** 69 | * @param self $other 70 | * @return self 71 | * @suppress PhanUnreferencedPublicMethod Kept for consistency 72 | */ 73 | public function asMergedWith( self $other ): self { 74 | $emptySingleton = self::emptySingleton(); 75 | if ( $this === $emptySingleton ) { 76 | return $other; 77 | } 78 | if ( $other === $emptySingleton ) { 79 | return $this; 80 | } 81 | 82 | $ret = clone $this; 83 | $ret->ownOffsets = $ret->ownOffsets->asMergedWith( $other->ownOffsets ); 84 | 85 | if ( $other->keysOffsets && !$ret->keysOffsets ) { 86 | $ret->keysOffsets = $other->keysOffsets; 87 | } elseif ( $other->keysOffsets ) { 88 | $ret->keysOffsets = $ret->keysOffsets->asMergedWith( $other->keysOffsets ); 89 | } 90 | 91 | if ( $other->unknownDimsTaint && !$ret->unknownDimsTaint ) { 92 | $ret->unknownDimsTaint = $other->unknownDimsTaint; 93 | } elseif ( $other->unknownDimsTaint ) { 94 | $ret->unknownDimsTaint = $ret->unknownDimsTaint->asMergedWith( $other->unknownDimsTaint ); 95 | } 96 | foreach ( $other->dimTaint as $key => $val ) { 97 | if ( !array_key_exists( $key, $ret->dimTaint ) ) { 98 | $ret->dimTaint[$key] = $val; 99 | } else { 100 | $ret->dimTaint[$key] = $ret->dimTaint[$key]->asMergedWith( $val ); 101 | } 102 | } 103 | 104 | return $ret; 105 | } 106 | 107 | /** 108 | * @param Taintedness $argTaint 109 | * @return Taintedness 110 | */ 111 | public function asTaintednessForArgument( Taintedness $argTaint ): Taintedness { 112 | $safeTaint = Taintedness::safeSingleton(); 113 | if ( $argTaint === $safeTaint || $this === self::emptySingleton() ) { 114 | return $safeTaint; 115 | } 116 | 117 | $ret = $this->ownOffsets->appliedToTaintedness( $argTaint ); 118 | 119 | $dimTaint = []; 120 | foreach ( $this->dimTaint as $k => $val ) { 121 | $dimTaint[$k] = $val->asTaintednessForArgument( $argTaint ); 122 | } 123 | $unknownDimsTaint = null; 124 | if ( $this->unknownDimsTaint ) { 125 | $unknownDimsTaint = $this->unknownDimsTaint->asTaintednessForArgument( $argTaint ); 126 | } 127 | $keysTaint = $this->keysOffsets 128 | ? $this->keysOffsets->appliedToTaintedness( $argTaint )->get() 129 | : SecurityCheckPlugin::NO_TAINT; 130 | return $ret->asMergedWith( Taintedness::newFromShape( $dimTaint, $unknownDimsTaint, $keysTaint ) ); 131 | } 132 | 133 | public function asTaintednessForBackpropError( Taintedness $sinkTaint ): Taintedness { 134 | $safeTaint = Taintedness::safeSingleton(); 135 | if ( $sinkTaint === $safeTaint || $this === self::emptySingleton() ) { 136 | return $safeTaint; 137 | } 138 | 139 | $ret = $this->ownOffsets->appliedToTaintednessForBackprop( $sinkTaint ); 140 | 141 | foreach ( $this->dimTaint as $val ) { 142 | $ret = $ret->asMergedWith( $val->asTaintednessForBackpropError( $sinkTaint ) ); 143 | } 144 | if ( $this->unknownDimsTaint ) { 145 | $ret = $ret->asMergedWith( 146 | $this->unknownDimsTaint->asTaintednessForBackpropError( $sinkTaint ) 147 | ); 148 | } 149 | if ( $this->keysOffsets ) { 150 | $ret = $ret->asMergedWith( 151 | $this->keysOffsets->appliedToTaintednessForBackprop( $sinkTaint ) 152 | ); 153 | } 154 | return $ret; 155 | } 156 | 157 | public function asTaintednessForVarBackpropError( Taintedness $newTaint ): Taintedness { 158 | $safeTaint = Taintedness::safeSingleton(); 159 | if ( $newTaint === $safeTaint || $this === self::emptySingleton() ) { 160 | return $safeTaint; 161 | } 162 | 163 | $ret = $this->ownOffsets->appliedToTaintednessForBackprop( $newTaint ); 164 | 165 | $dimTaint = []; 166 | foreach ( $this->dimTaint as $key => $val ) { 167 | $dimTaint[$key] = $val->asTaintednessForVarBackpropError( 168 | $newTaint->getTaintednessForOffsetOrWhole( $key ) 169 | ); 170 | } 171 | $unknownDimsTaint = null; 172 | if ( $this->unknownDimsTaint ) { 173 | $unknownDimsTaint = $this->unknownDimsTaint->asTaintednessForVarBackpropError( 174 | $newTaint->getTaintednessForOffsetOrWhole( null ) 175 | ); 176 | } 177 | $keysTaint = $this->keysOffsets 178 | ? $this->keysOffsets->appliedToTaintednessForBackprop( $newTaint )->get() 179 | : SecurityCheckPlugin::NO_TAINT; 180 | return $ret->asMergedWith( Taintedness::newFromShape( $dimTaint, $unknownDimsTaint, $keysTaint ) ); 181 | } 182 | 183 | public function isEmpty(): bool { 184 | if ( !$this->ownOffsets->isEmpty() || ( $this->keysOffsets && !$this->keysOffsets->isEmpty() ) ) { 185 | return false; 186 | } 187 | foreach ( $this->dimTaint as $val ) { 188 | if ( !$val->isEmpty() ) { 189 | return false; 190 | } 191 | } 192 | if ( $this->unknownDimsTaint && !$this->unknownDimsTaint->isEmpty() ) { 193 | return false; 194 | } 195 | return true; 196 | } 197 | 198 | /** 199 | * @codeCoverageIgnore 200 | */ 201 | public function toShortString(): string { 202 | $ret = "{Own: " . $this->ownOffsets->__toString(); 203 | if ( $this->keysOffsets ) { 204 | $ret .= '; Keys: ' . $this->keysOffsets->__toString(); 205 | } 206 | $keyParts = []; 207 | if ( $this->dimTaint ) { 208 | foreach ( $this->dimTaint as $key => $taint ) { 209 | $keyParts[] = "$key => " . $taint->toShortString(); 210 | } 211 | } 212 | if ( $this->unknownDimsTaint ) { 213 | $keyParts[] = 'Unknown => ' . $this->unknownDimsTaint->toShortString(); 214 | } 215 | if ( $keyParts ) { 216 | $ret .= '; Elements: {' . implode( '; ', $keyParts ) . '}'; 217 | } 218 | $ret .= '}'; 219 | return $ret; 220 | } 221 | 222 | /** 223 | * @codeCoverageIgnore 224 | */ 225 | public function __toString(): string { 226 | return $this->toShortString(); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/ReturnObjectsCollectVisitor.php: -------------------------------------------------------------------------------- 1 | kind === \ast\AST_RETURN ); 29 | $this->buffer = []; 30 | $this( $node->children['expr'] ); 31 | return $this->buffer; 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | */ 37 | public function visitProp( Node $node ): void { 38 | $this->handleReturnedObject( $this->getPropFromNode( $node ) ); 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function visitNullsafeProp( Node $node ): void { 45 | $this->handleReturnedObject( $this->getPropFromNode( $node ) ); 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | */ 51 | public function visitStaticProp( Node $node ): void { 52 | $this->handleReturnedObject( $this->getPropFromNode( $node ) ); 53 | } 54 | 55 | /** 56 | * @inheritDoc 57 | */ 58 | public function visitVar( Node $node ): void { 59 | $this->handleVarNode( $node ); 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public function visitClosureVar( Node $node ): void { 66 | // FIXME Is this needed? 67 | $this->handleVarNode( $node ); 68 | } 69 | 70 | /** 71 | * @param Node $node 72 | */ 73 | private function handleVarNode( Node $node ): void { 74 | $cn = $this->getCtxN( $node ); 75 | if ( Variable::isHardcodedGlobalVariableWithName( $cn->getVariableName() ) ) { 76 | return; 77 | } 78 | try { 79 | $this->handleReturnedObject( $cn->getVariable() ); 80 | } catch ( NodeException | IssueException $e ) { 81 | $this->debug( __METHOD__, "variable not in scope?? " . $this->getDebugInfo( $e ) ); 82 | } 83 | } 84 | 85 | /** 86 | * @inheritDoc 87 | */ 88 | public function visitEncapsList( Node $node ): void { 89 | foreach ( $node->children as $child ) { 90 | if ( $child instanceof Node ) { 91 | $this( $child ); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * @inheritDoc 98 | */ 99 | public function visitArray( Node $node ): void { 100 | foreach ( $node->children as $child ) { 101 | if ( $child instanceof Node ) { 102 | $this( $child ); 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * @inheritDoc 109 | */ 110 | public function visitArrayElem( Node $node ): void { 111 | if ( $node->children['key'] instanceof Node ) { 112 | $this( $node->children['key'] ); 113 | } 114 | if ( $node->children['value'] instanceof Node ) { 115 | $this( $node->children['value'] ); 116 | } 117 | } 118 | 119 | /** 120 | * @inheritDoc 121 | */ 122 | public function visitCast( Node $node ): void { 123 | // Future todo might be to ignore casts to ints, since 124 | // such things should be safe. Unclear if that makes 125 | // sense in all circumstances. 126 | if ( $node->children['expr'] instanceof Node ) { 127 | $this( $node->children['expr'] ); 128 | } 129 | } 130 | 131 | /** 132 | * @inheritDoc 133 | */ 134 | public function visitDim( Node $node ): void { 135 | if ( $node->children['expr'] instanceof Node ) { 136 | // For now just consider the outermost array. 137 | // FIXME. doesn't handle tainted array keys! 138 | $this( $node->children['expr'] ); 139 | } 140 | } 141 | 142 | /** 143 | * @inheritDoc 144 | */ 145 | public function visitUnaryOp( Node $node ): void { 146 | if ( $node->children['expr'] instanceof Node ) { 147 | $this( $node->children['expr'] ); 148 | } 149 | } 150 | 151 | /** 152 | * @inheritDoc 153 | */ 154 | public function visitBinaryOp( Node $node ): void { 155 | if ( $node->children['left'] instanceof Node ) { 156 | $this( $node->children['left'] ); 157 | } 158 | if ( $node->children['right'] instanceof Node ) { 159 | $this( $node->children['right'] ); 160 | } 161 | } 162 | 163 | /** 164 | * @inheritDoc 165 | */ 166 | public function visitConditional( Node $node ): void { 167 | if ( $node->children['true'] instanceof Node ) { 168 | $this( $node->children['true'] ); 169 | } 170 | if ( $node->children['false'] instanceof Node ) { 171 | $this( $node->children['false'] ); 172 | } 173 | } 174 | 175 | /** 176 | * @inheritDoc 177 | */ 178 | public function visitCall( Node $node ): void { 179 | $this->handleCall( $node ); 180 | } 181 | 182 | /** 183 | * @inheritDoc 184 | */ 185 | public function visitMethodCall( Node $node ): void { 186 | $this->handleCall( $node ); 187 | } 188 | 189 | /** 190 | * @inheritDoc 191 | */ 192 | public function visitStaticCall( Node $node ): void { 193 | $this->handleCall( $node ); 194 | } 195 | 196 | /** 197 | * @inheritDoc 198 | */ 199 | public function visitNullsafeMethodCall( Node $node ): void { 200 | $this->handleCall( $node ); 201 | } 202 | 203 | /** 204 | * @param Node $node @phan-unused-param 205 | */ 206 | private function handleCall( Node $node ): void { 207 | // TODO If the func being called already has retObjs, we might add them. 208 | } 209 | 210 | /** 211 | * @inheritDoc 212 | */ 213 | public function visitPreDec( Node $node ): void { 214 | $this->handleIncOrDec( $node ); 215 | } 216 | 217 | /** 218 | * @inheritDoc 219 | */ 220 | public function visitPreInc( Node $node ): void { 221 | $this->handleIncOrDec( $node ); 222 | } 223 | 224 | /** 225 | * @inheritDoc 226 | */ 227 | public function visitPostDec( Node $node ): void { 228 | $this->handleIncOrDec( $node ); 229 | } 230 | 231 | /** 232 | * @inheritDoc 233 | */ 234 | public function visitPostInc( Node $node ): void { 235 | $this->handleIncOrDec( $node ); 236 | } 237 | 238 | /** 239 | * @param Node $node 240 | */ 241 | private function handleIncOrDec( Node $node ): void { 242 | $children = $node->children; 243 | assert( count( $children ) === 1 ); 244 | $this( reset( $children ) ); 245 | } 246 | 247 | /** 248 | * @param TypedElementInterface|null $el 249 | */ 250 | private function handleReturnedObject( ?TypedElementInterface $el ): void { 251 | if ( $el ) { 252 | $this->buffer[] = $el; 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/SingleMethodLinks.php: -------------------------------------------------------------------------------- 1 | withParam( $i, $initialFlags ); 21 | } 22 | return $singletons[$singletonKey]; 23 | } 24 | 25 | public function withParam( int $i, int $flags ): self { 26 | $ret = clone $this; 27 | $ret->params[$i] = ParamLinksOffsets::getInstance( $flags ); 28 | return $ret; 29 | } 30 | 31 | public function asMergedWith( self $other ): self { 32 | $ret = clone $this; 33 | foreach ( $other->params as $i => $otherPar ) { 34 | if ( isset( $ret->params[$i] ) ) { 35 | $ret->params[$i] = $ret->params[$i]->asMergedWith( $otherPar ); 36 | } else { 37 | $ret->params[$i] = $otherPar; 38 | } 39 | } 40 | return $ret; 41 | } 42 | 43 | public function withoutShape( self $other ): self { 44 | $ret = clone $this; 45 | foreach ( $other->params as $i => $otherParam ) { 46 | if ( isset( $ret->params[$i] ) ) { 47 | $newParamData = $ret->params[$i]->withoutShape( $otherParam ); 48 | if ( !$newParamData->isEmpty() ) { 49 | $ret->params[$i] = $newParamData; 50 | } else { 51 | unset( $ret->params[$i] ); 52 | } 53 | } 54 | } 55 | return $ret; 56 | } 57 | 58 | /** 59 | * @param Node|string|int|null $offset 60 | */ 61 | public function withOffsetPushedToAll( $offset ): self { 62 | $ret = clone $this; 63 | foreach ( $ret->params as $i => $_ ) { 64 | $ret->params[$i] = $ret->params[$i]->withOffsetPushed( $offset ); 65 | } 66 | return $ret; 67 | } 68 | 69 | /** 70 | * @return self 71 | */ 72 | public function asAllParamsMovedToKeys(): self { 73 | $ret = new self; 74 | foreach ( $this->params as $i => $offsets ) { 75 | $ret->params[$i] = $offsets->asMovedToKeys(); 76 | } 77 | return $ret; 78 | } 79 | 80 | /** 81 | * @todo Try to avoid this method 82 | * @return ParamLinksOffsets[] 83 | */ 84 | public function getParams(): array { 85 | return $this->params; 86 | } 87 | 88 | /** 89 | * @param int $x 90 | * @return bool 91 | */ 92 | public function hasParam( int $x ): bool { 93 | return isset( $this->params[$x] ); 94 | } 95 | 96 | /** 97 | * @note This will fail hard if unset. 98 | * @param int $x 99 | * @return ParamLinksOffsets 100 | */ 101 | public function getParamOffsets( int $x ): ParamLinksOffsets { 102 | return $this->params[$x]; 103 | } 104 | 105 | /** 106 | * @param int[] $params 107 | */ 108 | public function withOnlyParams( array $params ): self { 109 | $ret = clone $this; 110 | $ret->params = array_intersect_key( $this->params, array_fill_keys( $params, 1 ) ); 111 | return $ret; 112 | } 113 | 114 | /** 115 | * @codeCoverageIgnore 116 | */ 117 | public function __toString(): string { 118 | $paramBits = []; 119 | foreach ( $this->params as $k => $paramOffsets ) { 120 | $paramBits[] = "$k: { " . $paramOffsets->__toString() . ' }'; 121 | } 122 | return '[ ' . implode( ', ', $paramBits ) . ' ]'; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/TaintednessAccessorsTrait.php: -------------------------------------------------------------------------------- 1 | taintedness ?? null; 22 | } 23 | 24 | /** 25 | * @param TypedElementInterface $element 26 | * @param Taintedness $taintedness 27 | */ 28 | protected static function setTaintednessRaw( TypedElementInterface $element, Taintedness $taintedness ): void { 29 | $element->taintedness = $taintedness; 30 | if ( $element instanceof PassByReferenceVariable ) { 31 | self::setTaintednessRef( $element->getElement(), $taintedness ); 32 | } 33 | } 34 | 35 | /** 36 | * @param TypedElementInterface $element 37 | * @return CausedByLines|null 38 | */ 39 | protected static function getCausedByRaw( TypedElementInterface $element ): ?CausedByLines { 40 | return $element->taintedOriginalError ?? null; 41 | } 42 | 43 | /** 44 | * @param TypedElementInterface $element 45 | * @return CausedByLines|null 46 | */ 47 | protected static function getCausedByRef( TypedElementInterface $element ): ?CausedByLines { 48 | return $element->taintedOriginalErrorRef ?? null; 49 | } 50 | 51 | /** 52 | * @param FunctionInterface $func 53 | * @return FunctionCausedByLines|null 54 | */ 55 | protected static function getFuncCausedByRaw( FunctionInterface $func ): ?FunctionCausedByLines { 56 | return $func->funcTaintedOriginalError ?? null; 57 | } 58 | 59 | /** 60 | * @param TypedElementInterface $element 61 | * @param CausedByLines $lines 62 | */ 63 | protected static function setCausedByRaw( TypedElementInterface $element, CausedByLines $lines ): void { 64 | $element->taintedOriginalError = $lines; 65 | if ( $element instanceof PassByReferenceVariable ) { 66 | self::setCausedByRef( $element->getElement(), $lines ); 67 | } 68 | } 69 | 70 | /** 71 | * @param TypedElementInterface $element 72 | * @param CausedByLines $lines 73 | */ 74 | protected static function setCausedByRef( TypedElementInterface $element, CausedByLines $lines ): void { 75 | $element->taintedOriginalErrorRef = $lines; 76 | } 77 | 78 | /** 79 | * @param FunctionInterface $func 80 | * @param FunctionCausedByLines $lines 81 | */ 82 | protected static function setFuncCausedByRaw( FunctionInterface $func, FunctionCausedByLines $lines ): void { 83 | $func->funcTaintedOriginalError = $lines; 84 | } 85 | 86 | /** 87 | * @param TypedElementInterface $element 88 | * @return MethodLinks|null 89 | */ 90 | protected static function getMethodLinks( TypedElementInterface $element ): ?MethodLinks { 91 | return $element->taintedMethodLinks ?? null; 92 | } 93 | 94 | /** 95 | * @param TypedElementInterface $element 96 | * @param MethodLinks $links 97 | */ 98 | protected static function setMethodLinks( TypedElementInterface $element, MethodLinks $links ): void { 99 | $element->taintedMethodLinks = $links; 100 | if ( $element instanceof PassByReferenceVariable ) { 101 | $element->getElement()->taintedMethodLinksRef = $links; 102 | } 103 | } 104 | 105 | /** 106 | * @param TypedElementInterface $element 107 | * @return MethodLinks|null 108 | */ 109 | protected static function getMethodLinksRef( TypedElementInterface $element ): ?MethodLinks { 110 | return $element->taintedMethodLinksRef ?? null; 111 | } 112 | 113 | /** 114 | * @param FunctionInterface $func 115 | * @param int $index 116 | * @return VarLinksSet|null 117 | */ 118 | protected static function getVarLinks( FunctionInterface $func, int $index ): ?VarLinksSet { 119 | return $func->taintedVarLinks[$index] ?? null; 120 | } 121 | 122 | /** 123 | * @param TypedElementInterface $element 124 | * @param int $arg 125 | */ 126 | protected static function ensureVarLinksForArgExist( TypedElementInterface $element, int $arg ): void { 127 | $element->taintedVarLinks ??= []; 128 | $element->taintedVarLinks[$arg] ??= new VarLinksSet; 129 | } 130 | 131 | /** 132 | * @param TypedElementInterface $element 133 | * @return Taintedness|null 134 | */ 135 | protected static function getTaintednessRef( TypedElementInterface $element ): ?Taintedness { 136 | return $element->taintednessRef ?? null; 137 | } 138 | 139 | /** 140 | * @param TypedElementInterface $element 141 | * @param Taintedness $taintedness 142 | */ 143 | protected static function setTaintednessRef( TypedElementInterface $element, Taintedness $taintedness ): void { 144 | $element->taintednessRef = $taintedness; 145 | } 146 | 147 | /** 148 | * @param TypedElementInterface $element 149 | */ 150 | protected static function clearRefData( TypedElementInterface $element ): void { 151 | unset( $element->taintednessRef, $element->taintedMethodLinksRef, $element->taintedOriginalErrorRef ); 152 | } 153 | 154 | /** 155 | * Get $func's taint, or null if not set. 156 | * 157 | * @param FunctionInterface $func 158 | * @return FunctionTaintedness|null 159 | */ 160 | protected static function getFuncTaint( FunctionInterface $func ): ?FunctionTaintedness { 161 | return $func->funcTaint ?? null; 162 | } 163 | 164 | /** 165 | * @param FunctionInterface $func 166 | * @param FunctionTaintedness $funcTaint 167 | */ 168 | protected static function doSetFuncTaint( FunctionInterface $func, FunctionTaintedness $funcTaint ): void { 169 | $func->funcTaint = $funcTaint; 170 | } 171 | 172 | /** 173 | * @param FunctionInterface $func 174 | * @return TypedElementInterface[]|null 175 | */ 176 | protected static function getRetObjs( FunctionInterface $func ): ?array { 177 | $funcNode = $func->getNode(); 178 | if ( !$funcNode ) { 179 | // If it has no node, it won't have any returned object, so don't return null, to avoid 180 | // potential recursive analysis attempts. 181 | return []; 182 | } 183 | return $funcNode->retObjs ?? null; 184 | } 185 | 186 | /** 187 | * @note These are saved in the function node so that they can be shared by all implementations, without 188 | * having to check the defining FQSEN of a method and canonicalize $func for lookup. 189 | * @param FunctionInterface $func 190 | * @param TypedElementInterface[] $retObjs 191 | * @suppress PhanUnreferencedProtectedMethod Used in TaintednessVisitor 192 | */ 193 | protected static function addRetObjs( FunctionInterface $func, array $retObjs ): void { 194 | $funcNode = $func->getNode(); 195 | if ( $funcNode ) { 196 | $funcNode->retObjs = array_merge( $funcNode->retObjs ?? [], $retObjs ); 197 | } 198 | } 199 | 200 | /** 201 | * @param FunctionInterface $func 202 | */ 203 | protected static function initRetObjs( FunctionInterface $func ): void { 204 | $funcNode = $func->getNode(); 205 | if ( $funcNode ) { 206 | $funcNode->retObjs ??= []; 207 | } 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /src/TaintednessAssignVisitor.php: -------------------------------------------------------------------------------- 1 | rightTaint = $rightTaint; 71 | $this->rightError = $rightLines; 72 | $this->rightLinks = $rightLinks; 73 | $this->errorTaint = $errorTaint; 74 | $this->errorLinks = $errorLinks; 75 | if ( is_callable( $rhsIsArrayOrGetter ) ) { 76 | $this->rhsIsArrayGetter = $rhsIsArrayOrGetter; 77 | } else { 78 | $this->rhsIsArray = $rhsIsArrayOrGetter; 79 | } 80 | $this->dimDepth = $depth; 81 | } 82 | 83 | private function isRHSArray(): bool { 84 | if ( $this->rhsIsArray !== null ) { 85 | return $this->rhsIsArray; 86 | } 87 | $this->rhsIsArray = ( $this->rhsIsArrayGetter )(); 88 | return $this->rhsIsArray; 89 | } 90 | 91 | /** 92 | * @param Node $node 93 | */ 94 | public function visitArray( Node $node ): void { 95 | $numKey = 0; 96 | foreach ( $node->children as $child ) { 97 | if ( $child === null ) { 98 | $numKey++; 99 | continue; 100 | } 101 | if ( !$child instanceof Node || $child->kind !== \ast\AST_ARRAY_ELEM ) { 102 | // Syntax error. 103 | return; 104 | } 105 | $key = $child->children['key'] !== null ? $this->resolveOffset( $child->children['key'] ) : $numKey++; 106 | $value = $child->children['value']; 107 | if ( !$value instanceof Node ) { 108 | // Syntax error, don't crash, and bail out immediately. 109 | return; 110 | } 111 | $childVisitor = new self( 112 | $this->code_base, 113 | $this->context, 114 | $this->rightTaint->getTaintednessForOffsetOrWhole( $key ), 115 | $this->rightError->getForDim( $key ), 116 | $this->rightLinks, 117 | $this->errorTaint->getTaintednessForOffsetOrWhole( $key ), 118 | $this->errorLinks, 119 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable 120 | $this->rhsIsArray ?? $this->rhsIsArrayGetter, 121 | $this->dimDepth 122 | ); 123 | $childVisitor( $value ); 124 | } 125 | } 126 | 127 | /** 128 | * @param Node $node 129 | */ 130 | public function visitVar( Node $node ): void { 131 | try { 132 | $var = $this->getCtxN( $node )->getVariable(); 133 | } catch ( NodeException | IssueException $_ ) { 134 | return; 135 | } 136 | $this->doAssignmentSingleElement( $var ); 137 | } 138 | 139 | /** 140 | * @param Node $node 141 | */ 142 | public function visitProp( Node $node ): void { 143 | try { 144 | $prop = $this->getCtxN( $node )->getProperty( false ); 145 | } catch ( NodeException | IssueException | UnanalyzableException $_ ) { 146 | return; 147 | } 148 | $this->doAssignmentSingleElement( $prop ); 149 | } 150 | 151 | /** 152 | * @param Node $node 153 | */ 154 | public function visitStaticProp( Node $node ): void { 155 | try { 156 | $prop = $this->getCtxN( $node )->getProperty( true ); 157 | } catch ( NodeException | IssueException | UnanalyzableException $_ ) { 158 | return; 159 | } 160 | $this->doAssignmentSingleElement( $prop ); 161 | } 162 | 163 | /** 164 | * If we're assigning an SQL tainted value as an array key 165 | * or as the value of a numeric key, then set NUMKEY taint. 166 | * 167 | * @param Node $dimLHS 168 | */ 169 | private function maybeAddNumkeyOnAssignmentLHS( Node $dimLHS ): void { 170 | if ( $this->rightTaint->has( SecurityCheckPlugin::SQL_NUMKEY_TAINT ) ) { 171 | // Already there, no need to add it again. 172 | return; 173 | } 174 | 175 | $dim = $dimLHS->children['dim']; 176 | if ( 177 | $this->rightTaint->has( SecurityCheckPlugin::SQL_TAINT ) 178 | && ( $dim === null || $this->nodeCanBeIntKey( $dim ) ) 179 | && !$this->isRHSArray() 180 | ) { 181 | $this->rightTaint = $this->rightTaint->with( SecurityCheckPlugin::SQL_NUMKEY_TAINT ); 182 | $this->errorTaint = $this->errorTaint->with( SecurityCheckPlugin::SQL_NUMKEY_TAINT ); 183 | } 184 | } 185 | 186 | /** 187 | * @param Node $node 188 | */ 189 | public function visitDim( Node $node ): void { 190 | if ( !$node->children['expr'] instanceof Node ) { 191 | // Invalid syntax. 192 | return; 193 | } 194 | $dimNode = $node->children['dim']; 195 | if ( $dimNode === null ) { 196 | $curOff = null; 197 | } else { 198 | $curOff = $this->resolveOffset( $dimNode ); 199 | } 200 | $this->dimDepth++; 201 | $dimTaintWithErr = $this->getTaintedness( $dimNode ); 202 | $dimTaintInt = $dimTaintWithErr->getTaintedness()->get(); 203 | $this->rightTaint = $this->rightTaint->asMaybeMovedAtOffset( $curOff, $dimTaintInt ); 204 | $dimLinks = $dimTaintWithErr->getMethodLinks()->getLinksCollapsing(); 205 | $this->rightLinks = $this->rightLinks->asMaybeMovedAtOffset( $curOff, $dimLinks ); 206 | $dimError = $dimTaintWithErr->getError()->asAllMovedToKeys(); 207 | $this->rightError = $this->rightError->asAllMaybeMovedAtOffset( $curOff )->asMergedWith( $dimError ); 208 | $this->errorTaint = $this->errorTaint->asMaybeMovedAtOffset( $curOff, $dimTaintInt ); 209 | $this->errorLinks = $this->errorLinks->asMaybeMovedAtOffset( $curOff, $dimLinks ); 210 | $this->maybeAddNumkeyOnAssignmentLHS( $node ); 211 | $this( $node->children['expr'] ); 212 | } 213 | 214 | /** 215 | * @param TypedElementInterface $variableObj 216 | */ 217 | private function doAssignmentSingleElement( 218 | TypedElementInterface $variableObj 219 | ): void { 220 | $globalVarObj = $variableObj instanceof GlobalVariable ? $variableObj->getElement() : null; 221 | 222 | // Make sure assigning to $this->bar doesn't kill the whole prop taint. 223 | // Note: If there is a local variable that is a reference to another non-local variable, this will not 224 | // affect the non-local one (Pass by reference arguments are handled separately and work as expected). 225 | // TODO Should we also check for normal Variables in the global scope? See test setafterexec 226 | $override = !( $variableObj instanceof Property ) && !$globalVarObj; 227 | 228 | $overrideTaint = $override; 229 | if ( $this->dimDepth > 0 ) { 230 | $curTaint = self::getTaintednessRaw( $variableObj ); 231 | if ( $curTaint ) { 232 | $newTaint = $override 233 | ? $curTaint->asMergedForAssignment( $this->rightTaint, $this->dimDepth ) 234 | : $curTaint->asMergedWith( $this->rightTaint ); 235 | } else { 236 | $newTaint = $this->rightTaint; 237 | } 238 | $overrideTaint = true; 239 | } else { 240 | $newTaint = $this->rightTaint; 241 | } 242 | $this->setTaintedness( $variableObj, $newTaint, $overrideTaint ); 243 | 244 | if ( $globalVarObj ) { 245 | // Merge the taint on the "true" global object, too 246 | if ( $this->dimDepth > 0 ) { 247 | $curGlobalTaint = self::getTaintednessRaw( $globalVarObj ); 248 | if ( $curGlobalTaint ) { 249 | $newGlobalTaint = $curGlobalTaint->asMergedWith( $this->rightTaint ); 250 | } else { 251 | $newGlobalTaint = $this->rightTaint; 252 | } 253 | $overrideGlobalTaint = true; 254 | } else { 255 | $newGlobalTaint = $this->rightTaint; 256 | $overrideGlobalTaint = false; 257 | } 258 | $this->setTaintedness( $globalVarObj, $newGlobalTaint, $overrideGlobalTaint ); 259 | } 260 | 261 | if ( $this->dimDepth > 0 ) { 262 | $curLinks = self::getMethodLinks( $variableObj ) ?? MethodLinks::emptySingleton(); 263 | $newLinks = $override 264 | ? $curLinks->asMergedForAssignment( $this->rightLinks, $this->dimDepth ) 265 | : $curLinks->asMergedWith( $this->rightLinks ); 266 | $overrideLinks = true; 267 | } else { 268 | $newLinks = $this->rightLinks; 269 | $overrideLinks = $override; 270 | } 271 | $this->mergeTaintDependencies( $variableObj, $newLinks, $overrideLinks ); 272 | if ( $globalVarObj ) { 273 | // Merge dependencies on the global copy as well 274 | if ( $this->dimDepth > 0 ) { 275 | $curGlobalLinks = self::getMethodLinks( $globalVarObj ); 276 | $newGlobalLinks = $curGlobalLinks 277 | ? $curGlobalLinks->asMergedWith( $this->rightLinks ) 278 | : $this->rightLinks; 279 | $overrideGlobalLinks = true; 280 | } else { 281 | $newGlobalLinks = $this->rightLinks; 282 | $overrideGlobalLinks = false; 283 | } 284 | $this->mergeTaintDependencies( $globalVarObj, $newGlobalLinks, $overrideGlobalLinks ); 285 | } 286 | 287 | $curLineCausedBy = $this->getCausedByLinesToAdd( $this->errorTaint, $this->errorLinks ); 288 | if ( $this->dimDepth > 0 ) { 289 | $curError = self::getCausedByRaw( $variableObj ) ?? CausedByLines::emptySingleton(); 290 | $newError = $override 291 | ? $curError->asMergedWith( $this->rightError, $this->dimDepth ) 292 | : $curError->asMergedWith( $this->rightError ); 293 | $overrideError = true; 294 | } else { 295 | $newError = $this->rightError; 296 | $overrideError = $override; 297 | } 298 | $curError = $overrideError 299 | ? CausedByLines::emptySingleton() 300 | : self::getCausedByRaw( $variableObj ) ?? CausedByLines::emptySingleton(); 301 | $newOverallError = $curError 302 | ->asMergedForAssignment( $newError, $curLineCausedBy, $this->errorTaint, $this->errorLinks ); 303 | self::setCausedByRaw( $variableObj, $newOverallError ); 304 | 305 | if ( $globalVarObj ) { 306 | if ( $this->dimDepth > 0 ) { 307 | $curGlobalError = self::getCausedByRaw( $globalVarObj ); 308 | $newGlobalError = $curGlobalError 309 | ? $curGlobalError->asMergedWith( $this->rightError ) 310 | : $this->rightError; 311 | $overrideGlobalError = true; 312 | } else { 313 | $newGlobalError = $this->rightError; 314 | $overrideGlobalError = false; 315 | } 316 | $curGlobalError = $overrideGlobalError 317 | ? CausedByLines::emptySingleton() 318 | : self::getCausedByRaw( $globalVarObj ) ?? CausedByLines::emptySingleton(); 319 | $newOverallGlobalError = $curGlobalError 320 | ->asMergedForAssignment( $newGlobalError, $curLineCausedBy, $this->errorTaint, $this->errorLinks ); 321 | self::setCausedByRaw( $globalVarObj, $newOverallGlobalError ); 322 | } 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/TaintednessBackpropVisitor.php: -------------------------------------------------------------------------------- 1 | taintedness = $taintedness; 39 | $this->additionalError = $additionalError; 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public function visitProp( Node $node ): void { 46 | $this->doBackpropElements( $this->getPropFromNode( $node ) ); 47 | } 48 | 49 | /** 50 | * @inheritDoc 51 | */ 52 | public function visitNullsafeProp( Node $node ): void { 53 | $this->doBackpropElements( $this->getPropFromNode( $node ) ); 54 | } 55 | 56 | /** 57 | * @inheritDoc 58 | */ 59 | public function visitStaticProp( Node $node ): void { 60 | $this->doBackpropElements( $this->getPropFromNode( $node ) ); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public function visitVar( Node $node ): void { 67 | $cn = $this->getCtxN( $node ); 68 | if ( Variable::isHardcodedGlobalVariableWithName( $cn->getVariableName() ) ) { 69 | return; 70 | } 71 | try { 72 | $this->doBackpropElements( $cn->getVariable() ); 73 | } catch ( NodeException | IssueException $e ) { 74 | $this->debug( __METHOD__, "variable not in scope?? " . $this->getDebugInfo( $e ) ); 75 | } 76 | } 77 | 78 | /** 79 | * @inheritDoc 80 | */ 81 | public function visitEncapsList( Node $node ): void { 82 | foreach ( $node->children as $child ) { 83 | if ( $child instanceof Node ) { 84 | $this->recurse( $child ); 85 | } 86 | } 87 | } 88 | 89 | /** 90 | * @inheritDoc 91 | */ 92 | public function visitArray( Node $node ): void { 93 | foreach ( $node->children as $child ) { 94 | if ( $child instanceof Node ) { 95 | $this->recurse( $child ); 96 | } 97 | } 98 | } 99 | 100 | /** 101 | * @inheritDoc 102 | */ 103 | public function visitArrayElem( Node $node ): void { 104 | $key = $node->children['key']; 105 | if ( $key instanceof Node ) { 106 | $this->recurse( 107 | $key, 108 | $this->taintedness->asKeyForForeach(), 109 | $this->additionalError ? $this->additionalError->asAllKeyForForeach() : null 110 | ); 111 | } 112 | $value = $node->children['value']; 113 | if ( $value instanceof Node ) { 114 | $this->recurse( 115 | $value, 116 | $this->taintedness->getTaintednessForOffsetOrWhole( $key ), 117 | $this->additionalError ? $this->additionalError->getForDim( $key ) : null 118 | ); 119 | } 120 | } 121 | 122 | /** 123 | * @inheritDoc 124 | */ 125 | public function visitCast( Node $node ): void { 126 | // Future todo might be to ignore casts to ints, since 127 | // such things should be safe. Unclear if that makes 128 | // sense in all circumstances. 129 | if ( $node->children['expr'] instanceof Node ) { 130 | $this->recurse( $node->children['expr'] ); 131 | } 132 | } 133 | 134 | /** 135 | * @inheritDoc 136 | */ 137 | public function visitDim( Node $node ): void { 138 | if ( $node->children['expr'] instanceof Node ) { 139 | // For now just consider the outermost array. 140 | // FIXME. doesn't handle tainted array keys! 141 | $offs = $node->children['dim']; 142 | $realOffs = $offs !== null ? $this->resolveOffset( $offs ) : null; 143 | $this->recurse( 144 | $node->children['expr'], 145 | $this->taintedness->asMaybeMovedAtOffset( $realOffs ), 146 | $this->additionalError ? $this->additionalError->asAllMaybeMovedAtOffset( $realOffs ) : null 147 | ); 148 | } 149 | } 150 | 151 | /** 152 | * @inheritDoc 153 | */ 154 | public function visitUnaryOp( Node $node ): void { 155 | if ( $node->children['expr'] instanceof Node ) { 156 | $this->recurse( $node->children['expr'] ); 157 | } 158 | } 159 | 160 | /** 161 | * @inheritDoc 162 | */ 163 | public function visitBinaryOp( Node $node ): void { 164 | if ( $node->children['left'] instanceof Node ) { 165 | $this->recurse( $node->children['left'] ); 166 | } 167 | if ( $node->children['right'] instanceof Node ) { 168 | $this->recurse( $node->children['right'] ); 169 | } 170 | } 171 | 172 | /** 173 | * @inheritDoc 174 | */ 175 | public function visitConditional( Node $node ): void { 176 | if ( $node->children['true'] instanceof Node ) { 177 | $this->recurse( $node->children['true'] ); 178 | } 179 | if ( $node->children['false'] instanceof Node ) { 180 | $this->recurse( $node->children['false'] ); 181 | } 182 | } 183 | 184 | /** 185 | * @inheritDoc 186 | */ 187 | public function visitCall( Node $node ): void { 188 | $this->handleCall( $node ); 189 | } 190 | 191 | /** 192 | * @inheritDoc 193 | */ 194 | public function visitMethodCall( Node $node ): void { 195 | $this->handleCall( $node ); 196 | } 197 | 198 | /** 199 | * @inheritDoc 200 | */ 201 | public function visitStaticCall( Node $node ): void { 202 | $this->handleCall( $node ); 203 | } 204 | 205 | /** 206 | * @inheritDoc 207 | */ 208 | public function visitNullsafeMethodCall( Node $node ): void { 209 | $this->handleCall( $node ); 210 | } 211 | 212 | /** 213 | * @param Node $node 214 | */ 215 | private function handleCall( Node $node ): void { 216 | $ctxNode = $this->getCtxN( $node ); 217 | // @todo Future todo might be to still return arguments when catching an exception. 218 | if ( $node->kind === \ast\AST_CALL ) { 219 | if ( $node->children['expr']->kind !== \ast\AST_NAME ) { 220 | // TODO Handle this case! 221 | return; 222 | } 223 | try { 224 | $func = $ctxNode->getFunction( $node->children['expr']->children['name'] ); 225 | } catch ( IssueException | FQSENException $e ) { 226 | $this->debug( __METHOD__, "FIXME func not found: " . $this->getDebugInfo( $e ) ); 227 | return; 228 | } 229 | } else { 230 | $methodName = $node->children['method']; 231 | try { 232 | $func = $ctxNode->getMethod( $methodName, $node->kind === \ast\AST_STATIC_CALL, true ); 233 | } catch ( NodeException | CodeBaseException | IssueException $e ) { 234 | $this->debug( __METHOD__, "FIXME method not found: " . $this->getDebugInfo( $e ) ); 235 | return; 236 | } 237 | } 238 | // intentionally resetting options to [] 239 | // here to ensure we don't recurse beyond 240 | // a depth of 1. 241 | try { 242 | $retObjs = $this->getReturnObjsOfFunc( $func ); 243 | } catch ( Exception $e ) { 244 | $this->debug( __METHOD__, "FIXME: " . $this->getDebugInfo( $e ) ); 245 | return; 246 | } 247 | $this->doBackpropElements( ...$retObjs ); 248 | } 249 | 250 | /** 251 | * @inheritDoc 252 | */ 253 | public function visitPreDec( Node $node ): void { 254 | $this->handleIncOrDec( $node ); 255 | } 256 | 257 | /** 258 | * @inheritDoc 259 | */ 260 | public function visitPreInc( Node $node ): void { 261 | $this->handleIncOrDec( $node ); 262 | } 263 | 264 | /** 265 | * @inheritDoc 266 | */ 267 | public function visitPostDec( Node $node ): void { 268 | $this->handleIncOrDec( $node ); 269 | } 270 | 271 | /** 272 | * @inheritDoc 273 | */ 274 | public function visitPostInc( Node $node ): void { 275 | $this->handleIncOrDec( $node ); 276 | } 277 | 278 | /** 279 | * @param Node $node 280 | */ 281 | private function handleIncOrDec( Node $node ): void { 282 | $children = $node->children; 283 | assert( count( $children ) === 1 ); 284 | $this->recurse( reset( $children ) ); 285 | } 286 | 287 | /** 288 | * Wrapper for __invoke. Allows changing the taintedness before recursing, and restoring later. 289 | * 290 | * @param Node $node 291 | * @param ?Taintedness $taint 292 | * @param ?CausedByLines $error 293 | */ 294 | private function recurse( Node $node, ?Taintedness $taint = null, ?CausedByLines $error = null ): void { 295 | if ( !$taint ) { 296 | $this( $node ); 297 | return; 298 | } 299 | 300 | [ $oldTaint, $oldErr ] = [ $this->taintedness, $this->additionalError ]; 301 | $this->taintedness = $taint; 302 | $this->additionalError = $error; 303 | try { 304 | $this( $node ); 305 | } finally { 306 | [ $this->taintedness, $this->additionalError ] = [ $oldTaint, $oldErr ]; 307 | } 308 | } 309 | 310 | /** 311 | * @param TypedElementInterface|null ...$elements 312 | */ 313 | private function doBackpropElements( ?TypedElementInterface ...$elements ): void { 314 | foreach ( array_unique( array_filter( $elements ) ) as $el ) { 315 | $this->markAllDependentMethodsExec( $el, $this->taintedness, $this->additionalError ); 316 | } 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/TaintednessLoopVisitor.php: -------------------------------------------------------------------------------- 1 | children['expr']; 22 | $lhsTaintednessWithError = $this->getTaintedness( $expr ); 23 | $lhsTaintedness = $lhsTaintednessWithError->getTaintedness(); 24 | $lhsLinks = $lhsTaintednessWithError->getMethodLinks(); 25 | 26 | $value = $node->children['value']; 27 | if ( $value->kind === \ast\AST_REF ) { 28 | // TODO, this doesn't propagate the taint to the outer scope 29 | // (FWIW, phan doesn't do much better with types, https://github.com/phan/phan/issues/4017) 30 | $value = $value->children['var']; 31 | } 32 | 33 | $valueTaint = $lhsTaintedness->asValueFirstLevel(); 34 | $valueError = $lhsTaintednessWithError->getError()->asAllValueFirstLevel(); 35 | $valueLinks = $lhsLinks->asValueFirstLevel(); 36 | // TODO Actually compute this 37 | $rhsIsArray = false; 38 | // NOTE: As mentioned in test 'foreach', we won't be able to retroactively attribute 39 | // the right taint to the value if we discover what the key is for the current iteration 40 | $valueVisitor = new TaintednessAssignVisitor( 41 | $this->code_base, 42 | $this->context, 43 | $valueTaint, 44 | $valueError, 45 | $valueLinks, 46 | $valueTaint, 47 | $valueLinks, 48 | $rhsIsArray 49 | ); 50 | $valueVisitor( $value ); 51 | 52 | $key = $node->children['key'] ?? null; 53 | if ( $key instanceof Node ) { 54 | $keyTaint = $lhsTaintedness->asKeyForForeach(); 55 | $keyError = $lhsTaintednessWithError->getError()->asAllKeyForForeach(); 56 | $keyLinks = $lhsLinks->asKeyForForeach(); 57 | $keyVisitor = new TaintednessAssignVisitor( 58 | $this->code_base, 59 | $this->context, 60 | $keyTaint, 61 | $keyError, 62 | $keyLinks, 63 | $keyTaint, 64 | $keyLinks, 65 | $rhsIsArray 66 | ); 67 | $keyVisitor( $key ); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/TaintednessWithError.php: -------------------------------------------------------------------------------- 1 | taintedness = $taintedness; 26 | $this->error = $error; 27 | $this->methodLinks = $methodLinks; 28 | } 29 | 30 | public static function emptySingleton(): self { 31 | static $singleton; 32 | if ( !$singleton ) { 33 | $singleton = new self( 34 | Taintedness::safeSingleton(), 35 | CausedByLines::emptySingleton(), 36 | MethodLinks::emptySingleton() 37 | ); 38 | } 39 | return $singleton; 40 | } 41 | 42 | public static function unknownSingleton(): self { 43 | static $singleton; 44 | if ( !$singleton ) { 45 | $singleton = new self( 46 | Taintedness::unknownSingleton(), 47 | CausedByLines::emptySingleton(), 48 | MethodLinks::emptySingleton() 49 | ); 50 | } 51 | return $singleton; 52 | } 53 | 54 | /** 55 | * @return Taintedness 56 | */ 57 | public function getTaintedness(): Taintedness { 58 | return $this->taintedness; 59 | } 60 | 61 | /** 62 | * @return CausedByLines 63 | */ 64 | public function getError(): CausedByLines { 65 | return $this->error; 66 | } 67 | 68 | /** 69 | * @return MethodLinks 70 | */ 71 | public function getMethodLinks(): MethodLinks { 72 | return $this->methodLinks; 73 | } 74 | 75 | /** 76 | * @param self $other 77 | * @return self 78 | */ 79 | public function asMergedWith( self $other ): self { 80 | $ret = clone $this; 81 | $ret->taintedness = $ret->taintedness->asMergedWith( $other->taintedness ); 82 | $ret->error = $ret->error->asMergedWith( $other->error ); 83 | $ret->methodLinks = $ret->methodLinks->asMergedWith( $other->methodLinks ); 84 | return $ret; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/VarLinksSet.php: -------------------------------------------------------------------------------- 1 | getName() . ': ' . $this[$var]->toShortString(); 26 | } 27 | return '[' . implode( ',', $children ) . ']'; 28 | } 29 | 30 | public function __clone() { 31 | foreach ( $this as $var ) { 32 | $this[$var] = clone $this[$var]; 33 | } 34 | } 35 | } 36 | --------------------------------------------------------------------------------