Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 607
0.00% covered (danger)
0.00%
0 / 23
CRAP
0.00% covered (danger)
0.00%
0 / 1
MWVisitor
0.00% covered (danger)
0.00%
0 / 607
0.00% covered (danger)
0.00%
0 / 23
43890
0.00% covered (danger)
0.00%
0 / 1
 analyzeCallNode
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 checkExternalLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 doSelectWrapperSpecialHandling
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
420
 maybeTriggerHook
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
156
 hasPassByReferenceParameter
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getHookTypeForRegistrationMethod
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 handleNormalHookRegistration
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 handleParserHookRegistration
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 registerHook
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 visitReturn
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 handleGetQueryInfoReturn
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
110
 checkMakeList
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
156
 literalListConstToName
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 checkSQLOptions
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 checkSQLOption
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 checkJoinConds
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
272
 visitReturnOfFunctionHook
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 getCallableFromHookRegistration
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
90
 getSingleCallable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getCallbackForVar
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
30
 visitAssign
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
272
 detectHTMLForm
0.00% covered (danger)
0.00%
0 / 167
0.00% covered (danger)
0.00%
0 / 1
1640
 visitArray
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * @license GPL-2.0-or-later
5 */
6namespace SecurityCheckPlugin;
7
8use ast\Node;
9use Phan\Analysis\PostOrderAnalysisVisitor;
10use Phan\AST\ContextNode;
11use Phan\AST\UnionTypeVisitor;
12use Phan\Exception\CodeBaseException;
13use Phan\Exception\InvalidFQSENException;
14use Phan\Exception\IssueException;
15use Phan\Exception\NodeException;
16use Phan\Language\Element\ClassAliasRecord;
17use Phan\Language\Element\FunctionInterface;
18use Phan\Language\Element\Method;
19use Phan\Language\FQSEN\FullyQualifiedClassName;
20use Phan\Language\FQSEN\FullyQualifiedFunctionLikeName;
21use Phan\Language\FQSEN\FullyQualifiedFunctionName;
22use Phan\Language\FQSEN\FullyQualifiedMethodName;
23use Phan\Language\Type;
24use Phan\Language\Type\BoolType;
25use Phan\Language\Type\TrueType;
26use Phan\Language\UnionType;
27use UnexpectedValueException;
28use const ast\AST_CALL;
29use const ast\AST_CALLABLE_CONVERT;
30use const ast\AST_METHOD_CALL;
31use const ast\AST_STATIC_CALL;
32
33/**
34 * MediaWiki specific node visitor
35 */
36class MWVisitor extends TaintednessVisitor {
37    /**
38     * @todo This is a temporary hack. Proper solution is refactoring/avoiding overrideContext
39     * @var bool|null
40     * @suppress PhanWriteOnlyProtectedProperty
41     */
42    protected $isHook;
43
44    /**
45     * Try and recognize hook registration
46     * @inheritDoc
47     */
48    protected function analyzeCallNode( Node $node, iterable $funcs ): void {
49        parent::analyzeCallNode( $node, $funcs );
50        if ( !isset( $node->children['method'] ) ) {
51            // Called by visitCall
52            return;
53        }
54
55        assert( is_array( $funcs ) && count( $funcs ) === 1 );
56        $method = $funcs[0];
57        assert( $method instanceof Method );
58
59        // Should this be getDefiningFQSEN() instead?
60        $methodName = (string)$method->getFQSEN();
61        // $this->debug( __METHOD__, "Checking to see if we should register $methodName" );
62        switch ( $methodName ) {
63            case "\\MediaWiki\\Parser\\Parser::setFunctionHook":
64            case "\\MediaWiki\\Parser\\Parser::setHook":
65                $type = $this->getHookTypeForRegistrationMethod( $methodName );
66                if ( $type === null ) {
67                    break;
68                }
69                // $this->debug( __METHOD__, "registering $methodName as $type" );
70                $this->handleParserHookRegistration( $node, $type );
71                break;
72            case '\MediaWiki\HookContainer\HookContainer::register':
73                $this->handleNormalHookRegistration( $node );
74                break;
75            case '\MediaWiki\Linker\Linker::makeExternalLink':
76                $this->checkExternalLink( $node );
77                break;
78            default:
79                if ( str_starts_with( $method->getName(), 'on' ) ) {
80                    $this->maybeTriggerHook( $node, $method );
81                }
82                $this->doSelectWrapperSpecialHandling( $node, $method );
83        }
84    }
85
86    /**
87     * MediaWiki\Linker\Linker::makeExternalLink escaping depends on third argument
88     */
89    private function checkExternalLink( Node $node ): void {
90        $escapeArg = $this->resolveValue( $node->children['args']->children[2] ?? true );
91        $text = $node->children['args']->children[1] ?? null;
92        if ( !$escapeArg && $text instanceof Node ) {
93            $this->maybeEmitIssueSimplified(
94                new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
95                $text,
96                "Calling Linker::makeExternalLink with user controlled text " .
97                "and third argument set to false"
98            );
99        }
100    }
101
102    /**
103     * Special casing for complex format of IReadableDatabase::select
104     *
105     * This handles the $options, and $join_cond. Other args are
106     * handled through normal means
107     *
108     * @param Node $node Either an AST_METHOD_CALL or AST_STATIC_CALL
109     * @param Method $method
110     */
111    private function doSelectWrapperSpecialHandling( Node $node, Method $method ): void {
112        static $relevantMethods;
113        if ( !$relevantMethods ) {
114            $makeFQSEN = [ FullyQualifiedClassName::class, 'fromFullyQualifiedString' ];
115            $relevantMethods = [
116                'makeList' => $makeFQSEN( '\\Wikimedia\\Rdbms\\Platform\\ISQLPlatform' ),
117                'select' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
118                'selectField' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
119                'selectFieldValues' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
120                'selectSQLText' => $makeFQSEN( '\\Wikimedia\\Rdbms\\Platform\\ISQLPlatform' ),
121                'selectRowCount' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
122                'selectRow' => $makeFQSEN( '\\Wikimedia\\Rdbms\\IReadableDatabase' ),
123                'option' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
124                'options' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
125                'joinConds' => $makeFQSEN( '\\Wikimedia\\Rdbms\\SelectQueryBuilder' ),
126            ];
127        }
128
129        $name = $method->getName();
130        if ( !isset( $relevantMethods[$name] ) ) {
131            return;
132        }
133
134        if ( !self::isSubclassOf( $method->getClassFQSEN(), $relevantMethods[$name], $this->code_base ) ) {
135            return;
136        }
137
138        $args = $node->children['args']->children;
139        switch ( $name ) {
140            case 'select':
141            case 'selectField':
142            case 'selectFieldValues':
143            case 'selectSQLText':
144            case 'selectRowCount':
145            case 'selectRow':
146                if ( isset( $args[4] ) ) {
147                    $this->checkSQLOptions( $args[4] );
148                }
149                if ( isset( $args[5] ) ) {
150                    $this->checkJoinConds( $args[5] );
151                }
152                return;
153            case 'option':
154                if ( count( $args ) >= 2 ) {
155                    $this->checkSQLOption( $args[0], $args[1], $node );
156                }
157                return;
158            case 'options':
159                if ( $args ) {
160                    $this->checkSQLOptions( $args[0] );
161                }
162                return;
163            case 'joinConds':
164                if ( $args ) {
165                    $this->checkJoinConds( $args[0] );
166                }
167                return;
168            case 'makeList':
169                $this->checkMakeList( $node );
170                return;
171            default:
172                throw new UnexpectedValueException( "Should be unreachable, got $name" );
173        }
174    }
175
176    /**
177     * Check if we are running a hook (i.e., calling a hook method on a HookRunner interface).
178     */
179    private function maybeTriggerHook( Node $node, FunctionInterface $method ): void {
180        if ( $node->kind !== AST_METHOD_CALL || !$method instanceof Method ) {
181            return;
182        }
183
184        try {
185            $implementedInterfaces = $method->getClass( $this->code_base )->getInterfaceFQSENList();
186        } catch ( CodeBaseException $e ) {
187            $this->debug( __METHOD__, "Class not found for method $method" . $this->getDebugInfo( $e ) );
188            return;
189        }
190
191        // We assume that for a hook called Foo, the interface is called FooHook and the handler onFoo, and that the
192        // hook runner (but not necessarily the handler) implements the hook interface.
193        $hookInterfaceName = preg_replace( '/^on/', '', $method->getName() ) . 'Hook';
194        $foundHookInterface = false;
195        foreach ( $implementedInterfaces as $implementedInterfaceFQSEN ) {
196            if ( $implementedInterfaceFQSEN->getName() === $hookInterfaceName ) {
197                $foundHookInterface = true;
198                break;
199            }
200        }
201
202        if ( !$foundHookInterface ) {
203            return;
204        }
205
206        $args = $node->children['args']->children;
207
208        $hasPassByRef = self::hasPassByReferenceParameter( $method );
209        $analyzer = new PostOrderAnalysisVisitor( $this->code_base, $this->context, [] );
210        $argumentTypes = array_fill( 0, count( $args ), UnionType::empty() );
211
212        $subscribers = MediaWikiHooksHelper::getInstance()->getHookSubscribers( $method->getName() );
213        foreach ( $subscribers as $subscriber ) {
214            if ( $subscriber instanceof FullyQualifiedMethodName ) {
215                if ( !$this->code_base->hasMethodWithFQSEN( $subscriber ) ) {
216                    $this->debug( __METHOD__, "Hook subscriber $subscriber not found!" );
217                    continue;
218                }
219                $func = $this->code_base->getMethodByFQSEN( $subscriber );
220            } else {
221                assert( $subscriber instanceof FullyQualifiedFunctionName );
222                if ( !$this->code_base->hasFunctionWithFQSEN( $subscriber ) ) {
223                    $this->debug( __METHOD__, "Hook subscriber $subscriber not found!" );
224                    continue;
225                }
226                $func = $this->code_base->getFunctionByFQSEN( $subscriber );
227            }
228
229            // $this->debug( __METHOD__, "Dispatching $hookName to $subscriber" );
230            // This is hacky, but try to ensure that the associated line
231            // number for any issues is in the extension, and not the
232            // line where the HookContainer::register() is in MW core.
233            // FIXME: In the case of reference parameters, this is
234            // still reporting things being in MW core instead of extension.
235            $oldContext = $this->overrideContext;
236            $fContext = $func->getContext();
237            $newContext = clone $this->context;
238            $newContext = $newContext->withFile( $fContext->getFile() )
239                ->withLineNumberStart( $fContext->getLineNumberStart() );
240            $this->overrideContext = $newContext;
241            $this->isHook = true;
242
243            if ( $hasPassByRef ) {
244                // Trigger an analysis of the function call (see e.g. ClosureReturnTypeOverridePlugin's
245                // handling of call_user_func_array). Note that it's not enough to use our
246                // handleMethodCall, because that doesn't handle references correctly.
247
248                // NOTE: This is only known to be necessary with references, hence the check above
249                // (for performance). There might be other edge cases, though...
250
251                // TODO We don't care about types, so we use an empty union type. However this looks
252                // very very fragile.
253                // TODO 2: Someday we could write a generic-purpose MW plugin, which could (among other
254                // things) understand hook. It could share some code with taint-check, and at that
255                // point we'd likely want to use the correct types here.
256                $analyzer->analyzeCallableWithArgumentTypes( $argumentTypes, $func, $args );
257            }
258            $this->handleMethodCall( $func, $subscriber, $args, false, true );
259
260            $this->overrideContext = $oldContext;
261            $this->isHook = false;
262        }
263    }
264
265    /**
266     * Check whether a function takes a parameter by reference (copy of
267     * {@link \Phan\Language\Element\FunctionTrait::hasPassByReferenceVariable()})
268     */
269    private static function hasPassByReferenceParameter( FunctionInterface $func ): bool {
270        foreach ( $func->getParameterList() as $param ) {
271            if ( $param->isPassByReference() ) {
272                return true;
273            }
274        }
275        return false;
276    }
277
278    /**
279     * @param string $method The method name of the registration function
280     * @return string|null The name of the hook that gets registered
281     */
282    private function getHookTypeForRegistrationMethod( string $method ): ?string {
283        switch ( $method ) {
284            case "\\MediaWiki\\Parser\\Parser::setFunctionHook":
285                return '!ParserFunctionHook';
286            case "\\MediaWiki\\Parser\\Parser::setHook":
287                return '!ParserHook';
288            default:
289                $this->debug( __METHOD__, "$method not a hook registerer" );
290                return null;
291        }
292    }
293
294    /**
295     * Handle registering a normal hook from HookContainer::register (Not from $wgHooks)
296     *
297     * @param Node $node The node representing the AST_METHOD_CALL
298     */
299    private function handleNormalHookRegistration( Node $node ): void {
300        assert( $node->kind === \ast\AST_METHOD_CALL );
301        $params = $node->children['args']->children;
302        if ( count( $params ) < 2 ) {
303            $this->debug( __METHOD__, "Could not understand HookContainer::register" );
304            return;
305        }
306        $hookName = $params[0];
307        if ( !is_string( $hookName ) ) {
308            $this->debug( __METHOD__, "Could not register hook. Name is complex" );
309            return;
310        }
311        $cb = $this->getCallableFromHookRegistration( $params[1], $hookName );
312        if ( $cb ) {
313            $this->registerHook( $hookName, $cb );
314        } else {
315            $this->debug( __METHOD__, "Could not register $hookName hook due to complex callback" );
316        }
317    }
318
319    /**
320     * When someone calls $parser->setFunctionHook() or setTagHook()
321     *
322     * @note Causes phan to error out if given non-existent class
323     * @param Node $node The AST_METHOD_CALL node
324     * @param string $hookType The name of the hook
325     */
326    private function handleParserHookRegistration( Node $node, string $hookType ): void {
327        $args = $node->children['args']->children;
328        if ( count( $args ) < 2 ) {
329            return;
330        }
331        $callback = $this->getCallableFromNode( $args[1] );
332        if ( $callback ) {
333            $this->registerHook( $hookType, $callback );
334        }
335    }
336
337    private function registerHook( string $hookType, FunctionInterface $callback ): void {
338        $fqsen = $callback->getFQSEN();
339        $alreadyRegistered = MediaWikiHooksHelper::getInstance()->registerHook( $hookType, $fqsen );
340        if ( !$alreadyRegistered ) {
341            // $this->debug( __METHOD__, "registering $fqsen for hook $hookType" );
342            // If this is the first time seeing this, make sure we reanalyze the hook function now that
343            // we know what it is, in case it's already been analyzed.
344            $this->analyzeFunc( $callback );
345        }
346    }
347
348    /**
349     * For special hooks, check their return value
350     *
351     * e.g. A tag hook's return value is output as html.
352     */
353    public function visitReturn( Node $node ): void {
354        parent::visitReturn( $node );
355        if (
356            !$node->children['expr'] instanceof Node ||
357            !$this->context->isInFunctionLikeScope()
358        ) {
359            return;
360        }
361        $funcFQSEN = $this->context->getFunctionLikeFQSEN();
362        $funcFQSENStr = $funcFQSEN->__toString();
363
364        if (
365            // The one in SelectQueryBuilder is generic, don't bother. The stuff it returns is analyzed separately when
366            // we find calls to SelectQueryBuilder methods.
367            $funcFQSENStr !== '\\Wikimedia\\Rdbms\\SelectQueryBuilder::getQueryInfo' &&
368            str_ends_with( $funcFQSENStr, '::getQueryInfo' )
369        ) {
370            $this->handleGetQueryInfoReturn( $node->children['expr'] );
371        }
372
373        $hookType = MediaWikiHooksHelper::getInstance()->isSpecialHookSubscriber( $funcFQSEN );
374        switch ( $hookType ) {
375            case '!ParserFunctionHook':
376                $this->visitReturnOfFunctionHook( $node->children['expr'], $funcFQSEN );
377                break;
378            case '!ParserHook':
379                $ret = $node->children['expr'];
380                $this->maybeEmitIssueSimplified(
381                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
382                    $ret,
383                    "Outputting user controlled HTML from Parser tag hook {FUNCTIONLIKE}",
384                    [ $funcFQSEN ]
385                );
386                break;
387        }
388    }
389
390    /**
391     * Methods named getQueryInfo() in MediaWiki usually
392     * return an array that is later fed to select
393     *
394     * @note This will only work where the return
395     *  statement is an array literal.
396     * @param Node|mixed $node Node from ast tree
397     */
398    private function handleGetQueryInfoReturn( mixed $node ): void {
399        if (
400            !( $node instanceof Node ) ||
401            $node->kind !== \ast\AST_ARRAY
402        ) {
403            return;
404        }
405        // The argument order is
406        // $table, $vars, $conds = '', $fname = __METHOD__,
407        // $options = [], $join_conds = []
408        $keysToArg = [
409            'tables' => 0,
410            'fields' => 1,
411            'conds' => 2,
412            'options' => 4,
413            'join_conds' => 5,
414        ];
415        $args = [ '', '', '', '' ];
416        foreach ( $node->children as $child ) {
417            // Can't have array destructuring in a return statement.
418            assert( $child !== null );
419            if ( $child->kind === \ast\AST_UNPACK ) {
420                // Can't analyze this, skip it.
421                continue;
422            }
423            assert( $child->kind === \ast\AST_ARRAY_ELEM );
424            $key = $child->children['key'];
425            if ( $key instanceof Node ) {
426                // Dynamic name, skip (T268055).
427                continue;
428            }
429            if ( !isset( $keysToArg[$key] ) ) {
430                continue;
431            }
432            $args[$keysToArg[$key]] = $child->children['value'];
433        }
434        $selectFQSEN = FullyQualifiedMethodName::fromFullyQualifiedString(
435            '\Wikimedia\Rdbms\IReadableDatabase::select'
436        );
437        if ( !$this->code_base->hasMethodWithFQSEN( $selectFQSEN ) ) {
438            // Huh. Core wasn't parsed. That's bad, but don't fail hard.
439            $this->debug( __METHOD__, 'Database::select does not exist.' );
440            return;
441        }
442        $select = $this->code_base->getMethodByFQSEN( $selectFQSEN );
443        // TODO: The message about calling Database::select here is not very clear.
444        $this->handleMethodCall( $select, $selectFQSEN, $args, false );
445        if ( isset( $args[4] ) ) {
446            $this->checkSQLOptions( $args[4] );
447        }
448        if ( isset( $args[5] ) ) {
449            $this->checkJoinConds( $args[5] );
450        }
451    }
452
453    /**
454     * Check IDatabase::makeList
455     *
456     * Special cased because the second arg totally changes
457     * how this function is interpreted.
458     */
459    private function checkMakeList( Node $node ): void {
460        $args = $node->children['args'];
461        // First determine which IDatabase::LIST_*
462        // 0 = IDatabase::LIST_COMMA is default value.
463        $typeArg = $args->children[1] ?? 0;
464        if ( $typeArg instanceof Node ) {
465            $typeArg = $this->getCtxN( $typeArg )->getEquivalentPHPValueForNode(
466                $typeArg,
467                ContextNode::RESOLVE_SCALAR_DEFAULT & ~ContextNode::RESOLVE_CONSTANTS
468            );
469        }
470        if ( $typeArg instanceof Node ) {
471            if ( $typeArg->kind === \ast\AST_CLASS_CONST ) {
472                // Probably IDatabase::LIST_*. Note that non-class constants are resolved
473                $typeArg = $typeArg->children['const'];
474            } elseif ( $typeArg->kind === \ast\AST_CONST ) {
475                $typeArg = $typeArg->children['name']->children['name'];
476            } else {
477                // Something that cannot be resolved statically. Since LIST_NAMES is very rare, and LIST_COMMA is
478                // default, assume its LIST_AND or LIST_OR
479                $this->debug( __METHOD__, "Could not determine 2nd arg makeList()" );
480                $this->maybeEmitIssueSimplified(
481                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
482                    $args->children[0],
483                    "IDatabase::makeList with unknown type arg is " .
484                    "given an array with unescaped keynames or " .
485                    "values for numeric keys (May be false positive)"
486                );
487
488                return;
489            }
490        }
491
492        // Make sure not to mix strings and ints in switch cases, as that will break horribly
493        if ( is_int( $typeArg ) ) {
494            $typeArg = $this->literalListConstToName( $typeArg );
495        }
496        switch ( $typeArg ) {
497            case 'LIST_COMMA':
498                // String keys ignored. Everything escaped. So nothing to worry about.
499                break;
500            case 'LIST_AND':
501            case 'LIST_SET':
502            case 'LIST_OR':
503                // exec_sql_numkey
504                $this->maybeEmitIssueSimplified(
505                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
506                    $args->children[0],
507                    "IDatabase::makeList with LIST_AND, LIST_OR or "
508                    . "LIST_SET must sql escape string key names and values of numeric keys"
509                );
510                break;
511            case 'LIST_NAMES':
512                // Like comma but with no escaping.
513                $this->maybeEmitIssueSimplified(
514                    new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT ),
515                    $args->children[0],
516                    "IDatabase::makeList with LIST_NAMES needs "
517                    . "to escape for SQL"
518                );
519                break;
520            default:
521                $this->debug( __METHOD__, "Unrecognized 2nd arg " . "to IDatabase::makeList: '$typeArg'" );
522        }
523    }
524
525    /**
526     * Convert a literal int value for a LIST_* constant to its name. This is a horrible hack for crappy code
527     * that uses the constants literally rather than by name. Such code shouldn't deserve taint analysis.
528     * This method can obviously break very easily if the values are changed.
529     */
530    private function literalListConstToName( int $value ): string {
531        switch ( $value ) {
532            case 0:
533                return 'LIST_COMMA';
534            case 1:
535                return 'LIST_AND';
536            case 2:
537                return 'LIST_SET';
538            case 3:
539                return 'LIST_NAMES';
540            case 4:
541                return 'LIST_OR';
542            default:
543                // Oh boy, what the heck are you doing? Well, DWIM
544                $this->debug(
545                    __METHOD__,
546                    'Someone specified a LIST_* constant literally but it is not a valid value. Wow.'
547                );
548                return 'LIST_AND';
549        }
550    }
551
552    /**
553     * Check the options parameter to IReadableDatabase::select
554     *
555     * This only works if its specified as an array literal.
556     *
557     * @param Node|mixed $node The node from the AST tree
558     */
559    private function checkSQLOptions( mixed $node ): void {
560        if ( !( $node instanceof Node ) || $node->kind !== \ast\AST_ARRAY ) {
561            return;
562        }
563
564        foreach ( $node->children as $arrayElm ) {
565            // Can't use array destructuring as an expression
566            assert( $arrayElm !== null );
567            if ( $arrayElm->kind === \ast\AST_UNPACK ) {
568                // Can't analyze this, skip it.
569                continue;
570            }
571            assert( $arrayElm->kind === \ast\AST_ARRAY_ELEM );
572            $val = $arrayElm->children['value'];
573            $key = $arrayElm->children['key'];
574            $this->checkSQLOption( $key, $val, $node );
575        }
576    }
577
578    /**
579     *  Relevant options:
580     *   GROUP BY is put directly in the query (array gets imploded)
581     *   HAVING is treated like a WHERE clause
582     *   ORDER BY is put directly in the query (array gets imploded)
583     *   USE INDEX is directly put in string (both array and string version)
584     *   IGNORE INDEX ditto
585     *
586     * @param Node|mixed $option
587     * @param Node|mixed $value
588     * @param Node $callNode
589     */
590    private function checkSQLOption( mixed $option, mixed $value, Node $callNode ): void {
591        $relevant = [
592            'GROUP BY' => true,
593            'ORDER BY' => true,
594            'HAVING' => true,
595            'USE INDEX' => true,
596            'IGNORE INDEX' => true,
597        ];
598
599        if ( !is_string( $option ) || !isset( $relevant[$option] ) ) {
600            return;
601        }
602        $taintType = ( $option === 'HAVING' && $this->nodeIsArray( $value ) ) ?
603            SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT :
604            SecurityCheckPlugin::SQL_EXEC_TAINT;
605        $taintType = new Taintedness( $taintType );
606
607        $this->backpropagateArgTaint( $callNode, $taintType );
608        if ( $value instanceof Node && $value->lineno !== $this->context->getLineNumberStart() ) {
609            $ctx = clone $this->context;
610            $this->overrideContext = $ctx->withLineNumberStart( $value->lineno );
611        }
612        $this->maybeEmitIssueSimplified(
613            $taintType,
614            $value,
615            "{STRING_LITERAL} clause is user controlled",
616            [ $option ]
617        );
618        $this->overrideContext = null;
619    }
620
621    /**
622     * Check a join_cond structure.
623     *
624     * Syntax is like
625     *
626     *  [ 'aliasOfTable' => [ 'JOIN TYPE', $onConditions ], ... ]
627     *  join type is usually something safe like INNER JOIN, but it is not
628     *  validated or escaped. $onConditions is the same form as a WHERE clause.
629     *
630     * @param Node|mixed $node
631     */
632    private function checkJoinConds( mixed $node ): void {
633        if ( !( $node instanceof Node ) || $node->kind !== \ast\AST_ARRAY ) {
634            return;
635        }
636
637        foreach ( $node->children as $table ) {
638            // Can't use array destructuring as an expression
639            assert( $table !== null );
640            if ( $table->kind === \ast\AST_UNPACK ) {
641                // Can't analyze this, skip it.
642                continue;
643            }
644            assert( $table->kind === \ast\AST_ARRAY_ELEM );
645
646            $tableName = is_string( $table->children['key'] ) ?
647                $table->children['key'] :
648                '[UNKNOWN TABLE]';
649            $joinInfo = $table->children['value'];
650            if ( $joinInfo instanceof Node && $joinInfo->kind === \ast\AST_ARRAY ) {
651                if (
652                    count( $joinInfo->children ) === 0 ||
653                    $joinInfo->children[0]->children['key'] !== null
654                ) {
655                    $this->debug( __METHOD__, "join info has named key??" );
656                    continue;
657                }
658                $joinType = $joinInfo->children[0]->children['value'];
659                // join type does not get escaped.
660                $this->maybeEmitIssueSimplified(
661                    new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT ),
662                    $joinType,
663                    "Join type for {STRING_LITERAL} is user controlled",
664                    [ $tableName ]
665                );
666                if ( $joinType instanceof Node ) {
667                    $this->backpropagateArgTaint(
668                        $joinType,
669                        new Taintedness( SecurityCheckPlugin::SQL_EXEC_TAINT )
670                    );
671                }
672                // On to the join ON conditions.
673                if (
674                    count( $joinInfo->children ) === 1 ||
675                    $joinInfo->children[1]->children['key'] !== null
676                ) {
677                    $this->debug( __METHOD__, "join info has named key??" );
678                    continue;
679                }
680                $onCond = $joinInfo->children[1]->children['value'];
681                if ( $onCond instanceof Node && $onCond->lineno !== $this->context->getLineNumberStart() ) {
682                    $ctx = clone $this->context;
683                    $this->overrideContext = $ctx->withLineNumberStart( $onCond->lineno );
684                }
685                $this->maybeEmitIssueSimplified(
686                    new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT ),
687                    $onCond,
688                    "The ON conditions are not properly escaped for the join to `{STRING_LITERAL}`",
689                    [ $tableName ]
690                );
691                if ( $onCond instanceof Node ) {
692                    $this->backpropagateArgTaint(
693                        $onCond,
694                        new Taintedness( SecurityCheckPlugin::SQL_NUMKEY_EXEC_TAINT )
695                    );
696                }
697                $this->overrideContext = null;
698            }
699        }
700    }
701
702    /**
703     * Check to see if isHTML => true and is tainted.
704     *
705     * @param Node $node The expr child of the return. NOT the return itself
706     * @param FullyQualifiedFunctionLikeName $funcName
707     */
708    private function visitReturnOfFunctionHook( Node $node, FullyQualifiedFunctionLikeName $funcName ): void {
709        // XXX: we limit this to literal arrays because otherwise, we wouldn't be able to match the output taintedness
710        // and the `isHTML` value from different branches. See `safeHookIndirect1` test.
711        if ( $node->kind !== \ast\AST_ARRAY || count( $node->children ) < 2 ) {
712            return;
713        }
714        $arg = $node->children[0];
715        // Can't have array destructuring in a return statement.
716        assert( $arg instanceof Node );
717        if ( $arg->kind === \ast\AST_UNPACK ) {
718            // Can't analyze this.
719            return;
720        }
721        assert( $arg->kind === \ast\AST_ARRAY_ELEM );
722
723        $retType = UnionTypeVisitor::unionTypeFromNode( $this->code_base, $this->context, $node );
724        $isHTMLElementType = UnionTypeVisitor::resolveArrayShapeElementTypesForOffset(
725            $retType,
726            'isHTML',
727            false,
728            $this->code_base
729        );
730        if ( !$isHTMLElementType instanceof UnionType ) {
731            // Can't be resolved statically.
732            return;
733        }
734
735        // NOTE: Cannot use `containsTrue` here, because it, huh, also returns true for `false`.
736        $containsTrue = $isHTMLElementType->hasRealTypeMatchingCallback(
737            static fn ( Type $t ): bool => $t instanceof BoolType || $t instanceof TrueType
738        );
739        if ( !$containsTrue ) {
740            return;
741        }
742
743        $this->maybeEmitIssueSimplified(
744            new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
745            $arg->children['value'],
746            "Outputting user controlled HTML from Parser function hook {FUNCTIONLIKE}",
747            [ $funcName ]
748        );
749    }
750
751    /**
752     * Given a MediaWiki hook registration, find the callback
753     *
754     * @note This is a different format than Parser hooks use.
755     *
756     * Valid examples of callbacks:
757     *  1) A normal callable (string, array, or first-class)
758     *  2) A class instance with an `on$hook` method
759     *  3) An extension hook handler spec (not handled here as we check extension.json instead)
760     *  4) `HookContainer::NOOP` for no-op handlers
761     *
762     * @param Node|mixed $node
763     * @param string $hookName
764     */
765    private function getCallableFromHookRegistration( mixed $node, string $hookName ): ?FunctionInterface {
766        if (
767            !$node instanceof Node ||
768            $node->kind === \ast\AST_CLOSURE ||
769            $node->kind === \ast\AST_ARRAY ||
770            (
771                ( $node->kind === AST_CALL || $node->kind === AST_METHOD_CALL || $node->kind === AST_STATIC_CALL ) &&
772                $node->children['args']->kind === AST_CALLABLE_CONVERT
773            )
774        ) {
775            return $this->getCallableFromNode( $node );
776        }
777
778        $cb = $this->getSingleCallable( $node, 'on' . $hookName );
779        if ( $cb ) {
780            return $cb;
781        }
782
783        // Either a callback we couldn't resolve, or extension hook handler spec. Ignore either way.
784        return null;
785    }
786
787    private function getSingleCallable( Node $node, string $methodName ): ?FunctionInterface {
788        if ( $node->kind === \ast\AST_VAR && is_string( $node->children['name'] ) ) {
789            return $this->getCallbackForVar( $node, $methodName );
790        }
791        if ( $node->kind === \ast\AST_NEW ) {
792            $cxn = $this->getCtxN( $node );
793            try {
794                $ctor = $cxn->getMethod( '__construct', false, false, true );
795                return $ctor->getClass( $this->code_base )->getMethodByName( $this->code_base, $methodName );
796            } catch ( CodeBaseException | NodeException $e ) {
797                // @todo Should probably emit a non-security issue
798                $this->debug( __METHOD__, "Missing hook handle: " . $this->getDebugInfo( $e ) );
799            }
800        }
801        return null;
802    }
803
804    /**
805     * Given an AST_VAR node, figure out what it represents as callback
806     *
807     * @param Node $node The variable
808     * @param string $defaultMethod If the var is an object, what method to use
809     */
810    private function getCallbackForVar( Node $node, string $defaultMethod = '' ): ?FunctionInterface {
811        assert( $node->kind === \ast\AST_VAR );
812        $cnode = $this->getCtxN( $node );
813        // Try the class case first, because the callable case might emit issues (about missing __invoke) if executed
814        // for a variable holding just a class instance.
815        try {
816            // Don't warn if it's the wrong type, for it might be a callable and not a class.
817            $classes = $cnode->getClassList( true, ContextNode::CLASS_LIST_ACCEPT_ANY, null, false );
818        } catch ( CodeBaseException | IssueException ) {
819            $classes = [];
820        }
821        foreach ( $classes as $class ) {
822            if ( $class->getFQSEN()->__toString() === '\Closure' ) {
823                // This means callable case, done below.
824                continue;
825            }
826            try {
827                return $class->getMethodByName( $this->code_base, $defaultMethod );
828            } catch ( CodeBaseException ) {
829                return null;
830            }
831        }
832
833        return $this->getCallableFromNode( $node );
834    }
835
836    /**
837     * Check for $wgHooks registration
838     *
839     * @param Node $node
840     * @note This assumes $wgHooks is always the global
841     *   even if there is no globals declaration.
842     */
843    public function visitAssign( Node $node ): void {
844        parent::visitAssign( $node );
845
846        $var = $node->children['var'];
847        if ( !$var instanceof Node ) {
848            // Syntax error
849            return;
850        }
851        $hookName = null;
852        $expr = $node->children['expr'];
853        // The $wgHooks['foo'][] case
854        if (
855            $var->kind === \ast\AST_DIM &&
856            $var->children['dim'] === null &&
857            $var->children['expr'] instanceof Node &&
858            $var->children['expr']->kind === \ast\AST_DIM &&
859            $var->children['expr']->children['expr'] instanceof Node &&
860            is_string( $var->children['expr']->children['dim'] ) &&
861            /* The $wgHooks['SomeHook'][] case */
862            ( ( $var->children['expr']->children['expr']->kind === \ast\AST_VAR &&
863            $var->children['expr']->children['expr']->children['name'] === 'wgHooks' ) ||
864            /* The $GLOBALS['wgHooks']['SomeHook'][] case */
865            ( $var->children['expr']->children['expr']->kind === \ast\AST_DIM &&
866            $var->children['expr']->children['expr']->children['expr'] instanceof Node &&
867            $var->children['expr']->children['expr']->children['expr']->kind === \ast\AST_VAR &&
868            $var->children['expr']->children['expr']->children['expr']->children['name'] === 'GLOBALS' ) )
869        ) {
870            $hookName = $var->children['expr']->children['dim'];
871        }
872
873        if ( $hookName !== null ) {
874            $cb = $this->getCallableFromHookRegistration( $expr, $hookName );
875            if ( $cb ) {
876                $this->registerHook( $hookName, $cb );
877            } else {
878                $this->debug( __METHOD__, "Could not register hook " .
879                    "$hookName due to complex callback"
880                );
881            }
882        }
883    }
884
885    /**
886     * Special implementation of visitArray to detect HTMLForm specifiers
887     */
888    private function detectHTMLForm( Node $node ): void {
889        // Try to immediately filter out things that certainly aren't HTMLForms
890        $maybeHTMLForm = false;
891        foreach ( $node->children as $child ) {
892            if ( $child instanceof Node && $child->kind === \ast\AST_ARRAY_ELEM ) {
893                $key = $child->children['key'];
894                if ( $key instanceof Node || $key === 'class' || $key === 'type' ) {
895                    $maybeHTMLForm = true;
896                    break;
897                }
898            }
899        }
900        if ( !$maybeHTMLForm ) {
901            return;
902        }
903
904        $authReqFQSEN = FullyQualifiedClassName::fromFullyQualifiedString(
905            'MediaWiki\Auth\AuthenticationRequest'
906        );
907
908        if (
909            $this->code_base->hasClassWithFQSEN( $authReqFQSEN ) &&
910            $this->context->isInClassScope() &&
911            self::isSubclassOf( $this->context->getClassFQSEN(), $authReqFQSEN, $this->code_base )
912        ) {
913            // AuthenticationRequest::getFieldInfo() defines a very
914            // similar array but with different rules. T202112
915            return;
916        }
917
918        // This is a rather superficial check. There
919        // are many ways to construct htmlform specifiers this
920        // won't catch, and it may also have some false positives.
921
922        static $HTMLFormTypesToClasses = null;
923        if ( !$HTMLFormTypesToClasses ) {
924            $makeFQSEN = FullyQualifiedClassName::fromFullyQualifiedString( ... );
925            $HTMLFormTypesToClasses = [
926                'api' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLApiField' ),
927                'text' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
928                'textwithbutton' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextFieldWithButton' ),
929                'textarea' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextAreaField' ),
930                'select' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectField' ),
931                'combobox' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLComboboxField' ),
932                'radio' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLRadioField' ),
933                'multiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLMultiSelectField' ),
934                'limitselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectLimitField' ),
935                'check' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckField' ),
936                'toggle' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckField' ),
937                'int' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLIntField' ),
938                'file' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFileField' ),
939                'float' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFloatField' ),
940                'info' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLInfoField' ),
941                'selectorother' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectOrOtherField' ),
942                'selectandother' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectAndOtherField' ),
943                'namespaceselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectNamespace' ),
944                'namespaceselectwithbutton' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectNamespaceWithButton' ),
945                'tagfilter' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTagFilter' ),
946                'sizefilter' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSizeFilterField' ),
947                'submit' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSubmitField' ),
948                'hidden' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLHiddenField' ),
949                'edittools' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLEditTools' ),
950                'checkmatrix' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLCheckMatrix' ),
951                'cloner' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLFormFieldCloner' ),
952                'autocompleteselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLAutoCompleteSelectField' ),
953                'language' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLSelectLanguageField' ),
954                'date' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
955                'time' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
956                'datetime' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLDateTimeField' ),
957                'expiry' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLExpiryField' ),
958                'timezone' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTimezoneField' ),
959                'email' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
960                'password' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
961                'url' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTextField' ),
962                'title' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTitleTextField' ),
963                'user' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLUserTextField' ),
964                'tagmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTagMultiselectField' ),
965                'orderedmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLOrderedMultiselectField' ),
966                'usersmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLUsersMultiselectField' ),
967                'titlesmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLTitlesMultiselectField' ),
968                'namespacesmultiselect' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLNamespacesMultiselectField' ),
969                // NOTE: it isn't actually possible to create an HTMLButtonField using `type => button` for some reason.
970                // Here, pretending it to be possible is simpler than special-casing the exception.
971                'button' => $makeFQSEN( '\MediaWiki\HTMLForm\Field\HTMLButtonField' ),
972            ];
973        }
974        static $rawProps = [
975            'label-raw',
976            'help-raw',
977            'buttonlabel-raw',
978        ];
979        static $propsToResolve = null;
980        $propsToResolve ??= [
981            'type',
982            'class',
983            'label',
984            'options',
985            'default',
986            'raw',
987            'rawrow',
988            // TODO: remove help key case when back compat is no longer needed (T356971)
989            'help',
990            ...$rawProps,
991        ];
992
993        $fieldProps = [];
994        foreach ( $node->children as $child ) {
995            if ( $child === null || $child->kind === \ast\AST_UNPACK ) {
996                // If we have list( , $x ) = foo(), or an in-place unpack, chances are this is not an HTMLForm.
997                return;
998            }
999            assert( $child->kind === \ast\AST_ARRAY_ELEM );
1000            if ( $child->children['key'] === null ) {
1001                // Implicit offset, hence most certainly not an HTMLForm.
1002                return;
1003            }
1004            $key = $this->resolveOffset( $child->children['key'] );
1005            if ( !is_string( $key ) ) {
1006                // Either not resolvable (so nothing we can say) or a non-string literal, skip.
1007                return;
1008            }
1009            if ( in_array( $key, $propsToResolve, true ) ) {
1010                $fieldProps[$key] = $this->resolveValue( $child->children['value'] );
1011            }
1012        }
1013        // Special case
1014        $raw = $fieldProps['raw'] ?? $fieldProps['rawrow'] ?? null;
1015
1016        // Also important to reject empty string, not just
1017        // null, otherwise 9e409c781015 of Wikibase causes
1018        // this to fatal
1019        if ( !empty( $fieldProps['type'] ) && is_string( $fieldProps['type'] ) ) {
1020            $type = $fieldProps['type'];
1021            if ( !isset( $HTMLFormTypesToClasses[$type] ) ) {
1022                // Not a valid HTMLForm field (or a new field type we don't recognize)
1023                return;
1024            }
1025        } elseif ( !empty( $fieldProps['class'] ) && is_string( $fieldProps['class'] ) ) {
1026            try {
1027                $fqsen = FullyQualifiedClassName::fromStringInContext(
1028                    $fieldProps['class'],
1029                    $this->context
1030                );
1031            } catch ( InvalidFQSENException ) {
1032                // 'class' refers to something which is not a class, and this is probably not
1033                // an HTMLForm
1034                return;
1035            }
1036
1037            $type = null;
1038            foreach ( $HTMLFormTypesToClasses as $curType => $fieldFQSEN ) {
1039                if ( $fqsen === $fieldFQSEN ) {
1040                    $type = $curType;
1041                    break;
1042                }
1043                // Note, this assumes that the list above uses the canonical FQSENs
1044                $fieldAliasFQSENs = array_map(
1045                    static fn ( ClassAliasRecord $car ): FullyQualifiedClassName => $car->alias_fqsen,
1046                    $this->code_base->getClassAliasesByFQSEN( $fieldFQSEN )
1047                );
1048                if ( in_array( $fqsen, $fieldAliasFQSENs, true ) ) {
1049                    $type = $curType;
1050                    break;
1051                }
1052            }
1053            if ( !$type ) {
1054                // Not a valid HTMLForm field (or a new field type we don't recognize)
1055                return;
1056            }
1057        } else {
1058            // Definitely not an HTMLForm
1059            return;
1060        }
1061
1062        $fieldPropsToCheck = array_diff_key( $fieldProps, [ 'class' => 1, 'type' => 1 ] );
1063        if ( !$fieldPropsToCheck ) {
1064            // e.g. [ 'class' => 'someCssClass' ] appears a lot
1065            // in the code base. If we don't have any of the interesting
1066            // fields, skip out early.
1067            return;
1068        }
1069
1070        if ( $fieldPropsToCheck['label'] ?? null ) {
1071            // double escape check for label.
1072            $this->maybeEmitIssueSimplified(
1073                new Taintedness( SecurityCheckPlugin::ESCAPED_EXEC_TAINT ),
1074                $fieldPropsToCheck['label'],
1075                'HTMLForm label key escapes its input'
1076            );
1077        }
1078        if ( $fieldPropsToCheck['help'] ?? null ) {
1079            $this->maybeEmitIssueSimplified(
1080                new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1081                $fieldPropsToCheck['help'],
1082                'HTMLForm help needs to escape input'
1083            );
1084        }
1085        foreach ( $rawProps as $prop ) {
1086            if ( $fieldPropsToCheck[$prop] ?? null ) {
1087                $this->maybeEmitIssueSimplified(
1088                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1089                    $fieldPropsToCheck[$prop],
1090                    "HTMLForm $prop needs to escape input"
1091                );
1092            }
1093        }
1094
1095        if ( $type === 'info' && ( $fieldPropsToCheck['default'] ?? null ) ) {
1096            if ( $raw === true ) {
1097                $this->maybeEmitIssueSimplified(
1098                    new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT ),
1099                    $fieldPropsToCheck['default'],
1100                    'HTMLForm info field in raw mode needs to escape default key'
1101                );
1102            }
1103            if ( $raw === false || $raw === null ) {
1104                $this->maybeEmitIssueSimplified(
1105                    new Taintedness( SecurityCheckPlugin::ESCAPED_EXEC_TAINT ),
1106                    $fieldPropsToCheck['default'],
1107                    'HTMLForm info field (non-raw) escapes default key already'
1108                );
1109            }
1110        }
1111
1112        // options key is really messed up with escaping.
1113        $isOptionsSafe = !in_array( $type, [ 'radio', 'multiselect' ], true );
1114        $options = $fieldProps['options'] ?? null;
1115        if ( !$isOptionsSafe && $options instanceof Node ) {
1116            $htmlExecTaint = new Taintedness( SecurityCheckPlugin::HTML_EXEC_TAINT );
1117            $optTaint = $this->getTaintedness( $options );
1118            $this->maybeEmitIssue(
1119                $htmlExecTaint,
1120                $optTaint->getTaintedness()->asKeyForForeach(),
1121                'HTMLForm option label needs escaping{DETAILS}',
1122                [ [ 'lines' => $optTaint->getError(), 'sink' => false ] ]
1123            );
1124        }
1125    }
1126
1127    /**
1128     * Try to detect HTMLForm specifiers
1129     */
1130    public function visitArray( Node $node ): void {
1131        parent::visitArray( $node );
1132        // Performance: use isset(), not property_exists
1133        if ( !isset( $node->skipHTMLFormAnalysis ) ) {
1134            $this->detectHTMLForm( $node );
1135        }
1136    }
1137}